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
@@ -3,6 +3,8 @@ package com.correx.testing.contracts.model.orchestration
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.PostFailureDiagnosedEvent
import com.correx.core.events.events.RecoveryProposal
import com.correx.core.events.events.RetryAttemptedEvent
import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowFailedEvent
@@ -45,6 +47,50 @@ class EventsTest {
assertEquals(event, decoded)
}
@Test
fun `PostFailureDiagnosedEvent survives round-trip with a proposal`() {
// Guards the #294 event AND its nested RecoveryProposal against the silent-registration trap.
val event: EventPayload = PostFailureDiagnosedEvent(
sessionId = SessionId("s1"),
stageId = StageId("stage-a"),
gate = "execution",
fingerprint = "fp-abc",
proposal = RecoveryProposal(
diagnosis = "type mismatch",
citedEvidence = "App.tsx: TS2322",
recoveryAction = "narrow the return type",
expectedFingerprint = "fp-xyz",
confidence = 0.9,
noRecovery = false,
),
decision = "ROUTE",
routed = true,
)
val json = eventJson.encodeToString(EventPayload.serializer(), event)
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
assertEquals(event, decoded)
}
@Test
fun `PostFailureDiagnosedEvent survives round-trip with a null proposal`() {
val event: EventPayload = PostFailureDiagnosedEvent(
sessionId = SessionId("s1"),
stageId = StageId("stage-a"),
gate = "execution",
fingerprint = "fp-abc",
proposal = null,
decision = "TERMINAL_NO_PROPOSAL",
routed = false,
)
val json = eventJson.encodeToString(EventPayload.serializer(), event)
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
assertEquals(event, decoded)
}
@Test
fun `WorkflowFailedEvent survives round-trip`() {
val event: EventPayload = WorkflowFailedEvent(