diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a1d007d18..3e4fdcb7e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -177,7 +177,7 @@ When a cycle's input tokens cross a threshold, the director compacts the inferen - **Idle (end-of-turn)** — An interactive turn can end with a reply and then sit idle with no tool batch to intercept; the governor requests a continuation at that pause and compacts when it arrives. An operator message that races the continuation still compacts first, then re-enters inference to answer it. - **Overflow recovery** — A `context_overflow` inference error would otherwise become a terminal error reply; the governor compacts and retries instead, bounded so a history the compactor cannot shrink does not loop forever. Overflow ignores hysteresis for the compact itself. -The compaction control flow is shaped by a reactor invariant: a `compact` action runs in its own cycle (it cannot be paired with `infer`), and **the reactor delivers no event after a compact cycle**. A director that simply emitted `compact` in place of the follow-up `infer` would leave the loop idle forever — the cause of an earlier stall. Instead the governor, after emitting `compact`, self-delivers a content-less inbound message (a host-supplied `requestContinuation` callback). That message adds no turn (`createInboundTurn` returns `null` for empty content) but re-enters the loop, where the director issues the follow-up `infer` against the freshly truncated history. +The compaction control flow is shaped by a reactor invariant: a `compact` action runs in its own cycle (it cannot be paired with `infer`), and **the reactor delivers no event after a compact cycle**. A director that simply emitted `compact` in place of the follow-up `infer` would leave the loop idle forever — the cause of an earlier stall. Instead the governor pairs `compact` with an emit action (`custom.compaction.continue`), and the host answers that emission by delivering a content-less inbound message (`buildCompactionContinuationMessage()`) through the serial operation queue, generation-guarded like every other deliver. That message adds no turn (`createInboundTurn` returns `null` for empty content) but re-enters the loop, where the director issues the follow-up `infer` against the freshly truncated history. Each emission is answered at most once (a replayed duplicate of an already-answered emission is ignored), and an unsolicited continuation with neither resume flag set answers `wait` rather than burning a billable inference. The legacy `requestContinuation` closure survives only on the sub-agent path, which delivers directly with no host emit hop; idle arming keeps the two channels exclusive (closure fires and the arming flag stays false, or no closure and the flag reports the arming) so a caller honoring both cannot double-deliver. Compaction replaces older turns with a structured, workflow-aware summary rather than a stats blob: sections for **What Happened / What We're Doing / Relevant Links / Action Items / Next Steps**, with the active workflow and step woven in so compacting mid-`/build` or mid-`/plan` preserves the contract. The summary is produced by a one-shot model call; on any failure it falls back to a deterministic summary so a compaction cycle never breaks the session. diff --git a/src/agent/compaction.test.ts b/src/agent/compaction.test.ts index 99d78c742..44e9a8808 100644 --- a/src/agent/compaction.test.ts +++ b/src/agent/compaction.test.ts @@ -6,7 +6,10 @@ import type { ReactorInboundEvent, TokenUsage, } from "@intx/types/runtime"; -import { createCompactionGovernor } from "./compaction.js"; +import { + COMPACTION_CONTINUATION_EVENT, + createCompactionGovernor, +} from "./compaction.js"; import { compactionResumeDeltaFor, compactionThresholdFor, @@ -28,6 +31,11 @@ const capabilities = { compactor, reason, }), + emit: (eventType: string, data: unknown) => ({ + type: "emit", + eventType, + data, + }), } as unknown as ReactorCapabilities; // Distinct, non-zero cacheRead/cacheWrite so a test asserting on the total @@ -182,15 +190,34 @@ describe("compaction governor", () => { ).toBeNull(); }); - test("stays inert without a continuation channel", () => { + test("expresses continuation as an emit action without a closure", () => { const governor = createCompactionGovernor(undefined); governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + const actions = governor.interceptActions( + toolDone(), + inferAction, + capabilities, + ); + expect(actions?.some((a) => a.type === "compact")).toBe(true); + expect(actions?.some((a) => a.type === "infer")).toBe(false); expect( - governor.interceptActions(toolDone(), inferAction, capabilities), - ).toBeNull(); - expect( - governor.interceptOverflow(overflowError(), capabilities), - ).toBeNull(); + actions?.some( + (a) => + a.type === "emit" && + "eventType" in a && + a.eventType === COMPACTION_CONTINUATION_EVENT, + ), + ).toBe(true); + expect( + governor + .interceptOverflow(overflowError(), capabilities) + ?.some( + (a) => + a.type === "emit" && + "eventType" in a && + a.eventType === COMPACTION_CONTINUATION_EVENT, + ), + ).toBe(true); }); test("recovers from context overflow a bounded number of times", () => { @@ -241,6 +268,61 @@ describe("compaction governor", () => { ).toBeNull(); }); + test("an idle over-threshold turn arms an emit continuation without a closure", () => { + const governor = createCompactionGovernor(undefined); + governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + + const terminal: ReactorAction[] = [{ type: "reply", content: "done" }]; + expect(governor.noteIdleTurn(inferenceDone(overThreshold), terminal)).toBe( + true, + ); + // Only arms once even if the idle turn is observed again. + expect(governor.noteIdleTurn(inferenceDone(overThreshold), terminal)).toBe( + false, + ); + + const actions = governor.interceptIdleContinuation( + emptyMessage(), + capabilities, + ); + expect( + actions?.some( + (a) => + a.type === "compact" && + "reason" in a && + a.reason === "context-threshold", + ), + ).toBe(true); + expect( + actions?.some( + (a) => + a.type === "emit" && + "eventType" in a && + a.eventType === COMPACTION_CONTINUATION_EVENT, + ), + ).toBe(true); + }); + + test("idle arming with a closure fires once and never returns true", () => { + // Single-delivery contract: a caller that both installs the legacy closure + // and honors the boolean (appending an emit action on true) must still + // deliver exactly once. The closure fires; the return stays false. + let continuations = 0; + const governor = createCompactionGovernor(() => continuations++); + governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + + const terminal: ReactorAction[] = [{ type: "reply", content: "done" }]; + expect(governor.noteIdleTurn(inferenceDone(overThreshold), terminal)).toBe( + false, + ); + expect(continuations).toBe(1); + // A repeated idle turn neither refires nor arms. + expect(governor.noteIdleTurn(inferenceDone(overThreshold), terminal)).toBe( + false, + ); + expect(continuations).toBe(1); + }); + // Idle compact with an empty continuation previously left postCompactInfer // unset, so resumeAfterCompact never fired and notePostCompact never ran — // the Ctx meter stayed on pre-compact lastTurnUsage until the next user turn. diff --git a/src/agent/compaction.ts b/src/agent/compaction.ts index 90ad892ce..9f3fab454 100644 --- a/src/agent/compaction.ts +++ b/src/agent/compaction.ts @@ -40,12 +40,26 @@ const MAX_CONSECUTIVE_THRESHOLD_COMPACTS = 2; // A compact action runs in its own reactor cycle, after which the reactor // idles until the next inbound event. Worker loops (sub-agents, the coding // director) have no operator to send that next message, so the governor swaps -// the post-tool infer for a compact action, asks the host to deliver an empty -// continuation message, and re-issues the infer when that message arrives. -// Without a continuation channel the governor stays inert: stalling the loop -// would be worse than growing the context. +// the post-tool infer for a compact action, requests a continuation re-entry, +// and re-issues the infer when that message arrives. Without a continuation +// channel the governor stays inert: stalling the loop would be worse than +// growing the context. export type CompactionGovernor = ReturnType; +// Continuation re-entry expressed as a ReactorAction: the reactor emits this +// event on the agent stream and the host answers it with +// buildCompactionContinuationMessage(), the same message the old +// requestContinuation closure delivered. Subscribers that only care about +// provider/connector traffic must ignore this event. +export const COMPACTION_CONTINUATION_EVENT = "custom.compaction.continue"; + +/** Build the continuation re-entry action for a compacted governor cycle. */ +export function compactionContinuationAction( + capabilities: ReactorCapabilities, +): ReactorAction { + return capabilities.emit(COMPACTION_CONTINUATION_EVENT, {}); +} + export function createCompactionGovernor( requestContinuation?: () => void, systemPrompt = "", @@ -119,6 +133,20 @@ export function createCompactionGovernor( noteCompactIssued(); } + // Continuation re-entry for a compact-bearing return. The legacy subagent + // path still holds a host closure and drives its stall-ping loop through + // it; the chat path holds none and gets an emit action the host answers + // with a deliver instead. + function continuationActions( + capabilities: ReactorCapabilities, + ): ReactorAction[] { + if (requestContinuation !== undefined) { + requestContinuation(); + return []; + } + return [compactionContinuationAction(capabilities)]; + } + function isSpacerEchoTerminal( event: ReactorInboundEvent, actions: ReactorAction[], @@ -141,7 +169,6 @@ export function createCompactionGovernor( if (event.turn.content.some((block) => block.type === "tool_call")) { consecutiveThresholdCompacts = 0; } - if (requestContinuation === undefined) return; syncFromTurns(turns); lastModel = event.source?.model; const reportedTokens = contextTokensFromUsage(event.usage); @@ -190,31 +217,43 @@ export function createCompactionGovernor( pending = false; postCompactInfer = true; issueThresholdCompact(); - requestContinuation?.(); return [ ...actions.filter((a) => a.type !== "infer"), capabilities.compact(COMPACTOR_NAME, "context-threshold"), + ...continuationActions(capabilities), ]; } // Interactive sessions can end a turn with a reply and then sit idle, so a // pending compaction would wait indefinitely for the next tool batch. When - // the turn ends without follow-up work, ask the host for a continuation and + // the turn ends without follow-up work, request a continuation re-entry and // compact when it (or the operator's next message) arrives. + // + // Single-delivery contract: the closure channel and the boolean return are + // mutually exclusive, matching continuationActions below. When a legacy + // closure is installed (the sub-agent path) it fires here and this returns + // false, so a caller that also honors the return cannot double-deliver. + // When no closure is installed (the chat path) nothing fires and the return + // reports whether this call newly armed the idle continuation — the caller + // must then append compactionContinuationAction to its returned actions. function noteIdleTurn( event: ReactorInboundEvent, actions: ReactorAction[], - ): void { - if (!pending || idlePending || requestContinuation === undefined) return; - if (atThresholdCompactCap()) return; - if (!onTurnBoundary(event)) return; - if (isSpacerEchoTerminal(event, actions)) return; + ): boolean { + if (!pending || idlePending) return false; + if (atThresholdCompactCap()) return false; + if (!onTurnBoundary(event)) return false; + if (isSpacerEchoTerminal(event, actions)) return false; const terminal = actions.some((a) => a.type === "reply" || a.type === "wait") && !actions.some((a) => a.type === "infer" || a.type === "execute_tools"); - if (!terminal) return; + if (!terminal) return false; idlePending = true; - requestContinuation(); + if (requestContinuation !== undefined) { + requestContinuation(); + return false; + } + return true; } function interceptIdleContinuation( @@ -240,8 +279,10 @@ export function createCompactionGovernor( postCompactMeter = true; } issueThresholdCompact(); - requestContinuation?.(); - return [capabilities.compact(COMPACTOR_NAME, "context-threshold")]; + return [ + capabilities.compact(COMPACTOR_NAME, "context-threshold"), + ...continuationActions(capabilities), + ]; } // A context-overflow inference error would otherwise terminate the loop @@ -251,7 +292,6 @@ export function createCompactionGovernor( event: ReactorInboundEvent, capabilities: ReactorCapabilities, ): ReactorAction[] | null { - if (requestContinuation === undefined) return null; if ( event.type !== "inference.error" || event.error.category !== "context_overflow" @@ -263,8 +303,10 @@ export function createCompactionGovernor( pending = false; postCompactInfer = true; noteCompactIssued(); - requestContinuation(); - return [capabilities.compact(COMPACTOR_NAME, "context-overflow")]; + return [ + capabilities.compact(COMPACTOR_NAME, "context-overflow"), + ...continuationActions(capabilities), + ]; } // After compact, a content-less continuation re-enters decide. "infer" means @@ -296,6 +338,14 @@ export function createCompactionGovernor( usingEstimate = true; } + // True while the governor expects the host to answer a continuation emit. + // The post-compact resume flags are consume-on-hit, so an empty + // message.received that finds neither set is unsolicited — forged or a + // replayed duplicate — and answering it would burn a billable inference. + function hasOutstandingContinuation(): boolean { + return postCompactInfer || postCompactMeter; + } + return { get estimatedTokens(): number { return estimate.tokens; @@ -314,5 +364,6 @@ export function createCompactionGovernor( interceptIdleContinuation, interceptOverflow, resumeAfterCompact, + hasOutstandingContinuation, }; } diff --git a/src/agent/director.ts b/src/agent/director.ts index 9d62e0450..a248bf200 100644 --- a/src/agent/director.ts +++ b/src/agent/director.ts @@ -21,6 +21,7 @@ import { } from "../session/compactor.js"; import type { WorkflowCoordinator } from "../workflows/coordinator.js"; import { + compactionContinuationAction, createCompactionGovernor, type CompactionGovernor, } from "./compaction.js"; @@ -444,7 +445,6 @@ export interface ChatDirectorOptions { inactivityTimeoutMs?: number | undefined; totalTimeoutMs?: number | undefined; workflowCoordinator?: WorkflowCoordinator | undefined; - requestContinuation?: (() => void) | undefined; provider?: { providerName: string; model?: string } | undefined; /** * CL-7918 decisions (both former closures removed, no new env key): @@ -572,8 +572,10 @@ class ChatDirectorImpl extends DefaultDirector { this.totalTimeoutMs = options.totalTimeoutMs; this.taskClassifier = options.taskClassifier; this.workflowCoordinator = options.workflowCoordinator; + // The chat path holds no continuation closure: the governor expresses + // continuation as an emit action the host answers with a deliver. this.compaction = createCompactionGovernor( - options.requestContinuation, + undefined, composedPrompt, toolDefinitions, ); @@ -769,6 +771,22 @@ class ChatDirectorImpl extends DefaultDirector { const recovery = this.compaction.interceptOverflow(event, capabilities); if (recovery !== null) return recovery; + // A forged or replayed compaction continuation arrives as an empty + // message.received with no outstanding compact state (the legit resume + // is consumed above). Answering it with infer would burn a billable + // model turn and reset the loop-protection budgets below, so hold the + // loop instead. + if (event.type === "message.received") { + const content = + typeof event.message.content === "string" ? event.message.content : ""; + if ( + content.length === 0 && + !this.compaction.hasOutstandingContinuation() + ) { + return capabilities.wait(); + } + } + // Only `aborted` (internal-recovery-abort) lands here: the harness's own // retry policy already owns `timeout`/`retryable`/`quota_exhausted` and // has exhausted its own attempt budget (up to MAX_ATTEMPTS full-context @@ -1064,13 +1082,21 @@ class ChatDirectorImpl extends DefaultDirector { ); } - this.compaction.noteIdleTurn(event, baseActions); + // Idle arming returns an emit action (continuation as a ReactorAction) + // so the host re-enters the loop and the governor can compact on the + // continuation's arrival. + const idleContinuationArmed = this.compaction.noteIdleTurn( + event, + baseActions, + ); const compacted = this.compaction.interceptActions( event, baseActions, capabilities, ); if (compacted !== null) return compacted; + if (idleContinuationArmed) + return [...baseActions, compactionContinuationAction(capabilities)]; // Loop protection takes precedence over workflow/open-task // continuation nudges below: those exist to keep a session moving, diff --git a/src/director.test.ts b/src/director.test.ts index b2b78c360..91b32d909 100644 --- a/src/director.test.ts +++ b/src/director.test.ts @@ -5,6 +5,7 @@ import { CHAT_TASKS_CHANGED_EVENT, CHAT_TOOLS_ACTIVATE_EVENT, } from "./agent/director.js"; +import { COMPACTION_CONTINUATION_EVENT } from "./agent/compaction.js"; import { createAgentToolset } from "./agent/tools.js"; import { createAdvertisedToolset } from "./session/assemble-runtime.js"; import { createPermissionGate } from "./permission/gate.js"; @@ -742,12 +743,7 @@ describe("chatDirector compaction", () => { } test("schedules idle compaction after an over-threshold text-only reply", async () => { - let continuations = 0; - const director = createChatDirector("", [], { - requestContinuation: () => { - continuations++; - }, - }); + const director = createChatDirector("", [], {}); // One turn past createPruningCompactor's own no-op floor (session/compactor.ts), // so the arming check finds a history actually worth compacting. const longState = { @@ -770,7 +766,15 @@ describe("chatDirector compaction", () => { ); expect(replyActions.some((a) => a.type === "reply")).toBe(true); expect(replyActions.some((a) => a.type === "compact")).toBe(false); - expect(continuations).toBe(1); + // Continuation is expressed as an emit action the host drives. + expect( + replyActions.some( + (a) => + a.type === "emit" && + "eventType" in a && + a.eventType === COMPACTION_CONTINUATION_EVENT, + ), + ).toBe(true); const compactActions = actionsArray( await director.decide(messageReceived(""), longState, mockCapabilities), @@ -781,16 +785,16 @@ describe("chatDirector compaction", () => { compactor: "pruning-compactor", reason: "context-threshold", }, + { + type: "emit", + eventType: COMPACTION_CONTINUATION_EVENT, + data: {}, + }, ]); - // Idle empty compact schedules a second continuation so decide can adopt - // the shrunk turns for the meter without starting a new inference. - expect(continuations).toBe(2); }); test("idle empty compact makes the post-compact estimate authoritative without inferring", async () => { - const director = createChatDirector("", [], { - requestContinuation: () => undefined, - }); + const director = createChatDirector("", [], {}); const largeTurns = Array.from( { length: compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS) + 1 }, (_, i) => ({ @@ -876,17 +880,12 @@ describe("chatDirector compaction", () => { } as unknown as ReactorInboundEvent; } - function chatDirectorWithContinuation( - systemPrompt: string, - onContinuation?: () => void, - ) { - return createChatDirector(systemPrompt, [], { - requestContinuation: onContinuation ?? (() => undefined), - }); + function chatDirector(systemPrompt: string) { + return createChatDirector(systemPrompt, [], {}); } test("compacts at the tool.done pause once over threshold", async () => { - const director = chatDirectorWithContinuation("Corbits operating prompt"); + const director = chatDirector("Corbits operating prompt"); await director.decide(overThresholdToolTurn(), longState, mockCapabilities); const actions = actionsArray( await director.decide( @@ -924,7 +923,7 @@ describe("chatDirector compaction", () => { // turn); it now falls through to the base director's terminal // checkpoint + reply instead of recovering. test("does not re-issue inference for a timeout already exhausted by the harness", async () => { - const director = chatDirectorWithContinuation(""); + const director = chatDirector(""); const timeout = { type: "inference.error", error: { category: "timeout", message: "request timed out" }, @@ -938,7 +937,7 @@ describe("chatDirector compaction", () => { }); test("recovers an internally aborted inference but keeps explicit abort terminal", async () => { - const director = chatDirectorWithContinuation("Corbits operating prompt"); + const director = chatDirector("Corbits operating prompt"); const internalAbort = { type: "inference.error", error: { @@ -968,7 +967,7 @@ describe("chatDirector compaction", () => { }); test("does not auto-recover user-stop aborted inference errors", async () => { - const director = chatDirectorWithContinuation(""); + const director = chatDirector(""); const userStopAbort = { type: "inference.error", error: { @@ -992,22 +991,23 @@ describe("chatDirector compaction", () => { }); test("a context_overflow inference error triggers compact-and-retry, not a terminal reply", async () => { - let continuations = 0; - const director = chatDirectorWithContinuation( - "Corbits operating prompt", - () => continuations++, - ); + const director = chatDirector("Corbits operating prompt"); const actions = actionsArray( await director.decide(overflowError(), longState, mockCapabilities), ); + // Continuation is expressed as an emit action the host drives. expect(actions).toEqual([ { type: "compact", compactor: "pruning-compactor", reason: "context-overflow", }, + { + type: "emit", + eventType: COMPACTION_CONTINUATION_EVENT, + data: {}, + }, ]); - expect(continuations).toBe(1); const resumed = actionsArray( await director.decide(messageReceived(""), longState, mockCapabilities), @@ -1020,7 +1020,7 @@ describe("chatDirector compaction", () => { }); test("overflow recovery is bounded so an incompressible history cannot loop forever", async () => { - const director = chatDirectorWithContinuation(""); + const director = chatDirector(""); for (let i = 0; i < 2; i++) { const actions = actionsArray( await director.decide(overflowError(), longState, mockCapabilities), @@ -1035,7 +1035,7 @@ describe("chatDirector compaction", () => { }); test("chat posture is preserved: an idle turn never terminates the session", async () => { - const director = chatDirectorWithContinuation(""); + const director = chatDirector(""); const idle = actionsArray( await director.decide(textInferenceDone(10), longState, mockCapabilities), ); @@ -1720,12 +1720,14 @@ describe("chatDirector spacer echo", () => { }); test("spacer-echo does not arm idle compact, including after the nudge cap", async () => { - let continuations = 0; - const director = createChatDirector("base", [], { - requestContinuation: () => { - continuations++; - }, - }); + const director = createChatDirector("base", [], {}); + const hasContinuationEmit = (actions: ReactorAction[]) => + actions.some( + (a) => + a.type === "emit" && + "eventType" in a && + a.eventType === COMPACTION_CONTINUATION_EVENT, + ); for (let i = 0; i < 2; i++) { const nudged = actionsArray( await director.decide( @@ -1735,7 +1737,7 @@ describe("chatDirector spacer echo", () => { ), ); expect(nudged.some((a) => a.type === "infer")).toBe(true); - expect(continuations).toBe(0); + expect(hasContinuationEmit(nudged)).toBe(false); } const settled = actionsArray( await director.decide( @@ -1750,7 +1752,7 @@ describe("chatDirector spacer echo", () => { (a) => a.type === "reply" && "content" in a && a.content === "", ), ).toBe(true); - expect(continuations).toBe(0); + expect(hasContinuationEmit(settled)).toBe(false); }); test("echo-nudge cap is two then empty settle, and resets on message.received", async () => { diff --git a/src/exec/runner.ts b/src/exec/runner.ts index 1196a1594..e347a8fc0 100644 --- a/src/exec/runner.ts +++ b/src/exec/runner.ts @@ -77,6 +77,7 @@ import { type ExpandPluginPathSkip, } from "../plugins/loader.js"; import { consumeStream } from "../session/stream-consumer.js"; +import { COMPACTION_CONTINUATION_EVENT } from "../agent/compaction.js"; import { generateSessionId, initSessionDir, @@ -104,6 +105,7 @@ import { buildCompactionContinuationMessage, buildShellBackgroundMessage, buildSubAgentProvider, + createContinuationGate, createSessionPruningCompactor, loadSessionChatPrompt, skillDirsFromEnabledPlugins, @@ -845,10 +847,6 @@ export async function runExec(config: Config): Promise { computeAdvertised, inactivityTimeoutMs: config.inactivityTimeoutMs ?? 750_000, totalTimeoutMs: config.totalTimeoutMs, - requestContinuation: () => { - // Compaction governor self-delivers after compact so the loop re-enters. - currentAgent?.deliver(buildCompactionContinuationMessage()); - }, getProvider: () => config, getWorkdir: () => workdir, getSessionId: () => sessionId, @@ -961,6 +959,9 @@ export async function runExec(config: Config): Promise { await workflowHost.resume(); const textChunks: string[] = []; + // Consume-once gate for the compaction continuation emit: a replayed + // duplicate of an already-answered emission must not re-deliver. + const continuationGate = createContinuationGate(); // Cycles persist to the context store only on inference.done; the recorder // keeps the in-flight cycle's text so an errored or aborted turn leaves // its partial output in partial.jsonl instead of vanishing. @@ -998,6 +999,13 @@ export async function runExec(config: Config): Promise { ? { providerId: error.providerId } : {}), }; + } else if (event.type === COMPACTION_CONTINUATION_EVENT) { + // Compaction governor self-delivers after compact so the loop re-enters. + // Each emission is answered once: a replayed duplicate of an + // already-answered emission is ignored instead of re-delivered. + if (continuationGate.shouldDeliver(event.seq)) { + currentAgent?.deliver(buildCompactionContinuationMessage()); + } } liveSink.sink(event); cycleRecorder.handleEvent(event); diff --git a/src/session/assemble-runtime.test.ts b/src/session/assemble-runtime.test.ts index f805e97b2..f56f3b194 100644 --- a/src/session/assemble-runtime.test.ts +++ b/src/session/assemble-runtime.test.ts @@ -238,7 +238,6 @@ function stubChatAgentWiring( }, computeAdvertised: () => [], inactivityTimeoutMs: 1_000, - requestContinuation: () => undefined, getProvider: () => ({ providerName: "test", model: "m" }), getWorkdir: () => "/build-dir", getSessionId: () => "test-session", diff --git a/src/session/assemble-runtime.ts b/src/session/assemble-runtime.ts index 25fcde2a8..a12cea832 100644 --- a/src/session/assemble-runtime.ts +++ b/src/session/assemble-runtime.ts @@ -477,8 +477,6 @@ export interface ChatAgentWiring { * the TUI seeds it (fleet lanes may appear mid-session). */ allowIdleWithFleet?: boolean; - /** Compaction governor re-entry (the reactor emits no event after compact). */ - requestContinuation: () => void; getProvider: () => { providerName: string; model: string }; /** Pre-created holder so the workflow controller can close over it first. */ directorHolder?: { instance?: ChatDirector }; @@ -529,7 +527,6 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent { { inactivityTimeoutMs: wiring.inactivityTimeoutMs, totalTimeoutMs: wiring.totalTimeoutMs, - requestContinuation: wiring.requestContinuation, provider: { ...wiring.getProvider() }, // CL-7918: idle-with-fleet seed replaces the former // getLiveFleetCount closure (CL-7972 keeps it live via the diff --git a/src/session/runtime-assembly.test.ts b/src/session/runtime-assembly.test.ts index ad97c2046..959237f1e 100644 --- a/src/session/runtime-assembly.test.ts +++ b/src/session/runtime-assembly.test.ts @@ -23,6 +23,7 @@ import { buildMailboxMailMessage, buildSubAgentProvider, createApprovalPersist, + createContinuationGate, createLiveSubAgentSources, createSessionPruningCompactor, loadSeededApprovals, @@ -538,6 +539,24 @@ describe("buildCompactionContinuationMessage", () => { }); }); +describe("createContinuationGate", () => { + test("delivers each continuation emission once and ignores a replayed duplicate", () => { + const gate = createContinuationGate(); + expect(gate.shouldDeliver(7)).toBe(true); + // A replayed duplicate of the answered emission must not re-deliver: + // each delivery costs a billable inference. + expect(gate.shouldDeliver(7)).toBe(false); + // A distinct emission is still answered. + expect(gate.shouldDeliver(8)).toBe(true); + }); + + test("gates are per-host: a fresh gate answers the same seq", () => { + const first = createContinuationGate(); + expect(first.shouldDeliver(3)).toBe(true); + expect(createContinuationGate().shouldDeliver(3)).toBe(true); + }); +}); + describe("buildMailboxMailMessage", () => { test("builds a system inbound with mailbox mail content", () => { const message = buildMailboxMailMessage("mailbox mail — reports"); diff --git a/src/session/runtime-assembly.ts b/src/session/runtime-assembly.ts index bf9229a37..0ed179064 100644 --- a/src/session/runtime-assembly.ts +++ b/src/session/runtime-assembly.ts @@ -442,6 +442,31 @@ export function buildCompactionContinuationMessage(): InboundMessage { }; } +/** + * Per-host consume-once gate for the compaction continuation emit. The + * reactor emits the continuation before compact runs and emits nothing + * after, so a host that only sees the stream cannot observe "compact + * occurred" — the emission itself is the outstanding-continuation claim. + * Each emission (keyed by its session-scoped seq) is answered at most once: + * a replayed duplicate of an already-answered emission is ignored instead + * of re-delivered, since each delivery costs a billable inference. A forged + * emission with a fresh seq still delivers, but the director answers an + * unsolicited continuation with wait rather than infer, so that path cannot + * burn a model turn either. + */ +export function createContinuationGate(): { + shouldDeliver: (seq: number) => boolean; +} { + let lastDeliveredSeq: number | null = null; + return { + shouldDeliver: (seq: number): boolean => { + if (seq === lastDeliveredSeq) return false; + lastDeliveredSeq = seq; + return true; + }, + }; +} + /** * System-originated inbound that re-enters the parent after the fleet goes dry * with todo/doing tasks still open. Not operator input, so no diff --git a/src/tui/deliver-agent-message.test.ts b/src/tui/deliver-agent-message.test.ts index aed319015..904182c64 100644 --- a/src/tui/deliver-agent-message.test.ts +++ b/src/tui/deliver-agent-message.test.ts @@ -3,6 +3,7 @@ import { AgentClosedError } from "@intx/agent"; import { deliverAgentMessage, deliveryResultNotice, + runGenerationGuardedDeliver, } from "./deliver-agent-message.js"; describe("deliverAgentMessage", () => { @@ -72,6 +73,58 @@ describe("deliverAgentMessage", () => { }); }); +describe("runGenerationGuardedDeliver", () => { + test("a reload between enqueue and execution drops the deliver without touching the agent", async () => { + // Pins the reload-vs-async-deliver verdict: the serial op queue is FIFO + // with no preemption, so a continuation answer queued ahead of a reload + // still executes — the generation re-check at execution time is what + // keeps the stale answer from reaching the replaced agent. + let generation = 1; + const stillCurrent = () => generation === 1; + // Enqueue captures the closure; the reload lands before it executes. + const queued = () => + runGenerationGuardedDeliver({ + stillCurrent, + onStale: () => ({ + status: "not-delivered", + reason: "superseded", + detail: "session identity changed before delivery", + }), + run: async () => { + delivered += 1; + return { status: "accepted" as const }; + }, + }); + let delivered = 0; + generation = 2; + const stale = await queued(); + expect(stale).toEqual({ + status: "not-delivered", + reason: "superseded", + detail: "session identity changed before delivery", + }); + expect(delivered).toBe(0); + }); + + test("a current deliver runs the real settle exactly once", async () => { + let runs = 0; + const result = await runGenerationGuardedDeliver({ + stillCurrent: () => true, + onStale: () => ({ + status: "not-delivered", + reason: "superseded", + detail: "stale", + }), + run: async () => { + runs += 1; + return { status: "accepted" as const }; + }, + }); + expect(result).toEqual({ status: "accepted" }); + expect(runs).toBe(1); + }); +}); + describe("deliveryResultNotice", () => { test("closed restored and deferred copy is actionable", () => { const closed = { diff --git a/src/tui/deliver-agent-message.ts b/src/tui/deliver-agent-message.ts index 7b2b8545b..0a70b3f07 100644 --- a/src/tui/deliver-agent-message.ts +++ b/src/tui/deliver-agent-message.ts @@ -59,6 +59,28 @@ export async function deliverAgentMessage( } } +/** + * Settles a deliver that was enqueued on the serial operation queue against + * the shoot generation captured at enqueue time. The queue is FIFO with no + * preemption, so a deliver queued ahead of a reload still executes after the + * reload has replaced the agent — the generation must be re-checked when the + * queued closure runs, not just when it enqueues. A stale deliver takes the + * `onStale` path (the caller reports `not-delivered`); a current deliver runs + * the real settle. This is what closes the reload-vs-async-deliver race: a + * reload that lands while a continuation answer is queued wins, and the stale + * answer is dropped instead of reaching the replaced agent. + */ +export async function runGenerationGuardedDeliver(options: { + stillCurrent: () => boolean; + run: () => Promise; + onStale: () => AgentDeliveryResult; +}): Promise { + if (!options.stillCurrent()) { + return options.onStale(); + } + return options.run(); +} + /** Operator-facing copy for a settled delivery that did not accept. */ export function deliveryResultNotice( result: Exclude, diff --git a/src/tui/runner/exit.ts b/src/tui/runner/exit.ts index 2a2bca448..db4fac458 100644 --- a/src/tui/runner/exit.ts +++ b/src/tui/runner/exit.ts @@ -13,6 +13,11 @@ import { import { getLogger } from "@intx/log"; import type { InferenceSource } from "@intx/types/runtime"; import { consumeStream } from "../../session/stream-consumer.js"; +import { COMPACTION_CONTINUATION_EVENT } from "../../agent/compaction.js"; +import { + buildCompactionContinuationMessage, + createContinuationGate, +} from "../../session/runtime-assembly.js"; import { liveFleetCount } from "../../subagent/index.js"; import { getTelemetry } from "../../telemetry/singleton.js"; import { onTurnBoundary } from "../../agent/reactor-events.js"; @@ -285,6 +290,9 @@ export async function createRunLifecycle( services.crashGuard.setPartialFlush(() => services.cycleRecorder.dispose("crashed").then(() => undefined), ); + // Consume-once gate for the compaction continuation emit: a replayed + // duplicate of an already-answered emission must not re-deliver. + const continuationGate = createContinuationGate(); const streamSink = ( event: Parameters[0], ): void => { @@ -312,6 +320,18 @@ export async function createRunLifecycle( } } else if (event.type === "message.run.ended") { providerFailureAttempts.consumeTerminal(); + } else if (event.type === COMPACTION_CONTINUATION_EVENT) { + // Compaction continuation as a ReactorAction: re-enter the loop with + // the same message the old requestContinuation closure delivered, + // through the serial op queue like every other deliver. Each emission + // is answered once: a replayed duplicate of an already-answered + // emission is ignored instead of re-delivered. + if (continuationGate.shouldDeliver(event.seq)) { + const targetAgent = liveAgent(state); + state.enqueueAgentDeliver?.(() => + targetAgent.deliver(buildCompactionContinuationMessage()), + ); + } } // Chat-director reactor events (replacing the former onTasksChange / // onActivateTools closures): task-list changes repaint the chrome panel, diff --git a/src/tui/runner/session.ts b/src/tui/runner/session.ts index 745c1400d..083ad5558 100644 --- a/src/tui/runner/session.ts +++ b/src/tui/runner/session.ts @@ -60,7 +60,6 @@ import type { CompactionArchive } from "../../session/compaction-archive.js"; import { createApprovalResume } from "../../session/approval-resume.js"; import { createReactorAuthorize } from "../../permission/reactor-authorize.js"; import { - buildCompactionContinuationMessage, buildShellBackgroundMessage, createLiveSubAgentSources, createSessionPruningCompactor, @@ -93,6 +92,7 @@ import type { ToolWatchdogConfig } from "../tool-execution-watchdog.js"; import { deliverAgentMessage, deliveryResultNotice, + runGenerationGuardedDeliver, type AgentDeliveryResult, } from "../deliver-agent-message.js"; import { createProviderFailureAttemptTracker } from "../provider/failure-attempt.js"; @@ -533,20 +533,24 @@ export async function assembleTUISession( ): void => { const stillCurrent = deliveryGeneration.capture(); void sessionOps.enqueue(async () => { - if (!stillCurrent()) { - onSettle?.({ + // The shell already popped the queue item and painted it as delivered + // by the time this runs, so a failed rebuild or closed agent must settle + // here — otherwise the message silently never reaches the agent. The + // generation is re-checked at execution time (the queue is FIFO with no + // preemption), so a reload that lands while this deliver is queued wins + // and the stale answer is dropped as superseded. + const result = await runGenerationGuardedDeliver({ + stillCurrent, + onStale: () => ({ status: "not-delivered", reason: "superseded", detail: "session identity changed before delivery", - }); - return; - } - // The shell already popped the queue item and painted it as delivered - // by the time this runs, so a failed rebuild or closed agent must settle - // here — otherwise the message silently never reaches the agent. - const result = await deliverAgentMessage({ - getFatalBuildError: () => state.fatalBuildError, - deliverToLiveAgent, + }), + run: () => + deliverAgentMessage({ + getFatalBuildError: () => state.fatalBuildError, + deliverToLiveAgent, + }), }); if (onSettle !== undefined) { onSettle(result); @@ -617,15 +621,11 @@ export async function assembleTUISession( // mid-session; CL-7972 keeps it live via the fleet-wake publisher); // retry stamping tracks the live source id in-reactor now. // (No onTasksChange: task/tool updates arrive as reactor events consumed - // in the stream sink; no getLiveFleetCount: the publisher drives the - // allowance through setAllowIdleWithFleet.) + // in the stream sink; no requestContinuation: compaction re-entry arrives + // as the COMPACTION_CONTINUATION_EVENT reactor emission consumed there + // instead of a host closure; no getLiveFleetCount: the publisher drives + // the allowance through setAllowIdleWithFleet.) allowIdleWithFleet: true, - requestContinuation: () => { - const targetAgent = liveAgent(state); - state.enqueueAgentDeliver?.(() => - targetAgent.deliver(buildCompactionContinuationMessage()), - ); - }, getProvider: () => state.config, directorHolder, getWorkdir: () => state.workdir, diff --git a/tests/integration/harness.ts b/tests/integration/harness.ts index 381f3adcf..2701ee8d5 100644 --- a/tests/integration/harness.ts +++ b/tests/integration/harness.ts @@ -56,8 +56,10 @@ import { } from "../../src/session/summarizer.js"; import { buildCompactionContinuationMessage, + createContinuationGate, createSessionPruningCompactor, } from "../../src/session/runtime-assembly.js"; +import { COMPACTION_CONTINUATION_EVENT } from "../../src/agent/compaction.js"; export const INTEGRATION_SOURCE: InferenceSource = { id: "anthropic:claude-integration", @@ -129,12 +131,6 @@ export async function openIntegrationSession( factory: (_config, _env, agentCtx) => createChatDirector(agentCtx.systemPrompt, [...agentCtx.toolDefinitions], { inactivityTimeoutMs: 750_000, - ...(opts.compactionCompletion !== undefined - ? { - requestContinuation: () => - agent.deliver(buildCompactionContinuationMessage()), - } - : {}), }), }); @@ -280,9 +276,20 @@ export async function runUntilDone( const events: ReactorEmittedEvent[] = []; const stream = session.agent.stream(); let turnComplete = false; + // Consume-once gate for the compaction continuation emit: a replayed + // duplicate of an already-answered emission must not re-deliver. + const continuationGate = createContinuationGate(); const collect = (async () => { for await (const event of stream) { events.push(event); + if (event.type === COMPACTION_CONTINUATION_EVENT) { + // Compaction continuation as a ReactorAction: re-enter the loop with + // the same message the old requestContinuation closure delivered. + // Each emission is answered once. + if (continuationGate.shouldDeliver(event.seq)) { + session.agent.deliver(buildCompactionContinuationMessage()); + } + } if (turnComplete && event.type === "message.run.ended") return; } })(); diff --git a/tests/unit/director.test.ts b/tests/unit/director.test.ts index bd422c2f2..a26a0e60a 100644 --- a/tests/unit/director.test.ts +++ b/tests/unit/director.test.ts @@ -8,6 +8,7 @@ import type { LastCycleSource, } from "@intx/types/runtime"; import { createChatDirector } from "../../src/agent/director.js"; +import { COMPACTION_CONTINUATION_EVENT } from "../../src/agent/compaction.js"; // --------------------------------------------------------------------------- // Minimal stubs @@ -88,8 +89,9 @@ function makeCapabilities(): ReactorCapabilities & { calls: string[] } { // --------------------------------------------------------------------------- // Chat director compaction: a compact cycle must not strand the reactor loop. -// The reactor delivers no event after compact, so the director self-delivers an -// empty message (requestContinuation) and infers on the next message.received. +// The reactor delivers no event after compact, so the director returns a +// continuation emit action (the host answers it with a deliver) and infers +// on the next message.received. // --------------------------------------------------------------------------- function toolDoneTurn(callId: string): ReactorInboundEvent { @@ -141,17 +143,21 @@ const manyTurnsState: ReactorState = { })), }; -function makeChatDirectorWithContinuation(onContinue: () => void) { - return createChatDirector("sys", [], { - requestContinuation: onContinue, - }); +function makeChatDirector() { + return createChatDirector("sys", [], {}); } -test("current context over threshold emits compact and a continuation request, not a dead loop", async () => { - let continuations = 0; - const director = makeChatDirectorWithContinuation(() => { - continuations++; - }); +function hasContinuationEmit(actions: ReactorAction[]): boolean { + return actions.some( + (a) => + a.type === "emit" && + "eventType" in a && + a.eventType === COMPACTION_CONTINUATION_EVENT, + ); +} + +test("current context over threshold emits compact and a continuation emit, not a dead loop", async () => { + const director = makeChatDirector(); // A cycle whose input usage exceeds ~60% of the default 128k window arms compaction. await director.decide( @@ -172,7 +178,7 @@ test("current context over threshold emits compact and a continuation request, n expect(arr.some((a) => a.type === "compact")).toBe(true); expect(arr.some((a) => a.type === "infer")).toBe(false); - expect(continuations).toBe(1); + expect(hasContinuationEmit(arr)).toBe(true); // The self-delivered empty message resumes inference against truncated history. const resumeCaps = makeCapabilities(); @@ -186,10 +192,7 @@ test("current context over threshold emits compact and a continuation request, n }); test("compaction is self-regulating: a cycle back under threshold does not re-compact", async () => { - let continuations = 0; - const director = makeChatDirectorWithContinuation(() => { - continuations++; - }); + const director = makeChatDirector(); // After a compaction truncates history, the next cycle's input usage falls // back under the threshold — so no further compaction is armed. This is the @@ -208,7 +211,54 @@ test("compaction is self-regulating: a cycle back under threshold does not re-co expect(arr.some((a) => a.type === "compact")).toBe(false); expect(arr.some((a) => a.type === "infer")).toBe(true); - expect(continuations).toBe(0); + expect(hasContinuationEmit(arr)).toBe(false); +}); + +test("a fresh director answers an unsolicited empty continuation without inferring", async () => { + const director = makeChatDirector(); + + // No compact ever ran, so no continuation is outstanding: a forged or + // replayed continuation message must not cost a billable inference. + const result = await director.decide( + emptyMessageReceived(), + state, + makeCapabilities(), + ); + const arr = Array.isArray(result) ? result : [result]; + expect(arr.some((a) => a.type === "infer")).toBe(false); +}); + +test("a duplicated continuation resumes inference at most once", async () => { + const director = makeChatDirector(); + + await director.decide( + inferenceDoneWithInput(100_000), + manyTurnsState, + makeCapabilities(), + ); + await director.decide( + toolDoneTurn("call-1"), + manyTurnsState, + makeCapabilities(), + ); + + // The first continuation resumes the interrupted loop. + const first = await director.decide( + emptyMessageReceived(), + state, + makeCapabilities(), + ); + const firstArr = Array.isArray(first) ? first : [first]; + expect(firstArr.some((a) => a.type === "infer")).toBe(true); + + // A replayed duplicate of the same continuation must not re-infer. + const second = await director.decide( + emptyMessageReceived(), + state, + makeCapabilities(), + ); + const secondArr = Array.isArray(second) ? second : [second]; + expect(secondArr.some((a) => a.type === "infer")).toBe(false); }); // ---------------------------------------------------------------------------