diff --git a/src/index.ts b/src/index.ts index 0af23d9..df89510 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,6 +59,7 @@ import { import { buildPlannerCompactInstructionBundle, buildPlannerPostCompactMessage, + choosePlannerMessageDelivery, clearPlannerCompactionInFlight, clearPlannerControlledCompact, collectAutoCompactInstructionSections, @@ -1414,6 +1415,18 @@ interface PlannerIdleRuntimeState { latestCwd: string | null; checking: boolean; timer: ReturnType | null; + /** + * Whether the agent is running, read from the most recent event context. + * + * The watchdog ticks on a timer and has no context of its own, but it must + * know: a wake sent as a follow-up while nothing is running is never + * delivered, it just sits in the input queue. Refreshed wherever `latestCwd` + * is, so it is exactly as current as the cwd the tick already relies on, and + * null before the first event — which the caller reads as "assume running", + * the safe direction, since a follow-up sent too early is delivered late + * rather than lost. + */ + isIdle: (() => boolean) | null; } export default function piCodePlannerExtension(pi: ExtensionAPI): void { @@ -1422,6 +1435,7 @@ export default function piCodePlannerExtension(pi: ExtensionAPI): void { latestCwd: null, checking: false, timer: null, + isIdle: null, }; const timerRuntime = createPlannerTimerRuntimeState(); installPlannerToolErrorBoundary(pi); @@ -3414,6 +3428,11 @@ function registerPlannerTools( projectPaths, toolName, params, + // The model may not clear a compact boundary while the compaction + // it asked for is still running. The planner's own resolve paths + // omit this deliberately — they run when the boundary must be + // released without compacting. + compactionInFlight: isPlannerCompactionInFlight(compactRuntime), }); const compact = await maybeStartPlannerControlledCompact({ pi, @@ -3877,13 +3896,21 @@ function registerPlannerCompactEvents( preflight, }); const message = buildPlannerPostCompactMessage({ preflight, sections }); - enqueuePlannerPostCompactMessage({ - message, - isIdle: ctx.isIdle(), - hasPendingMessages: ctx.hasPendingMessages(), - sendUserMessage: (content, options) => - pi.sendUserMessage(content, options as never), - }); + // Deliver on the next macrotask, not from inside this handler. `compact()` + // disconnects the session from agent events for its whole duration and + // reconnects in a `finally` that has not run yet when `session_compact` is + // emitted — a turn started here would run against a session that is not + // listening, which is how four minutes of real work went missing from a + // measured session file. The same deferral `ctx.compact()` itself uses. + // Idle is re-read at that point because it is only then that it matters. + setTimeout(() => { + enqueuePlannerPostCompactMessage({ + message, + isIdle: ctx.isIdle(), + sendUserMessage: (content, options) => + pi.sendUserMessage(content, options as never), + }); + }, 0); }); // The agent resuming work is our only extension-visible signal that a @@ -3923,9 +3950,11 @@ function registerPlannerIdleWatchdog( ): void { pi.on("session_start", async (_event, ctx) => { runtime.latestCwd = ctx.cwd; + runtime.isIdle = () => ctx.isIdle(); }); pi.on("tool_call", async (_event, ctx) => { runtime.latestCwd = ctx.cwd; + runtime.isIdle = () => ctx.isIdle(); }); if (runtime.timer) { @@ -3977,7 +4006,21 @@ async function runPlannerIdleWatchdogTick( markPlannerIdleWakeQueued(state, decision.timestamp), ); try { - pi.sendUserMessage(decision.message, FOLLOW_UP_MESSAGE_OPTIONS as never); + // A wake exists to reach a model that stopped, so it is normally sent + // into an idle session — and a follow-up queued with nothing running is + // never delivered. Unknown idleness (no event seen yet) is treated as + // running: a follow-up that arrives late still arrives. + const delivery = choosePlannerMessageDelivery({ + isIdle: runtime.isIdle?.() === true, + }); + if (delivery === "turn") { + pi.sendUserMessage(decision.message); + } else { + pi.sendUserMessage( + decision.message, + FOLLOW_UP_MESSAGE_OPTIONS as never, + ); + } } catch (error) { await updatePlanState(fs, context.planPaths, (state) => ({ ...state, @@ -4232,14 +4275,24 @@ async function handlePlannerCompactError(input: { formatPlannerCompactFailure(input.error, { boundaryResolved: resolved }), benign ? "info" : "error", ); - if (resolved) { - input.pi.sendUserMessage( - formatPlannerCompactSkipped( - benign ? "session too small to compact" : "compaction failed", - ), - FOLLOW_UP_MESSAGE_OPTIONS, - ); - } + if (!resolved) return; + const message = formatPlannerCompactSkipped( + benign ? "session too small to compact" : "compaction failed", + ); + // A failing ctx.compact() aborts the run on its way in, so this usually lands + // on an idle session — where a follow-up is never delivered. Deferred for the + // same reason the post-compact message is: compact() reconnects the session to + // the agent in a `finally` that has not run yet. + setTimeout(() => { + const delivery = choosePlannerMessageDelivery({ + isIdle: input.ctx.isIdle(), + }); + if (delivery === "turn") { + input.pi.sendUserMessage(message); + } else { + input.pi.sendUserMessage(message, FOLLOW_UP_MESSAGE_OPTIONS); + } + }, 0); } async function maybeStartPlannerStuckCompact(input: { diff --git a/src/runtime/AGENTS.md b/src/runtime/AGENTS.md index 00af7a8..a97249e 100644 --- a/src/runtime/AGENTS.md +++ b/src/runtime/AGENTS.md @@ -103,14 +103,14 @@ Runtime domain for planner stages, model-facing status, tool wrappers, timers, r **Timers / watchdog** — background wake-ups outside the tool-call path. - `timer.ts` → `PlannerTimerRuntimeState` + reconcile loop; reads `active-plan.ts`, gated by `index.tool-visibility.ts` `isPlanActive`, writes `PlannerTimerState` via `storage/state-store.ts`. -- `idle-watchdog.ts` → reads `state.activeTaskId`/`state.step` → emits a follow-up wake-up message if no activity for `idle.timeoutMinutes`; only fires for `IDLE_EXECUTION_STEPS` while not in a `USER_WAIT_STEPS`/blocked/compact state. +- `idle-watchdog.ts` → reads `state.activeTaskId`/`state.step` → emits a wake-up message if no activity for `idle.timeoutMinutes`; only fires for `IDLE_EXECUTION_STEPS` while not in a `USER_WAIT_STEPS`/blocked/compact state. A pending compact boundary with no compaction in flight is the one case that **bypasses** that gate (a compaction that neither completed nor errored would otherwise freeze the session forever) — so every refusal the gate makes has to be restated in `isStuckCompactBoundary`, which is why it asks `isPlannerWaitingOnUser` rather than only `requiresUserDecision`. **Misc** -- `compact.ts` → builds the system-instructions bundle injected after a context compact (`PLANNER_COMPACT_MARKER`/`PLANNER_SYSTEM_INSTRUCTIONS_HEADER`), pulling section content from `instructions/manager.ts`. +- `compact.ts` → builds the system-instructions bundle injected after a context compact (`PLANNER_COMPACT_MARKER`/`PLANNER_SYSTEM_INSTRUCTIONS_HEADER`), pulling section content from `instructions/manager.ts`. It also owns **how** a planner message reaches the model: `choosePlannerMessageDelivery` is the single answer for all three senders (post-compact, idle wake, compact-failure notice). `deliverAs: "followUp"` is a *streaming* mode — the agent loop drains that queue only where it would otherwise stop — so with no run in flight a follow-up is never delivered, it just sits in the input queue. A planner-controlled compaction aborts the run on its way in, which is exactly that state. Idle → a plain `sendUserMessage` (always starts a turn); running → follow-up, which is why the mode exists (a late compaction must not interrupt the run it landed in). Unknown idleness counts as running: delivered late beats not delivered. Both compact-path sends are deferred one macrotask, because `ctx.compact()` disconnects the session from agent events and reconnects in a `finally` that has not run when `session_compact` fires. - **Window management belongs to Pi, not to the planner.** The extension no longer decides *when* to compact for context relief: the window-management compact steps (`compact_discovery`/`spec`/`planning`/`task` and `compact_finalize`) were removed from `PLANNER_STAGE_STEPS`, and the proactive `turn_end` monitor that replaced them (an output-aware token floor) has now been removed too. Reason: the floor was derived from `model.maxTokens` because an extension cannot read Pi's `reserveTokens`, so the two thresholds land wherever the user's settings happen to put them. On a real session (window 131072, `reserveTokens` 24576) our floor sat at 106823 against Pi's 106496 — 0.25% of the window apart, i.e. a guaranteed race instead of the intended separation, and a second compaction started right after Pi's leaves nothing to summarize (`prepareCompaction` measures only what accumulated since the previous cut point, so it throws "Nothing to compact (session too small)" while the window is 96% full). Pi's own auto-compaction sees `reserveTokens` and cannot race itself, so it is the only trigger now. The one surviving compact step is `compact_before_doubt` — a deliberate confidence reset before the doubt audit, not window relief — so `isPlannerCompactEnabled` returns true for it unconditionally and `planner_request_compact` runs it with no token gate at all. Everything that reacts *after* a compaction is untouched and works for Pi's compactions exactly as it did for ours: the `session_before_compact` indicator, the `session_compact` handler, and the post-compact `[SYSTEM_INSTRUCTIONS]` bundle. A legacy `state.json` parked at a removed step is remapped forward by `normalizePlanState` (`storage/state-store.ts`). The `compactBoundaries` (`compact.stage`/`compact.task`) setting and the `state.compactBoundaries` field were removed with the boundaries they gated — a legacy `settings.json` with a `compact` block gets an "unknown key" note, and a legacy `state.json` with a `compactBoundaries` field just ignores it. - `prefix-watch.ts` → pure probe over the actual provider payload (`before_provider_request`). Tool schemas render INTO the system message, so they sit at the very front of the prompt, and every serving backend reuses exactly one thing: a prefix of bytes it already read. `describePayload` reduces a payload to `headChars`/`toolNames`/`toolChars`/`headKey`; `comparePayloads` reports what the head gained and lost; `PrefixWatch` holds ONE previous shape — no history, no defect log, nothing to report from later — and separates *when* (between runs is expected; between two calls of ONE run the prefix was discarded for nothing) from *who* (`setActiveTools` is a global setter and this extension is not the only writer, so `record()` takes the list WE last set — `plannerLastSetToolNames()`). Only mid-run AND foreign earns an alarm; wired in `index.ts` `registerPlannerPrefixWatch`, one `ctx.ui.notify` per run, nothing sent anywhere. It stays armed because the foreign writer is real, not hypothetical: the same session runs pi-telegram-manager, which answers `session_before_compact` with its own compaction. Ported from that extension's `core/payload-probe.ts`. - `compact-eta.ts` → pure empirical ETA for the compaction indicator. `estimateCompactionDuration` fits `T(x)=a+b·x` (weighted least squares, recency- and model-weighted; falls back to a through-origin rate model) over `storage/compact-timing-store.ts` history and reports a point ETA + band + variance (`cv` → `stable`/`noisy`/`single`/`none`). `compactionProgressFraction` drives an honest asymptotic bar that fills to a confidence-scaled target at the ETA and never reaches 100% before the real completion event. Wired in `index.ts` `registerPlannerCompactEvents`, gated by `isPlanActive`; the SDK does not stream summary generation, so this learned model is the only real progress signal. - `skill-library.ts` → `planner_skill_create`/`planner_skill_update`: persists reusable skill markdown + a JSON index (`storage/json.ts`) keyed by source kind (`stuck`/`debug`/`doubt_review`/…); `status.ts` lists active skills from here. - `question-tools.ts` → `planner_questions_submit`/`planner_questions_resolve`, the user-clarification loop; writes state via `storage/state-store.ts`. -- `workflow-tools.ts` → step-finish exit gates: blocks `finish_step` unless required artifacts/sections exist (via `contracts.ts`, `doubt-review.ts`) and the worktree is clean (`git-state-sync.ts` reality). The last checkpoint before `state-transition.ts` is allowed to advance. +- `workflow-tools.ts` → step-finish exit gates: blocks `finish_step` unless required artifacts/sections exist (via `contracts.ts`, `doubt-review.ts`) and the worktree is clean (`git-state-sync.ts` reality). The last checkpoint before `state-transition.ts` is allowed to advance. It also refuses `complete_compact` while `compactionInFlight` — passed in by the caller, never read from a module, because whether a compaction is running is the host's knowledge and the planner's own recovery paths must be able to release a boundary precisely when one *is* in flight. Without that refusal the compact boundary is decorative: nothing checked that the requested compaction had happened, and a measured session cleared it at once and finished the whole plan on the context the boundary exists to drop. diff --git a/src/runtime/compact.test.ts b/src/runtime/compact.test.ts index 7bdeb0c..ac651f4 100644 --- a/src/runtime/compact.test.ts +++ b/src/runtime/compact.test.ts @@ -92,7 +92,12 @@ describe("planner compact runtime", () => { expect(message).toContain("Check git state before resuming."); }); - it("queues post-compact instructions behind pending user messages", () => { + it("starts a turn when nothing is running, because a follow-up would not be delivered", () => { + // The defect this replaces: the post-compact instruction was always queued + // as a follow-up, and the agent loop drains that queue only where it would + // otherwise stop. A planner-controlled compaction aborts the run, so the + // message landed in an idle session and sat in the input queue — one + // measured session waited two hours for a keypress. const calls: Array<{ message: string; options?: { deliverAs: "followUp" }; @@ -102,45 +107,22 @@ describe("planner compact runtime", () => { enqueuePlannerPostCompactMessage({ message: "[SYSTEM_INSTRUCTIONS]\nCall planner_status.", isIdle: true, - hasPendingMessages: true, sendUserMessage(message, options) { calls.push({ message, options }); }, }), - ).toBe("followUp"); - expect(calls).toEqual([ - { - message: "[SYSTEM_INSTRUCTIONS]\nCall planner_status.", - options: { deliverAs: "followUp" }, - }, - ]); - }); - - it("queues post-compact instructions even when Pi reports idle", () => { - const calls: Array<{ - message: string; - options?: { deliverAs: "followUp" }; - }> = []; - - expect( - enqueuePlannerPostCompactMessage({ - message: "[SYSTEM_INSTRUCTIONS]\nCall planner_status.", - isIdle: true, - hasPendingMessages: false, - sendUserMessage(message, options) { - calls.push({ message, options }); - }, - }), - ).toBe("followUp"); + ).toBe("turn"); expect(calls).toEqual([ { message: "[SYSTEM_INSTRUCTIONS]\nCall planner_status.", - options: { deliverAs: "followUp" }, + options: undefined, }, ]); }); it("queues post-compact instructions while Pi is still processing", () => { + // The reason follow-up delivery exists and stays: a compaction that finishes + // late must not interrupt the run it landed in. const calls: Array<{ message: string; options?: { deliverAs: "followUp" }; @@ -150,7 +132,6 @@ describe("planner compact runtime", () => { enqueuePlannerPostCompactMessage({ message: "[SYSTEM_INSTRUCTIONS]\nCall planner_status.", isIdle: false, - hasPendingMessages: false, sendUserMessage(message, options) { calls.push({ message, options }); }, @@ -165,6 +146,8 @@ describe("planner compact runtime", () => { }); it("falls back to follow-up when idle state races with active processing", () => { + // Idle was read a moment earlier; a run may have started since. Starting a + // turn into a live run is the one case that throws rather than queueing. const calls: Array<{ message: string; options?: { deliverAs: "followUp" }; @@ -174,7 +157,6 @@ describe("planner compact runtime", () => { enqueuePlannerPostCompactMessage({ message: "[SYSTEM_INSTRUCTIONS]\nCall planner_status.", isIdle: true, - hasPendingMessages: false, sendUserMessage(message, options) { if (!options) { throw new Error( diff --git a/src/runtime/compact.ts b/src/runtime/compact.ts index 569ac2a..0cf9611 100644 --- a/src/runtime/compact.ts +++ b/src/runtime/compact.ts @@ -42,7 +42,39 @@ export interface PlannerCompactInstructionSection { appendPath: string | null; } -export type PlannerPostCompactDelivery = "followUp"; +/** + * How a planner message reaches the model. + * + * `followUp` queues behind a run that is still going; `turn` starts one. They + * are not interchangeable, and picking the wrong one loses the message — see + * {@link choosePlannerMessageDelivery}. + */ +export type PlannerPostCompactDelivery = "followUp" | "turn"; + +/** + * Which delivery a message needs right now. + * + * `deliverAs: "followUp"` is a *streaming* mode. The agent loop drains the + * follow-up queue only where it would otherwise stop, so a follow-up queued + * while a run is going is delivered at the end of it — which is exactly what + * this wants, and why the mode was introduced: a compaction that finished late + * used to drop its instruction into a turn that had already moved on. + * + * But with no run in flight there is nothing to queue behind. The message sits + * in the input queue as if the user had typed it and never pressed enter. That + * is the observed failure: a planner-controlled compaction aborts the run, so + * by the time `session_compact` fires the agent is idle, the post-compact + * instruction is queued, and the session waits — one measured session sat two + * hours that way. + * + * Idle is therefore the whole question, and `sendUserMessage` with no options + * always starts a turn. + */ +export function choosePlannerMessageDelivery(input: { + isIdle: boolean; +}): PlannerPostCompactDelivery { + return input.isIdle ? "turn" : "followUp"; +} export function createPlannerCompactRuntimeState(): PlannerCompactRuntimeState { return { @@ -251,12 +283,22 @@ export function buildPlannerPostCompactMessage(input: { export function enqueuePlannerPostCompactMessage(input: { message: string; isIdle: boolean; - hasPendingMessages: boolean; sendUserMessage: ( message: string, options?: { deliverAs: "followUp" }, ) => void; }): PlannerPostCompactDelivery { + const delivery = choosePlannerMessageDelivery({ isIdle: input.isIdle }); + if (delivery === "turn") { + try { + input.sendUserMessage(input.message); + return "turn"; + } catch { + // Idle was read a moment ago and a run may have started since. Starting a + // turn into a live run is the one case that throws instead of queueing, + // so fall back to the queue: delivered late beats not delivered. + } + } input.sendUserMessage(input.message, { deliverAs: "followUp" }); return "followUp"; } diff --git a/src/runtime/idle-watchdog.test.ts b/src/runtime/idle-watchdog.test.ts index 5598b38..79b8382 100644 --- a/src/runtime/idle-watchdog.test.ts +++ b/src/runtime/idle-watchdog.test.ts @@ -220,6 +220,26 @@ describe("planner idle watchdog", () => { ).toMatchObject({ action: "disabled" }); }); + it("does not rescue a pending compact where the next move is the user's", () => { + // The rescue bypasses the whole idle gate, so it has to restate every reason + // that gate would have refused for. `done` sets no requiresUserDecision flag + // — it has to be recognised through isPlannerWaitingOnUser, or a boundary + // left pending here would wake the model while the user is the one to act. + for (const position of [ + { stage: "done" as const, step: "await_user_acceptance" as const }, + { stage: "intake" as const, step: "await_goal_approval" as const }, + ]) { + expect( + evaluatePlannerIdleWake({ + state: { ...stuckCompact(), ...position }, + settings, + now: 601_000, + compactionInFlight: false, + }), + ).toMatchObject({ action: "disabled" }); + } + }); + it("waits for the timeout before rescuing a stuck compact", () => { expect( evaluatePlannerIdleWake({ diff --git a/src/runtime/idle-watchdog.ts b/src/runtime/idle-watchdog.ts index 16ccfde..d768688 100644 --- a/src/runtime/idle-watchdog.ts +++ b/src/runtime/idle-watchdog.ts @@ -76,9 +76,15 @@ export function evaluatePlannerIdleWake(input: { }; } -// A pending compact boundary with no compaction in flight, at a normal (not -// broken / user-decision) position — the exact shape a failed or hung -// compaction leaves behind. +// A pending compact boundary with no compaction in flight, at a normal position — +// the exact shape a failed or hung compaction leaves behind. +// +// The rescue bypasses the whole idle gate, so every reason that gate would have +// refused for has to be restated here or it is silently lost. It used to name +// only `broken` and `requiresUserDecision`, which left out the rest of "the next +// move is the user's" — the done stage, goal approval, unanswered questions. A +// boundary that happened to be pending at one of those would have woken the +// model while the user was the one expected to act. function isStuckCompactBoundary( state: PlanStateRecord, compactionInFlight: boolean, @@ -87,7 +93,7 @@ function isStuckCompactBoundary( state.requiresCompact && !compactionInFlight && !state.broken && - !state.requiresUserDecision + !isPlannerWaitingOnUser(state) ); } diff --git a/src/runtime/workflow-tools.test.ts b/src/runtime/workflow-tools.test.ts index 039a0f7..4a2987e 100644 --- a/src/runtime/workflow-tools.test.ts +++ b/src/runtime/workflow-tools.test.ts @@ -110,6 +110,93 @@ describe("workflowToolTransition", () => { expect(result.text).toContain("Call planner_status"); }); + describe("the compact boundary while a compaction runs", () => { + // finalize/compact_before_doubt with the boundary pending — the exact shape + // planner_request_compact leaves behind. + async function pendingCompactBoundary() { + const fs = new MockPlannerFs(); + const git = new MockGitRunner(); + const projectPaths = createProjectStoragePaths({ + agentDir: "/agent", + projectRoot: "/repo/app", + }); + const planPaths = createPlanStoragePaths(projectPaths, "plan-a"); + const worktreePath = "/repo/app/.pi/pi-code-planner/worktrees/plan-a"; + await ensureProjectRecord(fs, projectPaths); + await initializePlanFiles( + fs, + planPaths, + createPlanRecord({ planId: "plan-a", title: "Plan A" }), + ); + await fs.mkdirp(worktreePath); + await initializePlanState(fs, planPaths, { + ...createInitialPlanState({ + baseBranch: "main", + planBranch: "plan/plan-a", + worktreePath, + }), + stage: "finalize", + step: "compact_before_doubt", + stepStatus: "blocked", + requiresCompact: true, + blockedReason: "Planner compact boundary is required.", + currentBranch: "plan/plan-a", + }); + await setActivePlan(fs, projectPaths, "plan-a"); + return { fs, git, projectPaths }; + } + + it("refuses to clear the boundary while the compaction is still running", async () => { + // One measured session cleared it at once and then walked the whole of + // finalize on the context the boundary exists to drop. + const { fs, git, projectPaths } = await pendingCompactBoundary(); + + const result = await executePlannerWorkflowTool({ + fs, + git, + projectPaths, + toolName: "planner_complete_compact", + params: {}, + compactionInFlight: true, + }); + + expect(result.result.status).toBe("blocked"); + expect(result.text).toContain("still running"); + }); + + it("clears the boundary once the compaction has finished", async () => { + const { fs, git, projectPaths } = await pendingCompactBoundary(); + + const result = await executePlannerWorkflowTool({ + fs, + git, + projectPaths, + toolName: "planner_complete_compact", + params: {}, + compactionInFlight: false, + }); + + expect(result.result.status).toBe("applied"); + }); + + it("lets a caller that omits the flag resolve the boundary anyway", async () => { + // The planner's own recovery paths run precisely when a compaction is in + // flight or has just failed, and must be able to release the boundary + // rather than leave the session frozen on it. + const { fs, git, projectPaths } = await pendingCompactBoundary(); + + const result = await executePlannerWorkflowTool({ + fs, + git, + projectPaths, + toolName: "planner_complete_compact", + params: {}, + }); + + expect(result.result.status).toBe("applied"); + }); + }); + it("blocks discovery finish until discovered contracts are routed and read", async () => { const fs = new MockPlannerFs(); const git = new MockGitRunner(); diff --git a/src/runtime/workflow-tools.ts b/src/runtime/workflow-tools.ts index 43a13a4..95103a9 100644 --- a/src/runtime/workflow-tools.ts +++ b/src/runtime/workflow-tools.ts @@ -67,8 +67,18 @@ export type PlannerWorkflowToolName = type PlannerTargetPosition = { stage: PlannerStage; step: PlannerStep }; -export type PlannerWorkflowToolExecutionInput = - PlannerToolExecutionInput; +export interface PlannerWorkflowToolExecutionInput + extends PlannerToolExecutionInput { + /** + * True while a compaction this extension asked for is between the request and + * its completion. Passed in rather than read from anywhere, because whether a + * compaction is running is runtime knowledge that lives in the extension host, + * and because the planner's own recovery paths must be able to resolve a + * boundary precisely when one *is* in flight — they call this without the flag + * and say so on purpose. + */ + compactionInFlight?: boolean; +} export interface PlannerWorkflowToolExecutionResult { text: string; @@ -136,6 +146,7 @@ export async function executePlannerWorkflowTool( ...input, orchestrator, transition, + compactionInFlight: input.compactionInFlight === true, }); if (exitBlock) { const state = @@ -373,7 +384,21 @@ async function validateWorkflowExit(input: { git: GitRunner; orchestrator: Awaited>; transition: PlannerStateTransition; + compactionInFlight?: boolean; }): Promise { + // The compact boundary has to hold, or it means nothing. Nothing used to + // check that the compaction the model asked for had actually happened: the + // tool result asked it to wait, and one measured session shows it did not — + // it cleared the boundary at once and walked the whole of finalize (doubt + // review, verification, final summary) while summarization was still running. + // compact_before_doubt exists so the audit reads persisted artifacts instead + // of live confidence; run that way it achieves the exact opposite. + if ( + input.transition.type === "complete_compact" && + input.compactionInFlight + ) { + return "The compaction you requested is still running. Wait for it to finish — the boundary cannot be cleared while the context it exists to drop is still in the conversation. When it completes you will be told to call planner_status; call planner_complete_compact after that."; + } if ( input.transition.type !== "finish_step" || input.orchestrator.preflight.context.status !== "ready"