Files
correx/testing/integration/src/test/kotlin/RecoveryRoutingTest.kt
T
kami 1b58bc325e wip(freestyle/acr): grounding & edit-tool fixes + ACR-compiler experiment
This branch's uncommitted WIP, committed together (entangled at file level).
Distinct pieces of work:

Freestyle QA fixes (this session):
- FileEditTool: pre-validate replace anchor in validateRequest — reject a
  missing/ambiguous target BEFORE the approval gate, mirroring read/write's
  file-not-found / read-before-write pre-checks. Shared not-found/ambiguous
  messages between validate and execute so they can't drift.
- PlanGrounder: add `scanned` flag; when no RepoMapComputedEvent was recorded,
  repoMapPaths is "unknown" not "empty workspace" — skip scope grounding
  (which proves a path ABSENT) so real paths (apps/server/**) aren't falsely
  rejected. Build-manifest check still runs.
- FreestyleDriver: wire scanned=(repoMap!=null); on plan rejection emit a
  session-terminal WorkflowFailedEvent so a rejected run reads FAILED, not the
  COMPLETED-lie (last verdict was the planning-phase WorkflowCompleted).
- ServerModule: resolve project-memory workspace root from the session's bound
  workspace (sessionWorkspaceRoot) instead of boot-static pm.repoRoot(), fixing
  the workspace-binding divergence (correx vs empty scratch dir). Retire tracked
  in Vikunja #266.
- LaunchRegistrationRaceTest: join registered jobs before asserting launchCount
  — computeIfAbsent returns the Job immediately but the fire-and-forget launch
  body lagged awaitAll (the 49-vs-50 flake).

ACR concept-compiler experiment (pre-existing WIP on this branch):
- ExecutionPlanCompiler/Model/PlanLinter, #264 needs-seam (sessionArtifacts),
  LSP diagnostics subsystem (LspDiagnosticEvents/Runner/Lsp4j), BootWorkspace,
  config surface, workflow prompts/schemas, orchestrator advance-don't-rerun.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 01:20:37 +04:00

700 lines
37 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.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<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)
}
private fun buildOrchestrator(
staticAnalysis: StaticAnalysisRunner =
StaticAnalysisRunner { _, _ -> StaticAnalysisRunResult(exitCode = 1, output = "TS5023: bad option") },
): Pair<DefaultSessionOrchestrator, InMemoryEventStore> {
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<ModelCapability>) = provider
}
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 = 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<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",
)
},
)
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<String, Int>()
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<CapabilityScore> = 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<Set<String>>()
private val counts = mutableMapOf<String, Int>()
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<CapabilityScore> = setOf(CapabilityScore(ModelCapability.General, 1.0))
}
private class MultiToolRegistry(private val tools: List<Tool>) : ToolRegistry {
override fun resolve(name: String): Tool? = tools.firstOrNull { it.name == name }
override fun all(): List<Tool> = 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<DefaultSessionOrchestrator, InMemoryEventStore> {
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<ModelCapability>) = provider
}
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 = 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<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",
)
},
)
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<TransitionExecutedEvent>()
.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<FailureTicketOpenedEvent>()
.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
}
}