diff --git a/packages/coding-agent/src/exec/bash-executor.ts b/packages/coding-agent/src/exec/bash-executor.ts index dfdcee8a3..fc7d7dc69 100644 --- a/packages/coding-agent/src/exec/bash-executor.ts +++ b/packages/coding-agent/src/exec/bash-executor.ts @@ -14,6 +14,7 @@ import { buildNonInteractiveEnv } from "./non-interactive-env"; export interface BashExecutorOptions { cwd?: string; + /** Milliseconds before aborting the command; 0 disables the executor deadline. */ timeout?: number; onChunk?: (chunk: string) => void; chunkThrottleMs?: number; @@ -296,11 +297,15 @@ export async function executeBash(command: string, options?: BashExecutorOptions let timeoutTimer: NodeJS.Timeout | undefined; const timeoutDeferred = Promise.withResolvers<"timeout">(); - const baseTimeoutMs = Math.max(1_000, options?.timeout ?? 300_000); - timeoutTimer = setTimeout(() => { - abortCurrentExecution(); - timeoutDeferred.resolve("timeout"); - }, baseTimeoutMs); + const requestedTimeoutMs = options?.timeout; + const deadlineTimeoutMs = requestedTimeoutMs === 0 ? undefined : Math.max(1_000, requestedTimeoutMs ?? 300_000); + const nativeTimeoutMs = requestedTimeoutMs !== undefined && requestedTimeoutMs > 0 ? requestedTimeoutMs : undefined; + if (deadlineTimeoutMs !== undefined) { + timeoutTimer = setTimeout(() => { + abortCurrentExecution(); + timeoutDeferred.resolve("timeout"); + }, deadlineTimeoutMs); + } let resetSession = false; @@ -311,7 +316,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions command: finalCommand, cwd: commandCwd, env: commandEnv, - timeoutMs: options?.timeout, + timeoutMs: nativeTimeoutMs, signal: runAbortController.signal, }, (err, chunk) => { @@ -328,7 +333,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions sessionEnv: shellEnv, snapshotPath: snapshotPath ?? undefined, minimizer, - timeoutMs: options?.timeout, + timeoutMs: nativeTimeoutMs, signal: runAbortController.signal, }, (err, chunk) => { @@ -359,8 +364,8 @@ export async function executeBash(command: string, options?: BashExecutorOptions exitCode: undefined, cancelled: true, ...(await sink.dump( - winner.kind === "timeout" - ? `Command timed out after ${Math.round(baseTimeoutMs / 1000)} seconds` + winner.kind === "timeout" && deadlineTimeoutMs !== undefined + ? `Command timed out after ${Math.round(deadlineTimeoutMs / 1000)} seconds` : "Command cancelled", )), }; diff --git a/packages/coding-agent/src/prompts/tools/bash.md b/packages/coding-agent/src/prompts/tools/bash.md index 04bcca23c..ddc0f7b90 100644 --- a/packages/coding-agent/src/prompts/tools/bash.md +++ b/packages/coding-agent/src/prompts/tools/bash.md @@ -41,9 +41,9 @@ Anything below → `eval` cell, not bash: {{#if asyncEnabled}} # Timeout and async -- `timeout` is seconds, clamped to `1..3600`; the process is killed on elapse. -- `async: true` defers only reporting — it does NOT extend the timeout; a daemon with `async: true` is still killed at the clamped timeout. -- Need >3600s? Detach/manage lifecycle yourself (`cmd &`, supervisor, self-restarting script). The shell session persists across calls. +- `timeout` is seconds; nonzero values are clamped to `1..3600` and the process is killed on elapse. Set `timeout: 0` only for commands that must run until completion or explicit cancellation. +- `async: true` defers only reporting — it does NOT extend a nonzero timeout; use `timeout: 0` when a daemon or watcher must be cancellation-owned. +- Need a daemon or >3600s run? Use `async: true` with `timeout: 0` when the harness should keep it alive until cancellation, or detach/manage lifecycle yourself (`cmd &`, supervisor, self-restarting script). The shell session persists across calls. {{/if}} {{#if autoBackgroundEnabled}} diff --git a/packages/coding-agent/src/tools/bash-interactive.ts b/packages/coding-agent/src/tools/bash-interactive.ts index 3c1a57e6c..fc39cb0c8 100644 --- a/packages/coding-agent/src/tools/bash-interactive.ts +++ b/packages/coding-agent/src/tools/bash-interactive.ts @@ -300,7 +300,7 @@ export async function runInteractiveBashPty( options: { command: string; cwd: string; - timeoutMs: number; + timeoutMs?: number; signal?: AbortSignal; env?: Record; artifactPath?: string; diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index ad8d8faff..0a57890b0 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -131,7 +131,7 @@ async function saveBashOriginalArtifact(session: ToolSession, originalText: stri } } -const BASH_TIMEOUT_DESCRIPTION = `timeout in seconds; clamped to ${TOOL_TIMEOUTS.bash.min}-${TOOL_TIMEOUTS.bash.max}`; +const BASH_TIMEOUT_DESCRIPTION = `timeout in seconds; 0 disables the command deadline; nonzero values are clamped to ${TOOL_TIMEOUTS.bash.min}-${TOOL_TIMEOUTS.bash.max}`; const bashSchemaBase = type({ command: type("string").describe("command to execute"), @@ -166,6 +166,7 @@ export interface BashToolDetails { meta?: OutputMeta; timeoutSeconds?: number; requestedTimeoutSeconds?: number; + timeoutDisabled?: boolean; wallTimeMs?: number; /** Exit code of a command that ran to completion but failed (non-zero). */ exitCode?: number; @@ -421,7 +422,11 @@ export class BashTool implements AgentTool { const details: BashToolDetails = { - timeoutSeconds: timeoutSec, async: { state: "running", jobId, type: "bash" }, }; + if (timeoutSec === undefined) { + details.timeoutDisabled = true; + } else { + details.timeoutSeconds = timeoutSec; + } if (options.requestedTimeoutSec !== undefined && options.requestedTimeoutSec !== timeoutSec) { details.requestedTimeoutSeconds = options.requestedTimeoutSec; } @@ -539,8 +551,8 @@ export class BashTool implements AgentTool ({ kind: "timeout" as const })); + const timeoutPromise = timeoutMs + ? Bun.sleep(timeoutMs).then(() => ({ kind: "timeout" as const })) + : undefined; // Poll until the process exits, times out, or the caller aborts. for (;;) { const racers: Array> = [ exitPromise.then(s => ({ kind: "exit" as const, status: s })), - timeoutPromise, Bun.sleep(250).then(() => ({ kind: "poll" as const })), ]; + if (timeoutPromise) racers.push(timeoutPromise); if (signal) { racers.push(abortedP.then(() => ({ kind: "aborted" as const }))); } @@ -1053,7 +1072,7 @@ export class BashTool implements AgentTool(config: ShellRendererConfig) { const showingFullOutput = expanded && renderContext?.isFullOutput === true; // Build truncation warning - const timeoutSeconds = details?.timeoutSeconds ?? renderContext?.timeout; + const timeoutDisabled = details?.timeoutDisabled === true || renderContext?.timeout === 0; + const timeoutSeconds = timeoutDisabled ? undefined : (details?.timeoutSeconds ?? renderContext?.timeout); const requestedTimeoutSeconds = details?.requestedTimeoutSeconds; const wallTimeMs = details?.wallTimeMs; const statsParts: string[] = []; if (wallTimeMs !== undefined) { statsParts.push(`Wall: ${formatWallTimeSeconds(wallTimeMs)}s`); } + if (timeoutDisabled) { + statsParts.push("Timeout: disabled"); + } if (typeof timeoutSeconds === "number") { statsParts.push( requestedTimeoutSeconds !== undefined && requestedTimeoutSeconds !== timeoutSeconds diff --git a/packages/coding-agent/test/bash-executor.test.ts b/packages/coding-agent/test/bash-executor.test.ts index c8436d7da..2e32fd4ae 100644 --- a/packages/coding-agent/test/bash-executor.test.ts +++ b/packages/coding-agent/test/bash-executor.test.ts @@ -402,6 +402,15 @@ exit 64 expect(result.output).not.toContain("done"); }); + it("does not arm a deadline when timeout is zero", async () => { + if (process.platform === "win32") { + return; + } + const result = await executeBash("sleep 0.1; echo done", { cwd: tempDir, timeout: 0 }); + expect(result.cancelled).toBe(false); + expect(result.output.trim()).toBe("done"); + }); + it("aborts commands", async () => { if (process.platform === "win32") { return; diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index a556a4351..a3baf9bbd 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -1492,6 +1492,21 @@ function b() { expect(result.details?.requestedTimeoutSeconds).toBe(7200); }); + it("should disable the command deadline when timeout is zero", async () => { + vi.spyOn(toolTimeouts, "clampTimeout").mockReturnValue(0.05); + + const result = await bashTool.execute("test-call-timeout-disabled", { + command: "printf 'start\\n'; sleep 0.1; printf 'done\\n'", + timeout: 0, + }); + + const output = getTextOutput(result); + expect(output).toContain("start"); + expect(output).toContain("done"); + expect(result.details?.timeoutDisabled).toBe(true); + expect(result.details?.timeoutSeconds).toBeUndefined(); + }); + it("should respect timeout", async () => { // Reduce the effective timeout through the production clamp seam; the // real subprocess kill-on-timeout path is still exercised, just faster.