Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
15 changes: 6 additions & 9 deletions src/subagent/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -284,16 +283,14 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
// visible to the finally block, which is a sibling scope, not a child.
let stallWatchdog: ReturnType<typeof setInterval> | 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);

Expand Down
5 changes: 3 additions & 2 deletions src/subagent/task-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
29 changes: 29 additions & 0 deletions src/tui/tool-execution-watchdog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
5 changes: 5 additions & 0 deletions src/tui/tool-execution-watchdog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading