Implement closed-loop workspace follow-ups

This commit is contained in:
2026-07-15 23:48:12 +04:00
parent 3a48ecd24f
commit ed7efb6072
51 changed files with 702 additions and 54 deletions
@@ -398,6 +398,27 @@ class RecoveryRoutingTest {
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(
@@ -520,6 +541,33 @@ class RecoveryRoutingTest {
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.