refactor: decomposition WIP (orchestrator/server/execution-plan)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhFXmKe4WisSSPf9LrmmTg
This commit is contained in:
2026-07-15 13:17:01 +04:00
parent 9b925e141d
commit d69cb12ce9
17 changed files with 209 additions and 20 deletions
@@ -14,6 +14,8 @@ import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.json.JsonMapper
import com.fasterxml.jackson.module.kotlin.kotlinModule
import com.fasterxml.jackson.module.kotlin.readValue
import java.nio.file.FileSystems
import java.nio.file.Path
private const val TERMINAL = "done"
private const val RECOVERY_STAGE = "recovery"
@@ -88,6 +90,7 @@ class ExecutionPlanCompiler(
.getOrElse { throw WorkflowValidationException("Failed to parse execution_plan JSON: ${it.message}") }
if (plan.stages.isEmpty()) throw WorkflowValidationException("execution_plan has no stages")
validateTools(plan)
validateScope(plan)
// Parse + validate every stage's declared build_expectation up front — also feeds the
// deterministic build-gate guarantee below.
@@ -144,6 +147,7 @@ class ExecutionPlanCompiler(
// the contract gate will require to exist. Globs (src/**) are write-permission scopes,
// not existence guarantees, so they are excluded here.
expectedFiles = s.writes.map { it.trim() }.filter { it.isNotEmpty() && !isGlob(it) },
touches = s.touches.map { it.trim() }.filter { it.isNotEmpty() },
buildExpectation = declaredExpectations.getValue(s.id),
autoBuildGate = s.id == autoGateStageId,
semanticReview = s.semanticReview,
@@ -227,6 +231,37 @@ class ExecutionPlanCompiler(
)
}
/**
* Architecture-conformance scope check (design 2026-07-14). When a stage declares a `touches`
* scope, every concrete (non-glob) path it also declares in `writes` must fall within it —
* otherwise the stage has quietly widened past its stated intent (the observed failure: a
* "web-approval-CLIENT" stage that also lists backend routes in its writes). Rejecting here, at
* plan-compile time, hands the plan back to the architect via the existing plan-compile gate
* BEFORE any code is written — the [writeManifest] guard cannot catch this because it is DERIVED
* from the same `writes` and so can never contradict them. A glob in `writes` is a
* write-permission scope, not a concrete target, so it is not checked against `touches` here.
* A stage with no `touches` is unconstrained (opt-in), so existing plans are unaffected.
*/
private fun validateScope(plan: ExecutionPlanModel) {
for (s in plan.stages) {
val touches = s.touches.map { it.trim() }.filter { it.isNotEmpty() }
if (touches.isEmpty()) continue
val matchers = touches.map { FileSystems.getDefault().getPathMatcher("glob:$it") }
val escapees = s.writes
.map { it.trim() }
.filter { it.isNotEmpty() && !isGlob(it) }
.filterNot { path -> matchers.any { it.matches(Path.of(path)) } }
if (escapees.isNotEmpty()) {
throw WorkflowValidationException(
"stage '${s.id}' declares scope touches=$touches but writes files outside it: " +
"${escapees.sorted()} — either keep the stage within its declared scope, or, " +
"if the change genuinely belongs to another area, widen `touches` or move " +
"those writes to a stage that owns that area.",
)
}
}
}
/**
* A field-equals edge can only ever fire if the producing stage's kind schema declares
* that field — the kind's schema is also the LLM response format, so an undeclared field
@@ -21,6 +21,12 @@ data class PlanStage(
// contribution (the static manifest, if any, still applies). The planner emits this under
// the JSON key `writes`.
val writes: List<String> = emptyList(),
// Declared scope globs this stage's intent is confined to (architecture-conformance,
// 2026-07-14). The planner emits this under the JSON key `touches`; every concrete path in
// `writes` must fall within it or the plan is rejected at compile time. Absent/empty = no
// scope constraint. Set it on narrow stages (a frontend-client stage → ["frontend/**"]) so a
// stage that quietly widens into the backend is caught before it writes.
val touches: List<String> = emptyList(),
// Execution-gate scope (staged-verification §2): none|module|project|tests. Absent = none. The
// planner emits this under the JSON key `build_expectation`; it resolves at runtime to the
// project profile's typecheck/build/test command run as a deterministic Gate 4.
@@ -140,6 +140,60 @@ class ExecutionPlanCompilerTest {
assertTrue(graph.stages.values.all { it.writeManifest.isEmpty() })
}
@Test
fun `writes escaping declared touches scope throws WorkflowValidationException naming the escapee`() {
// The observed failure: a "client" stage confined to frontend that also lists a backend file.
val plan = """
{
"goal": "build the approval client",
"stages": [
{
"id": "impl_client",
"prompt": "Build the web approval client",
"produces": "patch",
"needs": [],
"tools": ["file_write"],
"touches": ["frontend/**"],
"writes": ["frontend/src/pages/Approvals.tsx", "apps/server/routes/ApprovalStageRoute.kt"]
}
],
"edges": [
{ "from": "impl_client", "to": "done", "condition": { "type": "always_true" } }
]
}
""".trimIndent()
val ex = assertThrows<WorkflowValidationException> { compiler.compile(plan, "scope-workflow") }
assertTrue(
ex.message!!.contains("apps/server/routes/ApprovalStageRoute.kt"),
"the rejection names the out-of-scope write: ${ex.message}",
)
}
@Test
fun `writes within declared touches scope compile, and globs are not scope-checked`() {
val plan = """
{
"goal": "build the approval client",
"stages": [
{
"id": "impl_client",
"prompt": "Build the web approval client",
"produces": "patch",
"needs": [],
"tools": ["file_write"],
"touches": ["frontend/**"],
"writes": ["frontend/src/pages/Approvals.tsx", "frontend/src/**"]
}
],
"edges": [
{ "from": "impl_client", "to": "done", "condition": { "type": "always_true" } }
]
}
""".trimIndent()
val graph = compiler.compile(plan, "scope-ok-workflow")
assertEquals(listOf("frontend/**"), graph.stages.getValue(StageId("impl_client")).touches)
}
@Test
fun `edge referencing unknown from-stage throws WorkflowValidationException`() {
val bad = validPlan.replace("\"from\": \"analyse\"", "\"from\": \"nonexistent\"")