Skip to content

Commit 4cb0e8b

Browse files
committed
Move child session lifecycle into shared state object
1 parent b4544ff commit 4cb0e8b

1 file changed

Lines changed: 62 additions & 1 deletion

File tree

state.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
* Mutable by design — this is session-scoped imperative state.
66
*/
77

8+
import type { AgentSession } from "@earendil-works/pi-coding-agent";
9+
810
export interface AgenticodingState {
911
/** Compact ledger entries keyed by kebab-case name */
1012
ledger: Map<string, string>;
@@ -24,24 +26,83 @@ export interface AgenticodingState {
2426
enforcementAttempts: number;
2527
toolCalled: boolean;
2628
} | null;
29+
30+
/**
31+
* Published child agent sessions keyed by toolCallId.
32+
* Lifecycle: executeSpawn publishes → renderSpawnResult claims via get+delete.
33+
* This is only the render handoff queue, not the full live-session registry.
34+
*/
35+
childSessions: Map<string, AgentSession>;
36+
37+
/**
38+
* All live child agent sessions keyed by toolCallId, including claimed ones.
39+
* Reset/teardown aborts this registry so claimed children cannot outlive /new or UI disposal.
40+
*
41+
* 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.
44+
*/
45+
liveChildSessions: Map<string, AgentSession>;
46+
47+
/**
48+
* Generation counter for child-session ownership.
49+
* Increment on /new so stale child updates/results cannot touch fresh state.
50+
*/
51+
childSessionEpoch: number;
2752
}
2853

2954
/** Create a fresh state instance. Call reset() on /new. */
3055
export function createState(): AgenticodingState {
31-
return {
56+
const childSessions = new Map<string, AgentSession>();
57+
const liveChildSessions = new Map<string, AgentSession>();
58+
const state: AgenticodingState = {
3259
ledger: new Map(),
3360
epoch: 0,
3461
lastContextPercent: null,
3562
pendingHandoff: null,
3663
pendingRequestedHandoff: null,
64+
childSessions,
65+
liveChildSessions,
66+
childSessionEpoch: 0,
3767
};
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.
71+
Object.defineProperty(state, 'childSessions', {
72+
get: () => childSessions,
73+
set: () => { throw new Error('childSessions cannot be replaced — use .clear() instead'); },
74+
enumerable: true,
75+
configurable: false,
76+
});
77+
Object.defineProperty(state, 'liveChildSessions', {
78+
get: () => liveChildSessions,
79+
set: () => { throw new Error('liveChildSessions cannot be replaced — use .clear() instead'); },
80+
enumerable: true,
81+
configurable: false,
82+
});
83+
return state;
3884
}
3985

4086
/** Reset all state. Used on /new or session reset. */
4187
export function resetState(state: AgenticodingState): void {
88+
state.childSessionEpoch++;
4289
state.ledger.clear();
4390
state.epoch = 0;
4491
state.lastContextPercent = null;
4592
state.pendingHandoff = null;
4693
state.pendingRequestedHandoff = null;
94+
abortAndClearChildSessions(state);
95+
}
96+
97+
/** Abort all active child sessions and clear both registries. Called on /new (session reset). */
98+
export function abortAndClearChildSessions(state: AgenticodingState): void {
99+
const seen = new Map<any, string>(); // session → first id (for logging)
100+
for (const [id, session] of [...state.childSessions.entries(), ...state.liveChildSessions.entries()]) {
101+
if (!seen.has(session)) seen.set(session, id);
102+
}
103+
state.childSessions.clear();
104+
state.liveChildSessions.clear();
105+
for (const [session, id] of seen) {
106+
session.abort().catch(e => console.warn("[spawn] abort failed:", id, e));
107+
}
47108
}

0 commit comments

Comments
 (0)