Skip to content

fix(agent): enforce a foreground bash timeout (PRI-3251) - #3

Closed
ada-sen wants to merge 1 commit into
fix-pri-3243-bash-stdin-and-killfrom
fix-pri-3251-bash-foreground-timeout
Closed

ada-sen wants to merge 1 commit into
fix-pri-3243-bash-stdin-and-killfrom
fix-pri-3251-bash-foreground-timeout

Conversation

@ada-sen

@ada-sen ada-sen commented Sep 25, 2026

Copy link
Copy Markdown
Owner

PRI-3251: Foreground bash tool timeout isn't enforced

Stacked on fix-pri-3243-bash-stdin-and-kill (base of this PR), which
made foreground bash spawns detached process-group leaders and closed
stdin, so an abort can kill a whole pipeline instead of orphaning it. This
PR reuses that same process-group kill for a new failure mode: a hung
foreground call with no abort in play at all.

The premise

The bash tool's own docstring has long claimed:

A sync command is subject to a runtime timeout (tens of seconds) and is
killed if it exceeds it

Nothing in the implementation enforced this. executeCommand only ever
killed the child process in response to context.signal's abort event —
there was no timer, no schema timeout/timeoutMs parameter, and no
host-level (agent loop / turn) timeout standing in for it either. A
command like sleep 999999 run in the foreground would simply hang until
something upstream (the caller aborting, or the whole turn timing out)
intervened — which for a bare tool call is never.

What changed

  • Default timeout: 60s, matching the docstring's own "tens of
    seconds" language.
  • Optional per-call override via a new timeoutMs schema field,
    bounded to [1_000, 600_000] ms (1s–10min). The upper bound matches the
    progressIntervalMs ceiling already established in this same file; the
    lower bound matches url_fetch's MIN_TIMEOUT and is low enough for a
    1s test to actually exercise the path.
  • On expiry: SIGTERM the whole process group, then SIGKILL after a 2s
    grace period if it's still alive — the exact signal sequence and
    group-kill helper PRI-3243 added for abort, reused here via a shared
    scheduleGracefulKill(). A grandchild in a pipeline (e.g. sleep | cat)
    is killed too, not left orphaned.
  • Result shape: the JSON result now carries timedOut: boolean and,
    when true, a timeoutMessage string ("Command timed out after Ns and
    was killed."). Partial stdout/stderr already captured before the kill
    are preserved unmodified in stdoutPreview/stderrPreview.
  • Background bash calls are unaffected — they're routed to a job before
    ever reaching this code path, so the new timeout only applies to
    genuine foreground/sync calls.

Tests (packages/agent/src/tools/bash.test.ts)

Added a PRI-3251: foreground timeout is enforced block:

  • a hung command (sleep 60) with timeoutMs: 1000 returns promptly with
    timedOut: true, a matching timeoutMessage, and the output produced
    before the sleep still present in stdoutPreview;
  • a grandchild in a pipeline (sh -c '...; exec sleep 60' | cat) is
    confirmed gone (not orphaned) after the timeout fires;
  • a fast command with the same timeoutMs: 1000 override returns well
    within budget with timedOut: false and no timeoutMessage.

Verified fail→pass on this branch: checking out the pre-fix
bash.ts from the fix-pri-3243-bash-stdin-and-kill base while keeping
the new test file reproduces exactly the bug this PR closes — the two new
timeout tests hang to their test-level timeout (they never see a
timedOut flag because nothing sets one), and the docstring-text
assertion fails because the docstring itself is unchanged. Restoring the
fix makes all cases pass.

packages/agent unit suite (excluding the integration/live-API test
files the repo's own test script separates out), lint, and typecheck
all pass on this branch.

Not in scope

  • Background (background: true) bash jobs — these have their own job
    lifecycle and are explicitly excluded here.
  • Any host-level (agent loop / turn) timeout — there isn't one today, and
    this PR only adds enforcement inside the bash tool itself.

The bash tool's docstring has long claimed sync (foreground) calls are
subject to a runtime timeout ("tens of seconds") and get killed if they
exceed it, but nothing in the code enforced one -- a hung foreground
command would block forever (until the model gave up or the caller
aborted).

Adds real enforcement:
- Default foreground timeout of 60s (matches the docstring's "tens of
  seconds"), configurable per-call via an optional `timeoutMs` schema
  param bounded to [1_000, 600_000]ms. The 600s ceiling matches the
  existing progressIntervalMs bound already in this file; the 1s floor
  matches url_fetch's MIN_TIMEOUT and lets a 1s test actually exercise
  it.
- On expiry: SIGTERM the whole process group, then SIGKILL after a
  2s grace period -- the same two-step signal sequence and group-kill
  helper PRI-3243 added for abort, so a grandchild in a pipeline
  (`sleep | cat`) is killed too, not orphaned.
- Result carries a `timedOut: boolean` flag and a `timeoutMessage`
  ("Command timed out after Ns and was killed.") string; partial
  stdout/stderr already captured before the kill are preserved
  unmodified in stdoutPreview/stderrPreview.
- Background bash calls are unaffected (they're routed to a job before
  ever reaching this code path; out of scope here regardless).

Stacked on fix-pri-3243-bash-stdin-and-kill: reuses its detached
process-group spawn and killProcessOrGroup() helper for the
SIGTERM/SIGKILL sequence.
@ada-sen

ada-sen commented Sep 25, 2026

Copy link
Copy Markdown
Owner Author

Superseded — opened against upstream instead: obra#414 (stacked on obra#413). Branch left in place; not deleting it.

@ada-sen ada-sen closed this Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant