diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4f8e5e406..0eef84dc7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -376,7 +376,7 @@ tool call - **queue** — Headless settle registry (`src/permission/queue.ts`). Surfaces enqueue outstanding requests; `wirePermissionGrantReconciliation` listens for `permission.grant` and drains every queued request the new grant covers, without a second prompt. Teardown calls `drain()` so no awaited resolve is left hanging. - **types** — `Approval`, `ApprovalScope`, `PermissionRequest`, `ApprovalOutcome`. -**Tool wall-clock budget vs. permission prompts.** Each tool `run()` is wrapped by an outer execution watchdog (`src/tui/tool-execution-watchdog.ts`). The watchdog arms only when Settings set `tools.timeoutMs` / `tools.maxTimeoutMs`, or when `run_shell` passes a positive timeout (requested plus slack, so this layer cannot beat shell-guard). Unset settings leave `task` and other tools unbounded; parent cancel, maxTurns, and eval `--agent-timeout-ms` still bound the run. By default (`tools.waitForApproval`, Settings → Tools, **On**), an armed budget freezes while the operator is deciding on a permission prompt, so a late approve still runs the tool and the agent waits for the decision instead of timing out under the modal. When **Off**, the budget keeps ticking during the prompt; if it expires first the tool is skipped and the permission modal is dismissed via the budget AbortSignal (auto-deny with a timeout message). The TUI permission queue (`src/tui/gate-wire.ts`, backed by `src/permission/queue.ts`) attaches that signal so ghost prompts cannot outlive an already-aborted tool. +**Tool wall-clock budget vs. permission prompts.** Each tool `run()` is wrapped by an outer execution watchdog (`src/tui/tool-execution-watchdog.ts`). The watchdog arms only when Settings set `tools.timeoutMs` / `tools.maxTimeoutMs`, or when `run_shell` passes a positive timeout (requested plus slack, so this layer cannot beat shell-guard). The `task` tool is always exempt, regardless of Settings — a sub-agent run is bounded by its own limits (maxTurns, no-progress, thrash, opt-in deadlineMs), so the generic per-tool budget never aborts a healthy long-running worker; parent cancel, maxTurns, and eval `--agent-timeout-ms` still bound the run. By default (`tools.waitForApproval`, Settings → Tools, **On**), an armed budget freezes while the operator is deciding on a permission prompt, so a late approve still runs the tool and the agent waits for the decision instead of timing out under the modal. When **Off**, the budget keeps ticking during the prompt; if it expires first the tool is skipped and the permission modal is dismissed via the budget AbortSignal (auto-deny with a timeout message). The TUI permission queue (`src/tui/gate-wire.ts`, backed by `src/permission/queue.ts`) attaches that signal so ghost prompts cannot outlive an already-aborted tool. Approval scopes offered: Allow Once (persist nothing), Allow Always for a file or its directory (file tools), or a command shape (shell). There is intentionally no "all files" rung. Project-scoped Allow Always grants are confined to the session that minted them: they match the session root and its registered git worktrees (`cwdMatchesGrant` in `src/permission/authz-grants.ts` via `createWorktreeRootsProvider`), not bare process-cwd equality — so a grant at the repo root still covers a sub-agent running in a sibling worktree of the same project. diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 97ab6ff1c..a093aae5b 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -230,7 +230,7 @@ Provider and model configuration lives in JSON settings files. The global file h } ``` - - `timeoutMs` / `maxTimeoutMs` — outer execution watchdog around each tool `run()`. Unset leaves the watchdog unarmed; set these to arm it. `maxTimeoutMs` clamps non-shell tools when set and does not cap a longer requested `run_shell`. + - `timeoutMs` / `maxTimeoutMs` — outer execution watchdog around each tool `run()`. Unset leaves the watchdog unarmed; set these to arm it. `maxTimeoutMs` clamps non-shell tools when set and does not cap a longer requested `run_shell`. The `task` tool is always exempt: a dispatched sub-agent is bounded by its own limits (maxTurns, no-progress, thrash, opt-in `deadlineMs`), not the generic per-tool budget. - `waitForApproval` (default **true** when unset) — freeze that budget while a permission prompt is open so a late approve still runs the tool. **Settings → Tools** toggles this live for the next tool call and persists it here. When **false**, the budget keeps ticking during the prompt; on expiry the tool is skipped and the modal is auto-dismissed. The freeze is bounded: after **30 minutes** with the prompt still unanswered the budget resumes ticking on its own, so a prompt that never becomes visible (overlay open, UI gone) cannot hang a tool run indefinitely. Optional `subagentMaxTurns` (integer **1–100**, default **30**) sets the default inference-turn budget for dispatched workers (not the parent chat session limit). Per-dispatch `task(maxTurns)` and agent profile `maxTurns` override this default; values above **100** are rejected on `task` and clamped for profiles. Always applies — the primary session is always orchestrator-capable (CL-5814). diff --git a/src/subagent/run.ts b/src/subagent/run.ts index c8b776411..b77238a48 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -61,7 +61,6 @@ import { resolveDefaultSubAgentMaxTurns, toolWatchdogFromSettings, } from "../config/settings.js"; -import { resolveToolExecutionTimeoutMs } from "../tui/tool-execution-watchdog.js"; import { createSearchAgentsTool } from "../agent/agent-search.js"; import { manageTasksDefinition, parseManageTasksArgs } from "../agent/tasks.js"; import { ID_PREFIX } from "../branding.js"; @@ -284,16 +283,14 @@ export async function runSubAgent(params: RunSubAgentParams): Promise { // visible to the finally block, which is a sibling scope, not a child. let stallWatchdog: ReturnType | undefined; // Combines the caller's cancel signal with an optional opt-in wall-clock - // deadline so a leaf that hits the deadline can still return a salvage report - // rather than racing the outer per-tool-call watchdog (which would discard the - // run wholesale). When deadlineMs is omitted, no timer is armed — maxTurns + - // cancel remain the only bounds. Declared before try so finally can dispose. + // deadline so a leaf that hits the deadline can still return a salvage + // report. When deadlineMs is omitted, no timer is armed — maxTurns + cancel + // remain the only bounds. Declared before try so finally can dispose. + // The task tool is exempt from the generic per-tool watchdog (see + // resolveToolExecutionTimeoutMs), so there is no outer budget to clamp under. const resolvedDeadlineMs = params.deadlineMs !== undefined - ? resolveSubAgentDeadlineMs( - params.deadlineMs, - resolveToolExecutionTimeoutMs(toolWatchdogFromSettings(params.settings)), - ) + ? resolveSubAgentDeadlineMs(params.deadlineMs, undefined) : undefined; const runController = createSubAgentRunController(params.signal, resolvedDeadlineMs); diff --git a/src/subagent/task-tool.ts b/src/subagent/task-tool.ts index e1fc75278..386b48dc4 100644 --- a/src/subagent/task-tool.ts +++ b/src/subagent/task-tool.ts @@ -178,8 +178,9 @@ export type TaskToolDeps = SubAgentSandboxDeps & { spawnAllowlist?: readonly string[]; /** * Optional wall-clock budget (ms) for each worker this tool spawns. Opt-in - * only — there is no default leaf death clock. When set, clamped below the - * outer tool-execution watchdog so a salvage report can return first. + * only — there is no default leaf death clock. The task tool is exempt from + * the generic tool-execution watchdog, so this deadline is the only + * wall-clock bound on a worker. */ deadlineMs?: number; diff --git a/src/tui/tool-execution-watchdog.test.ts b/src/tui/tool-execution-watchdog.test.ts index 0066535d4..a91fa4d36 100644 --- a/src/tui/tool-execution-watchdog.test.ts +++ b/src/tui/tool-execution-watchdog.test.ts @@ -39,6 +39,35 @@ describe("tool execution watchdog", () => { ).toBeUndefined(); }); + test("task is exempt from the settings watchdog", () => { + // Sub-agents carry their own bounds (maxTurns, no-progress, thrash, + // opt-in deadline); the generic per-tool budget must not abort them. + const call = { id: "1", name: "task", arguments: {} }; + expect(resolveToolExecutionTimeoutMs({ defaultMs: 660_000 }, call)).toBeUndefined(); + expect( + resolveToolExecutionTimeoutMs({ defaultMs: 660_000, maxMs: 1_800_000 }, call), + ).toBeUndefined(); + }); + + test("task run outlasting the generic budget completes with its own report", async () => { + const runner = createDynamicToolRunner( + [ + stringTool("task", async () => { + // Slow but progressing: runs well past the 30ms generic budget. + await new Promise((r) => setTimeout(r, 120)); + return "## Summary\nworker report"; + }), + ], + { defaultMs: 30 }, + ); + const result = await runner.run( + { id: "t", name: "task", arguments: {} }, + new AbortController().signal, + ); + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("worker report"); + }); + test("omitted config does not arm a default watchdog", () => { expect(resolveToolExecutionTimeoutMs(undefined)).toBeUndefined(); expect(resolveToolExecutionTimeoutMs({})).toBeUndefined(); diff --git a/src/tui/tool-execution-watchdog.ts b/src/tui/tool-execution-watchdog.ts index 885842837..baa736d43 100644 --- a/src/tui/tool-execution-watchdog.ts +++ b/src/tui/tool-execution-watchdog.ts @@ -50,11 +50,16 @@ const BUDGET_EXPIRED = Symbol("tool-execution-budget-expired"); * run_shell passes a positive arguments.timeout (requested + slack so this * layer cannot beat shell-guard). A requested run_shell timeout is not clamped * to MAX_TOOL_EXECUTION_TIMEOUT_MS or tools.maxTimeoutMs. + * + * The task tool is exempt: it runs an entire sub-agent that carries its own + * bounds (maxTurns, no-progress, thrash, opt-in deadline), so the generic + * per-tool budget would abort healthy long-running workers mid-run. */ export function resolveToolExecutionTimeoutMs( config?: ToolWatchdogConfig, call?: ToolCall, ): number | undefined { + if (call?.name === "task") return undefined; if (call?.name === "run_shell") { const requested = requestedRunShellTimeoutMs(call); if (requested !== undefined) {