From 9c9cb496df1f6aa3e2eeedf042bc2e470a2048fb Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 8 Sep 2026 21:45:16 -0700 Subject: [PATCH 1/3] Raise wait_agents timeout and extend while child shells run Thirty seconds was too short for typecheck, and a five-minute clamp could not cover a long child shell. Timeout still does not cancel workers. --- CHANGELOG.md | 10 + src/agent/directors/skywalker/package.test.ts | 4 + src/agent/directors/skywalker/package.ts | 2 +- src/subagent/agent-fleet.test.ts | 255 ++++++++++++++++++ src/subagent/agent-fleet.ts | 81 +++++- 5 files changed, 343 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab36cd39c..82c3c4093 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,16 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain parallel copies under `docs/` or `scripts/notes/`. At cut time: rename `## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script. +## [Unreleased] + +### Changed + +- wait_agents default timeout is 5 minutes (was 30 seconds) and the clamp is 30 + minutes (was 5). While a targeted child has `run_shell` or `shell` in flight, + the wait extends in default-length slices until the shell ends, the worker + terminals, abort, or the 30-minute elapsed ceiling. Timeout, extend, and abort + still do not cancel workers. + ## [0.3.18] - 2026-09-08 ### Added diff --git a/src/agent/directors/skywalker/package.test.ts b/src/agent/directors/skywalker/package.test.ts index 03ed6d9a0..718cb2123 100644 --- a/src/agent/directors/skywalker/package.test.ts +++ b/src/agent/directors/skywalker/package.test.ts @@ -130,6 +130,10 @@ describe("skywalkerPackage", () => { expect(p).toContain("timeout_ms"); expect(p).toContain("answer them first"); expect(p).toContain("Enter can land"); + expect(p).toContain("do not tight-loop wait_agents"); + expect(p).toContain("extends while a targeted child has run_shell or shell in flight"); + expect(p).not.toContain("timeout_ms: 1000"); + expect(p).not.toContain("timeout_ms: MAX"); }); test("systemPrompt anti-cascade keeps digs out of fleets", () => { diff --git a/src/agent/directors/skywalker/package.ts b/src/agent/directors/skywalker/package.ts index 60284e49e..950a7027c 100644 --- a/src/agent/directors/skywalker/package.ts +++ b/src/agent/directors/skywalker/package.ts @@ -16,7 +16,7 @@ You do not do the specialists' jobs by default. For tiny bounded product edits, Do not run long-blocking jobs on the parent (evals, full test suites, long installs, long-running implementation). Dispatch intern (mechanical shell), tester (suite / repro), or builder (substantial code). Path tools (write_file/edit_file/delete_file) are the DIY surface; shell file-writes stay denied. -Idle-orchestrator: fire one or more spawn_agent calls in a turn — each returns immediately with an agent_id and does not hold the parent. Then **reply to the operator** with who is running and what happens next before you block. Prefer ending that turn (or calling wait_agents with a short timeout_ms) so Enter can land; do not immediately fuse into a long wait_agents right after spawn. wait_agents later on the targets you need (or omit targets to wait on this session's own uncollected spawns — never a sibling's). list_agents shows that same fleet without blocking. Use mode="all" when you need every target to finish; interrupt_agent unblocks wait_agents immediately. A timeout means still running — do not tight-loop wait_agents hoping for a different answer. Enter mid-run delivers at the next parent tool.boundary — a long parent run_shell or awaiting wait_agents holds those steers. A bare spawn_agent does not. +Idle-orchestrator: fire one or more spawn_agent calls in a turn — each returns immediately with an agent_id and does not hold the parent. Then **reply to the operator** with who is running and what happens next before you block. Prefer ending that turn (or calling wait_agents with a short timeout_ms) so Enter can land; do not immediately fuse into a long wait_agents right after spawn. wait_agents later on the targets you need (or omit targets to wait on this session's own uncollected spawns — never a sibling's). list_agents shows that same fleet without blocking. Use mode="all" when you need every target to finish; interrupt_agent unblocks wait_agents immediately. A timeout means still running — do not tight-loop wait_agents hoping for a different answer. The wait extends while a targeted child has run_shell or shell in flight; a timeout is still not a kill and is not a cue to retry immediately with a short then maximum timeout. Enter mid-run delivers at the next parent tool.boundary — a long parent run_shell or awaiting wait_agents holds those steers. A bare spawn_agent does not. # Operator updates (mandatory while fleet is live) diff --git a/src/subagent/agent-fleet.test.ts b/src/subagent/agent-fleet.test.ts index 01ae267d4..e4624cc8b 100644 --- a/src/subagent/agent-fleet.test.ts +++ b/src/subagent/agent-fleet.test.ts @@ -3,12 +3,19 @@ import { mkdtemp, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import type { ReactorEmittedEvent } from "@intx/inference"; + import { createFleetMailbox, createSpawnAgentTool, createWaitAgentsTool, createListAgentsTool, MAX_FLEET_RECORDS, + DEFAULT_WAIT_TIMEOUT_MS, + MAX_WAIT_TIMEOUT_MS, + clampWaitTimeoutMs, + nextWaitTimerMs, + waitAgentsToolDefinition, type AgentFleetDeps, } from "./agent-fleet.js"; import { createAdmissionQueue, unlimitedAdmissionQueue } from "./admission.js"; @@ -640,6 +647,254 @@ describe("wait mailbox session tombstone and pin", () => { }); }); +describe("wait timeout helpers", () => { + test("default is 5 minutes and clamp is 30 minutes", () => { + expect(DEFAULT_WAIT_TIMEOUT_MS).toBe(300_000); + expect(MAX_WAIT_TIMEOUT_MS).toBe(1_800_000); + expect(DEFAULT_WAIT_TIMEOUT_MS).toBeGreaterThanOrEqual(60_000); + expect(MAX_WAIT_TIMEOUT_MS).toBeGreaterThanOrEqual(DEFAULT_WAIT_TIMEOUT_MS); + }); + + test("clampWaitTimeoutMs floors at 0 and caps at MAX", () => { + expect(clampWaitTimeoutMs(DEFAULT_WAIT_TIMEOUT_MS)).toBe(DEFAULT_WAIT_TIMEOUT_MS); + expect(clampWaitTimeoutMs(MAX_WAIT_TIMEOUT_MS + 1)).toBe(MAX_WAIT_TIMEOUT_MS); + expect(clampWaitTimeoutMs(-10)).toBe(0); + expect(clampWaitTimeoutMs(0)).toBe(0); + }); + + test("nextWaitTimerMs extends only while a shell is in flight and elapsed is under max", () => { + const defaultMs = DEFAULT_WAIT_TIMEOUT_MS; + const maxMs = MAX_WAIT_TIMEOUT_MS; + expect(nextWaitTimerMs({ elapsed: 80, hasInFlightShell: true, defaultMs, maxMs })).toBe( + defaultMs, + ); + expect( + nextWaitTimerMs({ elapsed: 80, hasInFlightShell: false, defaultMs, maxMs }), + ).toBeUndefined(); + expect( + nextWaitTimerMs({ elapsed: maxMs, hasInFlightShell: true, defaultMs, maxMs }), + ).toBeUndefined(); + expect( + nextWaitTimerMs({ elapsed: maxMs - 100_000, hasInFlightShell: true, defaultMs, maxMs }), + ).toBe(100_000); + }); + + test("wait_agents description interpolates the new default and clamp and mentions shell extend", () => { + expect(waitAgentsToolDefinition.description).toContain(String(DEFAULT_WAIT_TIMEOUT_MS)); + expect(waitAgentsToolDefinition.description).toContain(String(MAX_WAIT_TIMEOUT_MS)); + expect(waitAgentsToolDefinition.description).toContain("run_shell"); + expect(waitAgentsToolDefinition.description).toContain("tight zero-progress loop"); + const timeoutSchema = ( + waitAgentsToolDefinition.inputSchema as { + properties?: { timeout_ms?: { description?: string } }; + } + ).properties?.timeout_ms; + expect(timeoutSchema?.description).toContain(String(DEFAULT_WAIT_TIMEOUT_MS)); + expect(timeoutSchema?.description).toContain(String(MAX_WAIT_TIMEOUT_MS)); + }); +}); + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function stampToolStart( + sessions: ReturnType, + id: string, + name: string, + callId: string, + seq = 1, +): void { + sessions.appendEvent(id, { + type: "tool.start", + seq, + data: { call: { id: callId, name, arguments: {} } }, + } as unknown as ReactorEmittedEvent); +} + +describe("wait_agents shell-extend", () => { + test("extends while run_shell is in flight instead of returning a zero-progress timeout", async () => { + const gate = deferred(); + const deps = makeDeps(async () => gate.promise); + const spawn = createSpawnAgentTool(deps); + const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords }); + const spawned = await callTool(spawn, { + description: "slow job", + prompt: "do it", + intent: "explore", + }); + const id = spawned.agent_id as string; + stampToolStart(deps.sessions, id, "run_shell", "call-shell"); + + const waiting = callTool(wait, { targets: [id], timeout_ms: 80 }); + let settled = false; + void waiting.then(() => { + settled = true; + }); + await delay(150); + expect(settled).toBe(false); + expect(deps.sessions.get(id)?.status).toBe("running"); + + gate.resolve({ report: "done" }); + const result = await waiting; + expect(result.timed_out).toBe(false); + const results = result.results as { status: string; report?: string }[]; + expect(results[0]!.status).toBe("done"); + expect(results[0]!.report).toBe("done"); + }); + + test("extends while Codex shell is in flight", async () => { + const gate = deferred(); + const deps = makeDeps(async () => gate.promise); + const spawn = createSpawnAgentTool(deps); + const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords }); + const spawned = await callTool(spawn, { + description: "slow job", + prompt: "do it", + intent: "explore", + }); + const id = spawned.agent_id as string; + stampToolStart(deps.sessions, id, "shell", "call-codex-shell"); + + const waiting = callTool(wait, { targets: [id], timeout_ms: 80 }); + let settled = false; + void waiting.then(() => { + settled = true; + }); + await delay(150); + expect(settled).toBe(false); + + gate.resolve({ report: "codex done" }); + const result = await waiting; + expect(result.timed_out).toBe(false); + expect(deps.sessions.get(id)?.status).not.toBe("failed"); + }); + + test("extends when run_shell is outstanding even if currentToolName is grep", async () => { + const gate = deferred(); + const deps = makeDeps(async () => gate.promise); + const spawn = createSpawnAgentTool(deps); + const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords }); + const spawned = await callTool(spawn, { + description: "slow job", + prompt: "do it", + intent: "explore", + }); + const id = spawned.agent_id as string; + stampToolStart(deps.sessions, id, "grep", "call-grep", 1); + stampToolStart(deps.sessions, id, "run_shell", "call-shell", 2); + expect(deps.sessions.get(id)?.currentToolName).toBe("grep"); + + const waiting = callTool(wait, { targets: [id], timeout_ms: 80 }); + let settled = false; + void waiting.then(() => { + settled = true; + }); + await delay(150); + expect(settled).toBe(false); + + gate.resolve({ report: "done" }); + const result = await waiting; + expect(result.timed_out).toBe(false); + }); + + test("grep-only outstanding tools do not extend", async () => { + const gate = deferred(); + const deps = makeDeps(async () => gate.promise); + const spawn = createSpawnAgentTool(deps); + const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords }); + const spawned = await callTool(spawn, { + description: "slow job", + prompt: "do it", + intent: "explore", + }); + const id = spawned.agent_id as string; + stampToolStart(deps.sessions, id, "grep", "call-grep"); + + const first = await callTool(wait, { targets: [id], timeout_ms: 50 }); + expect(first.timed_out).toBe(true); + const firstResults = first.results as { status: string }[]; + expect(firstResults[0]!.status).toBe("running"); + expect(deps.sessions.get(id)?.status).toBe("running"); + + gate.resolve({ report: "finished" }); + const second = await callTool(wait, { targets: [id], timeout_ms: 5000 }); + expect(second.timed_out).toBe(false); + }); + + test("mode=all extends when any targeted live worker has run_shell in flight", async () => { + const gates = [deferred(), deferred()]; + let callIndex = 0; + const deps = makeDeps(async () => gates[callIndex++]!.promise); + const spawn = createSpawnAgentTool(deps); + const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords }); + const first = await callTool(spawn, { + description: "a", + prompt: "do it", + intent: "explore", + }); + const second = await callTool(spawn, { + description: "b", + prompt: "do it", + intent: "explore", + }); + const ids = [first.agent_id as string, second.agent_id as string]; + stampToolStart(deps.sessions, ids[0]!, "run_shell", "call-shell"); + + const waiting = callTool(wait, { targets: ids, mode: "all", timeout_ms: 80 }); + let settled = false; + void waiting.then(() => { + settled = true; + }); + await delay(150); + expect(settled).toBe(false); + + gates[0]!.resolve({ report: "a done" }); + gates[1]!.resolve({ report: "b done" }); + const result = await waiting; + expect(result.timed_out).toBe(false); + }); + + test("abort during a shell-extend slice returns immediately without cancelling the worker", async () => { + const gate = deferred(); + const deps = makeDeps(async () => gate.promise); + const spawn = createSpawnAgentTool(deps); + const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords }); + const spawned = await callTool(spawn, { + description: "slow job", + prompt: "do it", + intent: "explore", + }); + const id = spawned.agent_id as string; + stampToolStart(deps.sessions, id, "run_shell", "call-shell"); + + if (wait.kind !== "full") throw new Error("expected full tool"); + const ac = new AbortController(); + const pending = wait.handler( + { + id: "wait-extend-abort", + name: "wait_agents", + arguments: { targets: [id], timeout_ms: 80 }, + }, + ac.signal, + ); + await delay(120); + ac.abort(); + const result = await pending; + const content = + typeof result.content === "string" ? result.content : JSON.stringify(result.content); + const parsed = JSON.parse(content) as { + timed_out: boolean; + results: { status: string }[]; + }; + expect(parsed.timed_out).toBe(true); + expect(parsed.results[0]!.status).toBe("running"); + expect(deps.sessions.get(id)?.status).toBe("running"); + + gate.resolve({ report: "done" }); + }); +}); + describe("spawn_agent parentage", () => { test("records the caller session as parentSessionId", async () => { const gate = deferred(); diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index d2f8b821c..c29915cf2 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -64,6 +64,7 @@ import { buildDispatchBrief, type TaskIntent } from "./report.js"; import { DEFAULT_CANCEL_REASON, type AgentLifecycleStatus, + type SubAgentSession, type SubAgentSessionStore, } from "./session-store.js"; import { isLiveWaitStatus, projectWaitStatus, type WaitJSONStatus } from "./lifecycle.js"; @@ -450,8 +451,31 @@ const WaitAgentsArgs = type({ "mode?": "'any' | 'all'", }); -export const DEFAULT_WAIT_TIMEOUT_MS = 30_000; -export const MAX_WAIT_TIMEOUT_MS = 300_000; +export const DEFAULT_WAIT_TIMEOUT_MS = 300_000; +export const MAX_WAIT_TIMEOUT_MS = 1_800_000; + +const IN_FLIGHT_SHELL_TOOL_NAMES: ReadonlySet = new Set(["run_shell", "shell"]); + +export function clampWaitTimeoutMs(requested: number): number { + return Math.min(Math.max(requested, 0), MAX_WAIT_TIMEOUT_MS); +} + +export function nextWaitTimerMs(args: { + elapsed: number; + hasInFlightShell: boolean; + defaultMs: number; + maxMs: number; +}): number | undefined { + if (args.elapsed >= args.maxMs) return undefined; + if (!args.hasInFlightShell) return undefined; + const delay = Math.min(args.defaultMs, args.maxMs - args.elapsed); + if (delay <= 0) return undefined; + return delay; +} + +function sessionHasInFlightShell(session: SubAgentSession): boolean { + return session.outstandingTools.some((call) => IN_FLIGHT_SHELL_TOOL_NAMES.has(call.name)); +} export const waitAgentsToolDefinition: ToolDefinition = { name: "wait_agents", @@ -462,7 +486,10 @@ export const waitAgentsToolDefinition: ToolDefinition = { `Omit targets to wait on this caller's own uncollected fleet — the workers this spawn_agent/` + `wait_agents pair started — never every running session in the shared store. Default timeout ${DEFAULT_WAIT_TIMEOUT_MS}ms, ` + `clamped to a ${MAX_WAIT_TIMEOUT_MS}ms max. A timeout or parent-turn abort is NOT an error and never touches ` + - `the workers — they keep running and remain waitable. Live wait status includes "queued" (waiting for a burst ` + + `the workers — they keep running and remain waitable. If a targeted child still has run_shell or ` + + `shell in flight, the wait extends in default-length slices until the shell ends, the worker ` + + `terminals, abort, or the max elapsed clamp — a timeout still does not touch workers and is not a ` + + `cue to retry immediately. Live wait status includes "queued" (waiting for a burst ` + `slot), "running", and "awaiting_director". interrupt_agent and close_agent unblock this wait immediately with ` + `status "interrupted". awaiting_director is not terminal: re-wait while still pending re-delivers the same question. ` + `Answer with send_input (soft). Do not call this in a tight zero-progress loop: a timeout means the targets are still ` + @@ -1269,12 +1296,27 @@ function isWaitTerminal(id: string, fleetRecords: FleetMailboxHandle): boolean { return record !== undefined && !isLiveWaitStatus(record.status); } +function targetedHasInFlightShell( + sessions: SubAgentSessionStore, + fleetRecords: FleetMailboxHandle, + targets: readonly string[], +): boolean { + return targets.some((id) => { + const record = fleetRecords.peek(id); + if (record !== undefined && !isLiveWaitStatus(record.status)) return false; + const session = sessions.get(id); + return session !== undefined && sessionHasInFlightShell(session); + }); +} + /** * Blocks until `mode` is satisfied for `targets`, or `timeoutMs` / abort * elapses. Driven by the session store's mailbox (`subscribe`) raced against - * a timer and the parent tool signal; never polls. Timeout and abort have no - * side effects: workers keep running and remain waitable. Overlay writers - * wake this wait via `sessions.wake()`. + * a timer and the parent tool signal; never polls. On timer fire, if a + * targeted live worker still has run_shell or shell in flight, the wait + * extends in default-length slices until elapsed hits MAX_WAIT_TIMEOUT_MS. + * Timeout and abort have no side effects: workers keep running and remain + * waitable. Overlay writers wake this wait via `sessions.wake()`. */ async function waitForTerminal( sessions: SubAgentSessionStore, @@ -1293,8 +1335,10 @@ async function waitForTerminal( if (signal?.aborted) return true; if (ready()) return false; + const waitStartedAt = Date.now(); return await new Promise((resolve) => { let settled = false; + let timer: ReturnType; const finish = (timedOut: boolean): void => { if (settled) return; settled = true; @@ -1307,7 +1351,28 @@ async function waitForTerminal( const onChange = (): void => { if (ready()) finish(false); }; - const timer = setTimeout(() => finish(true), timeoutMs); + const onTimer = (): void => { + if (ready()) { + finish(false); + return; + } + if (signal?.aborted) { + finish(true); + return; + } + const next = nextWaitTimerMs({ + elapsed: Date.now() - waitStartedAt, + hasInFlightShell: targetedHasInFlightShell(sessions, fleetRecords, targets), + defaultMs: DEFAULT_WAIT_TIMEOUT_MS, + maxMs: MAX_WAIT_TIMEOUT_MS, + }); + if (next !== undefined) { + timer = setTimeout(onTimer, next); + return; + } + finish(true); + }; + timer = setTimeout(onTimer, timeoutMs); const unsubscribeSessions = sessions.subscribe(onChange); signal?.addEventListener("abort", onAbort, { once: true }); if (signal?.aborted) finish(true); @@ -1323,7 +1388,7 @@ export function createWaitAgentsTool(deps: WaitAgentsDeps): AgentTool { return fleetResult(call.id, `Error: wait_agents arguments invalid: ${parsed.summary}`); } const requestedTimeout = parsed.timeout_ms ?? DEFAULT_WAIT_TIMEOUT_MS; - const timeoutMs = Math.min(Math.max(requestedTimeout, 0), MAX_WAIT_TIMEOUT_MS); + const timeoutMs = clampWaitTimeoutMs(requestedTimeout); const mode = parsed.mode ?? "any"; const targets = From dc7a17998a6608e0c2ed5c65a7f8453d509091e1 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 8 Sep 2026 22:18:10 -0700 Subject: [PATCH 2/3] Time out wait_agents when the last child shell ends An extend slice used to keep blocking for the rest of the default interval after tool.done cleared the last in-flight shell. The wait reason is gone at that point, so the parent should return timed_out promptly. --- src/agent/directors/skywalker/package.test.ts | 2 + src/agent/directors/skywalker/package.ts | 2 +- src/subagent/agent-fleet.test.ts | 59 ++++++++++++++++++- src/subagent/agent-fleet.ts | 25 ++++++-- 4 files changed, 79 insertions(+), 9 deletions(-) diff --git a/src/agent/directors/skywalker/package.test.ts b/src/agent/directors/skywalker/package.test.ts index 718cb2123..bd9a06d02 100644 --- a/src/agent/directors/skywalker/package.test.ts +++ b/src/agent/directors/skywalker/package.test.ts @@ -132,6 +132,8 @@ describe("skywalkerPackage", () => { expect(p).toContain("Enter can land"); expect(p).toContain("do not tight-loop wait_agents"); expect(p).toContain("extends while a targeted child has run_shell or shell in flight"); + expect(p).toContain("that extend ends when the last such shell ends"); + expect(p).toContain("short timeout_ms when no child run_shell or shell is in flight"); expect(p).not.toContain("timeout_ms: 1000"); expect(p).not.toContain("timeout_ms: MAX"); }); diff --git a/src/agent/directors/skywalker/package.ts b/src/agent/directors/skywalker/package.ts index 950a7027c..775d3a902 100644 --- a/src/agent/directors/skywalker/package.ts +++ b/src/agent/directors/skywalker/package.ts @@ -16,7 +16,7 @@ You do not do the specialists' jobs by default. For tiny bounded product edits, Do not run long-blocking jobs on the parent (evals, full test suites, long installs, long-running implementation). Dispatch intern (mechanical shell), tester (suite / repro), or builder (substantial code). Path tools (write_file/edit_file/delete_file) are the DIY surface; shell file-writes stay denied. -Idle-orchestrator: fire one or more spawn_agent calls in a turn — each returns immediately with an agent_id and does not hold the parent. Then **reply to the operator** with who is running and what happens next before you block. Prefer ending that turn (or calling wait_agents with a short timeout_ms) so Enter can land; do not immediately fuse into a long wait_agents right after spawn. wait_agents later on the targets you need (or omit targets to wait on this session's own uncollected spawns — never a sibling's). list_agents shows that same fleet without blocking. Use mode="all" when you need every target to finish; interrupt_agent unblocks wait_agents immediately. A timeout means still running — do not tight-loop wait_agents hoping for a different answer. The wait extends while a targeted child has run_shell or shell in flight; a timeout is still not a kill and is not a cue to retry immediately with a short then maximum timeout. Enter mid-run delivers at the next parent tool.boundary — a long parent run_shell or awaiting wait_agents holds those steers. A bare spawn_agent does not. +Idle-orchestrator: fire one or more spawn_agent calls in a turn — each returns immediately with an agent_id and does not hold the parent. Then **reply to the operator** with who is running and what happens next before you block. Prefer ending that turn (or calling wait_agents with a short timeout_ms when no child run_shell or shell is in flight) so Enter can land; do not immediately fuse into a long wait_agents right after spawn. wait_agents later on the targets you need (or omit targets to wait on this session's own uncollected spawns — never a sibling's). list_agents shows that same fleet without blocking. Use mode="all" when you need every target to finish; interrupt_agent unblocks wait_agents immediately. A timeout means still running — do not tight-loop wait_agents hoping for a different answer. The wait extends while a targeted child has run_shell or shell in flight, up to the max clamp; that extend ends when the last such shell ends. A timeout is still not a kill and is not a cue to retry immediately with a short then maximum timeout. Enter mid-run delivers at the next parent tool.boundary — a long parent run_shell or awaiting wait_agents holds those steers. A bare spawn_agent does not. # Operator updates (mandatory while fleet is live) diff --git a/src/subagent/agent-fleet.test.ts b/src/subagent/agent-fleet.test.ts index e4624cc8b..7b06a1415 100644 --- a/src/subagent/agent-fleet.test.ts +++ b/src/subagent/agent-fleet.test.ts @@ -691,6 +691,8 @@ describe("wait timeout helpers", () => { ).properties?.timeout_ms; expect(timeoutSchema?.description).toContain(String(DEFAULT_WAIT_TIMEOUT_MS)); expect(timeoutSchema?.description).toContain(String(MAX_WAIT_TIMEOUT_MS)); + expect(timeoutSchema?.description).toContain("Enter hatch"); + expect(timeoutSchema?.description).toContain("extends"); }); }); @@ -712,6 +714,19 @@ function stampToolStart( } as unknown as ReactorEmittedEvent); } +function stampToolDone( + sessions: ReturnType, + id: string, + callId: string, + seq: number, +): void { + sessions.appendEvent(id, { + type: "tool.done", + seq, + data: { result: { callId, content: "ok", isError: false } }, + } as unknown as ReactorEmittedEvent); +} + describe("wait_agents shell-extend", () => { test("extends while run_shell is in flight instead of returning a zero-progress timeout", async () => { const gate = deferred(); @@ -849,10 +864,16 @@ describe("wait_agents shell-extend", () => { await delay(150); expect(settled).toBe(false); + const afterDone = Date.now(); + stampToolDone(deps.sessions, ids[0]!, "call-shell", 2); + const result = await waiting; + expect(Date.now() - afterDone).toBeLessThan(500); + expect(result.timed_out).toBe(true); + expect(deps.sessions.get(ids[0]!)?.status).toBe("running"); + expect(deps.sessions.get(ids[1]!)?.status).toBe("running"); + gates[0]!.resolve({ report: "a done" }); gates[1]!.resolve({ report: "b done" }); - const result = await waiting; - expect(result.timed_out).toBe(false); }); test("abort during a shell-extend slice returns immediately without cancelling the worker", async () => { @@ -893,6 +914,40 @@ describe("wait_agents shell-extend", () => { gate.resolve({ report: "done" }); }); + + test("after extend starts, tool.done on the last run_shell times out promptly while the worker still runs", async () => { + const gate = deferred(); + const deps = makeDeps(async () => gate.promise); + const spawn = createSpawnAgentTool(deps); + const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords }); + const spawned = await callTool(spawn, { + description: "slow job", + prompt: "do it", + intent: "explore", + }); + const id = spawned.agent_id as string; + stampToolStart(deps.sessions, id, "run_shell", "call-shell"); + + const waiting = callTool(wait, { targets: [id], timeout_ms: 80 }); + let settled = false; + void waiting.then(() => { + settled = true; + }); + await delay(150); + expect(settled).toBe(false); + expect(deps.sessions.get(id)?.status).toBe("running"); + + const afterDone = Date.now(); + stampToolDone(deps.sessions, id, "call-shell", 2); + const result = await waiting; + expect(Date.now() - afterDone).toBeLessThan(500); + expect(result.timed_out).toBe(true); + const results = result.results as { status: string }[]; + expect(results[0]!.status).toBe("running"); + expect(deps.sessions.get(id)?.status).toBe("running"); + + gate.resolve({ report: "done" }); + }, 2000); }); describe("spawn_agent parentage", () => { diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index c29915cf2..2e6a7a177 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -480,14 +480,14 @@ function sessionHasInFlightShell(session: SubAgentSession): boolean { export const waitAgentsToolDefinition: ToolDefinition = { name: "wait_agents", description: - `Block until the given agents reach a terminal state (done, failed, or interrupted), or a worker asks its director (awaiting_director), or timeout_ms elapses. ` + + `Block until the given agents reach a terminal state (done, failed, or interrupted), or a worker asks its director (awaiting_director), or timeout_ms elapses without a live targeted run_shell or shell. ` + `Default mode is "any" (return when the first target finishes or asks). Pass mode="all" to wait until every target is ` + `terminal — except a pending ask_director unblocks immediately regardless of mode so the director can send_input. ` + `Omit targets to wait on this caller's own uncollected fleet — the workers this spawn_agent/` + `wait_agents pair started — never every running session in the shared store. Default timeout ${DEFAULT_WAIT_TIMEOUT_MS}ms, ` + `clamped to a ${MAX_WAIT_TIMEOUT_MS}ms max. A timeout or parent-turn abort is NOT an error and never touches ` + `the workers — they keep running and remain waitable. If a targeted child still has run_shell or ` + - `shell in flight, the wait extends in default-length slices until the shell ends, the worker ` + + `shell in flight, the wait extends in default-length slices until the last such shell ends, the worker ` + `terminals, abort, or the max elapsed clamp — a timeout still does not touch workers and is not a ` + `cue to retry immediately. Live wait status includes "queued" (waiting for a burst ` + `slot), "running", and "awaiting_director". interrupt_agent and close_agent unblock this wait immediately with ` + @@ -506,7 +506,7 @@ export const waitAgentsToolDefinition: ToolDefinition = { }, timeout_ms: { type: "number", - description: `Max time to block, in ms. Default ${DEFAULT_WAIT_TIMEOUT_MS}, clamped to ${MAX_WAIT_TIMEOUT_MS}.`, + description: `Initial block in ms. Default ${DEFAULT_WAIT_TIMEOUT_MS}, clamped to ${MAX_WAIT_TIMEOUT_MS}. A short value is an Enter hatch when no targeted run_shell or shell is in flight. A live shell extends in default-length slices until the last such shell ends, the worker terminals, abort, or elapsed hits the max clamp.`, }, mode: { type: "string", @@ -1315,8 +1315,11 @@ function targetedHasInFlightShell( * a timer and the parent tool signal; never polls. On timer fire, if a * targeted live worker still has run_shell or shell in flight, the wait * extends in default-length slices until elapsed hits MAX_WAIT_TIMEOUT_MS. - * Timeout and abort have no side effects: workers keep running and remain - * waitable. Overlay writers wake this wait via `sessions.wake()`. + * When the last such shell ends after extend has started and the worker is + * still running, the wait times out on that store mutation instead of + * sitting on the remainder of the current slice. Timeout and abort have no + * side effects: workers keep running and remain waitable. Overlay writers + * wake this wait via `sessions.wake()`. */ async function waitForTerminal( sessions: SubAgentSessionStore, @@ -1349,7 +1352,17 @@ async function waitForTerminal( }; const onAbort = (): void => finish(true); const onChange = (): void => { - if (ready()) finish(false); + if (ready()) { + finish(false); + return; + } + if (signal?.aborted) { + finish(true); + return; + } + if (Date.now() - waitStartedAt < timeoutMs) return; + if (targetedHasInFlightShell(sessions, fleetRecords, targets)) return; + finish(true); }; const onTimer = (): void => { if (ready()) { From 7f8bbab80417783d36035f2b5d7ad9cc7b262fa5 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 9 Sep 2026 10:21:23 -0700 Subject: [PATCH 3/3] Honor wait_agents timeout as an Enter hatch timeout_ms is always the max block for this call. Omit stays a 30-second hatch; the clamp is 30 minutes so an explicit wait can cover a long check. Child shells no longer auto-extend the wait. --- CHANGELOG.md | 9 +- src/agent/directors/skywalker/package.test.ts | 5 +- src/agent/directors/skywalker/package.ts | 2 +- src/subagent/agent-fleet.test.ts | 295 ++---------------- src/subagent/agent-fleet.ts | 88 +----- 5 files changed, 43 insertions(+), 356 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82c3c4093..4dac91fc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,11 +15,10 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ### Changed -- wait_agents default timeout is 5 minutes (was 30 seconds) and the clamp is 30 - minutes (was 5). While a targeted child has `run_shell` or `shell` in flight, - the wait extends in default-length slices until the shell ends, the worker - terminals, abort, or the 30-minute elapsed ceiling. Timeout, extend, and abort - still do not cancel workers. +- wait_agents default timeout stays a 30-second Enter hatch. The clamp is 30 + minutes (was 5) so an explicit `timeout_ms` can cover a long typecheck or + full check. Timeout and abort still do not cancel workers. The wait does + not auto-extend while a child shell is in flight. ## [0.3.18] - 2026-09-08 diff --git a/src/agent/directors/skywalker/package.test.ts b/src/agent/directors/skywalker/package.test.ts index bd9a06d02..634daae9a 100644 --- a/src/agent/directors/skywalker/package.test.ts +++ b/src/agent/directors/skywalker/package.test.ts @@ -131,9 +131,8 @@ describe("skywalkerPackage", () => { expect(p).toContain("answer them first"); expect(p).toContain("Enter can land"); expect(p).toContain("do not tight-loop wait_agents"); - expect(p).toContain("extends while a targeted child has run_shell or shell in flight"); - expect(p).toContain("that extend ends when the last such shell ends"); - expect(p).toContain("short timeout_ms when no child run_shell or shell is in flight"); + expect(p).toContain("explicit large timeout_ms"); + expect(p).toContain("does not auto-extend"); expect(p).not.toContain("timeout_ms: 1000"); expect(p).not.toContain("timeout_ms: MAX"); }); diff --git a/src/agent/directors/skywalker/package.ts b/src/agent/directors/skywalker/package.ts index 775d3a902..1a51cc8fd 100644 --- a/src/agent/directors/skywalker/package.ts +++ b/src/agent/directors/skywalker/package.ts @@ -16,7 +16,7 @@ You do not do the specialists' jobs by default. For tiny bounded product edits, Do not run long-blocking jobs on the parent (evals, full test suites, long installs, long-running implementation). Dispatch intern (mechanical shell), tester (suite / repro), or builder (substantial code). Path tools (write_file/edit_file/delete_file) are the DIY surface; shell file-writes stay denied. -Idle-orchestrator: fire one or more spawn_agent calls in a turn — each returns immediately with an agent_id and does not hold the parent. Then **reply to the operator** with who is running and what happens next before you block. Prefer ending that turn (or calling wait_agents with a short timeout_ms when no child run_shell or shell is in flight) so Enter can land; do not immediately fuse into a long wait_agents right after spawn. wait_agents later on the targets you need (or omit targets to wait on this session's own uncollected spawns — never a sibling's). list_agents shows that same fleet without blocking. Use mode="all" when you need every target to finish; interrupt_agent unblocks wait_agents immediately. A timeout means still running — do not tight-loop wait_agents hoping for a different answer. The wait extends while a targeted child has run_shell or shell in flight, up to the max clamp; that extend ends when the last such shell ends. A timeout is still not a kill and is not a cue to retry immediately with a short then maximum timeout. Enter mid-run delivers at the next parent tool.boundary — a long parent run_shell or awaiting wait_agents holds those steers. A bare spawn_agent does not. +Idle-orchestrator: fire one or more spawn_agent calls in a turn — each returns immediately with an agent_id and does not hold the parent. Then **reply to the operator** with who is running and what happens next before you block. Prefer ending that turn, or calling wait_agents with a short timeout_ms, so Enter can land; do not immediately fuse into a long wait_agents right after spawn. If the child job is long, pass an explicit large timeout_ms (up to the clamp) or end the turn (idle-with-fleet). wait_agents later on the targets you need (or omit targets to wait on this session's own uncollected spawns — never a sibling's). list_agents shows that same fleet without blocking. Use mode="all" when you need every target to finish; interrupt_agent unblocks wait_agents immediately. A timeout means still running — do not tight-loop wait_agents hoping for a different answer. timeout_ms is the max block for this call and is always honored; the wait does not auto-extend on child shells. A timeout is still not a kill and is not a cue to retry immediately. Enter mid-run delivers at the next parent tool.boundary — a long parent run_shell or awaiting wait_agents holds those steers. A bare spawn_agent does not. # Operator updates (mandatory while fleet is live) diff --git a/src/subagent/agent-fleet.test.ts b/src/subagent/agent-fleet.test.ts index 7b06a1415..0457dc6f1 100644 --- a/src/subagent/agent-fleet.test.ts +++ b/src/subagent/agent-fleet.test.ts @@ -14,7 +14,6 @@ import { DEFAULT_WAIT_TIMEOUT_MS, MAX_WAIT_TIMEOUT_MS, clampWaitTimeoutMs, - nextWaitTimerMs, waitAgentsToolDefinition, type AgentFleetDeps, } from "./agent-fleet.js"; @@ -294,6 +293,29 @@ describe("spawn_agent + wait_agents", () => { expect(secondResults[0]!.report).toBe("finished"); }); + test("explicit short timeout_ms is honored even if a child has run_shell in flight", async () => { + const gate = deferred(); + const deps = makeDeps(async () => gate.promise); + const spawn = createSpawnAgentTool(deps); + const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords }); + + const spawned = await callTool(spawn, { + description: "slow job", + prompt: "do it", + intent: "explore", + }); + const id = spawned.agent_id as string; + stampToolStart(deps.sessions, id, "run_shell", "call-shell"); + + const first = await callTool(wait, { targets: [id], timeout_ms: 50 }); + expect(first.timed_out).toBe(true); + const firstResults = first.results as { agent_id: string; status: string }[]; + expect(firstResults[0]!.status).toBe("running"); + expect(deps.sessions.get(id)?.status).toBe("running"); + + gate.resolve({ report: "finished" }); + }); + test("wait_agents with no targets waits on all uncollected agents in this fleet", async () => { const gates = [deferred(), deferred()]; let callIndex = 0; @@ -648,11 +670,9 @@ describe("wait mailbox session tombstone and pin", () => { }); describe("wait timeout helpers", () => { - test("default is 5 minutes and clamp is 30 minutes", () => { - expect(DEFAULT_WAIT_TIMEOUT_MS).toBe(300_000); + test("default is a 30-second hatch and clamp is 30 minutes", () => { + expect(DEFAULT_WAIT_TIMEOUT_MS).toBe(30_000); expect(MAX_WAIT_TIMEOUT_MS).toBe(1_800_000); - expect(DEFAULT_WAIT_TIMEOUT_MS).toBeGreaterThanOrEqual(60_000); - expect(MAX_WAIT_TIMEOUT_MS).toBeGreaterThanOrEqual(DEFAULT_WAIT_TIMEOUT_MS); }); test("clampWaitTimeoutMs floors at 0 and caps at MAX", () => { @@ -662,28 +682,11 @@ describe("wait timeout helpers", () => { expect(clampWaitTimeoutMs(0)).toBe(0); }); - test("nextWaitTimerMs extends only while a shell is in flight and elapsed is under max", () => { - const defaultMs = DEFAULT_WAIT_TIMEOUT_MS; - const maxMs = MAX_WAIT_TIMEOUT_MS; - expect(nextWaitTimerMs({ elapsed: 80, hasInFlightShell: true, defaultMs, maxMs })).toBe( - defaultMs, - ); - expect( - nextWaitTimerMs({ elapsed: 80, hasInFlightShell: false, defaultMs, maxMs }), - ).toBeUndefined(); - expect( - nextWaitTimerMs({ elapsed: maxMs, hasInFlightShell: true, defaultMs, maxMs }), - ).toBeUndefined(); - expect( - nextWaitTimerMs({ elapsed: maxMs - 100_000, hasInFlightShell: true, defaultMs, maxMs }), - ).toBe(100_000); - }); - - test("wait_agents description interpolates the new default and clamp and mentions shell extend", () => { + test("wait_agents description interpolates the hatch default and 30-minute clamp", () => { expect(waitAgentsToolDefinition.description).toContain(String(DEFAULT_WAIT_TIMEOUT_MS)); expect(waitAgentsToolDefinition.description).toContain(String(MAX_WAIT_TIMEOUT_MS)); - expect(waitAgentsToolDefinition.description).toContain("run_shell"); expect(waitAgentsToolDefinition.description).toContain("tight zero-progress loop"); + expect(waitAgentsToolDefinition.description).not.toContain("extends"); const timeoutSchema = ( waitAgentsToolDefinition.inputSchema as { properties?: { timeout_ms?: { description?: string } }; @@ -691,15 +694,11 @@ describe("wait timeout helpers", () => { ).properties?.timeout_ms; expect(timeoutSchema?.description).toContain(String(DEFAULT_WAIT_TIMEOUT_MS)); expect(timeoutSchema?.description).toContain(String(MAX_WAIT_TIMEOUT_MS)); - expect(timeoutSchema?.description).toContain("Enter hatch"); - expect(timeoutSchema?.description).toContain("extends"); + expect(timeoutSchema?.description).toContain("Max time to block"); + expect(timeoutSchema?.description).not.toContain("extends"); }); }); -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - function stampToolStart( sessions: ReturnType, id: string, @@ -714,242 +713,6 @@ function stampToolStart( } as unknown as ReactorEmittedEvent); } -function stampToolDone( - sessions: ReturnType, - id: string, - callId: string, - seq: number, -): void { - sessions.appendEvent(id, { - type: "tool.done", - seq, - data: { result: { callId, content: "ok", isError: false } }, - } as unknown as ReactorEmittedEvent); -} - -describe("wait_agents shell-extend", () => { - test("extends while run_shell is in flight instead of returning a zero-progress timeout", async () => { - const gate = deferred(); - const deps = makeDeps(async () => gate.promise); - const spawn = createSpawnAgentTool(deps); - const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords }); - const spawned = await callTool(spawn, { - description: "slow job", - prompt: "do it", - intent: "explore", - }); - const id = spawned.agent_id as string; - stampToolStart(deps.sessions, id, "run_shell", "call-shell"); - - const waiting = callTool(wait, { targets: [id], timeout_ms: 80 }); - let settled = false; - void waiting.then(() => { - settled = true; - }); - await delay(150); - expect(settled).toBe(false); - expect(deps.sessions.get(id)?.status).toBe("running"); - - gate.resolve({ report: "done" }); - const result = await waiting; - expect(result.timed_out).toBe(false); - const results = result.results as { status: string; report?: string }[]; - expect(results[0]!.status).toBe("done"); - expect(results[0]!.report).toBe("done"); - }); - - test("extends while Codex shell is in flight", async () => { - const gate = deferred(); - const deps = makeDeps(async () => gate.promise); - const spawn = createSpawnAgentTool(deps); - const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords }); - const spawned = await callTool(spawn, { - description: "slow job", - prompt: "do it", - intent: "explore", - }); - const id = spawned.agent_id as string; - stampToolStart(deps.sessions, id, "shell", "call-codex-shell"); - - const waiting = callTool(wait, { targets: [id], timeout_ms: 80 }); - let settled = false; - void waiting.then(() => { - settled = true; - }); - await delay(150); - expect(settled).toBe(false); - - gate.resolve({ report: "codex done" }); - const result = await waiting; - expect(result.timed_out).toBe(false); - expect(deps.sessions.get(id)?.status).not.toBe("failed"); - }); - - test("extends when run_shell is outstanding even if currentToolName is grep", async () => { - const gate = deferred(); - const deps = makeDeps(async () => gate.promise); - const spawn = createSpawnAgentTool(deps); - const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords }); - const spawned = await callTool(spawn, { - description: "slow job", - prompt: "do it", - intent: "explore", - }); - const id = spawned.agent_id as string; - stampToolStart(deps.sessions, id, "grep", "call-grep", 1); - stampToolStart(deps.sessions, id, "run_shell", "call-shell", 2); - expect(deps.sessions.get(id)?.currentToolName).toBe("grep"); - - const waiting = callTool(wait, { targets: [id], timeout_ms: 80 }); - let settled = false; - void waiting.then(() => { - settled = true; - }); - await delay(150); - expect(settled).toBe(false); - - gate.resolve({ report: "done" }); - const result = await waiting; - expect(result.timed_out).toBe(false); - }); - - test("grep-only outstanding tools do not extend", async () => { - const gate = deferred(); - const deps = makeDeps(async () => gate.promise); - const spawn = createSpawnAgentTool(deps); - const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords }); - const spawned = await callTool(spawn, { - description: "slow job", - prompt: "do it", - intent: "explore", - }); - const id = spawned.agent_id as string; - stampToolStart(deps.sessions, id, "grep", "call-grep"); - - const first = await callTool(wait, { targets: [id], timeout_ms: 50 }); - expect(first.timed_out).toBe(true); - const firstResults = first.results as { status: string }[]; - expect(firstResults[0]!.status).toBe("running"); - expect(deps.sessions.get(id)?.status).toBe("running"); - - gate.resolve({ report: "finished" }); - const second = await callTool(wait, { targets: [id], timeout_ms: 5000 }); - expect(second.timed_out).toBe(false); - }); - - test("mode=all extends when any targeted live worker has run_shell in flight", async () => { - const gates = [deferred(), deferred()]; - let callIndex = 0; - const deps = makeDeps(async () => gates[callIndex++]!.promise); - const spawn = createSpawnAgentTool(deps); - const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords }); - const first = await callTool(spawn, { - description: "a", - prompt: "do it", - intent: "explore", - }); - const second = await callTool(spawn, { - description: "b", - prompt: "do it", - intent: "explore", - }); - const ids = [first.agent_id as string, second.agent_id as string]; - stampToolStart(deps.sessions, ids[0]!, "run_shell", "call-shell"); - - const waiting = callTool(wait, { targets: ids, mode: "all", timeout_ms: 80 }); - let settled = false; - void waiting.then(() => { - settled = true; - }); - await delay(150); - expect(settled).toBe(false); - - const afterDone = Date.now(); - stampToolDone(deps.sessions, ids[0]!, "call-shell", 2); - const result = await waiting; - expect(Date.now() - afterDone).toBeLessThan(500); - expect(result.timed_out).toBe(true); - expect(deps.sessions.get(ids[0]!)?.status).toBe("running"); - expect(deps.sessions.get(ids[1]!)?.status).toBe("running"); - - gates[0]!.resolve({ report: "a done" }); - gates[1]!.resolve({ report: "b done" }); - }); - - test("abort during a shell-extend slice returns immediately without cancelling the worker", async () => { - const gate = deferred(); - const deps = makeDeps(async () => gate.promise); - const spawn = createSpawnAgentTool(deps); - const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords }); - const spawned = await callTool(spawn, { - description: "slow job", - prompt: "do it", - intent: "explore", - }); - const id = spawned.agent_id as string; - stampToolStart(deps.sessions, id, "run_shell", "call-shell"); - - if (wait.kind !== "full") throw new Error("expected full tool"); - const ac = new AbortController(); - const pending = wait.handler( - { - id: "wait-extend-abort", - name: "wait_agents", - arguments: { targets: [id], timeout_ms: 80 }, - }, - ac.signal, - ); - await delay(120); - ac.abort(); - const result = await pending; - const content = - typeof result.content === "string" ? result.content : JSON.stringify(result.content); - const parsed = JSON.parse(content) as { - timed_out: boolean; - results: { status: string }[]; - }; - expect(parsed.timed_out).toBe(true); - expect(parsed.results[0]!.status).toBe("running"); - expect(deps.sessions.get(id)?.status).toBe("running"); - - gate.resolve({ report: "done" }); - }); - - test("after extend starts, tool.done on the last run_shell times out promptly while the worker still runs", async () => { - const gate = deferred(); - const deps = makeDeps(async () => gate.promise); - const spawn = createSpawnAgentTool(deps); - const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords }); - const spawned = await callTool(spawn, { - description: "slow job", - prompt: "do it", - intent: "explore", - }); - const id = spawned.agent_id as string; - stampToolStart(deps.sessions, id, "run_shell", "call-shell"); - - const waiting = callTool(wait, { targets: [id], timeout_ms: 80 }); - let settled = false; - void waiting.then(() => { - settled = true; - }); - await delay(150); - expect(settled).toBe(false); - expect(deps.sessions.get(id)?.status).toBe("running"); - - const afterDone = Date.now(); - stampToolDone(deps.sessions, id, "call-shell", 2); - const result = await waiting; - expect(Date.now() - afterDone).toBeLessThan(500); - expect(result.timed_out).toBe(true); - const results = result.results as { status: string }[]; - expect(results[0]!.status).toBe("running"); - expect(deps.sessions.get(id)?.status).toBe("running"); - - gate.resolve({ report: "done" }); - }, 2000); -}); - describe("spawn_agent parentage", () => { test("records the caller session as parentSessionId", async () => { const gate = deferred(); diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index 2e6a7a177..6697e2fda 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -64,7 +64,6 @@ import { buildDispatchBrief, type TaskIntent } from "./report.js"; import { DEFAULT_CANCEL_REASON, type AgentLifecycleStatus, - type SubAgentSession, type SubAgentSessionStore, } from "./session-store.js"; import { isLiveWaitStatus, projectWaitStatus, type WaitJSONStatus } from "./lifecycle.js"; @@ -451,45 +450,23 @@ const WaitAgentsArgs = type({ "mode?": "'any' | 'all'", }); -export const DEFAULT_WAIT_TIMEOUT_MS = 300_000; +export const DEFAULT_WAIT_TIMEOUT_MS = 30_000; export const MAX_WAIT_TIMEOUT_MS = 1_800_000; -const IN_FLIGHT_SHELL_TOOL_NAMES: ReadonlySet = new Set(["run_shell", "shell"]); - export function clampWaitTimeoutMs(requested: number): number { return Math.min(Math.max(requested, 0), MAX_WAIT_TIMEOUT_MS); } -export function nextWaitTimerMs(args: { - elapsed: number; - hasInFlightShell: boolean; - defaultMs: number; - maxMs: number; -}): number | undefined { - if (args.elapsed >= args.maxMs) return undefined; - if (!args.hasInFlightShell) return undefined; - const delay = Math.min(args.defaultMs, args.maxMs - args.elapsed); - if (delay <= 0) return undefined; - return delay; -} - -function sessionHasInFlightShell(session: SubAgentSession): boolean { - return session.outstandingTools.some((call) => IN_FLIGHT_SHELL_TOOL_NAMES.has(call.name)); -} - export const waitAgentsToolDefinition: ToolDefinition = { name: "wait_agents", description: - `Block until the given agents reach a terminal state (done, failed, or interrupted), or a worker asks its director (awaiting_director), or timeout_ms elapses without a live targeted run_shell or shell. ` + + `Block until the given agents reach a terminal state (done, failed, or interrupted), or a worker asks its director (awaiting_director), or timeout_ms elapses. ` + `Default mode is "any" (return when the first target finishes or asks). Pass mode="all" to wait until every target is ` + `terminal — except a pending ask_director unblocks immediately regardless of mode so the director can send_input. ` + `Omit targets to wait on this caller's own uncollected fleet — the workers this spawn_agent/` + `wait_agents pair started — never every running session in the shared store. Default timeout ${DEFAULT_WAIT_TIMEOUT_MS}ms, ` + `clamped to a ${MAX_WAIT_TIMEOUT_MS}ms max. A timeout or parent-turn abort is NOT an error and never touches ` + - `the workers — they keep running and remain waitable. If a targeted child still has run_shell or ` + - `shell in flight, the wait extends in default-length slices until the last such shell ends, the worker ` + - `terminals, abort, or the max elapsed clamp — a timeout still does not touch workers and is not a ` + - `cue to retry immediately. Live wait status includes "queued" (waiting for a burst ` + + `the workers — they keep running and remain waitable. Live wait status includes "queued" (waiting for a burst ` + `slot), "running", and "awaiting_director". interrupt_agent and close_agent unblock this wait immediately with ` + `status "interrupted". awaiting_director is not terminal: re-wait while still pending re-delivers the same question. ` + `Answer with send_input (soft). Do not call this in a tight zero-progress loop: a timeout means the targets are still ` + @@ -506,7 +483,7 @@ export const waitAgentsToolDefinition: ToolDefinition = { }, timeout_ms: { type: "number", - description: `Initial block in ms. Default ${DEFAULT_WAIT_TIMEOUT_MS}, clamped to ${MAX_WAIT_TIMEOUT_MS}. A short value is an Enter hatch when no targeted run_shell or shell is in flight. A live shell extends in default-length slices until the last such shell ends, the worker terminals, abort, or elapsed hits the max clamp.`, + description: `Max time to block, in ms. Default ${DEFAULT_WAIT_TIMEOUT_MS}, clamped to ${MAX_WAIT_TIMEOUT_MS}.`, }, mode: { type: "string", @@ -1296,28 +1273,10 @@ function isWaitTerminal(id: string, fleetRecords: FleetMailboxHandle): boolean { return record !== undefined && !isLiveWaitStatus(record.status); } -function targetedHasInFlightShell( - sessions: SubAgentSessionStore, - fleetRecords: FleetMailboxHandle, - targets: readonly string[], -): boolean { - return targets.some((id) => { - const record = fleetRecords.peek(id); - if (record !== undefined && !isLiveWaitStatus(record.status)) return false; - const session = sessions.get(id); - return session !== undefined && sessionHasInFlightShell(session); - }); -} - /** * Blocks until `mode` is satisfied for `targets`, or `timeoutMs` / abort * elapses. Driven by the session store's mailbox (`subscribe`) raced against - * a timer and the parent tool signal; never polls. On timer fire, if a - * targeted live worker still has run_shell or shell in flight, the wait - * extends in default-length slices until elapsed hits MAX_WAIT_TIMEOUT_MS. - * When the last such shell ends after extend has started and the worker is - * still running, the wait times out on that store mutation instead of - * sitting on the remainder of the current slice. Timeout and abort have no + * a timer and the parent tool signal; never polls. Timeout and abort have no * side effects: workers keep running and remain waitable. Overlay writers * wake this wait via `sessions.wake()`. */ @@ -1338,10 +1297,8 @@ async function waitForTerminal( if (signal?.aborted) return true; if (ready()) return false; - const waitStartedAt = Date.now(); return await new Promise((resolve) => { let settled = false; - let timer: ReturnType; const finish = (timedOut: boolean): void => { if (settled) return; settled = true; @@ -1352,40 +1309,9 @@ async function waitForTerminal( }; const onAbort = (): void => finish(true); const onChange = (): void => { - if (ready()) { - finish(false); - return; - } - if (signal?.aborted) { - finish(true); - return; - } - if (Date.now() - waitStartedAt < timeoutMs) return; - if (targetedHasInFlightShell(sessions, fleetRecords, targets)) return; - finish(true); - }; - const onTimer = (): void => { - if (ready()) { - finish(false); - return; - } - if (signal?.aborted) { - finish(true); - return; - } - const next = nextWaitTimerMs({ - elapsed: Date.now() - waitStartedAt, - hasInFlightShell: targetedHasInFlightShell(sessions, fleetRecords, targets), - defaultMs: DEFAULT_WAIT_TIMEOUT_MS, - maxMs: MAX_WAIT_TIMEOUT_MS, - }); - if (next !== undefined) { - timer = setTimeout(onTimer, next); - return; - } - finish(true); + if (ready()) finish(false); }; - timer = setTimeout(onTimer, timeoutMs); + const timer = setTimeout(() => finish(true), timeoutMs); const unsubscribeSessions = sessions.subscribe(onChange); signal?.addEventListener("abort", onAbort, { once: true }); if (signal?.aborted) finish(true);