From 1094b69a026eb3a37f2e351ae74c7103a2207f6b Mon Sep 17 00:00:00 2001 From: Jordan Pizza <10016234+Pizzaface@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:30:31 -0400 Subject: [PATCH] fix: auto-close spawned children on completion; subagent shutdown ordering - spawn_session tool now defaults autoClose=true; plumbed through POST /api/runners/spawn -> new_session -> spawner env, so completed child workers self-terminate instead of idling on the runner forever (auto-close remains guarded against pending messages/subscriptions). - Register subagentExtension before remoteExtension in factories so session_shutdown aborts in-flight relay mirrors and ends those child sessions before the remote extension disconnects the worker socket. - Docs: document the autoClose param. --- packages/cli/src/extensions/factories.test.ts | 2 +- packages/cli/src/extensions/factories.ts | 8 +++- .../cli/src/extensions/spawn-session.test.ts | 48 +++++++++++++++++++ packages/cli/src/extensions/spawn-session.ts | 13 +++++ .../src/content/docs/features/multi-agent.mdx | 1 + packages/server/src/routes/runners.ts | 2 + 6 files changed, 72 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/extensions/factories.test.ts b/packages/cli/src/extensions/factories.test.ts index 7ea1bf27c..373d2b3f7 100644 --- a/packages/cli/src/extensions/factories.test.ts +++ b/packages/cli/src/extensions/factories.test.ts @@ -55,6 +55,7 @@ const CORE_EXTENSIONS_HEAD: ExtensionFactory[] = [ providerRequestLogExtension, fallbackModelsExtension, triggersExtension, // Must be before remoteExtension (shutdown ordering) + subagentExtension, // Must be before remoteExtension (shutdown ordering: aborts mirrors first) remoteExtension, tunnelToolsExtension, serviceMessageBridgeExtension, @@ -75,7 +76,6 @@ const CORE_EXTENSIONS_TAIL: ExtensionFactory[] = [ updateTodoExtension, memoryExtension, spawnSessionExtension, - subagentExtension, workflowExtension, planModeToggleExtension, sandboxEventsExtension, diff --git a/packages/cli/src/extensions/factories.ts b/packages/cli/src/extensions/factories.ts index 0153c16ae..422a71700 100644 --- a/packages/cli/src/extensions/factories.ts +++ b/packages/cli/src/extensions/factories.ts @@ -101,6 +101,13 @@ export function buildPizzaPiExtensionFactories(options: BuildExtensionFactoriesO // session_complete is fired from remoteExtension's shutdown handler (before disconnect). factories.push(named(triggersExtension, "triggers")); + // Subagent must register BEFORE remote: on session_shutdown, pi emits in + // registration order, so subagent aborts its in-flight relay mirrors and + // ends those child sessions before remote disconnects the worker socket. + // (Unconditional — the subagent tool works without a relay; only mirroring + // is skipped.) + factories.push(named(subagentExtension, "subagent")); + if (!options.skipRelay) { factories.push(named(remoteExtension, "relay")); factories.push(named(tunnelToolsExtension, "tunnel-tools")); @@ -142,7 +149,6 @@ export function buildPizzaPiExtensionFactories(options: BuildExtensionFactoriesO named(updateTodoExtension, "todo"), named(memoryExtension, "memory"), named(spawnSessionExtension, "spawn-session"), - named(subagentExtension, "subagent"), named(workflowExtension, "workflow"), named(planModeToggleExtension, "plan-mode"), named(sandboxEventsExtension, "sandbox"), diff --git a/packages/cli/src/extensions/spawn-session.test.ts b/packages/cli/src/extensions/spawn-session.test.ts index feef0c189..324673875 100644 --- a/packages/cli/src/extensions/spawn-session.test.ts +++ b/packages/cli/src/extensions/spawn-session.test.ts @@ -101,3 +101,51 @@ describe("list_models tool — Ollama Cloud merge", () => { expect(ids).toContain("anthropic/claude-x"); }); }); + +// ── spawn_session tool — autoClose plumbing ──────────────────────────────── +// Completed children must self-terminate (auto-close) instead of idling on +// the runner forever. Default is true; explicit false opts out. + +describe("spawn_session tool — autoClose", () => { + const saved = { + RELAY: process.env.PIZZAPI_RELAY_URL, + KEY: process.env.PIZZAPI_API_KEY, + SESSION: process.env.PIZZAPI_SESSION_ID, + fetch: globalThis.fetch, + }; + + afterEach(() => { + if (saved.RELAY !== undefined) process.env.PIZZAPI_RELAY_URL = saved.RELAY; else delete process.env.PIZZAPI_RELAY_URL; + if (saved.KEY !== undefined) process.env.PIZZAPI_API_KEY = saved.KEY; else delete process.env.PIZZAPI_API_KEY; + if (saved.SESSION !== undefined) process.env.PIZZAPI_SESSION_ID = saved.SESSION; else delete process.env.PIZZAPI_SESSION_ID; + globalThis.fetch = saved.fetch; + }); + + function setup() { + process.env.PIZZAPI_RELAY_URL = "http://relay.test"; + process.env.PIZZAPI_API_KEY = "test-key"; + process.env.PIZZAPI_SESSION_ID = "parent-session"; + + const bodies: any[] = []; + globalThis.fetch = (async (_url: any, init: any) => { + bodies.push(JSON.parse(init.body)); + return new Response(JSON.stringify({ ok: true, sessionId: "child-1" }), { status: 200 }); + }) as any; + + const pi = createMockPi(); + spawnSessionExtension(pi as any); + return { tool: pi.tools.get("spawn_session"), bodies }; + } + + test("defaults autoClose to true in the spawn request body", async () => { + const { tool, bodies } = setup(); + await tool.execute("call-1", { prompt: "do things", runnerId: "r1" }); + expect(bodies[0].autoClose).toBe(true); + }); + + test("passes autoClose: false through when explicitly disabled", async () => { + const { tool, bodies } = setup(); + await tool.execute("call-1", { prompt: "stay up", runnerId: "r1", autoClose: false }); + expect(bodies[0].autoClose).toBe(false); + }); +}); diff --git a/packages/cli/src/extensions/spawn-session.ts b/packages/cli/src/extensions/spawn-session.ts index dd98c6399..676507692 100644 --- a/packages/cli/src/extensions/spawn-session.ts +++ b/packages/cli/src/extensions/spawn-session.ts @@ -105,6 +105,13 @@ export const spawnSessionExtension: ExtensionFactory = (pi) => { description: "Runner ID to spawn on. Usually not needed — defaults to the current runner.", }, + autoClose: { + type: "boolean", + description: + "Shut the child session down when it completes its work (default true). " + + "Set false to keep it alive for interactive follow-ups after completion.", + default: true, + }, }, required: ["prompt"], } as any, @@ -115,6 +122,7 @@ export const spawnSessionExtension: ExtensionFactory = (pi) => { model?: { provider: string; id: string }; cwd?: string; runnerId?: string; + autoClose?: boolean; }; const ok = (text: string, details?: Record) => ({ @@ -165,6 +173,11 @@ export const spawnSessionExtension: ExtensionFactory = (pi) => { } body.parentSessionId = ownSessionId; + // Default to auto-close so completed children don't idle forever on the + // runner. Auto-close itself is guarded: it won't fire if new messages, + // subscriptions, or linked children arrive during the idle re-check. + body.autoClose = params.autoClose !== false; + if (params.model) { // Note: hidden-model enforcement is done server-side (runners.ts). // A client-side check here using PIZZAPI_HIDDEN_MODELS would be diff --git a/packages/docs/src/content/docs/features/multi-agent.mdx b/packages/docs/src/content/docs/features/multi-agent.mdx index a25cdafe9..580611992 100644 --- a/packages/docs/src/content/docs/features/multi-agent.mdx +++ b/packages/docs/src/content/docs/features/multi-agent.mdx @@ -49,6 +49,7 @@ Spawn a new independent agent session on the runner. The new session runs in par | `model` | `{ provider, id }` | | Runner default | Model to use. Call `list_models` to discover available values. | | `cwd` | `string` | | Parent's cwd | Working directory for the new session. | | `runnerId` | `string` | | Current runner | Target runner ID. Usually not needed. | +| `autoClose` | `boolean` | | `true` | Shut the child down when it completes its work (guarded — it stays up if new messages or subscriptions arrive). Set `false` to keep it alive for interactive follow-ups after completion. | ### Return value diff --git a/packages/server/src/routes/runners.ts b/packages/server/src/routes/runners.ts index 854f4ba1e..005621014 100644 --- a/packages/server/src/routes/runners.ts +++ b/packages/server/src/routes/runners.ts @@ -125,6 +125,7 @@ export const handleRunnersRoute: RouteHandler = async (req, url) => { : undefined; const requestedParentSessionId = typeof body.parentSessionId === "string" ? body.parentSessionId : undefined; + const requestedAutoClose = body.autoClose === true; if (!requestedRunnerId) { return Response.json({ error: "Missing runnerId" }, { status: 400 }); @@ -195,6 +196,7 @@ export const handleRunnersRoute: RouteHandler = async (req, url) => { ...(hiddenModels.length > 0 ? { hiddenModels } : {}), ...(requestedAgent ? { agent: requestedAgent } : {}), ...(validatedParentSessionId ? { parentSessionId: validatedParentSessionId } : {}), + ...(requestedAutoClose ? { autoClose: true } : {}), ...(requestedResumePath ? { resumePath: requestedResumePath } : {}), ...(requestedResumeId && !requestedResumePath ? { resumeId: requestedResumeId } : {}), });