Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Fixed

- A session's `session_shutdown` now disposes only the children that session launched. Before, the extension's shutdown handler disposed every live in-process child through the process-wide factory, so in a multi-session host such as pi-web, one idle session being reaped aborted another session's running subagents mid-flight: the child died with `Request was aborted` while its parent session kept running. The detached runner's whole-process exit path still disposes everything.
Comment thread
Jarcis-cy marked this conversation as resolved.
Resolved

## [0.86.0] - 2026-11-05

### Highlights
Expand Down
4 changes: 3 additions & 1 deletion src/extension/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1264,7 +1264,9 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
parentWake.sessionShutdown(event?.reason);
runtimeEntry.cleanup(event?.reason);
try {
await disposeChildSessions();
// Only this session's children: a multi-session host (pi-web) shuts sessions down
// one at a time, and one session's shutdown must not abort another session's runs.
await disposeChildSessions(state.parentSessionFile ?? undefined);
Comment thread
Jarcis-cy marked this conversation as resolved.
Resolved
} catch (error) {
console.error("Failed to dispose in-process child sessions:", error);
}
Expand Down
61 changes: 44 additions & 17 deletions src/runs/shared/child-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,13 @@ export interface ChildSessionFactory {
create(launch: ChildSessionLaunch): Promise<ChildSession>;
/** Abort and dispose every live attached child; detached children keep running and hold the shared runtime. */
dispose(): Promise<void>;
/**
* Abort and dispose only the attached children that `parentSessionFile`'s session launched.
* A multi-session host such as pi-web shuts sessions down one at a time; one session's
* shutdown must not destroy another session's running children. Children of sessions
* without a session file are disposed when `parentSessionFile` is undefined.
*/
disposeForParent(parentSessionFile: string | undefined): Promise<void>;
}

export type PiCodingAgentModule = typeof import("@earendil-works/pi-coding-agent");
Expand Down Expand Up @@ -489,11 +496,12 @@ export function createDefaultChildSessionFactory(options: DefaultChildSessionFac
const loadPiCodingAgent = options.loadPiCodingAgent ?? loadHostPiCodingAgent;
const shutdownTimeoutMs = options.shutdownTimeoutMs ?? 5_000;
let runtime: ReturnType<PiCodingAgentModule["ModelRuntime"]["create"]> | undefined;
const live = new Set<ChildSession>();
/** Live children keyed by the session file of the parent that launched them; undefined for a parent without one. */
const live = new Map<ChildSession, string | undefined>();
/** Extension shutdowns still running for disposed children; `dispose()` waits for them. */
const shutdowns = new Set<Promise<void>>();
/** Launches still waiting for their MCP tools; `dispose()` ends the wait and waits for their shutdown. */
const mcpWaits = new Map<AbortController, Promise<void>>();
const mcpWaits = new Map<AbortController, { ready: Promise<void>; parent: string | undefined }>();
let disposals = 0;
const sharedRuntime = async (pi: PiCodingAgentModule) => {
runtime ??= pi.ModelRuntime.create().catch((error: unknown) => {
Expand All @@ -502,6 +510,22 @@ export function createDefaultChildSessionFactory(options: DefaultChildSessionFac
});
return runtime;
};
/** Abort and dispose the attached children `matches` selects, waiting for their shutdowns. */
const disposeMatching = async (matches: (parent: string | undefined) => boolean) => {
disposals += 1;
for (const [cancel, wait] of mcpWaits) if (matches(wait.parent)) cancel.abort();
Comment thread
Jarcis-cy marked this conversation as resolved.
Resolved
const children = [...live.keys()].filter((child) => !child.detached && matches(live.get(child)));
for (const child of children) child.shutDown = true;
await Promise.allSettled(children.map((child) => child.abort()));
for (const child of children) {
try { void child.dispose(); } catch { /* best effort */ }
}
await Promise.allSettled([
...shutdowns,
...[...mcpWaits.values()].filter((wait) => matches(wait.parent)).map((wait) => wait.ready),
]);
if (live.size === 0) runtime = undefined;
};
return {
async create(launch) {
const disposalsAtStart = disposals;
Expand Down Expand Up @@ -701,7 +725,7 @@ export function createDefaultChildSessionFactory(options: DefaultChildSessionFac
throw error;
}
})();
mcpWaits.set(cancel, ready);
mcpWaits.set(cancel, { ready, parent: launch.parentSessionFile });
try { await ready; } finally { mcpWaits.delete(cancel); }
}
const child: ChildSession = {
Expand Down Expand Up @@ -731,20 +755,14 @@ export function createDefaultChildSessionFactory(options: DefaultChildSessionFac
get virtualModelId() { return session.model?.api === "pi-virtual" ? `${session.model.provider}/${session.model.id}` : undefined; },
get contextWindow() { return session.model?.contextWindow; },
};
live.add(child);
live.set(child, launch.parentSessionFile);
return child;
},
async dispose() {
disposals += 1;
for (const cancel of mcpWaits.keys()) cancel.abort();
const children = [...live].filter((child) => !child.detached);
for (const child of children) child.shutDown = true;
await Promise.allSettled(children.map((child) => child.abort()));
for (const child of children) {
try { void child.dispose(); } catch { /* best effort */ }
}
await Promise.allSettled([...shutdowns, ...mcpWaits.values()]);
if (live.size === 0) runtime = undefined;
await disposeMatching(() => true);
},
async disposeForParent(parentSessionFile: string | undefined) {
await disposeMatching((parent) => parent === parentSessionFile);
},
};
}
Expand All @@ -764,6 +782,7 @@ function createLazyPlacementFactory(local: ChildSessionFactory): ChildSessionFac
return {
async create(launch) { return launch.machine ? (await factory()).create(launch) : local.create(launch); },
async dispose() { if (placed) await placed.dispose(); else await local.dispose(); },
async disposeForParent(parentSessionFile: string | undefined) { if (placed) await placed.disposeForParent(parentSessionFile); else await local.disposeForParent(parentSessionFile); },
};
}

Expand Down Expand Up @@ -793,9 +812,17 @@ export function setChildSessionFactoryModule(modulePath: string | undefined): vo
activeFactoryModule = modulePath;
}

/** Abort and dispose every live in-process child and release the shared runtime. */
export async function disposeChildSessions(): Promise<void> {
/**
* Abort and dispose live in-process children and release the shared runtime.
*
* Without `parentSessionFile`, every live attached child is disposed: the whole-process
* path the detached runner uses at exit. With it, only the children that session
* launched are disposed: a multi-session host such as pi-web shuts sessions down one
* at a time, and one session's shutdown must not destroy another session's children.
*/
export async function disposeChildSessions(parentSessionFile?: string): Promise<void> {
const factory = activeFactory;
if (!factory) return;
await factory.dispose();
if (parentSessionFile === undefined) await factory.dispose();
else await factory.disposeForParent(parentSessionFile);
Comment thread
Jarcis-cy marked this conversation as resolved.
Resolved
}
2 changes: 1 addition & 1 deletion src/runs/shared/herdr-placed-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,5 +172,5 @@ async function startPlacedPiSession<C extends PlacementSockets>(launch: ChildSes
function createPlacedPiSession(launch: ChildSessionLaunch): Promise<ChildSession> { return launch.machine?.provider === "rex" ? createRexPiSession(launch) : createHerdrPiSession(launch); }

export function createPlacementAwareChildSessionFactory(local: ChildSessionFactory, createRemote: (launch: ChildSessionLaunch) => Promise<ChildSession> = createPlacedPiSession): ChildSessionFactory {
const remote = new Set<ChildSession>(); return { async create(launch) { if (!launch.machine) return local.create(launch); serializeHerdrPiLaunch(launch); const child = await createRemote(launch); remote.add(child); return child; }, async dispose() { await Promise.allSettled([...remote].map((child) => child.dispose())); remote.clear(); await local.dispose(); } };
const remote = new Set<ChildSession>(); return { async create(launch) { if (!launch.machine) return local.create(launch); serializeHerdrPiLaunch(launch); const child = await createRemote(launch); remote.add(child); return child; }, async dispose() { await Promise.allSettled([...remote].map((child) => child.dispose())); remote.clear(); await local.dispose(); }, async disposeForParent(parentSessionFile: string | undefined) { await Promise.allSettled([...remote].map((child) => child.dispose())); remote.clear(); await local.disposeForParent(parentSessionFile); } };
Comment thread
Jarcis-cy marked this conversation as resolved.
Resolved
}
120 changes: 120 additions & 0 deletions test/unit/child-session-scoped-dispose.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
createDefaultChildSessionFactory,
type ChildSessionLaunch,
type PiCodingAgentModule,
} from "../../src/runs/shared/child-session.ts";

/**
* A multi-session host such as pi-web runs several parent sessions in one process.
* Its idle reaper shuts wrappers down one at a time, each shutdown emitting
* `session_shutdown` to that session's extension instance. The extension must dispose
* only that session's children: one session's shutdown must not abort another
* session's running children.
*/

interface SessionLog {
parent: string | undefined;
events: string[];
}

function stubPiModule(logs: SessionLog[]): () => PiCodingAgentModule {
return () => {
const log: SessionLog = { parent: undefined, events: [] };
logs.push(log);
return {
ModelRuntime: { create: async () => ({ refresh: async () => {} }) },
SettingsManager: { create: () => ({}) },
DefaultResourceLoader: class { async reload() {} getExtensions() { return { extensions: [], errors: [], runtime: { pendingProviderRegistrations: [], pendingNativeProviderRegistrations: [] } }; } },
SessionManager: { inMemory: () => ({}) },
resolveCliModel: () => ({}),
createAgentSession: async () => ({
session: {
bindExtensions: async () => {},
dispose() { log.events.push("dispose"); },
extensionRunner: { hasHandlers: () => false },
subscribe: () => () => {},
prompt: async () => {},
abort: async () => { log.events.push("abort"); },
steer: async () => {},
followUp: async () => {},
messages: [],
sessionId: `child-${logs.length}`,
},
}),
} as unknown as PiCodingAgentModule;
};
}

function launchFor(parentSessionFile: string | undefined, cwd: string): ChildSessionLaunch {
return {
cwd,
storage: { kind: "memory" },
parentSessionFile,
extensionPaths: [],
ambientExtensions: false,
hooks: [],
noSkills: true,
noContextFiles: true,
runtime: { fanoutChild: false, depth: 1, waitTool: { enabled: false }, fast: false },
};
}

describe("disposeForParent scopes child disposal to one session", () => {
it("disposes only the parent's own children, leaving other sessions' children running", async () => {
const logs: SessionLog[] = [];
const factory = createDefaultChildSessionFactory({ loadPiCodingAgent: stubPiModule(logs) });
const parentA = "/tmp/sessions/parent-a.jsonl";
const parentB = "/tmp/sessions/parent-b.jsonl";
const childA1 = await factory.create(launchFor(parentA, process.cwd()));
await factory.create(launchFor(parentB, process.cwd()));
const childA1Abort = childA1.abort.bind(childA1);

await factory.disposeForParent(parentA);

assert.deepEqual(logs[0]?.events, ["abort", "dispose"], "parent A's child is aborted and disposed");
assert.deepEqual(logs[1]?.events, [], "parent B's child is untouched");
// The surviving child still works and can be disposed on its own session's shutdown.
await assert.doesNotReject(childA1Abort);
});

it("disposes only children of file-less parents when the parent session has no file", async () => {
const logs: SessionLog[] = [];
const factory = createDefaultChildSessionFactory({ loadPiCodingAgent: stubPiModule(logs) });
await factory.create(launchFor("/tmp/sessions/parent-a.jsonl", process.cwd()));
await factory.create(launchFor(undefined, process.cwd()));

await factory.disposeForParent(undefined);

assert.deepEqual(logs[0]?.events, [], "the file-backed parent's child is untouched");
assert.deepEqual(logs[1]?.events, ["abort", "dispose"], "the file-less parent's child is disposed");
});

it("dispose() still disposes every live child", async () => {
const logs: SessionLog[] = [];
const factory = createDefaultChildSessionFactory({ loadPiCodingAgent: stubPiModule(logs) });
await factory.create(launchFor("/tmp/sessions/parent-a.jsonl", process.cwd()));
await factory.create(launchFor("/tmp/sessions/parent-b.jsonl", process.cwd()));
await factory.create(launchFor(undefined, process.cwd()));

await factory.dispose();

for (const log of logs) {
assert.deepEqual(log.events, ["abort", "dispose"], "every child is aborted and disposed");
}
});

it("detached children keep running across both disposal scopes", async () => {
const logs: SessionLog[] = [];
const factory = createDefaultChildSessionFactory({ loadPiCodingAgent: stubPiModule(logs) });
const parentA = "/tmp/sessions/parent-a.jsonl";
const child = await factory.create(launchFor(parentA, process.cwd()));
child.detached = true;

await factory.disposeForParent(parentA);
await factory.dispose();

assert.deepEqual(logs[0]?.events, [], "a detached child is never aborted by disposal");
});
});