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:
+4
-3
@@ -154,9 +154,6 @@ class DefaultModelManager(
|
||||
private suspend fun waitForHealthy(process: LlamaProcess): Boolean {
|
||||
val endTime = Clock.System.now().toEpochMilliseconds() + healthTimeoutMs
|
||||
while (Clock.System.now().toEpochMilliseconds() < endTime) {
|
||||
// A crashed process (bad --model, port clash) never gets healthy; polling the full
|
||||
// timeout is pure dead time. Bail as soon as it's gone.
|
||||
if (!process.isAlive) return false
|
||||
try {
|
||||
val response = httpClient.get("http://$host:$port/health").body<String>()
|
||||
if (response.contains("\"status\":\"healthy\"") || response.contains("ok")) {
|
||||
@@ -165,6 +162,10 @@ class DefaultModelManager(
|
||||
} catch (_: Exception) {
|
||||
// Continue polling
|
||||
}
|
||||
// A crashed process (bad --model, port clash) never gets healthy; polling the full
|
||||
// timeout is pure dead time. Bail as soon as it's gone — but only after probing health,
|
||||
// so a server that's already answering isn't missed on a lost startup/exit race.
|
||||
if (!process.isAlive) return false
|
||||
delay(POLL_INTERVAL)
|
||||
}
|
||||
return false
|
||||
|
||||
+35
@@ -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
|
||||
|
||||
+6
@@ -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.
|
||||
|
||||
+54
@@ -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\"")
|
||||
|
||||
Reference in New Issue
Block a user