1b58bc325e
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>
700 lines
37 KiB
Kotlin
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
|
|
}
|
|
}
|