ee69f9becc
Recovery runs freestyle for 3h+/800 events thrashing one file (22 rewrites, 9 rewrites of a single component) with no second FailureTicketOpenedEvent ever firing, terminated only by human CANCEL. Root cause: recovery is architected as ONE continuous ReAct loop (deliberately generous maxToolRounds — most rounds are re-investigation before the write), so the existing cumulative tool-failure breaker (stage_loop_break, #78/#304) never fires: each file_edit call itself SUCCEEDS, only the downstream lsp_diagnostics gate keeps failing on the same file. The per-gate progress-aware fingerprint is also unreliable here: it hashes the whole failure-reason string, which can look "different" each round from unrelated diagnostics elsewhere even while one file's defect never clears. Fix — two consumers sharing one event-derived data source (RecoveryFileLoopBreak.kt, FileRepairOutcome/fileRepairOutcomes): correlates each FileWrittenEvent with the next LspDiagnosticsCompletedEvent for the same path (invariant #9 — no re-observation, pure fold). - Guard (recoveryFileLoopBreak, wired into DefaultSessionOrchestratorStep's enterStage before the normal gate-retry machinery): once a single path has been rewritten recoveryFileRewriteLimit times (default 3) without its diagnostic ever going clean, escalateRecoveryLoop opens a FailureTicketOpenedEvent (gate=recovery_loop_break, escalated=true, same machinery every other escalation uses) and fails the workflow terminally instead of looping again. Recovery is the last tier — there's nowhere further to route to. - Ledger (buildRetryFeedbackEntry in ContextFeedback.kt): each already-written file in the trailing repair-mandate slot is now annotated with whether rewriting it actually moved the diagnostic, e.g. "SessionsList.tsx — written 3x. TS6133: present before AND after every write. Re-writing has not changed the result. Change the fix or report unresolvable." vs "MainLayout.tsx — written 2x. cleared after write 2. done, leave it." Existing trailing-slot precedence (recoveryTicket > retryFeedback > groundingFeedback > rejectionFeedback, #313) is untouched — this only changes retryFeedback's rendered content, still EntryRole.USER. FileRepairOutcome/fileRepairOutcomes/describeFileRepairOutcome and the recovery guard functions live in a new RecoveryFileLoopBreak.kt purely to keep ContextFeedback.kt and DefaultSessionOrchestratorRecovery.kt under detekt's per-file function-count threshold (11) — no behavioral reason for the split. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HgDL1v3GuQ9RZnYR6fDT95