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:
2026-08-11 22:15:49 +04:00
parent 700f59ef0d
commit d892587420
12 changed files with 674 additions and 7 deletions
@@ -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
@@ -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\"")