Preserve active subagents across reload

This commit is contained in:
jb
2026-08-27 01:05:46 -04:00
parent 4105b03981
commit 9a8169523b
6 changed files with 739 additions and 1518 deletions
+2
View File
@@ -19,6 +19,8 @@ The backend is selected automatically: Herdr is used when Pi runs in a Herdr-man
Spawn several in parallel — they run concurrently and steer results back independently as each finishes.
Running subagents survive a parent `/reload`. Their terminal sessions and completion watchers stay active, then rebind result delivery and the status widget to the fresh Pi extension runtime. Replacing or closing the parent session still cancels its running subagents. This lifecycle behavior requires Pi 0.68 or newer.
tmux panes are kept evenly sized: the extension re-applies an `even-horizontal` layout after every spawn and exit (debounced). The layout is a single constant, `SUBAGENT_TMUX_LAYOUT` in `pi-extension/subagents/tmux.ts` — change it to any named tmux layout (`main-vertical`, `tiled`, …). Herdr subagents use separate tabs, so they do not resize the parent tab.
If your shell startup is slow and launch commands get dropped before the prompt is ready, raise the delay:
+344 -1352
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -17,8 +17,8 @@
"test:integration": "node --test --test-concurrency=1 test/integration/*.test.ts"
},
"peerDependencies": {
"@mariozechner/pi-coding-agent": "*",
"@mariozechner/pi-tui": "*",
"@mariozechner/pi-coding-agent": ">=0.68.0",
"@mariozechner/pi-tui": ">=0.68.0",
"@sinclair/typebox": "*"
},
"pi": {
@@ -27,8 +27,8 @@
]
},
"devDependencies": {
"@mariozechner/pi-coding-agent": "^0.65.0",
"@mariozechner/pi-tui": "^0.65.0",
"@mariozechner/pi-coding-agent": "^0.68.0",
"@mariozechner/pi-tui": "^0.68.0",
"@sinclair/typebox": "^0.34.49"
}
}
+221 -161
View File
@@ -66,32 +66,11 @@ import {
/** Absolute path to `pi-extension/subagents`. https://github.com/nodejs/node/issues/37845 */
const SUBAGENTS_DIR = dirname(fileURLToPath(import.meta.url));
// Survive /reload: clear timers and abort poll loops from the previous module load.
// /reload re-imports this file, giving fresh module-level state, but closures from
// the old module keep running. See https://github.com/HazAT/pi-interactive-subagents/issues/5
const WIDGET_INTERVAL_KEY = Symbol.for("pi-subagents/widget-interval");
const STATUS_INTERVAL_KEY = Symbol.for("pi-subagents/status-interval");
const POLL_ABORT_KEY = Symbol.for("pi-subagents/poll-abort-controller");
{
const prevInterval = (globalThis as any)[WIDGET_INTERVAL_KEY];
if (prevInterval) {
clearInterval(prevInterval);
(globalThis as any)[WIDGET_INTERVAL_KEY] = null;
}
const prevStatusInterval = (globalThis as any)[STATUS_INTERVAL_KEY];
if (prevStatusInterval) {
clearInterval(prevStatusInterval);
(globalThis as any)[STATUS_INTERVAL_KEY] = null;
}
const prevAbort = (globalThis as any)[POLL_ABORT_KEY] as AbortController | undefined;
if (prevAbort) prevAbort.abort();
(globalThis as any)[POLL_ABORT_KEY] = new AbortController();
}
function getModuleAbortSignal(): AbortSignal {
return ((globalThis as any)[POLL_ABORT_KEY] as AbortController).signal;
}
// Pi re-imports extension modules during /reload while callbacks from the old
// module can still be running. Keep live subagent ownership process-global so a
// fresh extension runtime can rebind to existing watchers instead of cancelling
// their terminal sessions.
const SHARED_RUNTIME_KEY = Symbol.for("pi-subagents/shared-runtime");
const SubagentParams = Type.Object({
agent: Type.String({
@@ -634,10 +613,41 @@ interface RunningSubagent {
* subagent's pane (e.g. planner).
*/
interactive: boolean;
/** Suppress terminal delivery when a real session shutdown cancels this run. */
suppressDelivery?: boolean;
}
/** All currently running subagents, keyed by id. */
const runningSubagents = new Map<string, RunningSubagent>();
type PendingDelivery = (pi: ExtensionAPI) => void;
interface SharedSubagentRuntime {
runningSubagents: Map<string, RunningSubagent>;
latestCtx: ExtensionContext | null;
latestPi: ExtensionAPI | null;
widgetInterval: ReturnType<typeof setInterval> | null;
statusInterval: ReturnType<typeof setInterval> | null;
pendingDeliveries: PendingDelivery[];
deliveryFlushScheduled: boolean;
activeRuntimeToken: symbol | null;
}
function createSharedRuntime(): SharedSubagentRuntime {
return {
runningSubagents: new Map(),
latestCtx: null,
latestPi: null,
widgetInterval: null,
statusInterval: null,
pendingDeliveries: [],
deliveryFlushScheduled: false,
activeRuntimeToken: null,
};
}
const sharedRuntime = ((globalThis as any)[SHARED_RUNTIME_KEY] ??=
createSharedRuntime()) as SharedSubagentRuntime;
/** All currently running subagents, preserved across /reload. */
const runningSubagents = sharedRuntime.runningSubagents;
// When this extension is loaded inside a subagent that itself spawns children
// (e.g. a worker delegating to scout/researcher), `subagent-done.ts` runs in the
@@ -648,18 +658,42 @@ const runningSubagents = new Map<string, RunningSubagent>();
const RUNNING_CHILDREN_COUNT_KEY = Symbol.for("pi-subagents/running-children-count");
(globalThis as any)[RUNNING_CHILDREN_COUNT_KEY] = () => runningSubagents.size;
// ── Widget management ──
// ── Widget and runtime delivery management ──
/** Latest ExtensionContext from session_start, used for widget updates. */
let latestCtx: ExtensionContext | null = null;
/** Latest ExtensionAPI, used to deliver ask_question notifications from the watcher. */
let latestPi: ExtensionAPI | null = null;
function sendWhenRuntimeReady(delivery: PendingDelivery): void {
const pi = sharedRuntime.latestPi;
if (pi) {
delivery(pi);
return;
}
sharedRuntime.pendingDeliveries.push(delivery);
}
/** Interval timer for widget re-renders. */
let widgetInterval: ReturnType<typeof setInterval> | null = null;
function flushPendingDeliveries(): void {
sharedRuntime.deliveryFlushScheduled = false;
const pi = sharedRuntime.latestPi;
if (!pi || sharedRuntime.pendingDeliveries.length === 0) return;
/** Interval timer for status transition checks. */
let statusInterval: ReturnType<typeof setInterval> | null = null;
const pending = sharedRuntime.pendingDeliveries.splice(0);
for (const delivery of pending) delivery(pi);
}
function schedulePendingDeliveryFlush(): void {
if (sharedRuntime.deliveryFlushScheduled) return;
sharedRuntime.deliveryFlushScheduled = true;
setImmediate(flushPendingDeliveries);
}
function clearRefreshIntervals(): void {
if (sharedRuntime.widgetInterval) {
clearInterval(sharedRuntime.widgetInterval);
sharedRuntime.widgetInterval = null;
}
if (sharedRuntime.statusInterval) {
clearInterval(sharedRuntime.statusInterval);
sharedRuntime.statusInterval = null;
}
}
function formatElapsedMMSS(startTime: number): string {
const seconds = Math.floor((Date.now() - startTime) / 1000);
@@ -755,19 +789,19 @@ function renderSubagentWidgetLines(agents: RunningSubagent[], width: number): st
}
function updateWidget() {
if (!latestCtx?.hasUI) return;
const ctx = sharedRuntime.latestCtx;
if (!ctx?.hasUI) return;
if (runningSubagents.size === 0) {
latestCtx.ui.setWidget("subagent-status", undefined);
if (widgetInterval) {
clearInterval(widgetInterval);
widgetInterval = null;
(globalThis as any)[WIDGET_INTERVAL_KEY] = null;
ctx.ui.setWidget("subagent-status", undefined);
if (sharedRuntime.widgetInterval) {
clearInterval(sharedRuntime.widgetInterval);
sharedRuntime.widgetInterval = null;
}
return;
}
latestCtx.ui.setWidget(
ctx.ui.setWidget(
"subagent-status",
(_tui: any, _theme: any) => {
return {
@@ -1066,15 +1100,14 @@ function handleSubagentSteer(
};
}
function startStatusRefresh(pi: ExtensionAPI) {
if (!statusConfig.enabled || statusInterval) return;
function startStatusRefresh() {
if (!statusConfig.enabled || sharedRuntime.statusInterval) return;
statusInterval = setInterval(() => {
sharedRuntime.statusInterval = setInterval(() => {
if (runningSubagents.size === 0) {
if (statusInterval) {
clearInterval(statusInterval);
statusInterval = null;
(globalThis as any)[STATUS_INTERVAL_KEY] = null;
if (sharedRuntime.statusInterval) {
clearInterval(sharedRuntime.statusInterval);
sharedRuntime.statusInterval = null;
}
return;
}
@@ -1104,19 +1137,19 @@ function startStatusRefresh(pi: ExtensionAPI) {
if (transitionLines.length > 0) {
const capped = capStatusLines(transitionLines, statusConfig.lineLimit);
pi.sendMessage(
{
customType: "subagent_status",
content: formatStatusAggregate(transitionLines, statusConfig.lineLimit),
display: true,
details: { lines: capped.visibleLines, overflow: capped.overflow },
},
{ triggerTurn: true, deliverAs: "steer" },
);
sendWhenRuntimeReady((pi) => {
pi.sendMessage(
{
customType: "subagent_status",
content: formatStatusAggregate(transitionLines, statusConfig.lineLimit),
display: true,
details: { lines: capped.visibleLines, overflow: capped.overflow },
},
{ triggerTurn: true, deliverAs: "steer" },
);
});
}
}, 1000);
(globalThis as any)[STATUS_INTERVAL_KEY] = statusInterval;
}
// Resuming a finished session is always autonomous: the relaunched agent runs
@@ -1158,15 +1191,17 @@ export const __test__ = {
contextWindowFor,
formatUsageSegments,
widgetIcon,
sendWhenRuntimeReady,
flushPendingDeliveries,
sharedRuntime,
};
function startWidgetRefresh() {
if (widgetInterval) return;
if (sharedRuntime.widgetInterval) return;
updateWidget(); // immediate first render
widgetInterval = setInterval(() => {
sharedRuntime.widgetInterval = setInterval(() => {
updateWidget();
}, 1000);
(globalThis as any)[WIDGET_INTERVAL_KEY] = widgetInterval;
}
/**
@@ -1531,20 +1566,22 @@ function deliverPendingQuestion(running: RunningSubagent): void {
const elapsed = Math.floor((Date.now() - running.startTime) / 1000);
const replyHint = `\n\nReply with subagent_message({ name: "${name}", message: "…" }) — the same name works whether it is still running or has since exited. It stays open until you reply.`;
latestPi?.sendMessage(
{
customType: "subagent_question",
content: `Sub-agent "${name}" asks (${formatElapsed(elapsed)}):\n\n${payload.question}${replyHint}`,
display: true,
details: {
name,
agent: running.agent,
question: payload.question,
...(sessionId ? { sessionId } : {}),
sendWhenRuntimeReady((pi) => {
pi.sendMessage(
{
customType: "subagent_question",
content: `Sub-agent "${name}" asks (${formatElapsed(elapsed)}):\n\n${payload.question}${replyHint}`,
display: true,
details: {
name,
agent: running.agent,
question: payload.question,
...(sessionId ? { sessionId } : {}),
},
},
},
{ triggerTurn: true, deliverAs: "steer" },
);
{ triggerTurn: true, deliverAs: "steer" },
);
});
}
async function watchSubagent(
@@ -1554,7 +1591,7 @@ async function watchSubagent(
const { name, task, surface, startTime, sessionFile } = running;
try {
const result = await pollForExit(surface, AbortSignal.any([signal, getModuleAbortSignal()]), {
const result = await pollForExit(surface, signal, {
interval: 1000,
sessionFile,
sentinelFile: running.sentinelFile,
@@ -1668,35 +1705,45 @@ async function watchSubagent(
}
export default function subagentsExtension(pi: ExtensionAPI) {
latestPi = pi;
// Capture the UI context for widget updates
const runtimeToken = Symbol("pi-subagents/runtime");
// Bind only after Pi has installed the fresh runtime. During /reload the old
// session_shutdown handler clears these references first, so watcher results
// cannot enqueue a turn through stale extension APIs.
pi.on("session_start", (_event, ctx) => {
latestCtx = ctx;
// pi runs multiple sessions in one process. A prior session's shutdown
// aborts the shared module poll-abort controller; install a fresh one so
// subagents spawned in this session aren't watched against a dead signal.
// See https://github.com/HazAT/pi-interactive-subagents/issues/5
const prevAbort = (globalThis as any)[POLL_ABORT_KEY] as AbortController | undefined;
if (!prevAbort || prevAbort.signal.aborted) {
(globalThis as any)[POLL_ABORT_KEY] = new AbortController();
sharedRuntime.activeRuntimeToken = runtimeToken;
sharedRuntime.latestPi = pi;
sharedRuntime.latestCtx = ctx;
if (runningSubagents.size > 0) {
startWidgetRefresh();
startStatusRefresh();
}
// A completion can land in the short gap between old shutdown and this
// fresh session_start. Flush on the next macrotask, after reload finishes.
schedulePendingDeliveryFlush();
});
// Clean up on session shutdown
pi.on("session_shutdown", (_event, _ctx) => {
if (widgetInterval) {
clearInterval(widgetInterval);
widgetInterval = null;
(globalThis as any)[WIDGET_INTERVAL_KEY] = null;
}
if (statusInterval) {
clearInterval(statusInterval);
statusInterval = null;
(globalThis as any)[STATUS_INTERVAL_KEY] = null;
}
const moduleAbort = (globalThis as any)[POLL_ABORT_KEY] as AbortController | undefined;
if (moduleAbort) moduleAbort.abort();
for (const [_id, agent] of runningSubagents) {
pi.on("session_shutdown", (event, _ctx) => {
// A delayed event from an invalidated extension instance must not tear down
// state already rebound to the replacement runtime.
if (sharedRuntime.activeRuntimeToken !== runtimeToken) return;
sharedRuntime.activeRuntimeToken = null;
sharedRuntime.latestPi = null;
sharedRuntime.latestCtx = null;
clearRefreshIntervals();
// /reload keeps the same parent session and terminal surfaces. Existing
// watchers continue and rebind to the fresh runtime in session_start.
if (event.reason === "reload") return;
// Real session replacement/quit owns a different lifecycle. Cancel those
// children and suppress the synthetic cancellation result; there is no
// valid destination session to receive it.
sharedRuntime.pendingDeliveries.length = 0;
for (const agent of runningSubagents.values()) {
agent.suppressDelivery = true;
agent.abortController?.abort();
}
runningSubagents.clear();
@@ -1848,47 +1895,54 @@ export default function subagentsExtension(pi: ExtensionAPI) {
// Start widget refresh and status supervision when the first agent launches
startWidgetRefresh();
startStatusRefresh(pi);
startStatusRefresh();
// Fire-and-forget: start watching in background
// Fire-and-forget: start watching in background. Delivery resolves the
// current Pi runtime at completion time so /reload cannot retain a stale
// ExtensionAPI closure.
watchSubagent(running, watcherAbort.signal)
.then((result) => {
updateWidget(); // reflect removal from Map immediately
if (running.suppressDelivery) return;
const presentation = resolveResultPresentation(result, running.name);
pi.sendMessage(
{
customType: "subagent_result",
content: presentation,
display: true,
details: {
name: running.name,
task: running.task,
agent: running.agent,
exitCode: result.exitCode,
elapsed: result.elapsed,
sessionFile: result.sessionFile,
...(result.sessionId ? { sessionId: result.sessionId } : {}),
...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
...(result.claudeSessionId ? { claudeSessionId: result.claudeSessionId } : {}),
...(result.stats ? { stats: result.stats } : {}),
sendWhenRuntimeReady((currentPi) => {
currentPi.sendMessage(
{
customType: "subagent_result",
content: presentation,
display: true,
details: {
name: running.name,
task: running.task,
agent: running.agent,
exitCode: result.exitCode,
elapsed: result.elapsed,
sessionFile: result.sessionFile,
...(result.sessionId ? { sessionId: result.sessionId } : {}),
...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
...(result.claudeSessionId ? { claudeSessionId: result.claudeSessionId } : {}),
...(result.stats ? { stats: result.stats } : {}),
},
},
},
{ triggerTurn: true, deliverAs: "steer" },
);
{ triggerTurn: true, deliverAs: "steer" },
);
});
})
.catch((err) => {
updateWidget();
pi.sendMessage(
{
customType: "subagent_result",
content: `Sub-agent "${running.name}" error: ${err?.message ?? String(err)}`,
display: true,
details: { name: running.name, task: running.task, error: err?.message },
},
{ triggerTurn: true, deliverAs: "steer" },
);
if (running.suppressDelivery) return;
sendWhenRuntimeReady((currentPi) => {
currentPi.sendMessage(
{
customType: "subagent_result",
content: `Sub-agent "${running.name}" error: ${err?.message ?? String(err)}`,
display: true,
details: { name: running.name, task: running.task, error: err?.message },
},
{ triggerTurn: true, deliverAs: "steer" },
);
});
});
// Return immediately
@@ -2286,7 +2340,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
};
runningSubagents.set(id, running);
startWidgetRefresh();
startStatusRefresh(pi);
startStatusRefresh();
// Fire-and-forget watcher
const watcherAbort = new AbortController();
@@ -2295,6 +2349,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
watchSubagent(running, watcherAbort.signal)
.then((result) => {
updateWidget();
if (running.suppressDelivery) return;
const allEntries = getNewEntries(sessionPath, entryCountBefore);
const summary = findLastAssistantMessage(allEntries) ??
@@ -2308,35 +2363,40 @@ export default function subagentsExtension(pi: ExtensionAPI) {
name,
);
pi.sendMessage(
{
customType: "subagent_result",
content: presentation,
display: true,
details: {
name,
task: message,
exitCode: result.exitCode,
elapsed: result.elapsed,
sessionFile: sessionPath,
sessionId: resumedSessionId,
...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
sendWhenRuntimeReady((currentPi) => {
currentPi.sendMessage(
{
customType: "subagent_result",
content: presentation,
display: true,
details: {
name,
task: message,
exitCode: result.exitCode,
elapsed: result.elapsed,
sessionFile: sessionPath,
sessionId: resumedSessionId,
...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
},
},
},
{ triggerTurn: true, deliverAs: "steer" },
);
{ triggerTurn: true, deliverAs: "steer" },
);
});
})
.catch((err) => {
updateWidget();
pi.sendMessage(
{
customType: "subagent_result",
content: `Resume error: ${err?.message ?? String(err)}`,
display: true,
details: { name, error: err?.message },
},
{ triggerTurn: true, deliverAs: "steer" },
);
if (running.suppressDelivery) return;
sendWhenRuntimeReady((currentPi) => {
currentPi.sendMessage(
{
customType: "subagent_result",
content: `Resume error: ${err?.message ?? String(err)}`,
display: true,
details: { name, error: err?.message },
},
{ triggerTurn: true, deliverAs: "steer" },
);
});
});
return {
@@ -30,6 +30,7 @@ import {
uniqueId,
trackTempFile,
readScreen,
sendCommand,
PI_TIMEOUT,
type TestEnv,
} from "./harness.ts";
@@ -103,6 +104,57 @@ for (const backend of backends) {
}
});
// ── Reload preservation ──
it("keeps a running subagent and fresh tool wrappers across parent extension reload", async () => {
const id = uniqueId();
const startedFile = `/tmp/pi-integ-reload-start-${id}.txt`;
const completedFile = `/tmp/pi-integ-reload-done-${id}.txt`;
const followupFile = `/tmp/pi-integ-reload-followup-${id}.txt`;
trackTempFile(env, startedFile);
trackTempFile(env, completedFile);
trackTempFile(env, followupFile);
const surface = createTrackedSurface(env, `reload-${id}`);
await sleep(1000);
const task = [
`Call the subagent tool once with these EXACT parameters:`,
` name: "Reload-${id}"`,
` agent: "test-echo"`,
` task: "Run: echo 'START_${id}' > '${startedFile}'; sleep 10; echo 'DONE_${id}' > '${completedFile}'"`,
`Do not run the command yourself.`,
`After that subagent result arrives, call the subagent tool once more with these EXACT parameters:`,
` name: "Reload-followup-${id}"`,
` agent: "test-echo"`,
` task: "Write 'FOLLOWUP_${id}' to '${followupFile}'"`,
`After the second subagent result arrives, say RELOAD_SURVIVED_${id}.`,
].join("\n");
startPi(surface, env.dir, task);
await waitForFile(startedFile, PI_TIMEOUT, /START_/);
sendCommand(surface, "/reload");
const completed = await waitForFile(completedFile, PI_TIMEOUT, /DONE_/);
assert.ok(completed.includes(`DONE_${id}`), "child should finish after parent reload");
const followup = await waitForFile(followupFile, PI_TIMEOUT, /FOLLOWUP_/);
assert.ok(
followup.includes(`FOLLOWUP_${id}`),
"the result-triggered turn should spawn through the fresh tool wrapper",
);
const resultMarker = `RELOAD_SURVIVED_${id}`;
const responseLine = new RegExp(`^ ${resultMarker}\\s*$`, "m");
const screen = await waitForScreen(surface, responseLine, PI_TIMEOUT, 300);
assert.equal(
screen.match(new RegExp(`^ ${resultMarker}\\s*$`, "gm"))?.length,
1,
"the fresh runtime should receive the completion exactly once",
);
assert.doesNotMatch(screen, /Subagent cancelled|ctx is stale after session replacement or reload/i);
});
// ── In-progress activity snapshots ──
it("keeps a long active tool call from surfacing false stalled status", async () => {
+116 -1
View File
@@ -88,16 +88,22 @@ function createMockExtensionApi() {
const registeredTools: Array<any> = [];
const registeredCommands: Array<any> = [];
const registeredMessageRenderers: Array<any> = [];
const eventHandlers = new Map<string, Array<(event: any, ctx: any) => any>>();
const sentUserMessages: string[] = [];
const sentMessages: Array<any> = [];
return {
registeredTools,
registeredCommands,
registeredMessageRenderers,
eventHandlers,
sentUserMessages,
sentMessages,
api: {
on() {},
on(event: string, handler: (event: any, ctx: any) => any) {
const handlers = eventHandlers.get(event) ?? [];
handlers.push(handler);
eventHandlers.set(event, handlers);
},
registerTool(tool: any) {
registeredTools.push(tool);
},
@@ -117,10 +123,24 @@ function createMockExtensionApi() {
getAllTools() {
return [];
},
getActiveTools() {
return [];
},
} as any,
};
}
async function emitMockEvent(
mock: ReturnType<typeof createMockExtensionApi>,
event: string,
payload: Record<string, unknown>,
ctx: Record<string, unknown> = {},
) {
for (const handler of mock.eventHandlers.get(event) ?? []) {
await handler({ type: event, ...payload }, ctx);
}
}
function restoreEnvVar(name: string, value: string | undefined) {
if (value === undefined) {
delete process.env[name];
@@ -1838,6 +1858,101 @@ describe("commands", () => {
});
});
describe("reload lifecycle", () => {
const testApi = (subagentsModule as any).__test__;
const shared = testApi.sharedRuntime as {
runningSubagents: Map<string, any>;
latestCtx: any;
latestPi: any;
widgetInterval: ReturnType<typeof setInterval> | null;
statusInterval: ReturnType<typeof setInterval> | null;
pendingDeliveries: Array<(pi: any) => void>;
deliveryFlushScheduled: boolean;
activeRuntimeToken: symbol | null;
};
const ctx = { hasUI: false, ui: { setWidget() {} } };
function resetSharedRuntime() {
if (shared.widgetInterval) clearInterval(shared.widgetInterval);
if (shared.statusInterval) clearInterval(shared.statusInterval);
shared.widgetInterval = null;
shared.statusInterval = null;
shared.runningSubagents.clear();
shared.latestCtx = null;
shared.latestPi = null;
shared.pendingDeliveries.length = 0;
shared.deliveryFlushScheduled = false;
shared.activeRuntimeToken = null;
}
beforeEach(resetSharedRuntime);
after(resetSharedRuntime);
it("preserves a live subagent and delivers its queued result through the fresh runtime", async () => {
const oldRuntime = createMockExtensionApi();
(subagentsModule as any).default(oldRuntime.api);
await emitMockEvent(oldRuntime, "session_start", { reason: "startup" }, ctx);
const abortController = new AbortController();
const running: {
id: string;
name: string;
task: string;
surface: string;
startTime: number;
sessionFile: string;
statusState: ReturnType<typeof createStatusState>;
interactive: boolean;
abortController: AbortController;
suppressDelivery?: boolean;
} = {
id: "reload-child",
name: "reload-child",
task: "keep running",
surface: "surface-1",
startTime: Date.now(),
sessionFile: "/tmp/reload-child.jsonl",
statusState: createStatusState({ source: "pi", startTimeMs: Date.now() }),
interactive: false,
abortController,
};
shared.runningSubagents.set(running.id, running);
await emitMockEvent(oldRuntime, "session_shutdown", { reason: "reload" }, ctx);
assert.equal(abortController.signal.aborted, false, "reload must not cancel the child");
assert.equal(shared.runningSubagents.get(running.id), running);
testApi.sendWhenRuntimeReady((pi: any) => {
pi.sendMessage({ customType: "subagent_result", content: "done", display: true });
});
assert.equal(oldRuntime.sentMessages.length, 0, "the stale runtime must not receive completion");
assert.equal(shared.pendingDeliveries.length, 1);
const freshRuntime = createMockExtensionApi();
(subagentsModule as any).default(freshRuntime.api);
await emitMockEvent(freshRuntime, "session_start", { reason: "reload" }, ctx);
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(freshRuntime.sentMessages.length, 1);
assert.equal(freshRuntime.sentMessages[0].message.customType, "subagent_result");
assert.equal(oldRuntime.sentMessages.length, 0);
assert.equal(shared.pendingDeliveries.length, 0);
assert.equal(shared.runningSubagents.get(running.id), running);
testApi.flushPendingDeliveries();
assert.equal(freshRuntime.sentMessages.length, 1, "a queued completion must flush exactly once");
await emitMockEvent(oldRuntime, "session_shutdown", { reason: "quit" }, ctx);
assert.equal(abortController.signal.aborted, false, "a stale runtime cannot cancel rebound state");
assert.equal(shared.runningSubagents.get(running.id), running);
await emitMockEvent(freshRuntime, "session_shutdown", { reason: "quit" }, ctx);
assert.equal(abortController.signal.aborted, true, "real shutdown still cancels the child");
assert.equal(running.suppressDelivery, true);
assert.equal(shared.runningSubagents.size, 0);
});
});
describe("tool registration", () => {
it("always resumes subagents as autonomous (auto-exit, non-interactive tracking)", () => {
const testApi = (subagentsModule as any).__test__;