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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user