huskies: merge 954

This commit is contained in:
dave
2026-05-13 09:35:51 +00:00
parent c228ae1640
commit 765d54fc4b
8 changed files with 290 additions and 9 deletions
@@ -65,6 +65,7 @@ async fn mergemaster_blocks_and_sends_story_blocked_when_no_commits_ahead() {
summary: "done".to_string(),
gates_passed: true,
gate_output: String::new(),
needs_commit_recovery: false,
},
Some(root.to_path_buf()),
None,
@@ -182,6 +183,7 @@ stage = "qa"
summary: "QA done".to_string(),
gates_passed: true,
gate_output: String::new(),
needs_commit_recovery: false,
},
Some(root.to_path_buf()),
None,
@@ -266,6 +268,7 @@ async fn stale_mergemaster_advance_for_done_story_is_noop() {
summary: "stale advance".to_string(),
gates_passed: true,
gate_output: String::new(),
needs_commit_recovery: false,
},
Some(root.to_path_buf()),
None,
@@ -406,6 +409,7 @@ async fn work_survived_advances_to_qa_instead_of_blocking() {
summary: "Agent crashed".to_string(),
gates_passed: false,
gate_output: "Worktree has uncommitted changes".to_string(),
needs_commit_recovery: false,
},
Some(root.to_path_buf()),
Some(wt_path),
@@ -505,6 +509,7 @@ async fn no_committed_work_still_retries_and_blocks() {
summary: "Agent crashed".to_string(),
gates_passed: false,
gate_output: "Tests failed".to_string(),
needs_commit_recovery: false,
},
Some(root.to_path_buf()),
Some(wt_path),
@@ -635,6 +640,7 @@ async fn gates_failed_no_test_evidence_does_not_advance() {
summary: "Gates failed".to_string(),
gates_passed: false,
gate_output: "Tests failed".to_string(),
needs_commit_recovery: false,
},
Some(root.to_path_buf()),
Some(wt_path),
@@ -758,6 +764,7 @@ async fn gates_failed_with_test_evidence_and_committed_work_advances() {
summary: "Agent crashed".to_string(),
gates_passed: false,
gate_output: "PTY write assertion failed".to_string(),
needs_commit_recovery: false,
},
Some(root.to_path_buf()),
Some(wt_path),
@@ -839,6 +846,7 @@ stage = "coder"
summary: "Tests failed".to_string(),
gates_passed: false,
gate_output: "error[E0308]: mismatched types\n --> src/lib.rs:5:10".to_string(),
needs_commit_recovery: false,
},
Some(root.to_path_buf()),
None,
@@ -873,6 +881,191 @@ stage = "coder"
);
}
// ── story 954: commit-only recovery respawn ───────────────────────────────
/// AC1+AC2: when a coder exits with `needs_commit_recovery=true` (uncommitted
/// work, zero commits), the pipeline issues a commit-only recovery respawn
/// WITHOUT consuming a retry_count slot.
#[tokio::test]
async fn commit_recovery_respawn_does_not_consume_retry_count() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
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"
stage = "coder"
"#,
)
.unwrap();
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"9954_story_recovery",
"2_current",
"---\nname: Recovery Test\n---\n",
crate::db::ItemMeta::named("Recovery Test"),
);
// Ensure no stale recovery key exists.
crate::db::delete_content("9954_story_recovery:commit_recovery_pending");
let pool = AgentPool::new_test(3001);
pool.run_pipeline_advance(
"9954_story_recovery",
"coder-1",
CompletionReport {
summary: "exited".to_string(),
gates_passed: false,
gate_output: "Worktree has uncommitted changes".to_string(),
needs_commit_recovery: true,
},
Some(root.to_path_buf()),
None,
false,
None,
)
.await;
// The recovery respawn must have been issued — coder-1 should be Pending/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,
"Commit-recovery respawn must be issued when needs_commit_recovery=true. \
Pool: {:?}",
agents
.iter()
.map(|(k, a)| format!("{k}: {} ({})", a.agent_name, a.status))
.collect::<Vec<_>>()
);
drop(agents);
// retry_count must NOT have been incremented (AC 2).
let item = crate::crdt_state::read_item("9954_story_recovery").expect("story must be in CRDT");
assert_eq!(
item.retry_count(),
0,
"retry_count must NOT be incremented for a commit-recovery respawn (AC 2): got {}",
item.retry_count()
);
// The recovery key must be set so a second failure triggers a block.
assert!(
crate::db::read_content("9954_story_recovery:commit_recovery_pending").is_some(),
"commit_recovery_pending key must be set after issuing recovery respawn"
);
}
/// AC3: when the commit-recovery respawn also exits with `needs_commit_recovery=true`,
/// the story moves to `blocked` with reason "agent declined to commit recoverable work".
#[tokio::test]
async fn second_commit_recovery_failure_blocks_story() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
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"
stage = "coder"
"#,
)
.unwrap();
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"9955_story_recovery2",
"2_current",
"---\nname: Recovery2 Test\n---\n",
crate::db::ItemMeta::named("Recovery2 Test"),
);
// Simulate the recovery key already being set (first recovery respawn was
// issued previously).
crate::db::write_content("9955_story_recovery2:commit_recovery_pending", "1");
let pool = AgentPool::new_test(3001);
let mut rx = pool.watcher_tx.subscribe();
pool.run_pipeline_advance(
"9955_story_recovery2",
"coder-1",
CompletionReport {
summary: "exited again".to_string(),
gates_passed: false,
gate_output: "Worktree has uncommitted changes".to_string(),
needs_commit_recovery: true,
},
Some(root.to_path_buf()),
None,
false,
None,
)
.await;
// The story must be blocked (not retried again).
let mut got_blocked = false;
let mut block_reason = String::new();
while let Ok(evt) = rx.try_recv() {
if let WatcherEvent::StoryBlocked { story_id, reason } = evt
&& story_id == "9955_story_recovery2"
{
got_blocked = true;
block_reason = reason;
break;
}
}
assert!(
got_blocked,
"Story must be blocked when commit-recovery respawn also produces no commits (AC 3)"
);
assert_eq!(
block_reason, "agent declined to commit recoverable work",
"Block reason must match AC 3 spec"
);
// The recovery key must be cleared after blocking.
assert!(
crate::db::read_content("9955_story_recovery2:commit_recovery_pending").is_none(),
"commit_recovery_pending key must be cleared after blocking the story"
);
// retry_count must NOT have been incremented (AC 2: recovery never consumes a slot).
let item = crate::crdt_state::read_item("9955_story_recovery2").expect("story must be in CRDT");
assert_eq!(
item.retry_count(),
0,
"retry_count must NOT be incremented during commit-recovery path: got {}",
item.retry_count()
);
}
// ── bug 953: bug-645 path must not advance when feature branch has zero commits ──
/// Regression test for bug 953: when a coder agent exits with gates_passed=false
@@ -957,6 +1150,7 @@ async fn coder_completion_with_test_evidence_and_zero_commits_does_not_advance()
summary: "Agent crashed mid-output".to_string(),
gates_passed: false,
gate_output: "PTY write assertion failed".to_string(),
needs_commit_recovery: false,
},
Some(root.to_path_buf()),
Some(wt_path),