Skip to content

Commit 4db5277

Browse files
committed
Replace pointer-comparison stale check with epoch-based invalidation in spawn renderer
1 parent f3c698e commit 4db5277

4 files changed

Lines changed: 140 additions & 34 deletions

File tree

agenticoding.test.ts

Lines changed: 91 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -969,6 +969,7 @@ test("nested spawn live action tracks tool execution events", () => {
969969
const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
970970
const { session, emit } = createSubscribableSession([]);
971971
state.childSessions.set("tool-call-1", session);
972+
state.liveChildSessions.set("tool-call-1", session);
972973

973974
// Mock console.warn to suppress any expected-but-harmless warnings
974975
// (e.g., streaming component errors in headless test env).
@@ -1012,6 +1013,7 @@ test("nested spawn handleEvent recovers from malformed events", () => {
10121013
const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
10131014
const { session, emit } = createSubscribableSession([]);
10141015
state.childSessions.set("tool-call-1", session);
1016+
state.liveChildSessions.set("tool-call-1", session);
10151017

10161018
const warnings: any[] = [];
10171019
const originalWarn = console.warn;
@@ -1044,6 +1046,7 @@ test("nested spawn message_end with aborted stopReason clears pending tools", ()
10441046
const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
10451047
const { session, emit } = createSubscribableSession([]);
10461048
state.childSessions.set("tool-call-1", session);
1049+
state.liveChildSessions.set("tool-call-1", session);
10471050

10481051
const component = childSpawnTool.renderResult(
10491052
{ content: [{ type: "text", text: "ignored" }], details: { depth: 1, model: "m", thinking: "low", truncated: false } },
@@ -1066,6 +1069,7 @@ test("nested spawn dispose stops event processing", () => {
10661069
const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
10671070
const { session, emit } = createSubscribableSession([]);
10681071
state.childSessions.set("tool-call-1", session);
1072+
state.liveChildSessions.set("tool-call-1", session);
10691073

10701074
const component = childSpawnTool.renderResult(
10711075
{ content: [{ type: "text", text: "ignored" }], details: { depth: 1, model: "m", thinking: "low", truncated: false } },
@@ -1670,6 +1674,7 @@ test("nested spawn rapid events collapse to last state", () => {
16701674
const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
16711675
const { session, emit } = createSubscribableSession([]);
16721676
state.childSessions.set("tool-call-1", session);
1677+
state.liveChildSessions.set("tool-call-1", session);
16731678

16741679
const component = childSpawnTool.renderResult(
16751680
{ content: [], details: { depth: 1, model: "m", thinking: "low", truncated: false } },
@@ -1697,30 +1702,107 @@ test("nested spawn rapid events collapse to last state", () => {
16971702
assert.ok(finalLines.some((l: string) => l.includes("✓")));
16981703
});
16991704

1700-
test("nested spawn isDetachedFromLiveState drops events when session is replaced", () => {
1705+
test("nested spawn drops late events after live registry deletion", () => {
17011706
const state = createState();
17021707
const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
17031708
const { session, emit } = createSubscribableSession([]);
17041709
state.childSessions.set("tool-call-1", session);
17051710
state.liveChildSessions.set("tool-call-1", session);
1711+
let invalidateCalls = 0;
17061712

17071713
const component = childSpawnTool.renderResult(
17081714
{ content: [{ type: "text", text: "initial" }], details: { depth: 1, model: "m", thinking: "low", truncated: false } },
17091715
{ expanded: false },
17101716
theme,
1711-
createRenderContext(),
1717+
createRenderContext({ invalidate: () => { invalidateCalls++; } }),
1718+
) as any;
1719+
const before = component.render(120);
1720+
1721+
state.liveChildSessions.delete("tool-call-1");
1722+
emit({ type: "message_start", message: { role: "assistant", content: [] } });
1723+
1724+
const after = component.render(120);
1725+
assert.equal(invalidateCalls, 0, "completed-session deletion should stop rerenders from late events");
1726+
assert.deepEqual(after, before, "completed-session deletion should freeze the rendered state");
1727+
});
1728+
1729+
test("nested spawn drops events after resetState bumps child epoch", () => {
1730+
const state = createState();
1731+
const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
1732+
const { session, emit } = createSubscribableSession([]);
1733+
state.childSessions.set("tool-call-1", session);
1734+
state.liveChildSessions.set("tool-call-1", session);
1735+
let invalidateCalls = 0;
1736+
1737+
const component = childSpawnTool.renderResult(
1738+
{ content: [{ type: "text", text: "initial" }], details: { depth: 1, model: "m", thinking: "low", truncated: false } },
1739+
{ expanded: false },
1740+
theme,
1741+
createRenderContext({ invalidate: () => { invalidateCalls++; } }),
1742+
) as any;
1743+
const before = component.render(120);
1744+
1745+
resetState(state);
1746+
emit({ type: "message_start", message: { role: "assistant", content: [] } });
1747+
1748+
const after = component.render(120);
1749+
assert.equal(invalidateCalls, 0, "stale events should not request rerender after reset");
1750+
assert.deepEqual(after, before, "stale events should not change rendered state after reset");
1751+
});
1752+
1753+
test("nested spawn drops events when session is replaced in live state", () => {
1754+
const state = createState();
1755+
const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
1756+
const { session, emit } = createSubscribableSession([]);
1757+
state.childSessions.set("tool-call-1", session);
1758+
state.liveChildSessions.set("tool-call-1", session);
1759+
let invalidateCalls = 0;
1760+
1761+
const component = childSpawnTool.renderResult(
1762+
{ content: [{ type: "text", text: "initial" }], details: { depth: 1, model: "m", thinking: "low", truncated: false } },
1763+
{ expanded: false },
1764+
theme,
1765+
createRenderContext({ invalidate: () => { invalidateCalls++; } }),
17121766
) as any;
1767+
const before = component.render(120);
17131768

1714-
// Replace the session in liveChildSessions with a different object
17151769
const replacementSession = createSubscribableSession([]).session;
17161770
state.liveChildSessions.set("tool-call-1", replacementSession);
1771+
emit({ type: "message_start", message: { role: "assistant", content: [] } });
1772+
1773+
const after = component.render(120);
1774+
assert.equal(invalidateCalls, 0, "replaced sessions should not request rerender");
1775+
assert.deepEqual(after, before, "replaced sessions should not change rendered state");
1776+
});
17171777

1718-
// Emit a message_start event — should be silently dropped
1778+
test("nested spawn completed-session deletion stays stale even if the toolCallId is later reused", () => {
1779+
const state = createState();
1780+
const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
1781+
const { session, emit } = createSubscribableSession([]);
1782+
state.childSessions.set("tool-call-1", session);
1783+
state.liveChildSessions.set("tool-call-1", session);
1784+
let invalidateCalls = 0;
1785+
1786+
const component = childSpawnTool.renderResult(
1787+
{ content: [{ type: "text", text: "initial" }], details: { depth: 1, model: "m", thinking: "low", truncated: false } },
1788+
{ expanded: false },
1789+
theme,
1790+
createRenderContext({ invalidate: () => { invalidateCalls++; } }),
1791+
) as any;
1792+
const before = component.render(120);
1793+
1794+
state.liveChildSessions.delete("tool-call-1");
17191795
emit({ type: "message_start", message: { role: "assistant", content: [] } });
1796+
const afterDeletion = component.render(120);
1797+
assert.equal(invalidateCalls, 0, "completed-session deletion should immediately stale the old session");
1798+
assert.deepEqual(afterDeletion, before, "completed-session deletion should freeze the rendered state before reuse");
17201799

1721-
const lines = component.render(120);
1722-
// Should NOT contain "thinking" because the event was dropped
1723-
assert.ok(lines.every((l: string) => !l.includes("thinking")), `expected no thinking after detach, got: ${lines.join("\n")}`);
1800+
state.liveChildSessions.set("tool-call-1", createSubscribableSession([]).session);
1801+
emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "should be dropped" }] } });
1802+
const afterReuse = component.render(120);
1803+
assert.equal(invalidateCalls, 0, "toolCallId reuse should not revive a completed stale session");
1804+
assert.deepEqual(afterReuse, before, "toolCallId reuse should keep the old rendered state frozen");
1805+
assert.ok(afterReuse.every((l: string) => !l.includes("should be dropped")), "toolCallId reuse should not admit stale text updates");
17241806
});
17251807

17261808
test("concurrent spawn executions produce independent results", async () => {
@@ -1792,6 +1874,7 @@ test("nested spawn render cache preserves stable output for identical params", (
17921874
const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
17931875
const { session } = createSubscribableSession([]);
17941876
state.childSessions.set("tool-call-1", session);
1877+
state.liveChildSessions.set("tool-call-1", session);
17951878

17961879
const component = childSpawnTool.renderResult(
17971880
{ content: [{ type: "text", text: "hello" }], details: { depth: 1, model: "m", thinking: "low", truncated: false } },
@@ -1944,6 +2027,7 @@ test("handleEvent gracefully degrades with null message events", () => {
19442027
const childSpawnTool = createChildTools(new MockPi() as any, state, "medium", 0).find(t => t.name === "spawn")!;
19452028
const { session, emit } = createSubscribableSession([]);
19462029
state.childSessions.set("tool-call-1", session);
2030+
state.liveChildSessions.set("tool-call-1", session);
19472031

19482032
const component = childSpawnTool.renderResult(
19492033
{ content: [], details: { depth: 1, model: "m", thinking: "low", truncated: false } },

spawn/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -335,8 +335,8 @@ export async function executeSpawn(
335335
await abortAndInvalidate();
336336
}
337337

338-
// liveChildSessions must be set first — renderSpawnResult checks it to decide
339-
// whether to pass the live registry to attachSession for stale detection.
338+
// liveChildSessions must be set before childSessions so the renderer can
339+
// attach with a fully-published live ownership record.
340340
state.liveChildSessions.set(toolCallId, session);
341341
state.childSessions.set(toolCallId, session);
342342

spawn/renderer.ts

Lines changed: 41 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,8 @@ class NestedAgentSessionComponent extends Container {
178178
private details?: SpawnResultDetails;
179179
private nestTheme?: Theme;
180180
private ownedToolCallId?: string;
181-
private liveChildSessions?: Map<string, AgentSession>;
181+
private state?: AgenticodingState;
182+
private attachedChildSessionEpoch?: number;
182183
private liveOutcome: SpawnOutcome = "running";
183184
// States: "⏳ initializing…" → "💭 thinking…" → "[tool] …/preview" or live text → terminal outcome
184185
private lastAction = "";
@@ -226,11 +227,16 @@ class NestedAgentSessionComponent extends Container {
226227
if (changed) this.clearRenderCache();
227228
}
228229

229-
attachSession(toolCallId: string, session: AgentSession, liveChildSessions?: Map<string, AgentSession>): void {
230+
attachSession(
231+
toolCallId: string,
232+
session: AgentSession,
233+
state: AgenticodingState,
234+
): void {
230235
if (
231236
this.session === session
232237
&& this.ownedToolCallId === toolCallId
233-
&& this.liveChildSessions === liveChildSessions
238+
&& this.state === state
239+
&& this.attachedChildSessionEpoch === state.childSessionEpoch
234240
) {
235241
return;
236242
}
@@ -239,7 +245,8 @@ class NestedAgentSessionComponent extends Container {
239245
this.unsubscribe = undefined;
240246
this.session = session;
241247
this.ownedToolCallId = toolCallId;
242-
this.liveChildSessions = liveChildSessions;
248+
this.state = state;
249+
this.attachedChildSessionEpoch = state.childSessionEpoch;
243250
this.liveOutcome = this.details?.outcome ?? "running";
244251
this.toolNames.clear();
245252
this.toolComponentFailures.clear();
@@ -270,18 +277,34 @@ class NestedAgentSessionComponent extends Container {
270277
}
271278

272279
/**
273-
* Returns true when the session held by this component has been replaced
274-
* in the liveChildSessions map (e.g., after a resetState). When stale, the
275-
* component silently drops all events to avoid operating on a different
276-
* session than what it was attached to.
280+
* Returns the ownership invalidation reason for the attached session.
281+
*
282+
* Three stale paths:
283+
* 1. resetState() bumped childSessionEpoch after attach, invalidating all
284+
* prior child sessions even if their objects still exist.
285+
* 2. state.liveChildSessions no longer contains this toolCallId because the
286+
* child completed and cleared its live ownership.
287+
* 3. state.liveChildSessions now points this toolCallId at a different
288+
* session, meaning a newer child claimed the slot.
277289
*/
290+
private getStaleSessionReason(): "epoch" | "completion" | "replacement" | undefined {
291+
if (!this.session || !this.ownedToolCallId) {
292+
return undefined;
293+
}
294+
if (this.state && this.attachedChildSessionEpoch !== this.state.childSessionEpoch) {
295+
return "epoch";
296+
}
297+
const liveChildSessions = this.state?.liveChildSessions;
298+
if (!liveChildSessions?.has(this.ownedToolCallId)) {
299+
return "completion";
300+
}
301+
return liveChildSessions.get(this.ownedToolCallId) !== this.session
302+
? "replacement"
303+
: undefined;
304+
}
305+
278306
private isStaleSession(): boolean {
279-
return !!(
280-
this.session
281-
&& this.ownedToolCallId
282-
&& this.liveChildSessions
283-
&& this.liveChildSessions.get(this.ownedToolCallId) !== this.session
284-
);
307+
return this.getStaleSessionReason() !== undefined;
285308
}
286309

287310
dispose(): void {
@@ -291,7 +314,7 @@ class NestedAgentSessionComponent extends Container {
291314
// dispose, the nulled-out fields prevent double-abort.
292315
const session = this.session;
293316
const ownedToolCallId = this.ownedToolCallId;
294-
const liveChildSessions = this.liveChildSessions;
317+
const liveChildSessions = this.state?.liveChildSessions;
295318
this.clearRenderCache();
296319
this.details = undefined;
297320
this.nestTheme = undefined;
@@ -300,7 +323,8 @@ class NestedAgentSessionComponent extends Container {
300323
this.toolComponentFailures.clear();
301324
this.session = undefined;
302325
this.ownedToolCallId = undefined;
303-
this.liveChildSessions = undefined;
326+
this.state = undefined;
327+
this.attachedChildSessionEpoch = undefined;
304328
if (session && ownedToolCallId && liveChildSessions?.get(ownedToolCallId) === session) {
305329
session.abort().catch(e => console.error("[spawn] abort failed:", ownedToolCallId, e));
306330
liveChildSessions.delete(ownedToolCallId);
@@ -776,10 +800,7 @@ function renderSpawnResult(
776800
}
777801
const child = state.childSessions.get(context.toolCallId);
778802
if (child) {
779-
const liveChildSessions = state.liveChildSessions.get(context.toolCallId) === child
780-
? state.liveChildSessions
781-
: undefined;
782-
component.attachSession(context.toolCallId, child, liveChildSessions);
803+
component.attachSession(context.toolCallId, child, state);
783804
state.childSessions.delete(context.toolCallId);
784805
return component;
785806
}

state.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,11 @@ export interface AgenticodingState {
3737
/**
3838
* All live child agent sessions keyed by toolCallId, including claimed ones.
3939
* Reset/teardown aborts this registry so claimed children cannot outlive /new or UI disposal.
40+
* Completed children remove themselves from this registry before returning.
4041
*
4142
* INVARIANT: This Map is never replaced — only cleared via .clear().
42-
* NestedAgentSessionComponent holds a direct reference and depends on it
43-
* staying valid. If you change this, update attachSession in spawn/renderer.ts.
43+
* Spawn renderer ownership checks read this registry after attach, so its
44+
* identity must stay stable across resets, completion cleanup, and disposal.
4445
*/
4546
liveChildSessions: Map<string, AgentSession>;
4647

@@ -65,9 +66,9 @@ export function createState(): AgenticodingState {
6566
liveChildSessions,
6667
childSessionEpoch: 0,
6768
};
68-
// Prevent replacement — NestedAgentSessionComponent holds direct references
69-
// to both maps and depends on reference stability. Only .clear() and .delete()
70-
// are valid — assigning a new Map would silently break session lifecycle.
69+
// Prevent replacement — spawn lifecycle code and renderer ownership checks
70+
// depend on stable map identity. Only .clear() and .delete() are valid —
71+
// assigning a new Map would silently break child-session invalidation.
7172
Object.defineProperty(state, 'childSessions', {
7273
get: () => childSessions,
7374
set: () => { throw new Error('childSessions cannot be replaced — use .clear() instead'); },

0 commit comments

Comments
 (0)