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,7 +59,11 @@ class DefaultContextPackBuilder(
// the model a progress signal that outlives the truncation which otherwise wipes its memory.
// "retryFeedback" carries WHY the last attempt failed + the files already written this stage; if
// truncation evicts it the model cold-starts on the same wrong idea every turn (the write-loop rot).
private val neverDropSourceTypes = setOf("steeringNote", "eventHistory", "factSheet", "remainingDelta", "retryFeedback")
// "actionLedger" is the stage's one-line-per-tool-call history (#706). L2 keeps only the last
// ten conversation entries, so without it a stage past round five re-issues calls it already
// made; the ledger is the memory that survives that eviction.
private val neverDropSourceTypes =
setOf("steeringNote", "eventHistory", "factSheet", "remainingDelta", "retryFeedback", "actionLedger")
private companion object {
const val CHARS_PER_TOKEN = 4
@@ -0,0 +1,100 @@
package com.correx.core.kernel.orchestration
import com.correx.core.context.model.ContextEntry
import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.EntryRole
import com.correx.core.events.types.ContextEntryId
import com.correx.core.inference.ToolCallRequest
import java.util.UUID
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
/**
* Deterministic action ledger (#706, post-mortem of run 954da1a9). L2 holds the last ten
* conversation entries — five tool call/result pairs — so a stage past round five has no memory of
* what it already tried. 29% of that run's 258 tool calls were byte-identical repeats: `list_dir
* frontend` nine times, `./gradlew assemble` four times, each one failing the same way.
*
* The ledger is one line per call — tool, target, outcome — pinned so it never evicts. A whole run
* is roughly 3k tokens, cheaper than the duplicates it removes, and it carries the "already tried,
* same failure" signal no window size provides. Repeats collapse to a count, so a thrashing loop
* reads as `shell ./gradlew assemble -> exit 1 (x4)` rather than four separate lines.
*/
private const val LEDGER_MAX_LINES = 80
private const val LEDGER_OUTCOME_CHARS = 90
private const val LEDGER_TARGET_CHARS = 70
private val ledgerJson = Json { ignoreUnknownKeys = true }
/**
* Folds this round's tool entries into `tool target -> outcome` lines. Pairs the `assistantToolCall`
* entry with its `toolResult` by sourceId (both are stamped with it in dispatchToolCalls), so this
* reads only what the loop already has — no event-store re-read.
*/
internal fun ledgerLinesFrom(entries: List<ContextEntry>): List<String> {
val results = entries.filter { it.sourceType == "toolResult" }.associateBy { it.sourceId }
return entries.filter { it.sourceType == "assistantToolCall" }.mapNotNull { call ->
val request = runCatching {
ledgerJson.decodeFromString(ToolCallRequest.serializer(), call.content)
}.getOrNull() ?: return@mapNotNull null
val target = ledgerTarget(request.function.arguments)
val outcome = ledgerOutcome(results[call.sourceId]?.content)
listOfNotNull(request.function.name, target).joinToString(" ") + " -> " + outcome
}
}
/** The one argument worth showing: the path/command a call acted on, else the first string value. */
private fun ledgerTarget(arguments: String): String? {
val obj = runCatching { ledgerJson.parseToJsonElement(arguments).jsonObject }.getOrNull() ?: return null
val strings = obj.mapValues { (_, v) -> (v as? JsonPrimitive)?.takeIf { it.isString }?.content }
val picked = listOf("path", "command", "file_path", "query", "pattern")
.firstNotNullOfOrNull { strings[it] }
?: strings.values.filterNotNull().firstOrNull()
return picked?.trim()?.take(LEDGER_TARGET_CHARS)
}
private fun ledgerOutcome(result: String?): String = when {
result == null -> "no result"
result.startsWith("ERROR:") || result.startsWith("BLOCKED:") ->
result.lineSequence().first().take(LEDGER_OUTCOME_CHARS)
else -> "ok"
}
/**
* Renders the pinned ledger entry. Identical lines collapse to one with a repeat count — that count
* IS the signal, so it must not be lost to dedup. Keeps the most recent [LEDGER_MAX_LINES] distinct
* lines and says how many it dropped, rather than silently truncating.
*/
internal fun buildActionLedgerEntry(lines: List<String>): ContextEntry? {
if (lines.isEmpty()) return null
val counted = LinkedHashMap<String, Int>()
lines.forEach { counted[it] = (counted[it] ?: 0) + 1 }
val dropped = (counted.size - LEDGER_MAX_LINES).coerceAtLeast(0)
val content = buildString {
append("## Already done this stage\n")
append(
"Every tool call you have made in this stage, in order, with its outcome. Do NOT repeat " +
"a call listed here: it will return the same thing. A line marked (xN) is a call you " +
"have already retried N times without the result changing — try something different " +
"or move on.\n",
)
if (dropped > 0) append("- ... $dropped earlier calls omitted\n")
counted.entries.drop(dropped).forEach { (line, count) ->
append("- ").append(line)
if (count > 1) append(" (x").append(count).append(")")
append("\n")
}
}.trimEnd()
return ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L1,
content = content,
sourceType = "actionLedger",
sourceId = "action-ledger",
tokenEstimate = content.length / 4,
// Rebuilt every round, like remainingDelta — USER, so it never invalidates the cached
// system prefix and never competes with the stage's own instructions.
role = EntryRole.USER,
)
}
@@ -14,6 +14,16 @@ internal fun SessionOrchestrator.stageProducedToolchain(
stageId: StageId,
): KindContractTable.Toolchain? = toolchainForPaths(stageWrittenPaths(sessionId, stageId))
/**
* The toolchain the execution gate runs for a stage: what this stage wrote, else what the session
* wrote. The gate is armed session-scoped, so a stage that wrote nothing (a reviewer) must not fall
* through to the profile's flat `build` alias and run some other stack's build (#705).
*/
internal fun resolveGateToolchain(
stagePaths: List<String>,
sessionPaths: List<String>,
): KindContractTable.Toolchain? = toolchainForPaths(stagePaths) ?: toolchainForPaths(sessionPaths)
internal fun toolchainForPaths(paths: List<String>): KindContractTable.Toolchain? =
paths.asReversed().firstNotNullOfOrNull { path ->
KindInference.kindFor(path)?.let(KindContractTable::toolchainFor)
@@ -106,6 +106,12 @@ internal const val TOOL_RESULT_HEAD_LINES = 60
internal const val TOOL_RESULT_TAIL_LINES = 60
internal const val TOOL_OUTPUT_TOOL = "tool_output"
// ponytail: near-greedy, not greedy (temperature 0) — a hard 0 makes a stuck model repeat the same
// failing call forever, and the repeat rate is already the problem (#706). Fixed constants, not
// config: they describe the field type (argv/path), not an operator preference.
internal const val TOOL_CALL_TEMPERATURE = 0.15
internal const val TOOL_CALL_TOP_P = 0.9
/**
* Frame an over-cap tool output as `header` + head lines + a truncation marker (naming the
* [tool_output] ref that retrieves the full text) + tail lines. Head and tail are each char-capped
@@ -332,8 +338,9 @@ abstract class SessionOrchestrator(
// A stage that grants tools needs a tool-calling model, so request ToolCalling on top of any
// declared capabilities — the capability-aware strategy then ranks eligible providers by their
// ToolCalling score and routes the stage to the best tool-caller.
val toolCallRound = withTools && stageConfig.allowedTools.isNotEmpty()
val requiredCapabilities = stageConfig.requiredCapabilities +
if (withTools && stageConfig.allowedTools.isNotEmpty()) setOf(ModelCapability.ToolCalling) else emptySet()
if (toolCallRound) setOf(ModelCapability.ToolCalling) else emptySet()
// Routing itself can fail transiently (provider mid-crash-recovery) — it must be retryable
// like any other inference failure, not escape and kill the whole session (see #299).
val provider = try {
@@ -353,7 +360,15 @@ abstract class SessionOrchestrator(
sessionId = sessionId,
stageId = stageId,
contextPack = contextPack,
generationConfig = stageConfig.generationConfig,
// A round that carries tools is answered with argv, paths and flags — fields where
// exactly one string is correct. Chat-temperature sampling there emits `./gradlew_`,
// `npm_prefix=frontend`, `create_vite@latest` (#710, run 954da1a9). Go near-greedy for
// those rounds and keep the operator's sampling for prose rounds (artifact + review).
generationConfig = if (toolCallRound) {
stageConfig.generationConfig.copy(temperature = TOOL_CALL_TEMPERATURE, topP = TOOL_CALL_TOP_P)
} else {
stageConfig.generationConfig
},
responseFormat = responseFormat,
tools = if (!withTools) {
emptyList()
@@ -318,6 +318,10 @@ internal suspend fun SessionOrchestrator.executeStage(
// already seen — a stage reading N distinct files before writing is legitimate context
// gathering, not a loop, and shouldn't trip the same counter as re-reading the same file.
val seenReadFingerprints = mutableSetOf<String>()
// #706: every tool call this stage made, one line each, pinned so it outlives L2 eviction.
// Without it the model's memory is the last five call/result pairs and it re-issues calls it
// already made (29% of run 954da1a9's tool calls were byte-identical repeats).
val ledgerLines = mutableListOf<String>()
// Set when the model produces its artifact via the emit_artifact tool instead of a final
// JSON message; overrides the post-loop capture of the (then-empty) assistant text.
var llmArtifactOverride: String? = null
@@ -473,6 +477,12 @@ internal suspend fun SessionOrchestrator.executeStage(
// loop as tool-result context so the model can see the error and adapt (bounded by
// MAX_TOOL_ROUNDS). Only FATAL: failures (handled above) abort the stage.
accumulatedEntries = accumulatedEntries + toolEntries
// #706: fold this round into the pinned action ledger before any pushBack rebuilds the pack,
// so a nudged round already carries the "you have tried this N times" line.
ledgerLines += ledgerLinesFrom(toolEntries)
buildActionLedgerEntry(ledgerLines)?.let { ledger ->
accumulatedEntries = accumulatedEntries.filterNot { it.sourceType == "actionLedger" } + ledger
}
// Read-loop breaker: this round called only read-only tools yet the stage still owes a
// file_written artifact. Left alone the model keeps reading until MAX_TOOL_ROUNDS and
// never writes (F-018 nudges only cover a prose turn or a premature stage_complete, not
@@ -174,7 +174,15 @@ internal suspend fun SessionOrchestrator.runExecutionGate(
val runner = staticAnalysisRunner
val workspaceRoot = effectives.policy?.workspaceRoot
if (runner == null || workspaceRoot == null) return StageExecutionResult.Success(emptyList())
val toolchain = stageProducedToolchain(sessionId, stageId)?.profileKey
// The gate is turned on session-scoped (sessionProducedBuildTarget) but the toolchain was read
// stage-scoped, so a stage that wrote nothing (a reviewer) resolved null and fell back to the
// flat `build` alias — run 954da1a9 ran `./gradlew assemble` on an all-`frontend/**` session and
// died on it (#705). Two lookups deciding one command must share a scope: fall back to the
// session's own manifest, the same one that armed the gate, before the flat alias.
val toolchain = resolveGateToolchain(
stageWrittenPaths(sessionId, stageId),
sessionWrittenPaths(sessionId),
)?.profileKey
val command = expectation.commandFor(profileCommands, toolchain)
if (command.isNullOrBlank()) {
log.warn(
@@ -305,12 +305,22 @@ internal suspend fun SessionOrchestrator.dispatchToolCalls(
assessment
}
val plane2Prompts = plane2Risk?.recommendedAction == RiskAction.PROMPT_USER
// #712: a write that lands inside the stage's declared manifest (or the claimed task's
// affected_paths) needs no interrupt — the operator already approved that path set when the
// plan was approved, and ManifestContainmentRule BLOCKs anything outside it above, so
// reaching here with a clean plane-2 verdict IS the containment proof. Run 954da1a9 spent
// 19 min over 94 prompts, every one APPROVED with no steering. DENY mode still denies.
val writeInsideManifest = plane2Risk?.recommendedAction == RiskAction.PROCEED &&
approvalMode != ApprovalMode.DENY &&
effectiveManifest.isNotEmpty() &&
tool?.requiredCapabilities?.contains(ToolCapability.FILE_WRITE) == true
// A steering note attached to a human approval is captured here and injected after the
// tool result, so the same-stage loop re-infers with it and the model acts on the note.
var approvalNote: String? = null
if ((tier.isAtMost(Tier.T1) && !plane2Prompts) || alreadyGranted) {
// no approval needed — either within the auto-approve tier, or this out-of-workspace
// read path was already approved earlier this session (this-path-this-session).
if ((tier.isAtMost(Tier.T1) && !plane2Prompts) || alreadyGranted || writeInsideManifest) {
// no approval needed — within the auto-approve tier, an out-of-workspace read path
// already approved earlier this session (this-path-this-session), or a write contained
// by the stage's declared manifest.
} else {
// Grants in effect = this session's own (SESSION/STAGE) unioned with the
// cross-session ledger (PROJECT/GLOBAL). projectId is derived from the bound
@@ -0,0 +1,76 @@
package com.correx.core.kernel.orchestration
import com.correx.core.context.model.ContextEntry
import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.EntryRole
import com.correx.core.events.types.ContextEntryId
import com.correx.core.inference.ToolCallFunction
import com.correx.core.inference.ToolCallRequest
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class ActionLedgerTest {
private fun round(id: String, tool: String, args: String, result: String): List<ContextEntry> = listOf(
entry(id, "assistantToolCall", Json.encodeToString(
ToolCallRequest.serializer(),
ToolCallRequest(id = id, function = ToolCallFunction(tool, args)),
), EntryRole.ASSISTANT),
entry(id, "toolResult", result, EntryRole.TOOL),
)
private fun entry(sourceId: String, sourceType: String, content: String, role: EntryRole) = ContextEntry(
id = ContextEntryId(sourceId + sourceType),
layer = ContextLayer.L2,
content = content,
sourceType = sourceType,
sourceId = sourceId,
tokenEstimate = content.length / 4,
role = role,
)
@Test
fun `a call folds to one tool-target-outcome line`() {
val lines = ledgerLinesFrom(round("1", "file_read", """{"path":"frontend/package.json"}""", "{...}"))
assertEquals(listOf("file_read frontend/package.json -> ok"), lines)
}
@Test
fun `a failed call keeps its first error line`() {
val lines = ledgerLinesFrom(
round("1", "shell", """{"command":"./gradlew assemble"}""", "ERROR: exit 1\nCould not resolve io.ktor"),
)
assertEquals(listOf("shell ./gradlew assemble -> ERROR: exit 1"), lines)
}
@Test
fun `repeats collapse to a count instead of N lines`() {
val line = "list_dir frontend -> ok"
val content = buildActionLedgerEntry(List(9) { line })!!.content
assertTrue(content.contains("$line (x9)"), content)
assertEquals(1, content.lines().count { it.contains("list_dir") }, content)
}
@Test
fun `the ledger is empty until a call is made`() {
assertNull(buildActionLedgerEntry(emptyList()))
}
@Test
fun `an overlong ledger drops the oldest lines and says so`() {
val content = buildActionLedgerEntry((1..100).map { "file_read f$it.kt -> ok" })!!.content
assertTrue(content.contains("20 earlier calls omitted"), content)
assertFalse(content.contains("f1.kt"), content)
assertTrue(content.contains("f100.kt"), content)
}
@Test
fun `a call whose result never landed is still recorded`() {
val call = round("1", "file_write", """{"path":"a.kt","content":"x"}""", "ok").first()
assertEquals(listOf("file_write a.kt -> no result"), ledgerLinesFrom(listOf(call)))
}
}
@@ -24,6 +24,18 @@ class BuildPrerequisiteDecisionTest {
assertEquals(KindContractTable.Toolchain.JVM, toolchainForPaths(listOf("core/kernel/FooService.kt")))
}
@Test
fun `a stage that wrote nothing falls back to the session toolchain (#705)`() {
val session = listOf("frontend/package.json", "frontend/src/App.tsx", "README.md")
assertEquals(KindContractTable.Toolchain.NODE, resolveGateToolchain(emptyList(), session))
// The stage's own writes still win when it has any.
assertEquals(
KindContractTable.Toolchain.JVM,
resolveGateToolchain(listOf("core/kernel/FooService.kt"), session),
)
assertNull(resolveGateToolchain(emptyList(), listOf("README.md")))
}
private val reason =
"stage impl repeatedly referenced missing build prerequisite 'frontend/package.json' " +
"(3 blocked attempts). Create or repair the project setup before continuing."
@@ -0,0 +1,347 @@
# Session post-mortem: 954da1a9 (freestyle web-ui run)
Date: 2026-08-11
Branch: `master`
HEAD at analysis: `700f59ef`
Session: `954da1a9-56cb-48da-a7dc-3ef472c42bf6`, 11:52:14 to 13:31:10 UTC
Method: full event-log replay from `~/.config/correx/correx.db` plus CAS artifact reads
(`scripts/artread.py`). No inference was re-run. Every claim below cites a session sequence
number, an artifact hash, or a source line.
## Executive summary
The run did not fail at the end. It failed at minute 7. It then passed every gate for 92
minutes and died on a build gate that was never its job.
| | |
|---|---|
| Wall clock | 98.9 min |
| Inference | 73.8 min, 274 rounds, p50 12.9 s, max 190 s |
| Blocked on approvals | 19.0 min, 94 pauses, 100% approved, 0 steering |
| Tool calls | 258, of which **110 were waste** (43%) |
| Files written | 25 `FileWritten` events, **9 distinct files** |
| UI views delivered | 0 of 8 requested |
`postcss.config.js` was written 5 times. `QueryClientProvider.tsx` was written 8 times.
Eight findings. Seven are open. One (§1) was closed by #699 after the run ended.
Vikunja: #705, #706, #707, #708, #709, #710, #711, #712.
## Timeline
| seq | time | event |
|---|---|---|
| 2 | 11:52 | `InitialIntent` — eight-view web UI, React/Vite/Tailwind/Ethos/TanStack |
| 171 | 11:57 | discovery artifact: `brief.scope` holds all 8 items, `ready: true`, no questions |
| 236 | 11:59 | dod artifact: **4 criteria, all `part: "Project Foundation"`** |
| 249-256 | 12:02 | execution plan locked; plan-compile, plan-lint (score 0) and grounding all PASS |
| 609 | 12:14 | first retry — Tailwind v4 PostCSS plugin moved, `npm run build` exit 1 |
| 951 | 12:28 | `WorkspaceVerificationObserved` PROJECT `npm --prefix frontend run build` **passed** |
| 1510 | 12:41 | retry — `verbatimModuleSyntax` type-only import |
| 1697 | 12:49 | retry — `final_review` PROJECT gate runs **`./gradlew assemble`**, exit 1 |
| 1994 | 13:00 | reviewer correctly names `~/.gradle/init.d/offline.gradle` as the cause |
| 2306 | 13:12 | reviewer **retracts** the correct diagnosis: "was not found" |
| 2374 | 13:14 | `FailureTicketOpened` `stage_loop_break`, 6 identical failures, routes to recovery |
| 2584 | 13:23 | `RefinementIteration` recovery→final_review, back into the same wall |
| 2723-2731 | 13:30 | 4 retries in 40 s on `No provider satisfies capabilities [ToolCalling]` |
| 2733 | 13:31 | `WorkflowFailed` |
## 1. Scope collapse at the analyst (closed by #699)
Discovery settled 8 scope items (artifact `8053f0d7`): session driver, sessions list,
workflow listing, event log viewer, artifacts/timeline, ideas board, profiles, configuration.
The analyst emitted 4 criteria (artifact `5d46f805`), every one `part: "Project Foundation"`.
Its summary reads *"Initialize the Correx web-ui project with the required tech stack."* The
architect planned against the shrunken DoD (`13d08806`): `scaffold_frontend`,
`install_styling_and_ethos`, `configure_tanstack_query`, `final_review`. Its `goal` field
never mentions a view.
Nothing downstream sees discovery again. Implementer stages receive `neededArtifact: dod` and
nothing else, so the loss is total and silent from seq 236 onward.
All three plan gates passed, because all three grade structure and the structure was sound.
Closed by `ScopeCoverage` (#699, commit `700f59ef`, 19:56 the same day). Stated ceiling holds:
it catches a dropped index, not a weak criterion.
## 2. The build gate ran the wrong toolchain (#705, open)
[`SessionOrchestratorGates2.kt:156-178`](../../core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorGates2.kt#L156-L178):
- `sessionProducedBuildTarget(sessionId)` is **session-scoped** and turns the gate on.
- `stageProducedToolchain(sessionId, stageId)` is **stage-scoped** and returns null when the
stage wrote no files.
- `commandFor(profileCommands, null)` then falls back to the flat `build` alias.
`final_review` is a reviewer. Its tools are `[file_read]` and the plan set
`build_expectation: "none"`. It wrote nothing, so the toolchain lookup returned null and the
gate ran `./gradlew assemble` against a run whose every write was `frontend/**`. The correct
command, `node.build` = `npm --prefix frontend run build`, had already passed at seq 951, 1286
and 1683.
The failure it produced was environmental:
```
> Could not resolve io.ktor:ktor-client-websockets:3.0.3.
> No cached version ... available for offline mode.
```
That is `~/.gradle/init.d/offline.gradle` on workpc, outside the workspace jail, predating the
session.
Cost: 4 retries plus a recovery detour, seq 1694 to 2733. That is **32.5 min, 33% of wall
clock**, and the run's death.
Fix: when the stage produced no toolchain, fall back to the session's toolchain (the same
manifest `sessionProducedBuildTarget` already reads) before the flat alias. Two lookups
deciding one command must share a scope.
## 3. The harness destroyed a correct diagnosis (#707, open)
Only 4 of 274 inference rounds returned any text at all (§5). Three of them are this finding.
- **seq 1994**: *"The build failed because the Gradle environment is in offline mode... The
`org.gradle.offline=true` flag is present in `~/.gradle/init.d/offline.gradle`."* Correct
root cause, reached in about ten minutes.
- **seq 2306**: *"Since `~/.gradle/init.d/offline.gradle` was not found, I will check for global
Gradle properties..."* The model **retracted the correct answer**, because at seq 1793
`file_read ~/.gradle/gradle.properties` returned `[REFERENCE_EXISTS] ... does not exist. Do
NOT keep retrying`. The tool layer does not expand `~`, and the path is outside the jail
regardless. The guard reported a true thing as false.
- **seq 2715**: *"the DoD criteria do not require a successful backend build... the frontend
build passed... I cannot fix the backend dependencies, it's out of scope. However, since the
PROJECT build gate is a hard gate, I must reject it."*
Right three times, and wrong-footed each time by the harness.
The structural gap: every verdict the reviewer can emit (`approved`, `changes_requested`,
`rejected`) routes back into the agent. There is no way to say *this failure is not
attributable to this run*. `FailureTicketOpened` fired correctly at seq 2374 after 6 identical
failures, routed to `recovery`, and recovery routed straight back (`RefinementIteration`,
seq 2584).
The retry feedback closed with *"Do not re-discover unrelated files before it builds."*
The harness forbade the only move that would have worked.
## 4. Ten-turn amnesia inside a mostly empty window (#706, open)
176 `ContextTruncated` events, every one L2, `entriesDropped` climbing to 40+ and pinning.
| stage | truncations | max dropped |
|---|---|---|
| discovery | 14 | 28 |
| scaffold_frontend | 40 | 40 |
| install_styling_and_ethos | 20 | 40 |
| configure_tanstack_query | 21 | 16 |
| final_review | 62 | 42 |
| recovery | 16 | 32 |
[`DefaultContextPackBuilder.kt:380`](../../core/context/src/main/kotlin/com/correx/core/context/builder/DefaultContextPackBuilder.kt#L380)
uses `CompressionStrategy.Conversation()`. The default is `keepLast = 10`
([`CompressionStrategy.kt:7`](../../core/context/src/main/kotlin/com/correx/core/context/compression/CompressionStrategy.kt#L7)).
L2 holds **5 tool-call/result pairs**. The assembled pack at round 31 of `scaffold_frontend`
(seq 599) confirms it. It carries 4 L0 entries, 2 L1, 3 L3, and 10 L2, exactly the last ten.
**The window is not full.** Those same packs run 1.7k to 13k tokens, median 5.3k. The cap is
entry count, not tokens, so the model is starved at 5k while a local model's context sits
mostly idle.
Consequence, measured: **76 byte-identical repeat calls**, 29% of all tool invocations.
| repeats | stage | call |
|---|---|---|
| 9 | final_review | `list_dir {"path":"frontend"}` |
| 7 | scaffold_frontend | `list_dir {"path":"frontend/"}` |
| 6 | final_review | `file_read {"path":"gradle.properties"}` |
| 5 | scaffold_frontend | `file_read {"path":"frontend/package.json"}` |
| 4 | final_review | `shell ./gradlew assemble --no-configuration-cache` |
The `decisionJournal` is the only memory surviving eviction. Its complete content at the end of
the run:
```
- Goal: Write a web-ui for Correx. ...
- scaffold_frontend: 1 retry, resolved — stage completed
- configure_tanstack_query: 1 retry, resolved — stage completed
- final_review: 3 retries, resolved — stage completed
```
Bookkeeping, not knowledge.
Preferred fix is not a larger `keepLast` (it costs local-inference latency and still forgets).
Add a deterministic **action ledger**. One line per tool call for the stage: tool, target,
exit, short result hash. Never evicted. 258 calls at roughly 12 tokens is about 3k tokens for
a whole run. That is cheaper than the duplicates it removes, and it carries the "already
tried, same failure" signal no window size provides.
Related: the plan set `kind: "process_result"` on every stage, which silently disables the
`file_written` manifest injection
([`SessionOrchestratorContext.kt:458`](../../core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorContext.kt#L458)
builds it only for `file_written` slots). A feature built to prevent this thrash was switched
off by one planner field.
## 5. There was no chain of thought
**270 of 274 inference rounds returned an empty response body** (blake3
`af1349b9...`, the empty string). Per stage: discovery 25/25, analyst 9/9, architect 1/1,
scaffold_frontend 62/62, install_styling 31/31, configure_tanstack 32/33, final_review 83/86,
recovery 27/27.
Every assistant message in the assembled prompts is a bare function call with no content. The
system prompt instructs *"make one coherent step per response."*
The loop reduces to this. Context in, one tool call out, no reasoning, no plan, no self-check,
with a 10-turn memory (§4) at temperature 1.0 (§6). The 29% repeat rate is what that
combination produces. Surfacing reasoning is already tracked as #298. This finding is that
none was produced to surface.
## 6. Temperature 1.0 on structured arguments (#710, open)
[`Main.kt:435-451`](../../apps/server/src/main/kotlin/com/correx/apps/server/Main.kt#L435-L451)
sends `[sampling] temperature = 1.0, top_p = 0.95, top_k = 64` on **every** stage inference.
Chat-quality settings applied to fields where exactly one string is correct.
| seq | emitted | intended |
|---|---|---|
| 277 | `create_vite@latest` | `create vite@latest` |
| 465 | `npm_prefix=frontend` | `npm --prefix frontend` |
| 1302 | `npm_install_package` | `npm install <package>` |
| 2060 | `./gradlew_` | `./gradlew` |
| 2708 | `gradlew` | `./gradlew` |
Space-to-underscore substitution on an argv field is sampling noise. correx already sends a
per-request `GenerationConfig`, so the override is per-call. Keep 1.0 for artifact and review
prose. Go near-greedy when the response is constrained to tool calls. #303 repairs these after
the fact. Not emitting them is cheaper.
**Same task, second half: files are copied through the token stream.** There is no `file_copy`
tool. `ethos-icons.svg` moved by a `file_read` of 6124 characters, then a `file_write` of 6845
characters of tool-call arguments. A `READ_BEFORE_WRITE` rejection round came first (seq 540),
when the model tried to write before reading. About 6 rounds and 13k tokens for two static
assets, at temperature 1.0, with silent corruption possible. The project profile says *"COPY
design-system/ethos.tokens.css and ethos-icons.svg into the project and import them, do NOT
read their contents."* The toolset offers no way to obey that instruction.
## 7. The plan contradicts itself, and the kernel sides against the model (#711, open)
The architect emits a stage's `prompt` and its `writes` manifest independently. They disagreed
twice, and the kernel enforces `writes`.
- `install_styling_and_ethos` prompt step 4: *"Copy ... into `frontend/src/assets/ethos/`."*
Manifest: `[tailwind.config.js, postcss.config.js, src/index.css]`. Result at seq 1086:
`[PATH_OUTSIDE_MANIFEST]`.
- `configure_tanstack_query` prompt step 2: *"Create a `QueryClient` instance and a wrapper
component."* Manifest: `[frontend/src/lib/query-client.ts]`. Blocked at seq 1321.
`QueryClientProvider.tsx` was then written 8 times.
Both fields are already parsed by the plan-compile gate, so this is a string check over paths
the prompt itself spells out.
## 8. The stage prompt never updates (#709, open)
Verified against the assembled prompt for round 31 of `scaffold_frontend` (artifact
`47ebf7b0`). The system message still read:
> Use the `shell` tool to initialize a new React + TypeScript project using Vite in the
> `frontend/` directory. Run `npm create vite@latest frontend -- --template react-ts` ...
The project had existed for 30 rounds. The instruction is a fixed string from the execution
plan, and nothing recomputes it against what exists. Combined with §4, the model's strongest
signal every round is an order to redo step one.
The plan's stage prompts are literal command transcripts written from training memory, and both
were wrong:
- `npx tailwindcss init -p`. `npx` is **blocked by tool policy**, rejected at seq 329 and 988.
The policy message helpfully names the alternative. A plan-compile lint rejecting a plan that
names a forbidden executable is roughly ten lines.
- `npm install -D tailwindcss postcss autoprefixer` plus `tailwind.config.js` is the **Tailwind
v3** procedure. `npm install tailwindcss` now resolves v4, where the PostCSS plugin moved to
`@tailwindcss/postcss`. That is the build failure at seq 609 verbatim, and 22 rounds of
thrash followed it.
## 9. Ninety-four approvals that carried no information (#712, open)
Every `ApprovalDecisionResolved` in the run is `APPROVED` with `reason: null` and
`userSteering: null`. Pause durations: p50 13.6 s, p90 19.0 s, max 20.3 s. Total **19.0 min,
19% of wall clock**.
By tool: `shell` T2 x63, `file_write` T2 x13, `file_edit` T3 x10, remainder task and delete
calls.
Two problems. The gate is priced per tool call, so it charges 94 interrupts for one operator
intention. The attention also lands in the wrong place. All 94 chances to intervene were
spent on "may I run `npm install`". The decision that determined the outcome (seq 236, §1)
had no operator checkpoint at all.
Auto-approving reads and writes inside the stage's declared `writes` manifest costs nothing in
safety, because `PATH_OUTSIDE_MANIFEST` already enforces that boundary. It returns most of the
19 minutes.
## Waste breakdown
258 tool calls. 110 wasted, counting each call once (repeat, failed, or policy-rejected).
| stage | waste / total | |
|---|---|---|
| discovery | 2 / 24 | 8% |
| analyst | 1 / 8 | 12% |
| scaffold_frontend | 26 / 60 | 43% |
| install_styling_and_ethos | 11 / 30 | 37% |
| configure_tanstack_query | 9 / 31 | 29% |
| **final_review** | **55 / 79** | **70%** |
| recovery | 6 / 26 | 23% |
Context composition across all 279 assemblies: `toolResult` 47.9%, `projectProfile` 8.4%,
`assistantToolCall` 8.1%, `decisionJournal` 6.8%, `retryFeedback` 5.4%. By layer: L2 56.0%,
L0 18.0%, L1 13.2%, L3 12.9%.
## Ranked remediation
inference, from this run's own timings:
1. **Baseline every gate command at session start (#708).** One build at t=0. A command already
red before the agent touched anything reports `pre-existing` and never fails a stage.
Recovers `final_review` plus most of `recovery`. **~40 min.**
2. **Fix the toolchain scope mismatch (#705).** One line. Prevents the same 40 minutes by an
independent route. Do both.
3. **Deterministic action ledger (#706).** ~16 min of duplicate calls, plus the loops they feed.
Raising `keepLast` alone does not fix it.
4. **Auto-approve inside the declared manifest (#712).** **~17 min.**
5. **Split the sampling config (#710).** Near-greedy for tool-call rounds.
Items 1, 3 and 4 account for roughly 70 of the 99 minutes.
## The structural point
Every gate in this run was local. Each asked whether a stage did its stage correctly. None
asked whether the run was still building the requested thing, until `final_review` at minute 90
graded against the already-collapsed DoD.
The path from intent to work is five lossy model summarisations: intent, discovery, DoD, plan,
stage prompt. Only the last link was ever checked. #699 now checks the DoD-to-discovery link,
which is the one that broke here. The general shape remains.
The cheap version needs no model. The intent names eight views. After three of four stages,
`FileWritten` holds nine files and none is a view. That comparison is a set difference over
recorded events and costs nothing. It would have fired at minute 25, not failed silently at
minute 99.
The deeper item, for #168: this run produced a genuinely valuable artifact and discarded it. A
local model correctly diagnosed a Gradle offline-mode misconfiguration in ten minutes from
build output alone. That knowledge existed at seq 1994 and was gone by seq 2306. A guard
reporting a real file as missing erased it. The event log still has it. Nothing reads it back.
## Verification and scope
- fact: every number above is derived from `events` rows for session `954da1a9` and from CAS
artifacts resolved through `~/.config/correx/artifacts/index.sqlite`. No inference re-run.
- fact: `ScopeCoverage` (#699) landed at 19:56 on 2026-08-11, after this session ended at
13:31. It was not active during the run.
- inference: the `./gradlew assemble` failure is attributed to `~/.gradle/init.d/offline.gradle`
on workpc. The build output names offline mode and an uncached `io.ktor:ktor-client-websockets`.
The file itself was not read during this analysis.
- unknown: whether the 94 approvals were resolved by a human operator or by an auto-approver.
The tight 13-20 s clustering suggests a poll interval. No event records the decider.
- This post-mortem implemented no fixes. It filed Vikunja #705 through #712.
@@ -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\"")