Skip to content

Commit 523cd6c

Browse files
committed
Exempt task tool from the generic tool-execution watchdog
The task tool runs an entire sub-agent that carries its own bounds (maxTurns, no-progress, thrash, opt-in deadlineMs). The uniform per-tool wall-clock budget (default 660s) aborted healthy workers past 11 minutes through the cancel path. resolveToolExecutionTimeoutMs now returns undefined for task calls, and the opt-in sub-agent deadline is no longer clamped under an outer watchdog that no longer applies.
1 parent 64542d7 commit 523cd6c

6 files changed

Lines changed: 45 additions & 13 deletions

File tree

‎docs/ARCHITECTURE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,7 @@ tool call
376376
- **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.
377377
- **types** — `Approval`, `ApprovalScope`, `PermissionRequest`, `ApprovalOutcome`.
378378

379-
**Tool wall-clock budget vs. permission prompts.** Each tool `run()` is wrapped by an outer execution watchdog (`src/tui/tool-execution-watchdog.ts`, defaults ~11 min). By default (`tools.waitForApproval`, Settings → Tools, **On**), that 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.
379+
**Tool wall-clock budget vs. permission prompts.** Each tool `run()` is wrapped by an outer execution watchdog (`src/tui/tool-execution-watchdog.ts`, defaults ~11 min). The `task` tool is exempt — a sub-agent run is bounded by its own limits (maxTurns, no-progress, thrash, opt-in deadline), so the generic per-tool budget never aborts a healthy long-running worker. By default (`tools.waitForApproval`, Settings → Tools, **On**), that 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.
380380

381381
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.
382382

‎docs/IMPLEMENTATION.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ Provider and model configuration lives in JSON settings files. The global file h
230230
}
231231
```
232232

233-
- `timeoutMs` / `maxTimeoutMs` — outer execution watchdog around each tool `run()` (defaults ~11 min / 30 min).
233+
- `timeoutMs` / `maxTimeoutMs` — outer execution watchdog around each tool `run()` (defaults ~11 min / 30 min). The `task` tool is exempt: a dispatched sub-agent is bounded by its own limits (maxTurns, no-progress, thrash, opt-in `deadlineMs`), not the generic per-tool budget.
234234
- `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.
235235

236236
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).

‎src/subagent/run.ts‎

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,6 @@ import {
6161
resolveDefaultSubAgentMaxTurns,
6262
toolWatchdogFromSettings,
6363
} from "../config/settings.js";
64-
import { resolveToolExecutionTimeoutMs } from "../tui/tool-execution-watchdog.js";
6564
import { createSearchAgentsTool } from "../agent/agent-search.js";
6665
import { manageTasksDefinition, parseManageTasksArgs } from "../agent/tasks.js";
6766
import { ID_PREFIX } from "../branding.js";
@@ -284,16 +283,14 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
284283
// visible to the finally block, which is a sibling scope, not a child.
285284
let stallWatchdog: ReturnType<typeof setInterval> | undefined;
286285
// Combines the caller's cancel signal with an optional opt-in wall-clock
287-
// deadline so a leaf that hits the deadline can still return a salvage report
288-
// rather than racing the outer per-tool-call watchdog (which would discard the
289-
// run wholesale). When deadlineMs is omitted, no timer is armed — maxTurns +
290-
// cancel remain the only bounds. Declared before try so finally can dispose.
286+
// deadline so a leaf that hits the deadline can still return a salvage
287+
// report. When deadlineMs is omitted, no timer is armed — maxTurns + cancel
288+
// remain the only bounds. Declared before try so finally can dispose.
289+
// The task tool is exempt from the generic per-tool watchdog (see
290+
// resolveToolExecutionTimeoutMs), so there is no outer budget to clamp under.
291291
const resolvedDeadlineMs =
292292
params.deadlineMs !== undefined
293-
? resolveSubAgentDeadlineMs(
294-
params.deadlineMs,
295-
resolveToolExecutionTimeoutMs(toolWatchdogFromSettings(params.settings)),
296-
)
293+
? resolveSubAgentDeadlineMs(params.deadlineMs, undefined)
297294
: undefined;
298295
const runController = createSubAgentRunController(params.signal, resolvedDeadlineMs);
299296

‎src/subagent/task-tool.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,9 @@ export type TaskToolDeps = SubAgentSandboxDeps & {
178178
spawnAllowlist?: readonly string[];
179179
/**
180180
* Optional wall-clock budget (ms) for each worker this tool spawns. Opt-in
181-
* only — there is no default leaf death clock. When set, clamped below the
182-
* outer tool-execution watchdog so a salvage report can return first.
181+
* only — there is no default leaf death clock. The task tool is exempt from
182+
* the generic tool-execution watchdog, so this deadline is the only
183+
* wall-clock bound on a worker.
183184
*/
184185
deadlineMs?: number;
185186

‎src/tui/tool-execution-watchdog.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,35 @@ describe("tool execution watchdog", () => {
3939
).toBeUndefined();
4040
});
4141

42+
test("task is exempt from the settings watchdog", () => {
43+
// Sub-agents carry their own bounds (maxTurns, no-progress, thrash,
44+
// opt-in deadline); the generic per-tool budget must not abort them.
45+
const call = { id: "1", name: "task", arguments: {} };
46+
expect(resolveToolExecutionTimeoutMs({ defaultMs: 660_000 }, call)).toBeUndefined();
47+
expect(
48+
resolveToolExecutionTimeoutMs({ defaultMs: 660_000, maxMs: 1_800_000 }, call),
49+
).toBeUndefined();
50+
});
51+
52+
test("task run outlasting the generic budget completes with its own report", async () => {
53+
const runner = createDynamicToolRunner(
54+
[
55+
stringTool("task", async () => {
56+
// Slow but progressing: runs well past the 30ms generic budget.
57+
await new Promise((r) => setTimeout(r, 120));
58+
return "## Summary\nworker report";
59+
}),
60+
],
61+
{ defaultMs: 30 },
62+
);
63+
const result = await runner.run(
64+
{ id: "t", name: "task", arguments: {} },
65+
new AbortController().signal,
66+
);
67+
expect(result.isError).toBeUndefined();
68+
expect(result.content).toContain("worker report");
69+
});
70+
4271
test("omitted config does not arm a default watchdog", () => {
4372
expect(resolveToolExecutionTimeoutMs(undefined)).toBeUndefined();
4473
expect(resolveToolExecutionTimeoutMs({})).toBeUndefined();

‎src/tui/tool-execution-watchdog.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,16 @@ const BUDGET_EXPIRED = Symbol("tool-execution-budget-expired");
5050
* run_shell passes a positive arguments.timeout (requested + slack so this
5151
* layer cannot beat shell-guard). A requested run_shell timeout is not clamped
5252
* to MAX_TOOL_EXECUTION_TIMEOUT_MS or tools.maxTimeoutMs.
53+
*
54+
* The task tool is exempt: it runs an entire sub-agent that carries its own
55+
* bounds (maxTurns, no-progress, thrash, opt-in deadline), so the generic
56+
* per-tool budget would abort healthy long-running workers mid-run.
5357
*/
5458
export function resolveToolExecutionTimeoutMs(
5559
config?: ToolWatchdogConfig,
5660
call?: ToolCall,
5761
): number | undefined {
62+
if (call?.name === "task") return undefined;
5863
if (call?.name === "run_shell") {
5964
const requested = requestedRunShellTimeoutMs(call);
6065
if (requested !== undefined) {

0 commit comments

Comments
 (0)