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:
+5
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
+10
@@ -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)
|
||||
|
||||
+17
-2
@@ -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()
|
||||
|
||||
+10
@@ -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
|
||||
|
||||
+9
-1
@@ -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(
|
||||
|
||||
+13
-3
@@ -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)))
|
||||
}
|
||||
}
|
||||
+12
@@ -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.
|
||||
+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