huskies: merge 881_bug_inject_prior_gate_failure_output_into_retry_agent_s_system_prompt

This commit is contained in:
dave
2026-04-29 22:48:28 +00:00
parent 9a3f60d5d3
commit e02e566648
2 changed files with 215 additions and 48 deletions
+61 -47
View File
@@ -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}");
}
}
}
}
+154 -1
View File
@@ -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 <section>` pair.
fn inject_gate_failure_section(args: &mut Vec<String>, 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(&section);
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<String>, 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<String> = 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<String> = 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"
);
}
}