diff --git a/CHANGELOG.md b/CHANGELOG.md index ab36cd39c..4dac91fc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,15 @@ 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 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 ### Added diff --git a/src/agent/directors/skywalker/package.test.ts b/src/agent/directors/skywalker/package.test.ts index 03ed6d9a0..634daae9a 100644 --- a/src/agent/directors/skywalker/package.test.ts +++ b/src/agent/directors/skywalker/package.test.ts @@ -130,6 +130,11 @@ 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("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"); }); 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..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) 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. 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 01ae267d4..0457dc6f1 100644 --- a/src/subagent/agent-fleet.test.ts +++ b/src/subagent/agent-fleet.test.ts @@ -3,12 +3,18 @@ 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, + waitAgentsToolDefinition, type AgentFleetDeps, } from "./agent-fleet.js"; import { createAdmissionQueue, unlimitedAdmissionQueue } from "./admission.js"; @@ -287,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; @@ -640,6 +669,50 @@ describe("wait mailbox session tombstone and pin", () => { }); }); +describe("wait timeout helpers", () => { + 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); + }); + + 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("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("tight zero-progress loop"); + expect(waitAgentsToolDefinition.description).not.toContain("extends"); + 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)); + expect(timeoutSchema?.description).toContain("Max time to block"); + expect(timeoutSchema?.description).not.toContain("extends"); + }); +}); + +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("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..6697e2fda 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -451,7 +451,11 @@ const WaitAgentsArgs = type({ }); export const DEFAULT_WAIT_TIMEOUT_MS = 30_000; -export const MAX_WAIT_TIMEOUT_MS = 300_000; +export const MAX_WAIT_TIMEOUT_MS = 1_800_000; + +export function clampWaitTimeoutMs(requested: number): number { + return Math.min(Math.max(requested, 0), MAX_WAIT_TIMEOUT_MS); +} export const waitAgentsToolDefinition: ToolDefinition = { name: "wait_agents", @@ -1323,7 +1327,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 =