diff --git a/extensions/pi-subagents/README.md b/extensions/pi-subagents/README.md index 85c22b5..c58ebe3 100644 --- a/extensions/pi-subagents/README.md +++ b/extensions/pi-subagents/README.md @@ -13,7 +13,6 @@ Spawn one child Pi process. This is the only exposed model-callable tool. ```ts spawn_subagent({ task: string, - timeout?: number, cwd?: string, model?: string }) @@ -23,8 +22,6 @@ spawn_subagent({ - You will be notified when the subagent completes. - The returned ID is also the child Pi session ID and can be used with Pi session lookup/resume behavior. - `model` is an explicit override; omitting it inherits the active parent provider/model. -- `timeout` is optional (default 600s = 10 minutes) and measured in seconds. When reached, the parent is informed that the child is still running; the child is **not killed**. -- Give `timeout` a healthy margin above expected runtime because child execution time can be wildly unpredictable. - Subagents always start with fresh session history. Put any desired context explicitly in `task`. - Child subagent directories contain separate `result.log`, `stdout.log`, and `stderr.log` files. diff --git a/extensions/pi-subagents/skills/pi-subagents/SKILL.md b/extensions/pi-subagents/skills/pi-subagents/SKILL.md index 4b32d52..b5ff766 100644 --- a/extensions/pi-subagents/skills/pi-subagents/SKILL.md +++ b/extensions/pi-subagents/skills/pi-subagents/SKILL.md @@ -9,11 +9,9 @@ Use this tool to launch unrestricted child Pi sessions. The caller must include ## Tools -- `spawn_subagent({ task, timeout?, cwd?, model? })` +- `spawn_subagent({ task, cwd?, model? })` - Calls return immediately; the parent will be notified when the subagent completes. - `model` is an explicit override; omitting it inherits the active parent provider/model. - - `timeout` is optional; default is `600` seconds (10 minutes). - - Timeout is only a notification threshold: the parent is informed that the child is still running; the child is not killed. - The returned subagent id is also the child Pi session id. - Child output is written to `result.log` under the child subagent directory. - When `model` is omitted, the child inherits the parent session's active model (e.g., `openai-codex/gpt-5.6-sol`). Pass an explicit `model` to override (e.g., `"anthropic/claude-sonnet-4-5"`). @@ -23,11 +21,9 @@ Use this tool to launch unrestricted child Pi sessions. The caller must include ```ts spawn_subagent({ task: "Worker A full instructions...", - timeout: 900, }); spawn_subagent({ task: "Worker B full instructions...", - timeout: 900, }); ``` @@ -40,10 +36,6 @@ Calls return immediately; the parent will be notified when each subagent complet - No subagent types exist. - No chain or parallel-list mode exists. - `model` is an explicit override; omitting it inherits the active parent provider/model. -- `timeout` is optional and measured in seconds; omitted timeout defaults to 10 minutes. -- When `timeout` expires, the parent is informed that the subagent is still running; the child is not killed. -- Do not kill subagents autonomously to enforce `timeout`. -- Give explicit `timeout` values a healthy margin above expected runtime because child execution time can be wildly unpredictable. - Tell the user/caller in second person that they **will be notified** when the subagent completes. - Child Pi receives normal tools, skills, extensions, and project context. - Child Pi gets only one automatic system line: `You are a Pi subagent controlled by another Pi agent.` @@ -56,13 +48,8 @@ Calls return immediately; the parent will be notified when each subagent complet When changing the pi-subagents extension contract, update every surface together: 1. Spawn tool schema in `extensions/pi-subagents/src/extension/schemas.ts`. -2. Spawn runtime defaults/validation and user-facing messages in `extensions/pi-subagents/src/extension/index.ts`. +2. Spawn runtime validation and user-facing messages in `extensions/pi-subagents/src/extension/index.ts`. 3. Skill docs in `extensions/pi-subagents/skills/pi-subagents/SKILL.md`. 4. GitHub issues/PR text exactly as requested by the user; do not fabricate details. -For timeout semantics specifically: - -- Make schema and runtime agree that `timeout` is optional. -- Apply `timeout ?? 600` before constructing persisted records or timers. -- Phrase launch responses in second person: “you will be notified…” when the subagent completes. -- Preserve existing timeout behavior: timeout only notifies/marks timeout; it must not kill the child process. +Phrase launch responses in second person: “you will be notified…” when the subagent completes. diff --git a/extensions/pi-subagents/src/extension/index.ts b/extensions/pi-subagents/src/extension/index.ts index 0cf472c..471197f 100644 --- a/extensions/pi-subagents/src/extension/index.ts +++ b/extensions/pi-subagents/src/extension/index.ts @@ -28,17 +28,9 @@ import { interface ToolDetails { id?: string; - sessionId?: string; - sessionFile?: string; running?: boolean; - result?: string; resultPath?: string; - error?: string; - timedOut?: boolean; - timeoutAt?: number; - timeoutMessage?: string; model?: string; - subagents?: Array<{ id: string; running: boolean }>; } interface PersistedSubagentRecord { @@ -47,7 +39,6 @@ interface PersistedSubagentRecord { cwd: string; taskPreview: string; keepContext?: boolean; - timeout: number; model?: string; running: boolean; pid?: number; @@ -61,11 +52,6 @@ interface PersistedSubagentRecord { createdAt: number; updatedAt: number; completedAt?: number; - timeoutAt?: number; - timeoutNotified?: boolean; - pendingTimeoutNotice?: boolean; - timeoutNotifyError?: string; - timeoutNotifiedAt?: number; completionNotificationPending?: boolean; notifiedCompletion?: boolean; pendingCompletionNotice?: boolean; @@ -87,12 +73,9 @@ interface ReconcileResult { interface StartChildHooks { onRunning?(record: PersistedSubagentRecord): void; - onTimeout?(record: PersistedSubagentRecord): void; onTerminal?(record: PersistedSubagentRecord): void; onSetupFailure?(record: PersistedSubagentRecord, error: unknown): void; } - -const DEFAULT_TIMEOUT_SECONDS = 600; const runningChildren = new Map(); // Lifeline pipes keyed by record.id. Each entry holds the parent's end of the @@ -194,15 +177,6 @@ function upsertRecord(record: PersistedSubagentRecord): void { writeStore(record.parentSessionId, store); } -function findRecord( - parentId: string, - id: string, -): PersistedSubagentRecord | undefined { - return readStore(parentId).records.find( - (r) => r.id === id || r.id.startsWith(id), - ); -} - function updateRecordFields( parentId: string, id: string, @@ -247,45 +221,6 @@ function markCompletionNoticeSent( ); } -function markTimeoutNoticePending( - record: PersistedSubagentRecord, - error?: unknown, -): PersistedSubagentRecord { - return ( - updateRecordFields(record.parentSessionId, record.id, (latest) => { - latest.pendingTimeoutNotice = true; - latest.timeoutNotified = false; - if (error !== undefined) - latest.timeoutNotifyError = - error instanceof Error ? error.message : String(error); - }) ?? record - ); -} - -function markTimeoutNoticeSent( - record: PersistedSubagentRecord, -): PersistedSubagentRecord { - return ( - updateRecordFields(record.parentSessionId, record.id, (latest) => { - latest.timeoutNotified = true; - latest.pendingTimeoutNotice = false; - delete latest.timeoutNotifyError; - latest.timeoutNotifiedAt = Date.now(); - }) ?? record - ); -} - -function markTimeoutNoticeSkipped( - record: PersistedSubagentRecord, -): PersistedSubagentRecord { - return ( - updateRecordFields(record.parentSessionId, record.id, (latest) => { - latest.pendingTimeoutNotice = false; - delete latest.timeoutNotifyError; - }) ?? record - ); -} - function isPidRunning(pid: number | undefined): boolean { if (!pid) return false; try { @@ -432,7 +367,6 @@ function mergeRecord( pid: refreshed.pid ?? latest.pid, cwd: refreshed.cwd, taskPreview: refreshed.taskPreview, - timeout: refreshed.timeout, model: refreshed.model ?? latest.model, running: refreshed.running, sessionDir: refreshed.sessionDir ?? latest.sessionDir, @@ -445,7 +379,6 @@ function mergeRecord( createdAt: refreshed.createdAt, updatedAt: refreshed.updatedAt, completedAt: refreshed.completedAt ?? latest.completedAt, - timeoutAt: refreshed.timeoutAt ?? latest.timeoutAt, cohortId: refreshed.cohortId ?? latest.cohortId, cohortCreatedAt: refreshed.cohortCreatedAt ?? latest.cohortCreatedAt, @@ -471,16 +404,11 @@ function applyNotificationFields( "notifiedCompletion", latest.notifiedCompletion || refreshed.notifiedCompletion, ); - set("timeoutNotified", latest.timeoutNotified || refreshed.timeoutNotified); set( "cohortFinalNotified", latest.cohortFinalNotified || refreshed.cohortFinalNotified, ); set("notifiedAt", refreshed.notifiedAt ?? latest.notifiedAt); - set( - "timeoutNotifiedAt", - refreshed.timeoutNotifiedAt ?? latest.timeoutNotifiedAt, - ); const notifiedCompletion = latest.notifiedCompletion || refreshed.notifiedCompletion; set( @@ -494,17 +422,7 @@ function applyNotificationFields( refreshed.completionNotificationPending) && !notifiedCompletion, ); - const timeoutNotified = latest.timeoutNotified || refreshed.timeoutNotified; - set( - "pendingTimeoutNotice", - (latest.pendingTimeoutNotice || refreshed.pendingTimeoutNotice) && - !timeoutNotified, - ); set("notifyError", refreshed.notifyError || latest.notifyError); - set( - "timeoutNotifyError", - refreshed.timeoutNotifyError || latest.timeoutNotifyError, - ); return r as unknown as PersistedSubagentRecord; } @@ -655,11 +573,8 @@ function sanitizePreview(text: string): string { function formatRunningLine(record: PersistedSubagentRecord): string { const elapsed = Math.floor((Date.now() - record.createdAt) / 1000); - const timedOut = Boolean(record.timeoutAt); const statusText = record.running - ? timedOut - ? "timed out, still running" - : `running ${elapsed}s` + ? `running ${elapsed}s` : record.error ? "failed" : "complete"; @@ -672,95 +587,6 @@ function formatRunningLine(record: PersistedSubagentRecord): string { return `\x1b[2m${line}\x1b[22m`; } -function resultForRecord(record: PersistedSubagentRecord): string | undefined { - return ( - record.outputFile ?? - path.join(childDir(record.parentSessionId, record.id), "result.log") - ); -} - -function subagentSessionId( - record: PersistedSubagentRecord, -): string | undefined { - if (!record.sessionFile || !fs.existsSync(record.sessionFile)) - return undefined; - for (const line of fs.readFileSync(record.sessionFile, "utf-8").split("\n")) { - if (!line.trim()) continue; - try { - const event = JSON.parse(line) as { type?: string; id?: unknown }; - if (event.type === "session" && typeof event.id === "string") - return event.id; - } catch { - // Ignore malformed session log lines. - } - } - return undefined; -} - -function timeoutMessage(record: PersistedSubagentRecord): string { - const details = [ - `Subagent ${record.id} timed out after ${record.timeout}s; still running; not killed`, - ]; - const sessionId = subagentSessionId(record) ?? record.id; - details.push(`sessionId=${sessionId}`); - if (record.pid) details.push(`pid=${record.pid}`); - return `${details.join("; ")}.`; -} - -function formatStatus( - record: PersistedSubagentRecord, -): AgentToolResult { - const refreshed = refreshRecordFromDisk(record); - const result = resultForRecord(refreshed); - const timedOut = Boolean(refreshed.timeoutAt); - const timedOutMessage = timedOut ? timeoutMessage(refreshed) : undefined; - const doNotPollNotice = refreshed.running - ? "Do not poll for the result. Do not sleep for the result. You will be notified when the subagent completes." - : undefined; - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - id: refreshed.id, - sessionId: refreshed.id, - running: refreshed.running, - ...(result ? { resultPath: result } : {}), - ...(timedOut - ? { - timedOut: true, - timeoutAt: refreshed.timeoutAt, - timeoutMessage: timedOutMessage, - } - : {}), - ...(refreshed.error ? { error: refreshed.error } : {}), - }, - null, - 2, - ), - }, - ...(doNotPollNotice - ? [{ type: "text" as const, text: doNotPollNotice }] - : []), - ], - details: { - id: refreshed.id, - sessionId: refreshed.id, - running: refreshed.running, - ...(result ? { resultPath: result } : {}), - ...(timedOut - ? { - timedOut: true, - timeoutAt: refreshed.timeoutAt, - timeoutMessage: timedOutMessage, - } - : {}), - ...(refreshed.error ? { error: refreshed.error } : {}), - }, - }; -} - function childDir(parentId: string, id: string): string { return path.join(parentId, "subagents", id); } @@ -803,7 +629,6 @@ function makeRecord( parentSessionId: parentId, cwd: params.cwd ? path.resolve(ctx.cwd, params.cwd) : ctx.cwd, taskPreview: params.task.slice(0, 500), - timeout: params.timeout ?? DEFAULT_TIMEOUT_SECONDS, ...(model ? { model } : {}), running: false, sessionDir: dir, @@ -1004,113 +829,6 @@ function retryPendingCompletionNotices( } } -function retryPendingTimeoutNotices(pi: ExtensionAPI, parentId: string): void { - const records = readStore(parentId).records; - for (const record of records) { - const refreshed = refreshRecordFromDisk(record); - if (!refreshed.timeoutAt) continue; - if (!refreshed.running) { - if (refreshed.pendingTimeoutNotice) markTimeoutNoticeSkipped(refreshed); - continue; - } - if (refreshed.pendingTimeoutNotice && !refreshed.timeoutNotified) { - notifyTimeout(pi, refreshed); - } - } -} - -function retryPendingNotices(pi: ExtensionAPI, parentId: string): void { - retryPendingTimeoutNotices(pi, parentId); - retryPendingCompletionNotices(pi, parentId); -} - -function markTimedOut( - record: PersistedSubagentRecord, - queueNotice = true, -): PersistedSubagentRecord { - // Use updateRecordFields for atomic read-modify-write to avoid - // clobbering a newer terminal state written by the child close handler. - // WARNING: Do NOT call refreshRecordFromDisk or upsertRecord inside the - // callback. The outer updateRecordFields writes the store after the - // callback returns, so any inner upsert would be immediately clobbered. - // Instead only check liveness via isPidRunning and conditionally clear - // pending flags on the record that updateRecordFields will persist. - const updated = updateRecordFields( - record.parentSessionId, - record.id, - (latest) => { - // Already terminal (child close handler won): just clear pending - // timeout flags without re-marking running. - if (!latest.running) { - if (latest.pendingTimeoutNotice) { - latest.pendingTimeoutNotice = false; - delete latest.timeoutNotifyError; - } - return; - } - // Verify PID is still alive. If the PID died but store still - // says running, mark terminal inline (no separate upsert). - if (!isPidRunning(latest.pid)) { - latest.running = false; - latest.completedAt ??= Date.now(); - if (latest.pendingTimeoutNotice) { - latest.pendingTimeoutNotice = false; - delete latest.timeoutNotifyError; - } - return; - } - if (!latest.timeoutAt) latest.timeoutAt = Date.now(); - if (queueNotice && !latest.timeoutNotified) { - latest.pendingTimeoutNotice = true; - } - }, - ); - if (!updated) return record; - return updated; -} - -function notifyTimeout( - pi: ExtensionAPI, - record: PersistedSubagentRecord, -): boolean { - // Re-read latest from store before sending; skip if already notified. - // Return true: consistent with notifyCompletion ("did we handle it?"). - const freshLatest = findRecord(record.parentSessionId, record.id); - if (freshLatest?.timeoutNotified) return true; - - const timedOut = markTimedOut(record); - if (!timedOut.running || !timedOut.timeoutAt || timedOut.timeoutNotified) - return false; - try { - pi.sendMessage( - { - customType: "subagent-notify", - content: timeoutMessage(timedOut), - display: true, - }, - { triggerTurn: true }, - ); - } catch (error) { - markTimeoutNoticePending(timedOut, error); - return false; - } - markTimeoutNoticeSent(timedOut); - return true; -} - -function startTimeoutTimer( - pi: ExtensionAPI, - record: PersistedSubagentRecord, - notify: boolean, -): NodeJS.Timeout { - const timer = setTimeout(function onSubagentTimeout() { - if (notify) notifyTimeout(pi, record); - else markTimedOut(record, false); - }, record.timeout * 1000); - timer.unref(); - return timer; -} - function startChild( pi: ExtensionAPI, ctx: ExtensionContext, @@ -1255,11 +973,6 @@ function startChild( ? fs.readFileSync(record.stderrFile, "utf-8") : ""; - const latest = findRecord(record.parentSessionId, record.id); - if (latest?.timeoutAt) record.timeoutAt = latest.timeoutAt; - if (latest?.timeoutNotified) - record.timeoutNotified = latest.timeoutNotified; - record.running = false; record.completedAt = Date.now(); record.updatedAt = Date.now(); @@ -1330,7 +1043,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void { name: "spawn_subagent", label: "Spawn subagent", description: - "Spawn a child Pi subagent for one task. model is an explicit override; omitting it inherits the active parent provider/model. timeout is optional and measured in seconds (default 600 = 10 minutes). This returns immediately, allowing the parent to spawn multiple concurrent subagents by calling spawn_subagent multiple times. Do not kill subagents autonomously to enforce timeout; the parent will be informed when timeout expires. Give a healthy timeout margin above expected runtime because subagent execution may be wildly unpredictable.", + "Spawn a child Pi subagent for one task. model is an explicit override; omitting it inherits the active parent provider/model. This returns immediately, allowing the parent to spawn multiple concurrent subagents by calling spawn_subagent multiple times.", parameters: SpawnSubagentParams, async execute( id, @@ -1341,7 +1054,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void { ) { void id; const parentId = parentSessionId(ctx); - retryPendingNotices(pi, parentId); + retryPendingCompletionNotices(pi, parentId); rememberUiContext(ctx); const record = makeRecord(ctx, params); const cohort = getOrCreateActiveCohort(parentId); @@ -1353,9 +1066,6 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void { renderRunningWidget(ctx, parentId); scheduleWidgetRefresh(parentId); }, - onTimeout(_r) { - renderRunningWidget(ctx, parentId); - }, onTerminal(_r) { renderRunningWidget(ctx, parentId); stopWidgetRefreshIfIdle(parentId); @@ -1366,7 +1076,6 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void { }, }; const started = startChild(pi, ctx, record, params.task, true, hooks); - const timeoutTimer = startTimeoutTimer(pi, started.record, true); void started.done .catch((error) => { record.running = false; @@ -1379,8 +1088,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void { markPendingBeforeSend: false, }); }) - .catch(() => {}) - .finally(() => clearTimeout(timeoutTimer)); + .catch(() => {}); return { content: [ { @@ -1416,7 +1124,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void { renderRunningWidget(ctx, parentId); scheduleWidgetRefresh(parentId); } - retryPendingNotices(pi, parentId); + retryPendingCompletionNotices(pi, parentId); }); pi.on("agent_end", (_event, ctx) => { diff --git a/extensions/pi-subagents/src/extension/schemas.ts b/extensions/pi-subagents/src/extension/schemas.ts index 8a56811..be2cdec 100644 --- a/extensions/pi-subagents/src/extension/schemas.ts +++ b/extensions/pi-subagents/src/extension/schemas.ts @@ -5,13 +5,6 @@ import { Type } from "typebox"; export const SpawnSubagentParams = Type.Object( { task: Type.String({ description: "Task for the child Pi to perform." }), - timeout: Type.Optional( - Type.Number({ - minimum: 0.001, - description: - "Optional timeout in seconds (default 600 = 10 minutes). When the timeout is reached, the parent is informed that the subagent is still running; the subagent is not killed. Do not kill subagents autonomously to enforce this timeout. Give a healthy timeout margin on top of expected execution time because subagent runtime may be wildly unpredictable.", - }), - ), cwd: Type.Optional( Type.String({ description: @@ -42,7 +35,6 @@ export const ListSubagentsParams = Type.Object( export interface SpawnSubagentParamsLike { task: string; - timeout?: number; cwd?: string; model?: string; } diff --git a/extensions/pi-subagents/test/unit/minimal-subagents.test.ts b/extensions/pi-subagents/test/unit/minimal-subagents.test.ts index 310fa6a..afd94a3 100644 --- a/extensions/pi-subagents/test/unit/minimal-subagents.test.ts +++ b/extensions/pi-subagents/test/unit/minimal-subagents.test.ts @@ -512,7 +512,7 @@ test("async completion persists success and pending metadata when stale notifica try { await spawnTool.execute( "stale-notify-child", - { task: "finish", timeout: 30 }, + { task: "finish" }, new AbortController().signal, undefined, ctx, @@ -653,46 +653,6 @@ test("provider failure finalizes once with error text and result fallback", asyn } }); -test("timeout remains notification-only and child later completes", async () => { - const mockPi = createMockPi(); - mockPi.install(); - mockPi.onCall({ output: "late success", exitCode: 0, delay: 180 }); - - const { sessionId, ctx } = makeTestCtx("pi-subagents-timeout-notify-only"); - const notifications: string[] = []; - const { spawnTool } = registerTestTools((message) => { - const content = (message as { content?: unknown }).content; - if (typeof content === "string") notifications.push(content); - }); - - try { - const result = await spawnTool.execute( - "timeout-notify-child", - { task: "finish after threshold", timeout: 0.04 }, - new AbortController().signal, - undefined, - ctx, - ); - const timedOut = await waitForPersistedRecord( - sessionId, - result.details.id, - (record) => record.running === true && typeof record.timeoutAt === "number", - ); - assert.equal(timedOut.running, true, "timeout must not kill the child"); - assert.equal(timedOut.timeoutNotified, true); - assert.equal(notifications.filter((text) => text.includes("still running; not killed")).length, 1); - - const completed = await waitForPersistedRecord(sessionId, result.details.id); - assert.equal(completed.running, false); - assert.equal(completed.error, undefined); - assert.equal(fs.readFileSync(completed.outputFile, "utf-8").trim(), "late success"); - assert.equal(notifications.filter((text) => text.includes("completed")).length, 1); - } finally { - mockPi.uninstall(); - cleanupTestCtx(ctx, sessionId); - } -}); - test("spawn persists unified id session file result.log and fresh args", async () => { const mockPi = createMockPi(); mockPi.install(); @@ -1522,7 +1482,6 @@ test("Phase 7.14: session_start renders widget before user status", async () => parentSessionId: sessionId, cwd: ctx.cwd, taskPreview: "session start test", - timeout: 3600, running: true, pid: process.pid, sessionFile: path.join(childDataDir, "session.jsonl"), @@ -1758,7 +1717,7 @@ test("cohort: legacy records without cohortId notify solo", async () => { fs.mkdirSync(child, { recursive: true }); const outputFile = path.join(child, "result.log"); fs.writeFileSync(outputFile, `${id}\n`); - return { id, parentSessionId: sessionId, cwd: ctx.cwd, taskPreview: id, timeout: 3600, running: false, outputFile, stdoutFile: path.join(child, "stdout.log"), stderrFile: path.join(child, "stderr.log"), createdAt: now + index, updatedAt: now + index, completedAt: now + index, pendingCompletionNotice: id === "legacy-a" }; + return { id, parentSessionId: sessionId, cwd: ctx.cwd, taskPreview: id, running: false, outputFile, stdoutFile: path.join(child, "stdout.log"), stderrFile: path.join(child, "stderr.log"), createdAt: now + index, updatedAt: now + index, completedAt: now + index, pendingCompletionNotice: id === "legacy-a" }; }); fs.writeFileSync(storeFile(sessionId), JSON.stringify({ records }, null, 2)); const messages: string[] = []; @@ -1801,7 +1760,7 @@ test("cohort: reconcile preserves cohort metadata", async () => { const fake = makeFakeCtx(sessionId, ctx.cwd, false); const dir = path.join(sessionId, "subagents", "reconcile-a"); fs.mkdirSync(dir, { recursive: true }); - const record = { id: "reconcile-a", parentSessionId: sessionId, cwd: ctx.cwd, taskPreview: "x", timeout: 3600, running: true, outputFile: path.join(dir, "result.log"), stdoutFile: path.join(dir, "stdout.log"), stderrFile: path.join(dir, "stderr.log"), createdAt: Date.now(), updatedAt: Date.now(), cohortId: "cohort-keep", cohortCreatedAt: 12345 }; + const record = { id: "reconcile-a", parentSessionId: sessionId, cwd: ctx.cwd, taskPreview: "x", running: true, outputFile: path.join(dir, "result.log"), stdoutFile: path.join(dir, "stdout.log"), stderrFile: path.join(dir, "stderr.log"), createdAt: Date.now(), updatedAt: Date.now(), cohortId: "cohort-keep", cohortCreatedAt: 12345 }; fs.writeFileSync(record.stdoutFile, ""); fs.writeFileSync(record.stderrFile, ""); fs.writeFileSync(storeFile(sessionId), JSON.stringify({ records: [record] }, null, 2)); @@ -1883,7 +1842,7 @@ test("lifeline: session_shutdown terminates children via lifeline", async () => try { const result = await spawnTool.execute( "lifeline-kill-child", - { task: "lifeline kill test", timeout: 30 }, + { task: "lifeline kill test" }, new AbortController().signal, undefined, fake.ctx, @@ -1928,7 +1887,7 @@ test("lifeline: agent_end does NOT kill child process", async () => { try { const result = await spawnTool.execute( "agent-end-survive-child", - { task: "survive agent_end", timeout: 30 }, + { task: "survive agent_end" }, new AbortController().signal, undefined, fake.ctx, @@ -1979,7 +1938,7 @@ test("lifeline: abrupt parent SIGKILL cascades to child termination", async () = const result = await registered.get("spawn_subagent").execute( "cascade-child", - { task: "long-running cascade child", timeout: 60 }, + { task: "long-running cascade child" }, new AbortController().signal, undefined, ctx, @@ -2047,7 +2006,7 @@ test("lifeline: recursive cascade — grandparent death kills parent subagent", const result = await registered.get("spawn_subagent").execute( "recursive-parent", - { task: "parent that spawns child", timeout: 60 }, + { task: "parent that spawns child" }, new AbortController().signal, undefined, ctx, @@ -2284,53 +2243,6 @@ test("error lifecycle: normal successful completion unchanged", async () => { } }); -test("error lifecycle: timeout notification-only unchanged", async () => { - const mockPi = createMockPi(); - mockPi.install(); - // Keep child alive for a long time; timeout must fire but not kill - mockPi.onCall({ - output: "running", - exitCode: 0, - keepAliveAfterFinalMessageMs: 2000, - }); - - const { sessionId, ctx } = makeTestCtx("pi-subagents-error-timeout"); - const timeoutMessages: string[] = []; - const { spawnTool } = registerTestTools((message: any) => { - const content = String(message.content ?? ""); - if (content.includes("timed out")) timeoutMessages.push(content); - }); - - try { - const result = await spawnTool.execute( - "timeout-child", - { task: "do work", timeout: 0.1 }, - new AbortController().signal, - undefined, - ctx, - ); - - // Wait for timeout notice - for (let i = 0; i < 100; i++) { - if (timeoutMessages.length > 0) break; - await new Promise((resolve) => setTimeout(resolve, 20)); - } - assert.ok(timeoutMessages.length >= 1, "timeout notice must fire"); - assert.match(timeoutMessages[0], /still running; not killed/); - - // Verify record has timeoutAt but child is still running (not killed) - const runningRecord = readPersistedRecord(sessionId, result.details.id); - assert.equal(runningRecord.running, true, "child must still be running after timeout"); - assert.ok(runningRecord.timeoutAt, "timeoutAt must be set"); - - // Wait for child to actually finish before cleanup - await waitForPersistedRecord(sessionId, result.details.id); - } finally { - mockPi.uninstall(); - cleanupTestCtx(ctx, sessionId); - } -}); - test("error lifecycle: lifeline cleanup unchanged after provider error", async () => { const mockPi = createMockPi(); mockPi.install();