feat: progress-aware commit-recovery cap (no longer block on 2nd attempt)
The existing commit-recovery path blocked stories on the 2nd consecutive exit-without-commit. For long sweep refactors (e.g. story 997, the typed retries payload migration), claude-code's session-length boundary naturally terminates the coder mid-sweep before it can commit — even though substantial file-edit progress is being made each session. The old cap-of-1 misclassified normal mid-flight progress as 'agent declined to commit'. New behaviour: - Each commit-recovery respawn captures a worktree-diff byte-length fingerprint (git diff master | wc -c). - If the fingerprint differs from the previous attempt the agent made file-edit progress, the no-progress counter resets to 1. - If the fingerprint is byte-identical (no new edits between exits), increment the no-progress counter. - Block only when the counter reaches NO_PROGRESS_CAP (3) — i.e. three consecutive respawns where the agent did literally nothing. Adds ContentKey::CommitRecoveryDiffFingerprint to store the prior fingerprint. Updates the existing block-test to reflect the new cap semantics; existing 'first respawn issued' test continues to pass. All 2935 tests pass.
This commit is contained in:
@@ -991,10 +991,12 @@ stage = "coder"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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".
|
||||
/// AC3: when consecutive commit-recovery respawns make NO file-edit progress
|
||||
/// (worktree diff byte-identical across attempts), the story moves to `blocked`
|
||||
/// after the no-progress cap is hit. The agent gets unlimited respawns while
|
||||
/// progress is being made, only stalling triggers escalation.
|
||||
#[tokio::test]
|
||||
async fn second_commit_recovery_failure_blocks_story() {
|
||||
async fn no_progress_commit_recovery_blocks_story_at_cap() {
|
||||
use std::fs;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -1026,11 +1028,17 @@ stage = "coder"
|
||||
crate::db::ItemMeta::named("Recovery2 Test"),
|
||||
);
|
||||
|
||||
// Simulate the recovery key already being set (first recovery respawn was
|
||||
// issued previously).
|
||||
// Simulate two previous consecutive no-progress respawns: counter=2 and a
|
||||
// fingerprint stored that matches what the current (worktree-less) attempt
|
||||
// will produce (None vs Some(stored) differ, but the path with stored=Some
|
||||
// and current=None enters the else branch where we increment the counter).
|
||||
crate::db::write_content(
|
||||
crate::db::ContentKey::CommitRecoveryPending("9955_story_recovery2"),
|
||||
"1",
|
||||
"2",
|
||||
);
|
||||
crate::db::write_content(
|
||||
crate::db::ContentKey::CommitRecoveryDiffFingerprint("9955_story_recovery2"),
|
||||
"0",
|
||||
);
|
||||
|
||||
let pool = AgentPool::new_test(3001);
|
||||
@@ -1052,7 +1060,7 @@ stage = "coder"
|
||||
)
|
||||
.await;
|
||||
|
||||
// The story must be blocked (not retried again).
|
||||
// The story must be blocked once the cap is reached (counter 2 + 1 = 3).
|
||||
let mut got_blocked = false;
|
||||
let mut block_reason = String::new();
|
||||
while let Ok(evt) = rx.try_recv() {
|
||||
@@ -1066,23 +1074,30 @@ stage = "coder"
|
||||
}
|
||||
assert!(
|
||||
got_blocked,
|
||||
"Story must be blocked when commit-recovery respawn also produces no commits (AC 3)"
|
||||
"Story must be blocked after NO_PROGRESS_CAP consecutive no-progress respawns"
|
||||
);
|
||||
assert_eq!(
|
||||
block_reason, "agent declined to commit recoverable work",
|
||||
"Block reason must match AC 3 spec"
|
||||
assert!(
|
||||
block_reason.contains("without commits or new file edits"),
|
||||
"Block reason should describe the no-progress condition, got: {block_reason}"
|
||||
);
|
||||
|
||||
// The recovery key must be cleared after blocking.
|
||||
// Both recovery keys must be cleared after blocking.
|
||||
assert!(
|
||||
crate::db::read_content(crate::db::ContentKey::CommitRecoveryPending(
|
||||
"9955_story_recovery2"
|
||||
))
|
||||
.is_none(),
|
||||
"commit_recovery_pending key must be cleared after blocking the story"
|
||||
"commit_recovery_pending key must be cleared after blocking"
|
||||
);
|
||||
assert!(
|
||||
crate::db::read_content(crate::db::ContentKey::CommitRecoveryDiffFingerprint(
|
||||
"9955_story_recovery2"
|
||||
))
|
||||
.is_none(),
|
||||
"commit_recovery_diff_fingerprint key must be cleared after blocking"
|
||||
);
|
||||
|
||||
// retry_count must NOT have been incremented (AC 2: recovery never consumes a slot).
|
||||
// retry_count must NOT have been incremented (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(),
|
||||
|
||||
Reference in New Issue
Block a user