feat: outer cap on commit-recovery respawns catches flapping agents

The progress-aware no-progress cap (3 consecutive byte-identical diffs)
doesn't catch the degenerate pattern where the agent keeps making
DIFFERENT file edits each session but never commits — every respawn
resets the no-progress counter, infinite loop, budget burns.

Adds ContentKey::CommitRecoveryTotalAttempts: an absolute counter that
increments on every commit-recovery respawn regardless of progress.
TOTAL_ATTEMPTS_CAP = 8; when hit, block with reason 'agent flapped — N
respawns without ever committing'.

Two caps now bound the recovery loop:
- NO_PROGRESS_CAP (3): catches stuck-agent (same diff repeatedly)
- TOTAL_ATTEMPTS_CAP (8): catches flapping-agent (different diffs, no commits)

Easy to tune the constant lower if we see runaway in practice.
All 2936 tests pass.
This commit is contained in:
Timmy
2026-05-14 11:34:17 +01:00
parent bab337b289
commit 0572af2193
3 changed files with 172 additions and 1 deletions
+9
View File
@@ -34,6 +34,12 @@ pub enum ContentKey<'a> {
/// between consecutive session-boundary-clean exits. Same byte length on
/// two consecutive attempts → no progress → increment CommitRecoveryPending.
CommitRecoveryDiffFingerprint(&'a str),
/// Absolute count of commit-recovery respawns issued for a story since the
/// last successful commit. Increments every respawn regardless of whether
/// the diff fingerprint changed. Outer cap that catches the "agent flaps
/// between different file edits each session but never commits" pattern
/// where the progress-aware counter would never trigger.
CommitRecoveryTotalAttempts(&'a str),
/// Flag indicating a merge gate fixup coder session is in progress.
///
/// Set when the merge gate fails with a self-evident-fix class of failure
@@ -68,6 +74,9 @@ impl<'a> ContentKey<'a> {
ContentKey::CommitRecoveryDiffFingerprint(id) => {
format!("{id}:commit_recovery_diff_fingerprint")
}
ContentKey::CommitRecoveryTotalAttempts(id) => {
format!("{id}:commit_recovery_total_attempts")
}
ContentKey::MergeFixupPending(id) => format!("{id}:merge_fixup_pending"),
ContentKey::MergeFailureKind(id) => format!("{id}:merge_failure_kind"),
ContentKey::MergeSuccess(id) => format!("{id}:merge_success"),