From 6092f7efbb97611226af71e0b1b66b1881733c4f Mon Sep 17 00:00:00 2001 From: dave Date: Tue, 28 Apr 2026 23:06:40 +0000 Subject: [PATCH] huskies: merge 822 --- .../src/agents/pool/pipeline/advance/mod.rs | 22 ++++- .../pool/pipeline/advance/tests_regression.rs | 86 +++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/server/src/agents/pool/pipeline/advance/mod.rs b/server/src/agents/pool/pipeline/advance/mod.rs index a7931c76..db1b868f 100644 --- a/server/src/agents/pool/pipeline/advance/mod.rs +++ b/server/src/agents/pool/pipeline/advance/mod.rs @@ -13,6 +13,26 @@ use tokio::sync::broadcast; use super::super::super::{CompletionReport, PipelineStage, agent_config_stage, pipeline_stage}; use super::super::{AgentPool, StoryAgent}; +/// Maximum number of bytes of gate output to include in the failure context +/// injected into the resumed session. Keeps the injected message focused — +/// the tail of the output (where errors appear) is always preserved. +const MAX_GATE_OUTPUT_BYTES: usize = 8_000; + +/// Truncate gate output to [`MAX_GATE_OUTPUT_BYTES`], keeping the **tail** +/// (where compiler errors and test failures are reported). +fn truncate_gate_output(output: &str) -> &str { + if output.len() <= MAX_GATE_OUTPUT_BYTES { + return output; + } + let start = output.len() - MAX_GATE_OUTPUT_BYTES; + // Advance to the next valid UTF-8 char boundary. + let mut adjusted = start; + while !output.is_char_boundary(adjusted) { + adjusted += 1; + } + &output[adjusted..] +} + impl AgentPool { /// Pipeline advancement: after an agent completes, move the story to /// the next pipeline stage and start the appropriate agent. @@ -197,7 +217,7 @@ impl AgentPool { "\n\n---\n## Previous Attempt Failed\n\ The acceptance gates failed with the following output:\n{}\n\n\ Please review the failures above, fix the issues, and try again.", - completion.gate_output + truncate_gate_output(&completion.gate_output) ); if let Err(e) = self .start_agent( diff --git a/server/src/agents/pool/pipeline/advance/tests_regression.rs b/server/src/agents/pool/pipeline/advance/tests_regression.rs index 0eefd928..a7b79121 100644 --- a/server/src/agents/pool/pipeline/advance/tests_regression.rs +++ b/server/src/agents/pool/pipeline/advance/tests_regression.rs @@ -770,3 +770,89 @@ async fn gates_failed_with_test_evidence_and_committed_work_advances() { "run_tests evidence must be cleared after pipeline advance consumes it" ); } + +// ── story 822: warm-resume coder on gate failure ────────────────────────── + +/// Story 822 / AC 1 & 4: when a coder fails gates and a prior session ID is +/// provided, the pipeline re-spawns the coder so it can warm-resume the prior +/// conversation with the failure context injected — rather than starting from +/// scratch and re-reading the spec. +/// +/// The test verifies: +/// - The coder is re-spawned (Pending/Running) rather than blocked. +/// - The retry counter is incremented (AC 3). +#[tokio::test] +async fn warm_resume_coder_on_gate_failure_with_session_id() { + use std::fs; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + // Set up a project with a fast no-op coder agent. + fs::create_dir_all(root.join(".huskies")).unwrap(); + fs::write( + root.join(".huskies/project.toml"), + r#" +max_retries = 3 + +[[agent]] +name = "coder-1" +role = "Coder" +command = "echo" +args = ["noop"] +prompt = "test prompt" +stage = "coder" +"#, + ) + .unwrap(); + + crate::db::ensure_content_store(); + crate::db::write_item_with_content( + "9950_story_warm_resume", + "2_current", + "---\nname: Warm Resume Test\n---\n", + ); + + let pool = AgentPool::new_test(3001); + + // Simulate a coder failing gates. A prior session ID is provided to + // trigger the warm-resume path (--resume ). + pool.run_pipeline_advance( + "9950_story_warm_resume", + "coder-1", + CompletionReport { + summary: "Tests failed".to_string(), + gates_passed: false, + gate_output: "error[E0308]: mismatched types\n --> src/lib.rs:5:10".to_string(), + }, + Some(root.to_path_buf()), + None, + false, + Some("prior-session-abc123".to_string()), + ) + .await; + + // The coder must be re-spawned — Pending or Running. + let agents = pool.agents.lock().unwrap(); + let coder_restarted = agents.values().any(|a| { + a.agent_name == "coder-1" && matches!(a.status, AgentStatus::Pending | AgentStatus::Running) + }); + assert!( + coder_restarted, + "Coder must be re-spawned (warm-resumed) when gates fail and prior session ID provided. \ + Pool: {:?}", + agents + .iter() + .map(|(k, a)| format!("{k}: {} ({})", a.agent_name, a.status)) + .collect::>() + ); + drop(agents); + + // Retry counter must have been incremented (AC 3). + let content = crate::db::read_content("9950_story_warm_resume") + .expect("story must exist in content store"); + assert!( + content.contains("retry_count"), + "retry_count must be incremented after warm-resume: {content}" + ); +}