diff --git a/server/src/agents/pool/pipeline/advance/mod.rs b/server/src/agents/pool/pipeline/advance/mod.rs index db1b868f..68765758 100644 --- a/server/src/agents/pool/pipeline/advance/mod.rs +++ b/server/src/agents/pool/pipeline/advance/mod.rs @@ -199,39 +199,46 @@ impl AgentPool { } } } - } else - // Increment retry count and check if blocked. - if let Some(reason) = - should_block_story(story_id, config.max_retries, "coder") - { - // Story has exceeded retry limit — do not restart. - let _ = self.watcher_tx.send(WatcherEvent::StoryBlocked { - story_id: story_id.to_string(), - reason, - }); } else { - slog!( - "[pipeline] Coder '{agent_name}' failed gates for '{story_id}'. Restarting." + // Persist gate_output so the retry spawn can inject it into + // --append-system-prompt (story 881). + crate::db::write_content( + &format!("{story_id}:gate_output"), + &completion.gate_output, ); - let context = format!( - "\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.", - truncate_gate_output(&completion.gate_output) - ); - if let Err(e) = self - .start_agent( - &project_root, - story_id, - Some(agent_name), - Some(&context), - previous_session_id, - ) - .await + // Increment retry count and check if blocked. + if let Some(reason) = + should_block_story(story_id, config.max_retries, "coder") { - slog_error!( - "[pipeline] Failed to restart coder '{agent_name}' for '{story_id}': {e}" + // Story has exceeded retry limit — do not restart. + let _ = self.watcher_tx.send(WatcherEvent::StoryBlocked { + story_id: story_id.to_string(), + reason, + }); + } else { + slog!( + "[pipeline] Coder '{agent_name}' failed gates for '{story_id}'. Restarting." ); + let context = format!( + "\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.", + truncate_gate_output(&completion.gate_output) + ); + if let Err(e) = self + .start_agent( + &project_root, + story_id, + Some(agent_name), + Some(&context), + previous_session_id, + ) + .await + { + slog_error!( + "[pipeline] Failed to restart coder '{agent_name}' for '{story_id}': {e}" + ); + } } } } @@ -318,26 +325,33 @@ impl AgentPool { slog_error!("[pipeline] Failed to restart qa for '{story_id}': {e}"); } } - } else if let Some(reason) = should_block_story(story_id, config.max_retries, "qa") - { - // Story has exceeded retry limit — do not restart. - let _ = self.watcher_tx.send(WatcherEvent::StoryBlocked { - story_id: story_id.to_string(), - reason, - }); } else { - slog!("[pipeline] QA failed gates for '{story_id}'. Restarting."); - let context = format!( - "\n\n---\n## Previous QA Attempt Failed\n\ - The acceptance gates failed with the following output:\n{}\n\n\ - Please re-run and fix the issues.", - completion.gate_output + // Persist gate_output so the retry spawn can inject it into + // --append-system-prompt (story 881). + crate::db::write_content( + &format!("{story_id}:gate_output"), + &completion.gate_output, ); - if let Err(e) = self - .start_agent(&project_root, story_id, Some("qa"), Some(&context), None) - .await - { - slog_error!("[pipeline] Failed to restart qa for '{story_id}': {e}"); + if let Some(reason) = should_block_story(story_id, config.max_retries, "qa") { + // Story has exceeded retry limit — do not restart. + let _ = self.watcher_tx.send(WatcherEvent::StoryBlocked { + story_id: story_id.to_string(), + reason, + }); + } else { + slog!("[pipeline] QA failed gates for '{story_id}'. Restarting."); + let context = format!( + "\n\n---\n## Previous QA Attempt Failed\n\ + The acceptance gates failed with the following output:\n{}\n\n\ + Please re-run and fix the issues.", + completion.gate_output + ); + if let Err(e) = self + .start_agent(&project_root, story_id, Some("qa"), Some(&context), None) + .await + { + slog_error!("[pipeline] Failed to restart qa for '{story_id}': {e}"); + } } } } diff --git a/server/src/agents/pool/start/spawn.rs b/server/src/agents/pool/start/spawn.rs index 7f0494ea..d0ba5afe 100644 --- a/server/src/agents/pool/start/spawn.rs +++ b/server/src/agents/pool/start/spawn.rs @@ -26,6 +26,57 @@ use super::super::super::{ use super::super::AgentPool; use super::super::types::StoryAgent; +/// Maximum bytes of gate output to include in the `--append-system-prompt` +/// failure section. The tail is preserved (where errors appear). +const GATE_OUTPUT_PROMPT_BYTES: usize = 3_000; + +/// Truncate `output` to at most [`GATE_OUTPUT_PROMPT_BYTES`], keeping the tail. +fn truncate_for_system_prompt(output: &str) -> &str { + if output.len() <= GATE_OUTPUT_PROMPT_BYTES { + return output; + } + let start = output.len() - GATE_OUTPUT_PROMPT_BYTES; + let mut adjusted = start; + while !output.is_char_boundary(adjusted) { + adjusted += 1; + } + &output[adjusted..] +} + +/// Inject a gate-failure section into the `--append-system-prompt` CLI arg. +/// +/// If a `--append-system-prompt` pair already exists, appends to its value. +/// Otherwise adds a new `--append-system-prompt
` pair. +fn inject_gate_failure_section(args: &mut Vec, gate_output: &str) { + let section = format!( + "Your previous run's quality gates failed:\n{}", + truncate_for_system_prompt(gate_output) + ); + if let Some(pos) = args.iter().position(|a| a == "--append-system-prompt") + && let Some(val) = args.get_mut(pos + 1) + { + val.push_str("\n\n"); + val.push_str(§ion); + return; + } + args.push("--append-system-prompt".to_string()); + args.push(section); +} + +/// On retry spawns (retry_count > 0), read the stored gate_output from the DB +/// and inject it into `--append-system-prompt` so the agent always sees the +/// prior failure context, even when session-resuming (story 881). +pub(super) fn maybe_inject_gate_failure(args: &mut Vec, story_id: &str) { + let retry_count = crate::crdt_state::read_item(story_id) + .and_then(|item| item.retry_count) + .unwrap_or(0); + if retry_count > 0 + && let Some(gate_output) = crate::db::read_content(&format!("{story_id}:gate_output")) + { + inject_gate_failure_section(args, &gate_output); + } +} + /// Run the background worktree-creation + agent-launch flow. /// /// Caller (`AgentPool::start_agent`) wraps this in `tokio::spawn` and stores @@ -131,7 +182,7 @@ pub(super) async fn run_agent_spawn( } } - let (command, args, mut prompt) = match config_clone.render_agent_args( + let (command, mut args, mut prompt) = match config_clone.render_agent_args( &wt_path_str, &sid, Some(&aname), @@ -160,6 +211,10 @@ pub(super) async fn run_agent_spawn( } }; + // On retry spawns (retry_count > 0), inject prior gate failure output into + // --append-system-prompt so the agent always sees the failure context (story 881). + maybe_inject_gate_failure(&mut args, &sid); + // Append project-local prompt content (.huskies/AGENT.md) to the // baked-in prompt so every agent role sees project-specific guidance // without any config changes. The file is read fresh each spawn; @@ -430,3 +485,101 @@ pub(super) async fn run_agent_spawn( } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// AC1 + AC3 (story 881): when retry_count=1 and gate_output is stored in + /// the DB, `maybe_inject_gate_failure` injects a failure section beginning + /// `Your previous run's quality gates failed:` into `--append-system-prompt`. + #[test] + fn gate_failure_injected_into_system_prompt_on_retry() { + crate::crdt_state::init_for_test(); + crate::db::ensure_content_store(); + + let story_id = "9960_story_gate_injection_881"; + crate::db::write_item_with_content(story_id, "2_current", "---\nname: Test\n---\n"); + crate::crdt_state::set_retry_count(story_id, 1); + + let gate_output = + "error[E0308]: mismatched types\n --> src/lib.rs:5:10\n = expected i32, found &str"; + crate::db::write_content(&format!("{story_id}:gate_output"), gate_output); + + let mut args: Vec = vec!["--verbose".to_string()]; + maybe_inject_gate_failure(&mut args, story_id); + + let pos = args + .iter() + .position(|a| a == "--append-system-prompt") + .expect("--append-system-prompt must be present after retry injection"); + let value = &args[pos + 1]; + assert!( + value.starts_with("Your previous run's quality gates failed:"), + "--append-system-prompt must begin with the failure marker; got: {value}" + ); + assert!( + value.contains("mismatched types"), + "--append-system-prompt must contain a snippet of gate_output; got: {value}" + ); + } + + /// AC2 (story 881): first-attempt spawn (retry_count == 0) must NOT add the + /// failure section to `--append-system-prompt`. + #[test] + fn gate_failure_not_injected_on_first_attempt() { + crate::crdt_state::init_for_test(); + crate::db::ensure_content_store(); + + let story_id = "9961_story_no_gate_injection_881"; + crate::db::write_item_with_content(story_id, "2_current", "---\nname: Test\n---\n"); + // retry_count is 0 (default — never bumped). + + crate::db::write_content(&format!("{story_id}:gate_output"), "some previous output"); + + let mut args: Vec = vec!["--verbose".to_string()]; + maybe_inject_gate_failure(&mut args, story_id); + + assert!( + !args.iter().any(|a| a == "--append-system-prompt"), + "no --append-system-prompt should be added when retry_count is 0; args: {args:?}" + ); + } + + /// Injection appends to an existing `--append-system-prompt` value rather + /// than adding a duplicate flag. + #[test] + fn gate_failure_appends_to_existing_system_prompt() { + let gate_output = "test failure output"; + let mut args = vec![ + "--append-system-prompt".to_string(), + "base prompt".to_string(), + ]; + inject_gate_failure_section(&mut args, gate_output); + + // Only one --append-system-prompt flag. + let count = args + .iter() + .filter(|a| a.as_str() == "--append-system-prompt") + .count(); + assert_eq!(count, 1, "must not duplicate --append-system-prompt"); + + let pos = args + .iter() + .position(|a| a == "--append-system-prompt") + .unwrap(); + let value = &args[pos + 1]; + assert!( + value.contains("base prompt"), + "original prompt must be preserved" + ); + assert!( + value.contains("Your previous run's quality gates failed:"), + "failure section must be appended" + ); + assert!( + value.contains("test failure output"), + "gate_output must appear in value" + ); + } +}