fix(workflow): reject execution plans with unreachable stages
Live repro: the architect wired all three stages straight to 'done' instead of chaining them, so phase 2 legitimately completed after stage 1 of 3 — a silently truncated plan reported as success. The compiler now walks the edge graph from start and fails compilation when any declared stage is unreachable, surfacing the broken topology as ExecutionPlanRejected at lock time instead. Architect prompt edge rules rewritten to demand explicit chaining (the old wording was garbled).
This commit is contained in:
+19
@@ -66,6 +66,25 @@ class ExecutionPlanCompiler(
|
||||
}.toSet()
|
||||
|
||||
val start = StageId(plan.stages.first().id)
|
||||
// A declared stage with no path from start can never run — typical LLM topology
|
||||
// failure: every stage wired straight to "done" instead of chained, which silently
|
||||
// truncates the plan to its first stage.
|
||||
val reachable = mutableSetOf(start.value)
|
||||
var frontier = setOf(start.value)
|
||||
while (frontier.isNotEmpty()) {
|
||||
frontier = transitions
|
||||
.filter { it.from.value in frontier }
|
||||
.map { it.to.value }
|
||||
.filter { it != TERMINAL && reachable.add(it) }
|
||||
.toSet()
|
||||
}
|
||||
val unreachable = declared - reachable
|
||||
if (unreachable.isNotEmpty()) {
|
||||
throw WorkflowValidationException(
|
||||
"stages unreachable from start '${start.value}': ${unreachable.sorted().joinToString(", ")} — " +
|
||||
"chain stages with edges instead of pointing every stage to 'done'",
|
||||
)
|
||||
}
|
||||
return WorkflowGraph(id = workflowId, stages = stageMap, transitions = transitions, start = start)
|
||||
}
|
||||
}
|
||||
|
||||
+45
@@ -129,6 +129,51 @@ class ExecutionPlanCompilerTest {
|
||||
assertThrows<WorkflowValidationException> { compiler.compile(plan, "bad-workflow") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plan with unreachable stages throws WorkflowValidationException`() {
|
||||
// Live repro (2026-06-12): the architect wired every stage straight to "done"
|
||||
// instead of chaining, so the workflow completed after stage 1 of 3.
|
||||
val plan = """
|
||||
{
|
||||
"goal": "create three files",
|
||||
"stages": [
|
||||
{ "id": "create_1", "prompt": "p1", "produces": "f1", "kind": "file_written" },
|
||||
{ "id": "create_2", "prompt": "p2", "produces": "f2", "kind": "file_written" },
|
||||
{ "id": "create_3", "prompt": "p3", "produces": "f3", "kind": "file_written" }
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "create_1", "to": "done", "condition": { "type": "artifact_validated", "artifact_id": "f1" } },
|
||||
{ "from": "create_2", "to": "done", "condition": { "type": "artifact_validated", "artifact_id": "f2" } },
|
||||
{ "from": "create_3", "to": "done", "condition": { "type": "artifact_validated", "artifact_id": "f3" } }
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
val ex = assertThrows<WorkflowValidationException> { compiler.compile(plan, "bad-workflow") }
|
||||
assertEquals(true, ex.message?.contains("unreachable"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chained plan with single terminal edge compiles`() {
|
||||
val plan = """
|
||||
{
|
||||
"goal": "create three files",
|
||||
"stages": [
|
||||
{ "id": "create_1", "prompt": "p1", "produces": "f1", "kind": "file_written" },
|
||||
{ "id": "create_2", "prompt": "p2", "produces": "f2", "kind": "file_written" },
|
||||
{ "id": "create_3", "prompt": "p3", "produces": "f3", "kind": "file_written" }
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "create_1", "to": "create_2", "condition": { "type": "artifact_validated", "artifact_id": "f1" } },
|
||||
{ "from": "create_2", "to": "create_3", "condition": { "type": "artifact_validated", "artifact_id": "f2" } },
|
||||
{ "from": "create_3", "to": "done", "condition": { "type": "artifact_validated", "artifact_id": "f3" } }
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
val graph = compiler.compile(plan, "chained-workflow")
|
||||
assertEquals(3, graph.stages.size)
|
||||
assertEquals("create_1", graph.start.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty stages throws WorkflowValidationException`() {
|
||||
val bad = """{ "goal": "x", "stages": [], "edges": [] }"""
|
||||
|
||||
Reference in New Issue
Block a user