From a4f0c6d0a284e5b6061f2e311e88758318e13d20 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 12 Jun 2026 12:55:17 +0400 Subject: [PATCH] fix(workflow): reject execution plans with unreachable stages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../workflows/prompts/architect_freestyle.md | 7 ++- .../workflow/ExecutionPlanCompiler.kt | 19 ++++++++ .../workflow/ExecutionPlanCompilerTest.kt | 45 +++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/examples/workflows/prompts/architect_freestyle.md b/examples/workflows/prompts/architect_freestyle.md index 60bb2ec3..66e88db6 100644 --- a/examples/workflows/prompts/architect_freestyle.md +++ b/examples/workflows/prompts/architect_freestyle.md @@ -67,14 +67,17 @@ Emit a JSON object that validates against the `execution_plan` schema: - `from` and `to` must each be a declared stage `id` or the literal string `"done"`. - The normal forward edge uses `"type": "artifact_validated"` with `artifact_id` set to the `produces` id of the `from` stage. +- Chain sequential stages: stage N's edge goes `to` stage N+1, not to `"done"`. Only the + final stage's edge points to `"done"`. A plan where an intermediate stage jumps to + `"done"` ends the whole workflow there and never runs the remaining stages. - For a review loop (implementer ↔ reviewer): emit two conditional edges from the reviewer stage: - `"type": "artifact_field_equals"`, `artifact_id`: reviewer's produces id, `field`: `"verdict"`, `value`: `"approved"`, `operator`: `"eq"` → `to: "done"` - `"type": "artifact_field_equals"`, `artifact_id`: reviewer's produces id, `field`: `"verdict"`, `value`: `"approved"`, `operator`: `"neq"` → `to: ""` -- Every stage reachable from `"done"` must have an inbound edge. Every non-terminal stage - must have an outbound edge. +- Every stage except the first must have an inbound edge from an earlier stage; every + stage must have an outbound edge. Unreachable stages fail to compile. ## Constraints diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt index d3c91a3a..97e9d6b7 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt @@ -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) } } diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt index f054e1d2..0eb21285 100644 --- a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt @@ -129,6 +129,51 @@ class ExecutionPlanCompilerTest { assertThrows { 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 { 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": [] }"""