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.FailureTicketOpenedEvent import com.correx.core.events.events.StaticAnalysisCompletedEvent import com.correx.core.events.events.TransitionExecutedEvent import com.correx.core.events.events.WorkflowCompletedEvent 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.StaticAnalysisRunResult import com.correx.core.kernel.orchestration.StaticAnalysisRunner import com.correx.core.kernel.retry.DefaultRetryCoordinator import com.correx.core.risk.DefaultRiskAssessor 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.contract.Tool import com.correx.core.tools.contract.ToolExecutor import com.correx.core.tools.contract.ToolResult import com.correx.core.events.events.ToolRequest import com.correx.core.tools.registry.ToolRegistry import com.correx.infrastructure.tools.filesystem.FileWriteTool 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.shell.ShellTool 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 kotlinx.datetime.Instant import java.nio.file.Files import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue /** * Retry-agency / recovery-routing coverage. A write-less `verify` stage runs `shell true` (so it * passes the producer gate, exactly as #41's `final_verification` ran shell) then fails a * `static_analysis` gate (fake runner always non-clean). Because `verify`'s allowedTools lack the * `file_write` capability that gate requires, the orchestrator must NOT retry it in place (futile) — * it opens a [FailureTicketOpenedEvent] and routes to the `recovery` stage (marked * `metadata["role"]="recovery"`), which loops back to `verify`. After RECOVERY_ROUTE_BUDGET routes * the run fails terminally. */ class RecoveryRoutingTest { private val workspace = Files.createTempDirectory("recovery-routing-test") /** Odd calls issue a `shell true` tool call; even calls end the round — repeats across re-entries. */ private class ShellThenStopProvider : InferenceProvider { override val id = ProviderId("shell-caller") override val name = "shell-caller" override val tokenizer: Tokenizer = com.correx.testing.fixtures.inference.MockTokenizer() private var callCount = 0 override suspend fun infer(request: InferenceRequest): InferenceResponse { callCount++ 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 = "shell", arguments = """{"argv":["true"]}"""), ), ), ) } 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 = 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 = listOf(tool) } private fun buildOrchestrator( staticAnalysis: StaticAnalysisRunner = StaticAnalysisRunner { _, _ -> StaticAnalysisRunResult(exitCode = 1, output = "TS5023: bad option") }, ): Pair { val eventStore = InMemoryEventStore() val shell = ShellTool(allowedExecutables = setOf("true"), workingDir = workspace) val toolRegistry = SingleToolRegistry(shell) val executor = SandboxedToolExecutor( delegate = shell, registry = toolRegistry, eventDispatcher = EventDispatcher(eventStore), workDir = workspace, artifactStore = NoopArtifactStore(), ) val provider = ShellThenStopProvider() val inferenceRouter = object : InferenceRouter { override suspend fun route(stageId: StageId, requiredCapabilities: Set) = provider } val repositories = OrchestratorRepositories( eventStore = eventStore, inferenceRepository = InferenceRepository(object : EventReplayer { override fun rebuild(sessionId: SessionId) = InferenceState() }), orchestrationRepository = OrchestrationRepository( DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())), ), sessionRepository = DefaultSessionRepository( DefaultEventReplayer(eventStore, SessionProjector(DefaultSessionReducer())), ), artifactRepository = com.correx.infrastructure.persistence.artifact.LiveArtifactRepository( eventStore, DefaultArtifactReducer(), ), approvalRepository = DefaultApprovalRepository( DefaultEventReplayer(eventStore, ApprovalProjector(DefaultApprovalReducer())), ), ) val engines = OrchestratorEngines( transitionResolver = DefaultTransitionResolver { _, _ -> true }, contextPackBuilder = ContextFixtures.simpleBuilder(), inferenceRouter = inferenceRouter, validationPipeline = ValidationPipeline(validators = listOf(cyclePolicyMissingValidator())), riskAssessor = DefaultRiskAssessor(), toolExecutor = executor, toolRegistry = toolRegistry, workspacePolicy = WorkspacePolicy(workspace), staticAnalysisRunner = staticAnalysis, approvalEngine = object : ApprovalEngine { override fun evaluate( request: DomainApprovalRequest, context: ApprovalContext, grants: List, 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", ) }, ) 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 } // verify (write-less, failing static gate) --t-verify--> done // recovery (role=recovery, holds file_write) has NO outgoing edge on purpose: the kernel returns // it to the ticket's origin stage (recoveryReturnMove), so the recovery->verify loop is driven // dynamically. If the return-to-sender path regresses, recovery dead-ends and the route count is // wrong — this graph would fail rather than silently pass on a stale static edge. private fun recoveryGraph(): WorkflowGraph = WorkflowGraph( id = "recovery-routing-test", stages = mapOf( StageId("verify") to StageConfig( allowedTools = setOf("shell"), staticAnalysis = listOf("typecheck"), ), StageId("recovery") to StageConfig( allowedTools = setOf("shell", "file_write"), metadata = mapOf("role" to "recovery"), ), ), transitions = setOf( TransitionEdge(TransitionId("t-verify"), StageId("verify"), StageId("done"), condition = { true }), ), start = StageId("verify"), ) @Test fun `write-less gate failure routes to recovery and exhausts its own route budget`(): Unit = runBlocking { val (orchestrator, eventStore) = buildOrchestrator() val sessionId = SessionId("recovery-route") // Generous in-place per-gate budget proves routing pre-empts it: without the agency guard, // static_analysis would retry verify in place (never able to change the outcome) instead. val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 10, backoffMs = 0)) val result = orchestrator.run(sessionId, recoveryGraph(), 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("verify", it.stageId.value) assertEquals("static_analysis", it.gate) assertEquals("implementation", it.category) assertEquals("file_write", it.requiredCapability) assertEquals("recovery", it.routeTo.value) } assertEquals(listOf(1, 2), tickets.map { it.routeAttempt }) assertTrue(events.any { it.payload is WorkflowFailedEvent }) } @Test fun `recovery whose failure keeps changing is not charged the route budget (progress-aware)`(): Unit = runBlocking { // Each verify run surfaces a DIFFERENT failure (distinct non-digit words so FailureFingerprint, // which collapses digit runs, actually sees a changed fingerprint). This models a genuine // multi-cause repair: recovery fixes one cause, the gate now fails on the next. Progress must // NOT charge the small route budget — so the run routes to recovery MORE than // RECOVERY_ROUTE_BUDGET times and is instead bounded by the recovery->verify refinement guard. val variants = listOf("cannot resolve module alpha", "cannot resolve module beta", "cannot resolve module gamma", "cannot resolve module delta", "cannot resolve module epsilon") var call = 0 val changingStaticAnalysis = StaticAnalysisRunner { _, _ -> StaticAnalysisRunResult(exitCode = 1, output = variants[minOf(call++, variants.lastIndex)]) } val (orchestrator, eventStore) = buildOrchestrator(changingStaticAnalysis) val sessionId = SessionId("recovery-progress") val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 10, backoffMs = 0)) val result = orchestrator.run(sessionId, recoveryGraph(), config) assertTrue(result is WorkflowResult.Failed, "still terminates — via the refinement guard, not the route budget") val tickets = eventStore.read(sessionId).mapNotNull { it.payload as? FailureTicketOpenedEvent } assertTrue(tickets.size > RECOVERY_ROUTE_BUDGET_TEST, "progress routes are free, so more than the budget of routes happen: got ${tickets.size}") // Every route progressed (changed fingerprint) except the first, so the charged count never // climbs past its initial 1 — the old unconditional charging would have read [1, 2] then died. assertEquals(1, tickets.map { it.routeAttempt }.max(), "no-progress charge count must never exceed 1 when every round progresses") assertTrue(tickets.map { it.fingerprint }.toSet().size == tickets.size, "each route recorded a distinct fingerprint") } /** Emits a tool call on odd infers, ends the round on even — but the tool depends on the stage: * `impl` writes a file (so it becomes the file's author), any other stage runs `shell true`. */ private class StageRoutingProvider : InferenceProvider { override val id = ProviderId("stage-router") override val name = "stage-router" override val tokenizer: Tokenizer = com.correx.testing.fixtures.inference.MockTokenizer() private val counts = mutableMapOf() override suspend fun infer(request: InferenceRequest): InferenceResponse { val stage = request.stageId.value val n = (counts[stage] ?: 0) + 1 counts[stage] = n if (n % 2 == 0) { return InferenceResponse( requestId = request.requestId, text = "done", finishReason = FinishReason.Stop, tokensUsed = TokenUsage(10, 5), latencyMs = 0, ) } val (tool, args) = if (stage == "impl") { "file_write" to """{"path":"App.tsx","content":"export const x = 1;\n"}""" } else { "shell" to """{"argv":["true"]}""" } return InferenceResponse( requestId = request.requestId, text = "", finishReason = FinishReason.ToolCall, tokensUsed = TokenUsage(10, 5), latencyMs = 0, toolCalls = listOf( ToolCallRequest(id = "tc-$stage-$n", function = ToolCallFunction(name = tool, arguments = args)), ), ) } override suspend fun healthCheck(): ProviderHealth = ProviderHealth.Healthy override fun capabilities(): Set = setOf(CapabilityScore(ModelCapability.General, 1.0)) } /** Like [StageRoutingProvider], but records the context-entry sourceTypes it saw on each `impl` * inference — so a test can assert whether impl's own prompt (agentPrompt) was present. */ private class RecordingRoutingProvider : InferenceProvider { override val id = ProviderId("recording-router") override val name = "recording-router" override val tokenizer: Tokenizer = com.correx.testing.fixtures.inference.MockTokenizer() val implSourceTypes = mutableListOf>() private val counts = mutableMapOf() override suspend fun infer(request: InferenceRequest): InferenceResponse { val stage = request.stageId.value if (stage == "impl") { implSourceTypes.add(request.contextPack.layers.values.flatten().map { it.sourceType }.toSet()) } val n = (counts[stage] ?: 0) + 1 counts[stage] = n if (n % 2 == 0) { return InferenceResponse( requestId = request.requestId, text = "done", finishReason = FinishReason.Stop, tokensUsed = TokenUsage(10, 5), latencyMs = 0, ) } val (tool, args) = if (stage == "impl") { "file_write" to """{"path":"App.tsx","content":"export const x = 1;\n"}""" } else { "shell" to """{"argv":["true"]}""" } return InferenceResponse( requestId = request.requestId, text = "", finishReason = FinishReason.ToolCall, tokensUsed = TokenUsage(10, 5), latencyMs = 0, toolCalls = listOf( ToolCallRequest(id = "tc-$stage-$n", function = ToolCallFunction(name = tool, arguments = args)), ), ) } override suspend fun healthCheck(): ProviderHealth = ProviderHealth.Healthy override fun capabilities(): Set = setOf(CapabilityScore(ModelCapability.General, 1.0)) } private class MultiToolRegistry(private val tools: List) : ToolRegistry { override fun resolve(name: String): Tool? = tools.firstOrNull { it.name == name } override fun all(): List = tools } // impl (file_write) --t-impl--> verify (write-less, failing static gate naming impl's file) --> done // No `recovery` stage exists: the ticket must route to the file's AUTHOR (impl) via route-to-owner, // not to a generic recovery stage. impl repairs and returns to verify (ticketReturnMove). private fun ownerGraph(): WorkflowGraph = WorkflowGraph( id = "route-to-owner-test", stages = mapOf( StageId("impl") to StageConfig(allowedTools = setOf("file_write")), StageId("verify") to StageConfig(allowedTools = setOf("shell"), staticAnalysis = listOf("typecheck")), ), transitions = setOf( TransitionEdge(TransitionId("t-impl"), StageId("impl"), StageId("verify"), condition = { true }), TransitionEdge(TransitionId("t-verify"), StageId("verify"), StageId("done"), condition = { true }), ), start = StageId("impl"), ) // impl (file_write) + verify + a recovery stage (role=recovery, holds file_write). Tier 1 routes to // the author (impl); when its budget exhausts, tier 2 escalates to the arbiter (recovery). private fun ownerAndArbiterGraph(): WorkflowGraph = WorkflowGraph( id = "route-to-owner-escalation-test", stages = mapOf( StageId("impl") to StageConfig(allowedTools = setOf("file_write")), StageId("verify") to StageConfig(allowedTools = setOf("shell"), staticAnalysis = listOf("typecheck")), StageId("recovery") to StageConfig( allowedTools = setOf("shell", "file_write"), metadata = mapOf("role" to "recovery"), ), ), transitions = setOf( TransitionEdge(TransitionId("t-impl"), StageId("impl"), StageId("verify"), condition = { true }), TransitionEdge(TransitionId("t-verify"), StageId("verify"), StageId("done"), condition = { true }), ), start = StageId("impl"), ) // impl owns file_write but repeatedly produces the same static-analysis failure. Capability // possession alone is not progress: after the per-gate retry budget is spent, this must still // reach the recovery/intent-holder stage instead of terminating impl in place. private fun capableButStuckGraph(): WorkflowGraph = WorkflowGraph( id = "capable-but-stuck-test", stages = mapOf( StageId("impl") to StageConfig( allowedTools = setOf("file_write"), staticAnalysis = listOf("typecheck"), ), StageId("recovery") to StageConfig( allowedTools = setOf("shell", "file_write"), metadata = mapOf("role" to "recovery"), ), ), transitions = setOf( TransitionEdge(TransitionId("t-impl"), StageId("impl"), StageId("done"), condition = { true }), ), start = StageId("impl"), ) /** Builds an orchestrator whose only writing stage is `impl` (via file_write) and whose `verify` * static gate fails with [staticOutput] — so route-to-owner can resolve `impl` as the file author. */ private fun buildMultiToolOrchestrator( staticOutput: String = "App.tsx: TS2322 type mismatch", provider: InferenceProvider = StageRoutingProvider(), staticExitCode: Int = 1, ): Pair { val eventStore = InMemoryEventStore() val shell = ShellTool(allowedExecutables = setOf("true"), workingDir = workspace) val fileWrite = FileWriteTool(allowedPaths = setOf(workspace), workingDir = workspace) val toolRegistry = MultiToolRegistry(listOf(shell, fileWrite)) // Dispatch delegate: SandboxedToolExecutor resolves the tool from the registry, then hands the // request to this delegate — which routes to the matching tool by name. val dispatch = object : ToolExecutor { override suspend fun execute(request: ToolRequest): ToolResult = (toolRegistry.resolve(request.toolName) as ToolExecutor).execute(request) } val executor = SandboxedToolExecutor( delegate = dispatch, registry = toolRegistry, eventDispatcher = EventDispatcher(eventStore), workDir = workspace, artifactStore = NoopArtifactStore(), ) val inferenceRouter = object : InferenceRouter { override suspend fun route(stageId: StageId, requiredCapabilities: Set) = provider } val repositories = OrchestratorRepositories( eventStore = eventStore, inferenceRepository = InferenceRepository(object : EventReplayer { override fun rebuild(sessionId: SessionId) = InferenceState() }), orchestrationRepository = OrchestrationRepository( DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())), ), sessionRepository = DefaultSessionRepository( DefaultEventReplayer(eventStore, SessionProjector(DefaultSessionReducer())), ), artifactRepository = com.correx.infrastructure.persistence.artifact.LiveArtifactRepository( eventStore, DefaultArtifactReducer(), ), approvalRepository = DefaultApprovalRepository( DefaultEventReplayer(eventStore, ApprovalProjector(DefaultApprovalReducer())), ), ) val engines = OrchestratorEngines( transitionResolver = DefaultTransitionResolver { _, _ -> true }, contextPackBuilder = ContextFixtures.simpleBuilder(), inferenceRouter = inferenceRouter, validationPipeline = ValidationPipeline(validators = listOf(cyclePolicyMissingValidator())), riskAssessor = DefaultRiskAssessor(), toolExecutor = executor, toolRegistry = toolRegistry, workspacePolicy = WorkspacePolicy(workspace), // Gate names the file impl wrote, so route-to-owner can resolve the author from the manifest. staticAnalysisRunner = StaticAnalysisRunner { _, _ -> StaticAnalysisRunResult(exitCode = staticExitCode, output = staticOutput) }, approvalEngine = object : ApprovalEngine { override fun evaluate( request: DomainApprovalRequest, context: ApprovalContext, grants: List, 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", ) }, ) val orchestrator = DefaultSessionOrchestrator( repositories = repositories, engines = engines, retryCoordinator = DefaultRetryCoordinator(eventStore), artifactStore = NoopArtifactStore(), decisionJournalRepository = DefaultDecisionJournalRepository( DefaultEventReplayer(eventStore, DecisionJournalProjector(DefaultDecisionJournalReducer())), ), ) return orchestrator to eventStore } @Test fun `non-writing review terminal records static analysis before workflow completion`(): Unit = runBlocking { val (orchestrator, eventStore) = buildMultiToolOrchestrator( staticOutput = "static floor clean", staticExitCode = 0, ) val sessionId = SessionId("review-terminal-static-floor") val graph = WorkflowGraph( id = "freestyle-review-terminal", stages = mapOf( StageId("impl") to StageConfig( allowedTools = setOf("file_write"), writeManifest = listOf("App.tsx"), ), StageId("review") to StageConfig( allowedTools = setOf("shell"), staticAnalysis = listOf("correx-static-floor -- App.tsx"), autoBuildGate = true, metadata = mapOf("role" to "reviewer"), ), ), transitions = setOf( TransitionEdge(TransitionId("impl->review"), StageId("impl"), StageId("review")) { true }, TransitionEdge(TransitionId("review->done"), StageId("review"), StageId("done")) { true }, ), start = StageId("impl"), ) orchestrator.run(sessionId, graph, OrchestrationConfig()) val payloads = eventStore.read(sessionId).map { it.payload } val staticIndex = payloads.indexOfFirst { it is StaticAnalysisCompletedEvent && it.stageId == StageId("review") } val completedIndex = payloads.indexOfFirst { it is WorkflowCompletedEvent } assertTrue(staticIndex >= 0, "the non-writing review terminal must execute its static floor") assertTrue(completedIndex > staticIndex, "static analysis must be durably recorded before completion") } @Test fun `review loop routes to recovery after exactly three back edges and never emits a fourth`(): Unit = runBlocking { val (orchestrator, eventStore) = buildMultiToolOrchestrator() val sessionId = SessionId("review-loop-convergence") val graph = WorkflowGraph( id = "review-loop-convergence", stages = mapOf( StageId("impl") to StageConfig(allowedTools = setOf("file_write")), StageId("review") to StageConfig( allowedTools = setOf("shell"), metadata = mapOf("role" to "reviewer"), ), StageId("recovery") to StageConfig( allowedTools = setOf("shell", "file_write"), metadata = mapOf("role" to "recovery"), ), ), transitions = setOf( TransitionEdge(TransitionId("impl->review"), StageId("impl"), StageId("review")) { true }, TransitionEdge(TransitionId("review->impl"), StageId("review"), StageId("impl")) { true }, ), start = StageId("impl"), ) val result = orchestrator.run(sessionId, graph, OrchestrationConfig()) assertTrue(result is WorkflowResult.Failed) val payloads = eventStore.read(sessionId).map { it.payload } val reviewBackEdges = payloads.filterIsInstance() .filter { it.from == StageId("review") && it.to == StageId("impl") } assertEquals(3, reviewBackEdges.size, "the fourth rejected review must not enter rework") val ticket = payloads.filterIsInstance() .single { it.gate == "review_loop" } assertEquals(StageId("review"), ticket.stageId) assertEquals(StageId("recovery"), ticket.routeTo) assertTrue(payloads.any { it is WorkflowFailedEvent }) } @Test fun `write-less gate failure routes the ticket to the file's author (route-to-owner)`(): Unit = runBlocking { val (orchestrator, eventStore) = buildMultiToolOrchestrator() val sessionId = SessionId("route-to-owner") val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 10, backoffMs = 0)) val result = orchestrator.run(sessionId, ownerGraph(), config) assertTrue(result is WorkflowResult.Failed, "route budget still exhausts terminally") val tickets = eventStore.read(sessionId).mapNotNull { it.payload as? FailureTicketOpenedEvent } assertTrue(tickets.isNotEmpty(), "verify's write-less gate failure must open a ticket") tickets.forEach { assertEquals("verify", it.stageId.value, "the gate that failed is verify") assertEquals("impl", it.routeTo.value, "ticket routes to the file's author, not a recovery stage") assertEquals("file_write", it.requiredCapability) assertTrue(!it.escalated, "no recovery stage exists, so all routes stay tier-1 (owner)") } } @Test fun `owner budget exhaustion escalates the ticket to the arbiter (intent-holder)`(): Unit = runBlocking { // Same constant failure every round, so neither tier makes progress: the owner (impl) burns its // RECOVERY_ROUTE_BUDGET, THEN the ticket escalates to the arbiter (recovery) for INTENT_ROUTE_BUDGET // more routes, THEN terminal. Proves the two-tier ladder: author first, intent-holder as escalation. val (orchestrator, eventStore) = buildMultiToolOrchestrator() val sessionId = SessionId("owner-escalation") val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 20, backoffMs = 0)) val result = orchestrator.run(sessionId, ownerAndArbiterGraph(), config) assertTrue(result is WorkflowResult.Failed, "ladder still exhausts terminally") val tickets = eventStore.read(sessionId).mapNotNull { it.payload as? FailureTicketOpenedEvent } val tier1 = tickets.filter { !it.escalated } val tier2 = tickets.filter { it.escalated } assertEquals(RECOVERY_ROUTE_BUDGET_TEST, tier1.size, "tier-1 spends the owner budget first") tier1.forEach { assertEquals("impl", it.routeTo.value, "tier-1 routes to the file's author") } assertEquals(INTENT_ROUTE_BUDGET_TEST, tier2.size, "then tier-2 spends the arbiter budget") tier2.forEach { assertEquals("recovery", it.routeTo.value, "tier-2 escalates to the arbiter") } // ordering: every tier-1 ticket precedes every tier-2 ticket (escalation only after owner exhaust) val firstEscalatedIdx = tickets.indexOfFirst { it.escalated } assertTrue(tickets.take(firstEscalatedIdx).none { it.escalated }, "owner routes come before escalation") } @Test fun `capable stage with unchanged failure exhausts to the recovery arbiter`(): Unit = runBlocking { val (orchestrator, eventStore) = buildMultiToolOrchestrator( staticOutput = "App.tsx: TS2322 type mismatch", ) val sessionId = SessionId("capable-but-stuck") val config = OrchestrationConfig( retryPolicy = RetryPolicy( maxAttempts = 10, backoffMs = 0, perGateMaxAttempts = mapOf("static_analysis" to 1), ), ) val result = orchestrator.run(sessionId, capableButStuckGraph(), config) assertTrue(result is WorkflowResult.Failed, "the bounded repair ladder must still terminate") val tickets = eventStore.read(sessionId).mapNotNull { it.payload as? FailureTicketOpenedEvent } assertEquals(RECOVERY_ROUTE_BUDGET_TEST, tickets.size) tickets.forEach { assertEquals("impl", it.stageId.value) assertEquals("recovery", it.routeTo.value) assertEquals("static_analysis", it.gate) assertTrue(it.escalated, "a self-owned failure must escalate directly to the intent-holder") } } // 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. private fun ownerGraphWithMandate(): WorkflowGraph = WorkflowGraph( id = "owner-mandate-suppression-test", stages = mapOf( StageId("impl") to StageConfig( allowedTools = setOf("file_write"), metadata = mapOf("promptInline" to "Scaffold the entire project by creating placeholder files."), ), StageId("verify") to StageConfig(allowedTools = setOf("shell"), staticAnalysis = listOf("typecheck")), ), transitions = setOf( TransitionEdge(TransitionId("t-impl"), StageId("impl"), StageId("verify"), condition = { true }), TransitionEdge(TransitionId("t-verify"), StageId("verify"), StageId("done"), condition = { true }), ), start = StageId("impl"), ) @Test fun `ticket re-entry suppresses the owner stage's own generative mandate`(): Unit = runBlocking { val provider = RecordingRoutingProvider() val (orchestrator, _) = buildMultiToolOrchestrator(provider = provider) val sessionId = SessionId("mandate-suppression") val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 10, backoffMs = 0)) orchestrator.run(sessionId, ownerGraphWithMandate(), config) val impl = provider.implSourceTypes assertTrue(impl.isNotEmpty(), "impl must have run") // Forward visit: impl's own prompt is present, no ticket yet. assertTrue(impl.first().contains("agentPrompt"), "forward visit keeps the stage's own prompt") assertTrue("recoveryTicket" !in impl.first(), "no recovery ticket before any failure") // Ticket re-entry: the generative mandate is gone, the recovery ticket is the sole mandate. assertTrue( impl.any { "recoveryTicket" in it && "agentPrompt" !in it }, "a repair re-entry dropped the generative mandate and carried only the recovery ticket; saw $impl", ) } private companion object { const val INTENT_ROUTE_BUDGET_TEST = 2 // Mirror of DefaultSessionOrchestrator.RECOVERY_ROUTE_BUDGET (private there); kept in sync by the // `[1, 2]` assertion in the exhaustion test above. const val RECOVERY_ROUTE_BUDGET_TEST = 2 } }