Skip to content

Commit 7f8bbab

Browse files
committed
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.
1 parent dc7a179 commit 7f8bbab

5 files changed

Lines changed: 43 additions & 356 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,10 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1515

1616
### Changed
1717

18-
- wait_agents default timeout is 5 minutes (was 30 seconds) and the clamp is 30
19-
minutes (was 5). While a targeted child has `run_shell` or `shell` in flight,
20-
the wait extends in default-length slices until the shell ends, the worker
21-
terminals, abort, or the 30-minute elapsed ceiling. Timeout, extend, and abort
22-
still do not cancel workers.
18+
- wait_agents default timeout stays a 30-second Enter hatch. The clamp is 30
19+
minutes (was 5) so an explicit `timeout_ms` can cover a long typecheck or
20+
full check. Timeout and abort still do not cancel workers. The wait does
21+
not auto-extend while a child shell is in flight.
2322

2423
## [0.3.18] - 2026-09-08
2524

src/agent/directors/skywalker/package.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,8 @@ describe("skywalkerPackage", () => {
131131
expect(p).toContain("answer them first");
132132
expect(p).toContain("Enter can land");
133133
expect(p).toContain("do not tight-loop wait_agents");
134-
expect(p).toContain("extends while a targeted child has run_shell or shell in flight");
135-
expect(p).toContain("that extend ends when the last such shell ends");
136-
expect(p).toContain("short timeout_ms when no child run_shell or shell is in flight");
134+
expect(p).toContain("explicit large timeout_ms");
135+
expect(p).toContain("does not auto-extend");
137136
expect(p).not.toContain("timeout_ms: 1000");
138137
expect(p).not.toContain("timeout_ms: MAX");
139138
});

src/agent/directors/skywalker/package.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ You do not do the specialists' jobs by default. For tiny bounded product edits,
1616
1717
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.
1818
19-
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.
19+
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.
2020
2121
# Operator updates (mandatory while fleet is live)
2222

src/subagent/agent-fleet.test.ts

Lines changed: 29 additions & 266 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
DEFAULT_WAIT_TIMEOUT_MS,
1515
MAX_WAIT_TIMEOUT_MS,
1616
clampWaitTimeoutMs,
17-
nextWaitTimerMs,
1817
waitAgentsToolDefinition,
1918
type AgentFleetDeps,
2019
} from "./agent-fleet.js";
@@ -294,6 +293,29 @@ describe("spawn_agent + wait_agents", () => {
294293
expect(secondResults[0]!.report).toBe("finished");
295294
});
296295

296+
test("explicit short timeout_ms is honored even if a child has run_shell in flight", async () => {
297+
const gate = deferred<RunSubAgentResult>();
298+
const deps = makeDeps(async () => gate.promise);
299+
const spawn = createSpawnAgentTool(deps);
300+
const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords });
301+
302+
const spawned = await callTool(spawn, {
303+
description: "slow job",
304+
prompt: "do it",
305+
intent: "explore",
306+
});
307+
const id = spawned.agent_id as string;
308+
stampToolStart(deps.sessions, id, "run_shell", "call-shell");
309+
310+
const first = await callTool(wait, { targets: [id], timeout_ms: 50 });
311+
expect(first.timed_out).toBe(true);
312+
const firstResults = first.results as { agent_id: string; status: string }[];
313+
expect(firstResults[0]!.status).toBe("running");
314+
expect(deps.sessions.get(id)?.status).toBe("running");
315+
316+
gate.resolve({ report: "finished" });
317+
});
318+
297319
test("wait_agents with no targets waits on all uncollected agents in this fleet", async () => {
298320
const gates = [deferred<RunSubAgentResult>(), deferred<RunSubAgentResult>()];
299321
let callIndex = 0;
@@ -648,11 +670,9 @@ describe("wait mailbox session tombstone and pin", () => {
648670
});
649671

650672
describe("wait timeout helpers", () => {
651-
test("default is 5 minutes and clamp is 30 minutes", () => {
652-
expect(DEFAULT_WAIT_TIMEOUT_MS).toBe(300_000);
673+
test("default is a 30-second hatch and clamp is 30 minutes", () => {
674+
expect(DEFAULT_WAIT_TIMEOUT_MS).toBe(30_000);
653675
expect(MAX_WAIT_TIMEOUT_MS).toBe(1_800_000);
654-
expect(DEFAULT_WAIT_TIMEOUT_MS).toBeGreaterThanOrEqual(60_000);
655-
expect(MAX_WAIT_TIMEOUT_MS).toBeGreaterThanOrEqual(DEFAULT_WAIT_TIMEOUT_MS);
656676
});
657677

658678
test("clampWaitTimeoutMs floors at 0 and caps at MAX", () => {
@@ -662,44 +682,23 @@ describe("wait timeout helpers", () => {
662682
expect(clampWaitTimeoutMs(0)).toBe(0);
663683
});
664684

665-
test("nextWaitTimerMs extends only while a shell is in flight and elapsed is under max", () => {
666-
const defaultMs = DEFAULT_WAIT_TIMEOUT_MS;
667-
const maxMs = MAX_WAIT_TIMEOUT_MS;
668-
expect(nextWaitTimerMs({ elapsed: 80, hasInFlightShell: true, defaultMs, maxMs })).toBe(
669-
defaultMs,
670-
);
671-
expect(
672-
nextWaitTimerMs({ elapsed: 80, hasInFlightShell: false, defaultMs, maxMs }),
673-
).toBeUndefined();
674-
expect(
675-
nextWaitTimerMs({ elapsed: maxMs, hasInFlightShell: true, defaultMs, maxMs }),
676-
).toBeUndefined();
677-
expect(
678-
nextWaitTimerMs({ elapsed: maxMs - 100_000, hasInFlightShell: true, defaultMs, maxMs }),
679-
).toBe(100_000);
680-
});
681-
682-
test("wait_agents description interpolates the new default and clamp and mentions shell extend", () => {
685+
test("wait_agents description interpolates the hatch default and 30-minute clamp", () => {
683686
expect(waitAgentsToolDefinition.description).toContain(String(DEFAULT_WAIT_TIMEOUT_MS));
684687
expect(waitAgentsToolDefinition.description).toContain(String(MAX_WAIT_TIMEOUT_MS));
685-
expect(waitAgentsToolDefinition.description).toContain("run_shell");
686688
expect(waitAgentsToolDefinition.description).toContain("tight zero-progress loop");
689+
expect(waitAgentsToolDefinition.description).not.toContain("extends");
687690
const timeoutSchema = (
688691
waitAgentsToolDefinition.inputSchema as {
689692
properties?: { timeout_ms?: { description?: string } };
690693
}
691694
).properties?.timeout_ms;
692695
expect(timeoutSchema?.description).toContain(String(DEFAULT_WAIT_TIMEOUT_MS));
693696
expect(timeoutSchema?.description).toContain(String(MAX_WAIT_TIMEOUT_MS));
694-
expect(timeoutSchema?.description).toContain("Enter hatch");
695-
expect(timeoutSchema?.description).toContain("extends");
697+
expect(timeoutSchema?.description).toContain("Max time to block");
698+
expect(timeoutSchema?.description).not.toContain("extends");
696699
});
697700
});
698701

699-
function delay(ms: number): Promise<void> {
700-
return new Promise((resolve) => setTimeout(resolve, ms));
701-
}
702-
703702
function stampToolStart(
704703
sessions: ReturnType<typeof createSubAgentSessionStore>,
705704
id: string,
@@ -714,242 +713,6 @@ function stampToolStart(
714713
} as unknown as ReactorEmittedEvent);
715714
}
716715

717-
function stampToolDone(
718-
sessions: ReturnType<typeof createSubAgentSessionStore>,
719-
id: string,
720-
callId: string,
721-
seq: number,
722-
): void {
723-
sessions.appendEvent(id, {
724-
type: "tool.done",
725-
seq,
726-
data: { result: { callId, content: "ok", isError: false } },
727-
} as unknown as ReactorEmittedEvent);
728-
}
729-
730-
describe("wait_agents shell-extend", () => {
731-
test("extends while run_shell is in flight instead of returning a zero-progress timeout", async () => {
732-
const gate = deferred<RunSubAgentResult>();
733-
const deps = makeDeps(async () => gate.promise);
734-
const spawn = createSpawnAgentTool(deps);
735-
const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords });
736-
const spawned = await callTool(spawn, {
737-
description: "slow job",
738-
prompt: "do it",
739-
intent: "explore",
740-
});
741-
const id = spawned.agent_id as string;
742-
stampToolStart(deps.sessions, id, "run_shell", "call-shell");
743-
744-
const waiting = callTool(wait, { targets: [id], timeout_ms: 80 });
745-
let settled = false;
746-
void waiting.then(() => {
747-
settled = true;
748-
});
749-
await delay(150);
750-
expect(settled).toBe(false);
751-
expect(deps.sessions.get(id)?.status).toBe("running");
752-
753-
gate.resolve({ report: "done" });
754-
const result = await waiting;
755-
expect(result.timed_out).toBe(false);
756-
const results = result.results as { status: string; report?: string }[];
757-
expect(results[0]!.status).toBe("done");
758-
expect(results[0]!.report).toBe("done");
759-
});
760-
761-
test("extends while Codex shell is in flight", async () => {
762-
const gate = deferred<RunSubAgentResult>();
763-
const deps = makeDeps(async () => gate.promise);
764-
const spawn = createSpawnAgentTool(deps);
765-
const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords });
766-
const spawned = await callTool(spawn, {
767-
description: "slow job",
768-
prompt: "do it",
769-
intent: "explore",
770-
});
771-
const id = spawned.agent_id as string;
772-
stampToolStart(deps.sessions, id, "shell", "call-codex-shell");
773-
774-
const waiting = callTool(wait, { targets: [id], timeout_ms: 80 });
775-
let settled = false;
776-
void waiting.then(() => {
777-
settled = true;
778-
});
779-
await delay(150);
780-
expect(settled).toBe(false);
781-
782-
gate.resolve({ report: "codex done" });
783-
const result = await waiting;
784-
expect(result.timed_out).toBe(false);
785-
expect(deps.sessions.get(id)?.status).not.toBe("failed");
786-
});
787-
788-
test("extends when run_shell is outstanding even if currentToolName is grep", async () => {
789-
const gate = deferred<RunSubAgentResult>();
790-
const deps = makeDeps(async () => gate.promise);
791-
const spawn = createSpawnAgentTool(deps);
792-
const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords });
793-
const spawned = await callTool(spawn, {
794-
description: "slow job",
795-
prompt: "do it",
796-
intent: "explore",
797-
});
798-
const id = spawned.agent_id as string;
799-
stampToolStart(deps.sessions, id, "grep", "call-grep", 1);
800-
stampToolStart(deps.sessions, id, "run_shell", "call-shell", 2);
801-
expect(deps.sessions.get(id)?.currentToolName).toBe("grep");
802-
803-
const waiting = callTool(wait, { targets: [id], timeout_ms: 80 });
804-
let settled = false;
805-
void waiting.then(() => {
806-
settled = true;
807-
});
808-
await delay(150);
809-
expect(settled).toBe(false);
810-
811-
gate.resolve({ report: "done" });
812-
const result = await waiting;
813-
expect(result.timed_out).toBe(false);
814-
});
815-
816-
test("grep-only outstanding tools do not extend", async () => {
817-
const gate = deferred<RunSubAgentResult>();
818-
const deps = makeDeps(async () => gate.promise);
819-
const spawn = createSpawnAgentTool(deps);
820-
const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords });
821-
const spawned = await callTool(spawn, {
822-
description: "slow job",
823-
prompt: "do it",
824-
intent: "explore",
825-
});
826-
const id = spawned.agent_id as string;
827-
stampToolStart(deps.sessions, id, "grep", "call-grep");
828-
829-
const first = await callTool(wait, { targets: [id], timeout_ms: 50 });
830-
expect(first.timed_out).toBe(true);
831-
const firstResults = first.results as { status: string }[];
832-
expect(firstResults[0]!.status).toBe("running");
833-
expect(deps.sessions.get(id)?.status).toBe("running");
834-
835-
gate.resolve({ report: "finished" });
836-
const second = await callTool(wait, { targets: [id], timeout_ms: 5000 });
837-
expect(second.timed_out).toBe(false);
838-
});
839-
840-
test("mode=all extends when any targeted live worker has run_shell in flight", async () => {
841-
const gates = [deferred<RunSubAgentResult>(), deferred<RunSubAgentResult>()];
842-
let callIndex = 0;
843-
const deps = makeDeps(async () => gates[callIndex++]!.promise);
844-
const spawn = createSpawnAgentTool(deps);
845-
const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords });
846-
const first = await callTool(spawn, {
847-
description: "a",
848-
prompt: "do it",
849-
intent: "explore",
850-
});
851-
const second = await callTool(spawn, {
852-
description: "b",
853-
prompt: "do it",
854-
intent: "explore",
855-
});
856-
const ids = [first.agent_id as string, second.agent_id as string];
857-
stampToolStart(deps.sessions, ids[0]!, "run_shell", "call-shell");
858-
859-
const waiting = callTool(wait, { targets: ids, mode: "all", timeout_ms: 80 });
860-
let settled = false;
861-
void waiting.then(() => {
862-
settled = true;
863-
});
864-
await delay(150);
865-
expect(settled).toBe(false);
866-
867-
const afterDone = Date.now();
868-
stampToolDone(deps.sessions, ids[0]!, "call-shell", 2);
869-
const result = await waiting;
870-
expect(Date.now() - afterDone).toBeLessThan(500);
871-
expect(result.timed_out).toBe(true);
872-
expect(deps.sessions.get(ids[0]!)?.status).toBe("running");
873-
expect(deps.sessions.get(ids[1]!)?.status).toBe("running");
874-
875-
gates[0]!.resolve({ report: "a done" });
876-
gates[1]!.resolve({ report: "b done" });
877-
});
878-
879-
test("abort during a shell-extend slice returns immediately without cancelling the worker", async () => {
880-
const gate = deferred<RunSubAgentResult>();
881-
const deps = makeDeps(async () => gate.promise);
882-
const spawn = createSpawnAgentTool(deps);
883-
const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords });
884-
const spawned = await callTool(spawn, {
885-
description: "slow job",
886-
prompt: "do it",
887-
intent: "explore",
888-
});
889-
const id = spawned.agent_id as string;
890-
stampToolStart(deps.sessions, id, "run_shell", "call-shell");
891-
892-
if (wait.kind !== "full") throw new Error("expected full tool");
893-
const ac = new AbortController();
894-
const pending = wait.handler(
895-
{
896-
id: "wait-extend-abort",
897-
name: "wait_agents",
898-
arguments: { targets: [id], timeout_ms: 80 },
899-
},
900-
ac.signal,
901-
);
902-
await delay(120);
903-
ac.abort();
904-
const result = await pending;
905-
const content =
906-
typeof result.content === "string" ? result.content : JSON.stringify(result.content);
907-
const parsed = JSON.parse(content) as {
908-
timed_out: boolean;
909-
results: { status: string }[];
910-
};
911-
expect(parsed.timed_out).toBe(true);
912-
expect(parsed.results[0]!.status).toBe("running");
913-
expect(deps.sessions.get(id)?.status).toBe("running");
914-
915-
gate.resolve({ report: "done" });
916-
});
917-
918-
test("after extend starts, tool.done on the last run_shell times out promptly while the worker still runs", async () => {
919-
const gate = deferred<RunSubAgentResult>();
920-
const deps = makeDeps(async () => gate.promise);
921-
const spawn = createSpawnAgentTool(deps);
922-
const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords });
923-
const spawned = await callTool(spawn, {
924-
description: "slow job",
925-
prompt: "do it",
926-
intent: "explore",
927-
});
928-
const id = spawned.agent_id as string;
929-
stampToolStart(deps.sessions, id, "run_shell", "call-shell");
930-
931-
const waiting = callTool(wait, { targets: [id], timeout_ms: 80 });
932-
let settled = false;
933-
void waiting.then(() => {
934-
settled = true;
935-
});
936-
await delay(150);
937-
expect(settled).toBe(false);
938-
expect(deps.sessions.get(id)?.status).toBe("running");
939-
940-
const afterDone = Date.now();
941-
stampToolDone(deps.sessions, id, "call-shell", 2);
942-
const result = await waiting;
943-
expect(Date.now() - afterDone).toBeLessThan(500);
944-
expect(result.timed_out).toBe(true);
945-
const results = result.results as { status: string }[];
946-
expect(results[0]!.status).toBe("running");
947-
expect(deps.sessions.get(id)?.status).toBe("running");
948-
949-
gate.resolve({ report: "done" });
950-
}, 2000);
951-
});
952-
953716
describe("spawn_agent parentage", () => {
954717
test("records the caller session as parentSessionId", async () => {
955718
const gate = deferred<RunSubAgentResult>();

0 commit comments

Comments
 (0)