Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/agent/directors/skywalker/package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
2 changes: 1 addition & 1 deletion src/agent/directors/skywalker/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
73 changes: 73 additions & 0 deletions src/subagent/agent-fleet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<RunSubAgentResult>();
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<RunSubAgentResult>(), deferred<RunSubAgentResult>()];
let callIndex = 0;
Expand Down Expand Up @@ -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<typeof createSubAgentSessionStore>,
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<RunSubAgentResult>();
Expand Down
8 changes: 6 additions & 2 deletions src/subagent/agent-fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 =
Expand Down
Loading