feat(recovery): one bounded post-failure diagnostic before terminal FAILED (#294)

At the terminal boundary (repair ladder spent, or a non-recoverable gate
exhaustion), run exactly one tool-free diagnostic inference per terminal
failure fingerprint over recorded facts only. A validated — materially new,
confident, recovery-stage-available — RecoveryProposal routes once into the
existing recovery stage via the ticket machinery, bypassing the spent route
budget but bounded by a one-diagnosis-per-fingerprint dedupe so no loop is
possible. Otherwise the run stays terminal FAILED (safe degrade when no
diagnoser is wired).

- New PostFailureDiagnosedEvent + nested RecoveryProposal (registered in
  eventModule); every observation/proposal/decision/route recorded for replay.
- PostFailureDiagnoser seam (nullable, mirrors SalvageJudge) + DiagnosisInput
  built from the event log only (no fresh workspace observation).
- diagnosisMinConfidence tuning knob.
- Hooked at both terminal boundaries: routeToRecovery ladder-exhausted and
  decideGateExhaustion.

Tests: RecoveryRoutingTest (route-once-then-bounded, low-confidence stays
terminal), EventsTest serialization round-trip (proposal + null).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 18:51:18 +04:00
parent 3bf4dd7379
commit 4da1653017
10 changed files with 307 additions and 9 deletions
@@ -169,6 +169,47 @@ data class RetrySalvageDecidedEvent(
val rationale: String,
) : EventPayload
/**
* A structured, untrusted recovery proposal produced by the one-shot post-failure diagnostic
* (design task #294). The diagnostic inference is tool-free and reads only recorded facts; this is
* its proposal. [expectedFingerprint] is the failure fingerprint the proposed [recoveryAction] is
* predicted to change the run to — the kernel routes only when it is materially different from the
* current terminal fingerprint (i.e. a genuinely new path, not the same dead end). [noRecovery]
* lets the model explicitly decline; [confidence] is thresholded by the kernel. LLM-proposed and
* therefore untrusted (invariant #7): validated deterministically before it can affect routing.
*/
@Serializable
data class RecoveryProposal(
val diagnosis: String,
val citedEvidence: String,
val recoveryAction: String,
val expectedFingerprint: String,
val confidence: Double,
val noRecovery: Boolean = false,
)
/**
* Records the one-shot post-failure diagnostic (design task #294): when a run is about to become
* terminal, exactly one tool-free diagnostic inference runs per terminal-failure [fingerprint]. The
* nondeterministic [proposal] (LLM-backed, null when none/unparseable), the kernel's deterministic
* validation [decision], and whether it [routed] into the existing recovery stage are all recorded
* here so replay reproduces the decision without re-invoking the diagnoser (invariants #7/#9). The
* per-fingerprint dedupe that bounds this to one attempt keys off this event.
*/
@Serializable
@SerialName("PostFailureDiagnosed")
data class PostFailureDiagnosedEvent(
val sessionId: SessionId,
val stageId: StageId,
val gate: String,
val fingerprint: String,
val proposal: RecoveryProposal?,
// ROUTE | TERMINAL_NO_PROPOSAL | TERMINAL_NO_RECOVERY | TERMINAL_LOW_CONFIDENCE |
// TERMINAL_NOT_MATERIAL | TERMINAL_NO_ROUTE
val decision: String,
val routed: Boolean,
) : EventPayload
/**
* A stage repeatedly blocked on a missing build prerequisite (see repeatedBuildCriticalReferenceBlock,
* design 2026-07-15 §#170) AND the stage both holds file_write and declares the path in-scope, so the
@@ -65,6 +65,7 @@ import com.correx.core.events.events.RepoKnowledgeRetrievedEvent
import com.correx.core.events.events.RepoMapComputedEvent
import com.correx.core.events.events.RetryAttemptedEvent
import com.correx.core.events.events.RetrySalvageDecidedEvent
import com.correx.core.events.events.PostFailureDiagnosedEvent
import com.correx.core.events.events.FailureTicketOpenedEvent
import com.correx.core.events.events.BuildPrerequisiteBootstrapAttemptedEvent
import com.correx.core.events.events.WorkspaceVerificationObservedEvent
@@ -161,6 +162,7 @@ val eventModule = SerializersModule {
subclass(WorkflowCompletedEvent::class)
subclass(RetryAttemptedEvent::class)
subclass(RetrySalvageDecidedEvent::class)
subclass(PostFailureDiagnosedEvent::class)
subclass(FailureTicketOpenedEvent::class)
subclass(BuildPrerequisiteBootstrapAttemptedEvent::class)
subclass(WorkspaceVerificationObservedEvent::class)