Files
correx/testing/integration/src/test/kotlin/GateRetryBudgetExhaustionTest.kt
T
kami 879672a47d feat(recovery): failure-ticket routing + review-gate RECOVER + return-to-sender
Retry-agency invariant: a stage may only retry a gate it has the capability
to change. A write-less stage failing a build/contract/static gate (e.g.
freestyle final_verification with allowedTools=[shell]) can never fix it, so
retrying in place is futile until the budget drains (Vikunja #41).

Instead: open a FailureTicketOpenedEvent (category + requiredCapability derived
deterministically from the gate id, no LLM) and route to a recovery stage that
holds the capability, bounded by a small per-stage route budget.

- Slice 2: SalvageDecision.RECOVER (ternary CONTINUE/RECOVER/FAIL) lets the
  review-gate salvage judge hand off to recovery. Shared routeToRecovery()
  unifies the deterministic agency guard and the judge on one destination;
  decideGateExhaustion now returns StepResult?.
- Return-to-sender: recovery's exit is dynamic (recoveryReturnMove) — it goes
  back to the exact ticket-origin stage to re-run its gate, so a write-less
  gate anywhere in the graph is handled without skipping intervening stages.
  The synthesized recovery->terminal edge is now only a no-ticket fallback.
- Freestyle wiring: ExecutionPlanCompiler injectRecovery flag (Main passes
  true) synthesizes a write-capable recovery stage; ticket evidence reaches it
  via buildRecoveryTicketEntry.
- Read-only tools always present: StageConfig.effectiveAllowedTools adds
  file_read/list_dir to any tool-granting stage (fixes the verifier reaching
  for `shell ls -R` to inspect the tree).

Tests: RecoveryRoutingTest (deterministic gate, no static return edge — proves
dynamic return) + GateRetryBudgetExhaustionTest RECOVER case + two compiler
tests. All green; detekt clean.
2026-07-07 12:05:26 +04:00

413 lines
21 KiB
Kotlin

import com.correx.core.approvals.ApprovalOutcome
import com.correx.core.approvals.ApprovalProjector
import com.correx.core.approvals.ApprovalStatus
import com.correx.core.approvals.DefaultApprovalReducer
import com.correx.core.approvals.DefaultApprovalRepository
import com.correx.core.approvals.domain.ApprovalEngine
import com.correx.core.approvals.model.ApprovalContext
import com.correx.core.approvals.model.ApprovalDecision
import com.correx.core.approvals.model.ApprovalGrant
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.ReviewFinding
import com.correx.core.events.events.ReviewFindingsRaisedEvent
import com.correx.core.events.events.ReviewSeverity
import com.correx.core.events.events.ReviewVerdict
import com.correx.core.events.events.FailureTicketOpenedEvent
import com.correx.core.events.events.RetrySalvageDecidedEvent
import com.correx.core.events.events.SalvageDecision
import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.execution.RetryPolicy
import com.correx.core.events.types.ProviderId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId
import com.correx.core.inference.CapabilityScore
import com.correx.core.inference.FinishReason
import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.InferenceRepository
import com.correx.core.inference.InferenceRequest
import com.correx.core.inference.InferenceResponse
import com.correx.core.inference.InferenceRouter
import com.correx.core.inference.InferenceState
import com.correx.core.inference.ModelCapability
import com.correx.core.inference.ProviderHealth
import com.correx.core.inference.Tokenizer
import com.correx.core.inference.ToolCallFunction
import com.correx.core.inference.ToolCallRequest
import com.correx.core.inference.TokenUsage
import com.correx.core.journal.DecisionJournalProjector
import com.correx.core.journal.DefaultDecisionJournalReducer
import com.correx.core.journal.DefaultDecisionJournalRepository
import com.correx.core.kernel.execution.WorkflowResult
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.kernel.orchestration.OrchestrationProjector
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.ReviewOutcome
import com.correx.core.kernel.orchestration.SalvageJudgment
import com.correx.core.kernel.retry.DefaultRetryCoordinator
import com.correx.core.risk.DefaultRiskAssessor
import kotlinx.datetime.Instant
import com.correx.core.sessions.DefaultSessionReducer
import com.correx.core.sessions.DefaultSessionRepository
import com.correx.core.sessions.SessionProjector
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.core.sessions.projections.replay.EventReplayer
import com.correx.core.toolintent.WorkspacePolicy
import com.correx.core.tools.registry.ToolRegistry
import com.correx.core.tools.contract.Tool
import com.correx.core.transitions.graph.StageConfig
import com.correx.core.transitions.graph.TransitionEdge
import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.core.transitions.resolution.DefaultTransitionResolver
import com.correx.core.validation.pipeline.ValidationPipeline
import com.correx.infrastructure.persistence.InMemoryEventStore
import com.correx.infrastructure.tools.SandboxedToolExecutor
import com.correx.infrastructure.tools.filesystem.FileWriteTool
import com.correx.testing.contracts.fixtures.artifactstore.NoopArtifactStore
import com.correx.testing.fixtures.context.ContextFixtures
import com.correx.testing.fixtures.cyclePolicyMissingValidator
import kotlinx.coroutines.runBlocking
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Integration coverage for the per-gate retry-budget + hybrid-salvage exhaustion routing
* (docs/plans/2026-07-06-per-gate-retry-budgets.md, T9). Drives a real single-stage workflow
* through [DefaultSessionOrchestrator] with a real [FileWriteTool] + [SandboxedToolExecutor] so the
* `review` gate's `stageWrittenPaths` manifest is populated from genuine [com.correx.core.events.events.FileWrittenEvent]s,
* not hand-seeded events.
*/
class GateRetryBudgetExhaustionTest {
private val workspace = Files.createTempDirectory("gate-retry-budget-test")
private class FixedToolCallProvider(private val toolName: String) : InferenceProvider {
override val id = ProviderId("fixed-tool-caller")
override val name = "fixed-tool-caller"
override val tokenizer: Tokenizer = com.correx.testing.fixtures.inference.MockTokenizer()
private var callCount = 0
override suspend fun infer(request: InferenceRequest): InferenceResponse {
callCount++
// Odd calls issue the tool call; even calls end the round — this repeats identically
// across stage re-entries, so a retried stage goes through the same tool-call cycle.
return if (callCount % 2 == 1) {
InferenceResponse(
requestId = request.requestId,
text = "",
finishReason = FinishReason.ToolCall,
tokensUsed = TokenUsage(10, 5),
latencyMs = 0,
toolCalls = listOf(
ToolCallRequest(
id = "tc-$callCount",
function = ToolCallFunction(
name = toolName,
arguments = """{"path":"out.txt","content":"attempt-$callCount"}""",
),
),
),
)
} else {
InferenceResponse(
requestId = request.requestId,
text = "done",
finishReason = FinishReason.Stop,
tokensUsed = TokenUsage(10, 5),
latencyMs = 0,
)
}
}
override suspend fun healthCheck(): ProviderHealth = ProviderHealth.Healthy
override fun capabilities(): Set<CapabilityScore> = setOf(CapabilityScore(ModelCapability.General, 1.0))
}
private class SingleToolRegistry(private val tool: Tool) : ToolRegistry {
override fun resolve(name: String): Tool? = if (name == tool.name) tool else null
override fun all(): List<Tool> = listOf(tool)
}
/** Always returns a FAIL verdict with a high-confidence correctness finding — every review-gate attempt blocks. */
private val alwaysFailingReviewer = com.correx.core.kernel.orchestration.SemanticReviewer { _, _, _, _, _ ->
ReviewOutcome(
verdict = ReviewVerdict.FAIL,
findings = listOf(
ReviewFinding(
severity = ReviewSeverity.CRITICAL,
confidence = 0.95,
category = "correctness",
target = "out.txt",
message = "off-by-one in the written content",
correctness = true,
),
),
)
}
// The stage drives a real T2 FileWriteTool; without auto-approval the orchestrator suspends on
// deferred.await() for a tool-call approval that no one resolves in a headless test. Auto-approve
// so execution actually reaches the review gate under test.
private val autoApproveEngine = object : ApprovalEngine {
override fun evaluate(
request: DomainApprovalRequest,
context: ApprovalContext,
grants: List<ApprovalGrant>,
now: Instant,
) = ApprovalDecision(
id = null,
requestId = request.id,
outcome = ApprovalOutcome.AUTO_APPROVED,
state = ApprovalStatus.COMPLETED,
tier = request.tier,
contextSnapshot = context,
resolutionTimestamp = now,
reason = "test-auto-approve",
)
}
private fun buildOrchestrator(
semanticReviewer: com.correx.core.kernel.orchestration.SemanticReviewer?,
salvageJudge: com.correx.core.kernel.orchestration.SalvageJudge?,
): Pair<DefaultSessionOrchestrator, InMemoryEventStore> {
val eventStore = InMemoryEventStore()
val tool = FileWriteTool(allowedPaths = setOf(workspace), workingDir = workspace)
val toolRegistry = SingleToolRegistry(tool)
val executor = SandboxedToolExecutor(
delegate = tool,
registry = toolRegistry,
eventDispatcher = EventDispatcher(eventStore),
workDir = workspace,
artifactStore = NoopArtifactStore(),
)
val provider = FixedToolCallProvider(tool.name)
val inferenceRouter = object : InferenceRouter {
override suspend fun route(stageId: StageId, requiredCapabilities: Set<ModelCapability>) = provider
}
val approvalRepository = DefaultApprovalRepository(
DefaultEventReplayer(eventStore, ApprovalProjector(DefaultApprovalReducer())),
)
val repositories = OrchestratorRepositories(
eventStore = eventStore,
inferenceRepository = InferenceRepository(object : EventReplayer<InferenceState> {
override fun rebuild(sessionId: SessionId) = InferenceState()
}),
orchestrationRepository = OrchestrationRepository(
DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())),
),
sessionRepository = DefaultSessionRepository(
DefaultEventReplayer(eventStore, SessionProjector(DefaultSessionReducer())),
),
artifactRepository = LiveArtifactRepositoryFor(eventStore),
approvalRepository = approvalRepository,
)
val engines = OrchestratorEngines(
transitionResolver = DefaultTransitionResolver { _, _ -> true },
contextPackBuilder = ContextFixtures.simpleBuilder(),
inferenceRouter = inferenceRouter,
validationPipeline = ValidationPipeline(validators = listOf(cyclePolicyMissingValidator())),
approvalEngine = autoApproveEngine,
riskAssessor = DefaultRiskAssessor(),
toolExecutor = executor,
toolRegistry = toolRegistry,
workspacePolicy = WorkspacePolicy(workspace),
semanticReviewer = semanticReviewer,
salvageJudge = salvageJudge,
)
val decisionJournalRepository = DefaultDecisionJournalRepository(
DefaultEventReplayer(eventStore, DecisionJournalProjector(DefaultDecisionJournalReducer())),
)
val orchestrator = DefaultSessionOrchestrator(
repositories = repositories,
engines = engines,
retryCoordinator = DefaultRetryCoordinator(eventStore),
artifactStore = NoopArtifactStore(),
decisionJournalRepository = decisionJournalRepository,
)
return orchestrator to eventStore
}
private fun reviewGraph(): WorkflowGraph = WorkflowGraph(
id = "gate-retry-budget-test",
stages = mapOf(
StageId("A") to StageConfig(allowedTools = setOf("file_write"), semanticReview = true),
),
transitions = setOf(
TransitionEdge(TransitionId("t1"), StageId("A"), StageId("done"), condition = { true }),
),
start = StageId("A"),
)
@Test
fun `deterministic gate exhaustion fails the workflow terminally with no salvage event`(): Unit = runBlocking {
// "produces" gate: a stage declaring artifacts it never emits fails with a stable
// fingerprint every attempt, so the budget is charged every time and exhausts deterministically.
val (orchestrator, eventStore) = buildOrchestrator(semanticReviewer = null, salvageJudge = null)
val sessionId = SessionId("gate-produces-exhaust")
val graph = WorkflowGraph(
id = "produces-exhaust",
stages = mapOf(
StageId("A") to StageConfig(
produces = listOf(
com.correx.core.artifacts.kind.TypedArtifactSlot(
name = com.correx.core.events.types.ArtifactId("never-produced"),
kind = com.correx.core.artifacts.kind.FileWrittenKind,
),
),
),
),
transitions = setOf(TransitionEdge(TransitionId("t1"), StageId("A"), StageId("done"), condition = { true })),
start = StageId("A"),
)
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 2, backoffMs = 0))
val result = orchestrator.run(sessionId, graph, config)
assertTrue(result is WorkflowResult.Failed)
assertTrue((result as WorkflowResult.Failed).retryExhausted)
val events = eventStore.read(sessionId)
assertTrue(events.any { it.payload is WorkflowFailedEvent })
assertTrue(events.none { it.payload is RetrySalvageDecidedEvent }, "deterministic gates never consult the salvage judge")
}
@Test
fun `review gate exhaustion with CONTINUE salvage resets the budget once then fails terminally on the second exhaustion`(): Unit =
runBlocking {
val salvageCalls = mutableListOf<String>()
val salvageJudge = com.correx.core.kernel.orchestration.SalvageJudge { _, _, reason ->
salvageCalls += reason
SalvageJudgment(SalvageDecision.CONTINUE, "worth one more shot")
}
val (orchestrator, eventStore) = buildOrchestrator(alwaysFailingReviewer, salvageJudge)
val sessionId = SessionId("gate-review-continue-then-fail")
val config = OrchestrationConfig(
retryPolicy = RetryPolicy(maxAttempts = 3, backoffMs = 0, perGateMaxAttempts = mapOf("review" to 1)),
)
val result = orchestrator.run(sessionId, reviewGraph(), config)
assertTrue(result is WorkflowResult.Failed, "second exhaustion after a salvage must be terminal")
assertTrue((result as WorkflowResult.Failed).retryExhausted)
// Salvage is consulted exactly once — the second exhaustion skips the judge entirely
// because the gate is already marked salvage-used.
assertEquals(1, salvageCalls.size)
val events = eventStore.read(sessionId)
val salvageEvents = events.mapNotNull { it.payload as? RetrySalvageDecidedEvent }
assertEquals(1, salvageEvents.size)
assertEquals(SalvageDecision.CONTINUE, salvageEvents.single().decision)
assertTrue(events.any { it.payload is WorkflowFailedEvent })
// The review gate blocked (and was retried) more than once, proving the CONTINUE reset worked.
val blockedReviews = events.mapNotNull { it.payload as? ReviewFindingsRaisedEvent }.count { it.blocked }
assertTrue(blockedReviews >= 2, "expected at least 2 blocked review attempts (pre- and post-salvage)")
}
// Stage A opts into the (always-failing) review gate; a sibling recovery stage holds file_write.
// Recovery has NO outgoing edge — the kernel returns it to the ticket origin (A) dynamically.
private fun recoveryReviewGraph(): WorkflowGraph = WorkflowGraph(
id = "gate-review-recover-test",
stages = mapOf(
StageId("A") to StageConfig(allowedTools = setOf("file_write"), semanticReview = true),
StageId("recovery") to StageConfig(
allowedTools = setOf("file_write"),
metadata = mapOf("role" to "recovery"),
),
),
transitions = setOf(
TransitionEdge(TransitionId("t1"), StageId("A"), StageId("done"), condition = { true }),
),
start = StageId("A"),
)
@Test
fun `review gate exhaustion with RECOVER salvage routes to the recovery stage and exhausts the route budget`(): Unit =
runBlocking {
// Slice 2: the salvage judge chooses RECOVER instead of CONTINUE/FAIL, so a review-gate
// exhaustion hands off to the recovery stage (same destination as the deterministic agency
// guard) rather than retrying A in place or failing outright. Bounded by RECOVERY_ROUTE_BUDGET.
val salvageJudge = com.correx.core.kernel.orchestration.SalvageJudge { _, _, _ ->
SalvageJudgment(SalvageDecision.RECOVER, "hand this to recovery")
}
val (orchestrator, eventStore) = buildOrchestrator(alwaysFailingReviewer, salvageJudge)
val sessionId = SessionId("gate-review-recover")
val config = OrchestrationConfig(
retryPolicy = RetryPolicy(maxAttempts = 3, backoffMs = 0, perGateMaxAttempts = mapOf("review" to 1)),
)
val result = orchestrator.run(sessionId, recoveryReviewGraph(), config)
assertTrue(result is WorkflowResult.Failed, "route budget must exhaust terminally")
assertTrue((result as WorkflowResult.Failed).retryExhausted)
val events = eventStore.read(sessionId)
val tickets = events.mapNotNull { it.payload as? FailureTicketOpenedEvent }
assertEquals(2, tickets.size, "one ticket per route, bounded by RECOVERY_ROUTE_BUDGET")
tickets.forEach {
assertEquals("A", it.stageId.value)
assertEquals("review", it.gate)
assertEquals("implementation", it.category)
assertEquals("file_write", it.requiredCapability)
assertEquals("recovery", it.routeTo.value)
}
assertEquals(listOf(1, 2), tickets.map { it.routeAttempt })
// Every salvage decision on this path is RECOVER (judge consulted afresh each re-entry
// because TransitionExecuted resets gateSalvageUsed).
val salvageDecisions = events.mapNotNull { it.payload as? RetrySalvageDecidedEvent }
assertTrue(salvageDecisions.size >= 2, "judge consulted on each review exhaustion")
assertTrue(salvageDecisions.all { it.decision == SalvageDecision.RECOVER })
}
@Test
fun `review gate exhaustion with FAIL salvage is terminal immediately`(): Unit = runBlocking {
val salvageJudge = com.correx.core.kernel.orchestration.SalvageJudge { _, _, _ ->
SalvageJudgment(SalvageDecision.FAIL, "not recoverable")
}
val (orchestrator, eventStore) = buildOrchestrator(alwaysFailingReviewer, salvageJudge)
val sessionId = SessionId("gate-review-fail")
val config = OrchestrationConfig(
retryPolicy = RetryPolicy(maxAttempts = 3, backoffMs = 0, perGateMaxAttempts = mapOf("review" to 1)),
)
val result = orchestrator.run(sessionId, reviewGraph(), config)
assertTrue(result is WorkflowResult.Failed)
assertTrue((result as WorkflowResult.Failed).retryExhausted)
val events = eventStore.read(sessionId)
val salvageEvents = events.mapNotNull { it.payload as? RetrySalvageDecidedEvent }
assertEquals(1, salvageEvents.size)
assertEquals(SalvageDecision.FAIL, salvageEvents.single().decision)
assertTrue(events.any { it.payload is WorkflowFailedEvent })
}
@Test
fun `no salvage judge wired falls back to the deterministic allow-one-reset-then-fail policy`(): Unit = runBlocking {
val (orchestrator, eventStore) = buildOrchestrator(alwaysFailingReviewer, salvageJudge = null)
val sessionId = SessionId("gate-review-no-judge")
val config = OrchestrationConfig(
retryPolicy = RetryPolicy(maxAttempts = 3, backoffMs = 0, perGateMaxAttempts = mapOf("review" to 1)),
)
val result = orchestrator.run(sessionId, reviewGraph(), config)
assertTrue(result is WorkflowResult.Failed, "even the null-judge fallback is only a one-time reset")
val events = eventStore.read(sessionId)
val salvageEvents = events.mapNotNull { it.payload as? RetrySalvageDecidedEvent }
assertEquals(1, salvageEvents.size)
assertEquals(SalvageDecision.CONTINUE, salvageEvents.single().decision)
assertTrue(events.any { it.payload is WorkflowFailedEvent })
}
}
private fun LiveArtifactRepositoryFor(eventStore: InMemoryEventStore) =
com.correx.infrastructure.persistence.artifact.LiveArtifactRepository(eventStore, DefaultArtifactReducer())