fix(kernel,workflow): five fixes from the 954da1a9 post-mortem (#705,#706,#709,#710,#712)
The run died on a build gate running the wrong toolchain and burned 43% of its tool calls on repeats, rejections and failures. Five fixes, each traceable to a measured cost in docs/audits/2026-08-11-session-954da1a9-postmortem.md. #705 build gate toolchain scope. The gate is armed session-scoped but read the toolchain stage-scoped, so a reviewer stage that wrote nothing fell back to the flat `build` alias and ran ./gradlew assemble on an all-frontend session. It now falls back to the session's own manifest first. 32.5 min, 33% of that run. #706 action ledger. L2 keeps ten conversation entries, so a stage past round five has no memory of what it tried; 29% of tool calls were byte-identical repeats. One pinned line per call (tool, target, outcome) with repeats collapsed to a count, ~3k tokens for a whole run. #709 plan-compile lint for blocked runners. Plans prescribed `npx tailwindcss init -p`, rejected by the shell denylist at every attempt. Rejected at compile time now, where the architect can still rewrite the step. #710 near-greedy sampling on tool-call rounds. temperature 1.0 on argv emitted `./gradlew_`, `npm_prefix=frontend`, `create_vite@latest`. Prose rounds keep the operator's sampling. #712 auto-approve manifest-contained writes. 94 approvals, all APPROVED, no steering, 19 min. A write inside the declared manifest already proved its containment by getting past ManifestContainmentRule. DENY mode still denies. Tests: core:kernel 133, infrastructure:workflow 99, testing:integration 176, testing:deterministic 79, all green. detekt clean (no new findings). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013dVqqci5H5b3s6xzv6Lojq
This commit is contained in:
+27
@@ -59,6 +59,10 @@ private const val RECOVERY_PROMPT =
|
||||
// leaves ample headroom for completion.)
|
||||
private const val DEFAULT_STAGE_TOKEN_BUDGET = 24576
|
||||
|
||||
// Mirrors ShellTool.REMOTE_EXEC_RUNNERS (other module, not worth a dependency for three strings).
|
||||
// Kept in sync by the message in RECOVERY_PROMPT, which names the same set.
|
||||
private val BLOCKED_RUNNERS = listOf("npx", "bunx", "pnpx")
|
||||
|
||||
// A compiled freestyle stage must also lift its inference completion cap off StageConfig's 2048
|
||||
// default, or the model is truncated (finishReason=length) mid-artifact — and a degenerating local
|
||||
// model burns the whole 2048 on garbage (e.g. a `<|channel>thought` repetition loop) before it can
|
||||
@@ -102,6 +106,7 @@ class ExecutionPlanCompiler(
|
||||
if (plan.stages.isEmpty()) throw WorkflowValidationException("execution_plan has no stages")
|
||||
validateTools(plan)
|
||||
validateScope(plan)
|
||||
validatePromptCommands(plan)
|
||||
|
||||
// Parse + validate every stage's declared build_expectation up front — also feeds the
|
||||
// deterministic build-gate guarantee below.
|
||||
@@ -264,6 +269,28 @@ class ExecutionPlanCompiler(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A stage prompt is a literal command transcript the architect writes from training memory, and
|
||||
* the model obeys it verbatim for the whole stage. When it prescribes an executable the shell
|
||||
* denylist blocks, every round burns on a call that can never run — run 954da1a9 spent rounds
|
||||
* on `npx tailwindcss init -p`, rejected at seq 329 and 988 (#709). Reject at plan-compile time
|
||||
* so the architect rewrites the step, rather than at seq 329 where nothing can rewrite it.
|
||||
*/
|
||||
private fun validatePromptCommands(plan: ExecutionPlanModel) {
|
||||
val offenders = plan.stages.flatMap { s ->
|
||||
BLOCKED_RUNNERS.filter { runner -> Regex("\\b$runner\\b").containsMatchIn(s.prompt) }
|
||||
.map { s.id to it }
|
||||
}
|
||||
if (offenders.isEmpty()) return
|
||||
val detail = offenders.joinToString(", ") { (stage, runner) -> "'$runner' in stage '$stage'" }
|
||||
throw WorkflowValidationException(
|
||||
"execution_plan prescribes blocked remote runner(s): $detail — the shell tool denies " +
|
||||
"bare remote runners (${BLOCKED_RUNNERS.joinToString(", ")}). Rewrite the step to use " +
|
||||
"the package manager directly (e.g. `npm create vite@latest <dir> -- --template " +
|
||||
"react-ts`, `npm install <pkg>` then a local binary from node_modules/.bin).",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
+48
@@ -195,6 +195,54 @@ class ExecutionPlanCompilerTest {
|
||||
assertEquals(listOf("frontend/**"), graph.stages.getValue(StageId("impl_client")).touches)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a prompt prescribing a blocked remote runner is rejected at compile time (#709)`() {
|
||||
val plan = """
|
||||
{
|
||||
"goal": "add tailwind",
|
||||
"stages": [
|
||||
{
|
||||
"id": "install_styling",
|
||||
"prompt": "Run npx tailwindcss init -p, then edit the generated config.",
|
||||
"produces": "patch",
|
||||
"needs": [],
|
||||
"tools": ["shell"],
|
||||
"writes": ["frontend/tailwind.config.js"]
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "install_styling", "to": "done", "condition": { "type": "always_true" } }
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
val ex = assertThrows<WorkflowValidationException> { compiler.compile(plan, "npx-workflow") }
|
||||
assertTrue(ex.message!!.contains("npx"), "the rejection names the runner: ${ex.message}")
|
||||
assertTrue(ex.message!!.contains("install_styling"), "and the stage: ${ex.message}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a word merely containing a runner name does not trip the prompt lint (#709)`() {
|
||||
val plan = """
|
||||
{
|
||||
"goal": "document the sandbox",
|
||||
"stages": [
|
||||
{
|
||||
"id": "docs",
|
||||
"prompt": "Explain why linux-sandboxing matters. Do not mention npxy tools.",
|
||||
"produces": "patch",
|
||||
"needs": [],
|
||||
"tools": ["file_write"],
|
||||
"writes": ["docs/sandbox.md"]
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "docs", "to": "done", "condition": { "type": "always_true" } }
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
assertEquals(1, compiler.compile(plan, "docs-workflow").stages.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `edge referencing unknown from-stage throws WorkflowValidationException`() {
|
||||
val bad = validPlan.replace("\"from\": \"analyse\"", "\"from\": \"nonexistent\"")
|
||||
|
||||
Reference in New Issue
Block a user