huskies: merge 954
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user