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
@@ -11,6 +11,8 @@ import com.correx.core.approvals.model.DomainApprovalRequest
import com.correx.core.artifacts.DefaultArtifactReducer
import com.correx.core.events.EventDispatcher
import com.correx.core.events.events.FailureTicketOpenedEvent
import com.correx.core.events.events.PostFailureDiagnosedEvent
import com.correx.core.events.events.RecoveryProposal
import com.correx.core.events.events.StaticAnalysisCompletedEvent
import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.events.events.WorkflowCompletedEvent
@@ -46,6 +48,7 @@ import com.correx.core.kernel.orchestration.OrchestrationRepository
import com.correx.core.kernel.orchestration.OrchestratorEngines
import com.correx.core.kernel.orchestration.OrchestratorRepositories
import com.correx.core.kernel.orchestration.StaticAnalysisRunResult
import com.correx.core.kernel.orchestration.PostFailureDiagnoser
import com.correx.core.kernel.orchestration.StaticAnalysisRunner
import com.correx.core.kernel.retry.DefaultRetryCoordinator
import com.correx.core.risk.DefaultRiskAssessor
@@ -77,6 +80,7 @@ import kotlinx.datetime.Instant
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
@@ -428,6 +432,7 @@ class RecoveryRoutingTest {
staticOutput: String = "App.tsx: TS2322 type mismatch",
provider: InferenceProvider = StageRoutingProvider(),
staticExitCode: Int = 1,
postFailureDiagnoser: PostFailureDiagnoser? = null,
): Pair<DefaultSessionOrchestrator, InMemoryEventStore> {
val eventStore = InMemoryEventStore()
val shell = ShellTool(allowedExecutables = setOf("true"), workingDir = workspace)
@@ -477,6 +482,7 @@ class RecoveryRoutingTest {
staticAnalysisRunner = StaticAnalysisRunner { _, _ ->
StaticAnalysisRunResult(exitCode = staticExitCode, output = staticOutput)
},
postFailureDiagnoser = postFailureDiagnoser,
approvalEngine = object : ApprovalEngine {
override fun evaluate(
request: DomainApprovalRequest,
@@ -650,6 +656,73 @@ class RecoveryRoutingTest {
}
}
@Test
fun `post-failure diagnosis routes one validated proposal into recovery then stays bounded`(): Unit = runBlocking {
// #294: after the recovery ladder is fully spent on an unchanged fingerprint, the one-shot
// diagnostic proposes a materially-new, confident route. It routes once more into recovery,
// then — the fingerprint still unchanged — the per-fingerprint dedupe forces terminal. No loop.
var diagnosisCalls = 0
val diagnoser = PostFailureDiagnoser { input ->
diagnosisCalls++
RecoveryProposal(
diagnosis = "impl keeps emitting the same type error",
citedEvidence = input.reason,
recoveryAction = "narrow the return type in App.tsx",
expectedFingerprint = "materially-different-fingerprint",
confidence = 0.9,
)
}
val sid = SessionId("diag-route")
val (orchestrator, eventStore) = buildMultiToolOrchestrator(
staticOutput = "App.tsx: TS2322 type mismatch",
postFailureDiagnoser = diagnoser,
)
val config = OrchestrationConfig(
retryPolicy = RetryPolicy(maxAttempts = 10, backoffMs = 0, perGateMaxAttempts = mapOf("static_analysis" to 1)),
)
val result = orchestrator.run(sid, capableButStuckGraph(), config)
assertTrue(result is WorkflowResult.Failed, "bounded: still terminates after the one diagnostic route")
val diagnoses = eventStore.read(sid).mapNotNull { it.payload as? PostFailureDiagnosedEvent }
assertEquals(1, diagnoses.size, "exactly one diagnosis per terminal fingerprint (#1/#6)")
assertEquals("ROUTE", diagnoses.single().decision)
assertTrue(diagnoses.single().routed, "a validated materially-new proposal routes into recovery (#3)")
assertEquals(1, diagnosisCalls, "the diagnoser is consulted at most once for the fingerprint")
// Acceptance #2: the diagnostic saw recorded gate evidence, not a fresh observation.
assertTrue(
diagnoses.single().proposal?.citedEvidence?.contains("TS2322") == true,
"diagnosis input carried the recorded gate evidence",
)
}
@Test
fun `low-confidence post-failure proposal leaves the session terminal`(): Unit = runBlocking {
// #294 acceptance #4: an invalid (here: low-confidence) proposal is recorded but never routed.
val diagnoser = PostFailureDiagnoser {
RecoveryProposal(
diagnosis = "unsure",
citedEvidence = it.reason,
recoveryAction = "maybe try again",
expectedFingerprint = "different-but-unconfident",
confidence = 0.1,
)
}
val sid = SessionId("diag-terminal")
val (orchestrator, eventStore) = buildMultiToolOrchestrator(postFailureDiagnoser = diagnoser)
val config = OrchestrationConfig(
retryPolicy = RetryPolicy(maxAttempts = 10, backoffMs = 0, perGateMaxAttempts = mapOf("static_analysis" to 1)),
)
val result = orchestrator.run(sid, capableButStuckGraph(), config)
assertTrue(result is WorkflowResult.Failed)
val diagnoses = eventStore.read(sid).mapNotNull { it.payload as? PostFailureDiagnosedEvent }
assertEquals(1, diagnoses.size, "still one diagnosis attempt, but not routed")
assertEquals("TERMINAL_LOW_CONFIDENCE", diagnoses.single().decision)
assertFalse(diagnoses.single().routed)
}
// impl carries a generative "scaffold" mandate as its own prompt. On the FORWARD visit it applies;
// when the ticket routes impl back to repair its own file, that mandate must be suppressed (else it
// re-scaffolds and stubs the real file). Same topology as ownerGraph otherwise.