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": [] }"""