Skip to content

Commit 4c68a02

Browse files
committed
fix(tui): land compact continue on the replacement agent
1 parent 445886d commit 4c68a02

9 files changed

Lines changed: 262 additions & 32 deletions

File tree

‎docs/ARCHITECTURE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ When a cycle's input tokens cross a threshold, the director compacts the inferen
192192
- **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.
193193
- **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.
194194

195-
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.
195+
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. A hop superseded because interrupt already bumped generation and enqueued a rebuild is re-queued onto the replacement agent rather than consumed on the outgoing liveAgent. 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.
196196

197197
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.
198198

‎src/subagent/ask-director.test.ts‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import {
55
ASK_DIRECTOR_MAX_QUESTIONS,
66
commitAskDirector,
77
createAskDirectorState,
8+
createDeferredContinuation,
89
evaluateAskDirector,
910
handleAskDirector,
1011
releaseAskDirector,
1112
resetAskDirectorTurn,
12-
skipStallContinuationWhileAskPending,
1313
} from "./ask-director.js";
1414

1515
describe("evaluateAskDirector", () => {
@@ -207,18 +207,43 @@ describe("evaluateAskDirector", () => {
207207
describe("requestContinuation stall skip", () => {
208208
test("is a no-op while askDirectorState.pending", () => {
209209
const state = createAskDirectorState();
210+
const latch = createDeferredContinuation();
210211
let delivered = 0;
211-
skipStallContinuationWhileAskPending(state, () => {
212+
const deliver = () => {
212213
delivered += 1;
213-
});
214+
};
215+
latch.request(state, deliver);
214216
expect(delivered).toBe(1);
215217

216218
expect(evaluateAskDirector({ question: "which file?", state }).ok).toBe(
217219
true,
218220
);
219-
skipStallContinuationWhileAskPending(state, () => {
221+
latch.request(state, deliver);
222+
expect(delivered).toBe(1);
223+
});
224+
225+
test("ask-pending skip defers compact continuation until the ask releases", () => {
226+
const state = createAskDirectorState();
227+
const latch = createDeferredContinuation();
228+
let delivered = 0;
229+
const deliver = () => {
220230
delivered += 1;
221-
});
231+
};
232+
233+
expect(evaluateAskDirector({ question: "which file?", state }).ok).toBe(
234+
true,
235+
);
236+
latch.request(state, deliver);
237+
expect(delivered).toBe(0);
238+
239+
latch.flush(state, deliver);
240+
expect(delivered).toBe(0);
241+
242+
releaseAskDirector(state);
243+
latch.flush(state, deliver);
244+
expect(delivered).toBe(1);
245+
246+
latch.flush(state, deliver);
222247
expect(delivered).toBe(1);
223248
});
224249
});

‎src/subagent/ask-director.ts‎

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -89,15 +89,30 @@ export function resetAskDirectorTurn(state: AskDirectorState): void {
8989
}
9090

9191
/**
92-
* Waiting on the spawning director is work, not silence. A stall ping
93-
* would otherwise salvage a blocked ask_director as stalled.
92+
* Compact continuation shares the stall ping channel. Skipping while
93+
* ask_director is parked must not drop the only continue: remember the skip
94+
* and flush after the ask releases.
9495
*/
95-
export function skipStallContinuationWhileAskPending(
96-
state: AskDirectorState,
97-
deliver: () => void,
98-
): void {
99-
if (state.pending) return;
100-
deliver();
96+
export function createDeferredContinuation(): {
97+
request: (state: AskDirectorState, deliver: () => void) => void;
98+
flush: (state: AskDirectorState, deliver: () => void) => void;
99+
} {
100+
let deferred = false;
101+
return {
102+
request(state, deliver) {
103+
if (state.pending) {
104+
deferred = true;
105+
return;
106+
}
107+
deferred = false;
108+
deliver();
109+
},
110+
flush(state, deliver) {
111+
if (!deferred || state.pending) return;
112+
deferred = false;
113+
deliver();
114+
},
115+
};
101116
}
102117

103118
export async function handleAskDirector(args: {

‎src/subagent/run.ts‎

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -152,9 +152,9 @@ import {
152152
ASK_DIRECTOR_MAX_BYTES,
153153
ASK_DIRECTOR_MAX_QUESTIONS,
154154
createAskDirectorState,
155+
createDeferredContinuation,
155156
handleAskDirector,
156157
resetAskDirectorTurn,
157-
skipStallContinuationWhileAskPending,
158158
} from "./ask-director.js";
159159
import {
160160
abortError,
@@ -625,6 +625,8 @@ async function runSubAgentInner(
625625
let turnToken = params.tier === "leaf" ? generateSessionId() : undefined;
626626
const submitResultState = createSubmitResultState();
627627
const askDirectorState = createAskDirectorState();
628+
const compactContinue = createDeferredContinuation();
629+
let deliverCompactContinue = (): void => undefined;
628630
const spawnRegistry = createSubAgentSpawnRegistryPlugin();
629631
// Assigned inside the try once the agent handle exists; before that (or after
630632
// close) a completion is dropped, matching the continuation contract.
@@ -862,12 +864,16 @@ async function runSubAgentInner(
862864
"Record the question under Blockers and finish with the markdown report envelope."
863865
);
864866
}
865-
return handleAskDirector({
866-
question: rawArgs.question,
867-
state: askDirectorState,
868-
port,
869-
signal,
870-
});
867+
try {
868+
return await handleAskDirector({
869+
question: rawArgs.question,
870+
state: askDirectorState,
871+
port,
872+
signal,
873+
});
874+
} finally {
875+
compactContinue.flush(askDirectorState, deliverCompactContinue);
876+
}
871877
},
872878
}),
873879
];
@@ -1038,13 +1044,14 @@ async function runSubAgentInner(
10381044
ReturnType<typeof createAgentWithLiveToolDispatch>
10391045
> | null = null;
10401046
const requestContinuation = (): void => {
1041-
skipStallContinuationWhileAskPending(askDirectorState, () => {
1042-
try {
1043-
agentHandle?.deliver(buildCompactionContinuationMessage());
1044-
} catch {
1045-
// Agent may be closing; a dropped continuation is harmless.
1046-
}
1047-
});
1047+
compactContinue.request(askDirectorState, deliverCompactContinue);
1048+
};
1049+
deliverCompactContinue = (): void => {
1050+
try {
1051+
agentHandle?.deliver(buildCompactionContinuationMessage());
1052+
} catch {
1053+
// Agent may be closing; a dropped continuation is harmless.
1054+
}
10481055
};
10491056

10501057
// modelFamilyPolicy is resolved above at the skill mount so the

‎src/tui/deliver-agent-message.test.ts‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import { describe, expect, test } from "bun:test";
22
import { AgentClosedError } from "@intx/agent";
33
import {
4+
createDeliveryGeneration,
5+
createSessionOperationQueue,
46
deliverAgentMessage,
57
deliveryResultNotice,
8+
enqueueCompactionContinuationHop,
69
runGenerationGuardedDeliver,
10+
settleCompactionContinuationHop,
711
} from "./delivery-queue.js";
12+
import { startInterruptRebuild } from "./runner/exit.js";
813

914
describe("deliverAgentMessage", () => {
1015
test("reports session-unavailable without calling deliver when rebuild failed", async () => {
@@ -125,6 +130,101 @@ describe("runGenerationGuardedDeliver", () => {
125130
});
126131
});
127132

133+
describe("settleCompactionContinuationHop", () => {
134+
test("a superseded hop requeues instead of delivering to the outgoing agent", async () => {
135+
let generation = 1;
136+
const stillCurrent = () => generation === 1;
137+
let delivered = 0;
138+
let requeued = 0;
139+
generation = 2;
140+
const result = await settleCompactionContinuationHop({
141+
stillCurrent,
142+
deliver: async () => {
143+
delivered += 1;
144+
return { status: "accepted" as const };
145+
},
146+
onSuperseded: () => {
147+
requeued += 1;
148+
},
149+
});
150+
expect(result).toEqual({
151+
status: "not-delivered",
152+
reason: "superseded",
153+
detail: "session identity changed before delivery",
154+
});
155+
expect(delivered).toBe(0);
156+
expect(requeued).toBe(1);
157+
});
158+
159+
test("a closed hop re-issues the continue once", async () => {
160+
let attempts = 0;
161+
const result = await settleCompactionContinuationHop({
162+
stillCurrent: () => true,
163+
deliver: async () => {
164+
attempts += 1;
165+
if (attempts === 1) {
166+
return {
167+
status: "not-delivered" as const,
168+
reason: "agent-closed" as const,
169+
detail: "agent is closed",
170+
};
171+
}
172+
return { status: "accepted" as const };
173+
},
174+
onSuperseded: () => {
175+
throw new Error("closed hop must not requeue as superseded");
176+
},
177+
});
178+
expect(result).toEqual({ status: "accepted" });
179+
expect(attempts).toBe(2);
180+
});
181+
182+
test("a current hop delivers once and does not re-issue", async () => {
183+
let runs = 0;
184+
const result = await settleCompactionContinuationHop({
185+
stillCurrent: () => true,
186+
deliver: async () => {
187+
runs += 1;
188+
return { status: "accepted" as const };
189+
},
190+
onSuperseded: () => {
191+
throw new Error("current hop must not requeue");
192+
},
193+
});
194+
expect(result).toEqual({ status: "accepted" });
195+
expect(runs).toBe(1);
196+
});
197+
198+
test("continuation enqueued then interrupt rebuild queued lands on the replacement agent", async () => {
199+
const { enqueue, awaitTail } = createSessionOperationQueue();
200+
const deliveryGeneration = createDeliveryGeneration();
201+
let agent = "outgoing";
202+
const deliveredTo: string[] = [];
203+
204+
enqueueCompactionContinuationHop({
205+
enqueue,
206+
captureGeneration: () => deliveryGeneration.capture(),
207+
deliver: async () => {
208+
deliveredTo.push(agent);
209+
return { status: "accepted" as const };
210+
},
211+
onResult: () => undefined,
212+
});
213+
214+
startInterruptRebuild({
215+
deliveryGeneration,
216+
markSendAborted: () => undefined,
217+
enqueue,
218+
rebuild: async () => {
219+
agent = "replacement";
220+
},
221+
});
222+
223+
await awaitTail();
224+
expect(deliveredTo).toEqual(["replacement"]);
225+
});
226+
});
227+
128228
describe("deliveryResultNotice", () => {
129229
test("closed restored and deferred copy is actionable", () => {
130230
const closed = {

‎src/tui/delivery-queue.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,68 @@ export async function runGenerationGuardedDeliver(options: {
104104
return options.run();
105105
}
106106

107+
function compactionContinuationIsSuperseded(
108+
result: AgentDeliveryResult,
109+
): boolean {
110+
return result.status === "not-delivered" && result.reason === "superseded";
111+
}
112+
113+
/**
114+
* Compact continuation is consume-once at the stream gate. Interrupt rebuild
115+
* bumps generation and enqueues a rebuild behind this hop, so a stale hop
116+
* must re-queue rather than retry liveAgent in this op. A closed hop still
117+
* retries once here.
118+
*/
119+
export async function settleCompactionContinuationHop(options: {
120+
stillCurrent: () => boolean;
121+
deliver: () => Promise<AgentDeliveryResult>;
122+
onSuperseded: () => void;
123+
}): Promise<AgentDeliveryResult> {
124+
const first = await runGenerationGuardedDeliver({
125+
stillCurrent: options.stillCurrent,
126+
onStale: () => ({
127+
status: "not-delivered",
128+
reason: "superseded",
129+
detail: "session identity changed before delivery",
130+
}),
131+
run: options.deliver,
132+
});
133+
if (compactionContinuationIsSuperseded(first)) {
134+
options.onSuperseded();
135+
return first;
136+
}
137+
if (first.status === "not-delivered" && first.reason === "agent-closed") {
138+
return options.deliver();
139+
}
140+
return first;
141+
}
142+
143+
/**
144+
* Enqueue a compact-continue hop. If interrupt already bumped generation and
145+
* queued a rebuild behind this hop, the continue is scheduled again after
146+
* that rebuild instead of retrying liveAgent in the same op.
147+
*/
148+
export function enqueueCompactionContinuationHop(options: {
149+
enqueue: (op: () => Promise<void>) => unknown;
150+
captureGeneration: () => () => boolean;
151+
deliver: () => Promise<AgentDeliveryResult>;
152+
onResult: (result: AgentDeliveryResult) => void;
153+
}): void {
154+
const schedule = (): void => {
155+
const stillCurrent = options.captureGeneration();
156+
void options.enqueue(async () => {
157+
const result = await settleCompactionContinuationHop({
158+
stillCurrent,
159+
deliver: options.deliver,
160+
onSuperseded: schedule,
161+
});
162+
if (compactionContinuationIsSuperseded(result)) return;
163+
options.onResult(result);
164+
});
165+
};
166+
schedule();
167+
}
168+
107169
/** Operator-facing copy for a settled delivery that did not accept. */
108170
export function deliveryResultNotice(
109171
result: Exclude<AgentDeliveryResult, { status: "accepted" }>,

‎src/tui/runner/exit.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -325,11 +325,13 @@ export async function createRunLifecycle(
325325
// the same message the old requestContinuation closure delivered,
326326
// through the serial op queue like every other deliver. Each emission
327327
// is answered once: a replayed duplicate of an already-answered
328-
// emission is ignored instead of re-delivered.
328+
// emission is ignored instead of re-delivered. A hop superseded by
329+
// interrupt rebuild (generation bump + rebuild already queued) is
330+
// re-queued onto the replacement agent so consume-once cannot land on
331+
// the outgoing liveAgent.
329332
if (continuationGate.shouldDeliver(event.seq)) {
330-
const targetAgent = liveAgent(state);
331-
state.enqueueAgentDeliver?.(() =>
332-
targetAgent.deliver(buildCompactionContinuationMessage()),
333+
state.enqueueCompactionContinuation?.(() =>
334+
liveAgent(state).deliver(buildCompactionContinuationMessage()),
333335
);
334336
}
335337
}

0 commit comments

Comments
 (0)