Skip to content

Add shell command timeout and cleanup - #15

Merged
CalvinSturm merged 2 commits into
mainfrom
feat/shell-command-lifecycle
Jun 30, 2026
Merged

Add shell command timeout and cleanup#15
CalvinSturm merged 2 commits into
mainfrom
feat/shell-command-lifecycle

Conversation

@CalvinSturm

Copy link
Copy Markdown
Owner

Summary

Adds bounded shell command execution for LocalAgent so shell tool calls can fail safely instead of hanging indefinitely.

This PR adds optional timeout_ms support to shell execution, returns a structured timeout failure with partial output, and uses managed child execution with best-effort direct-child cleanup.

What changed

  • Added ShellReq.timeout_ms

  • Advertised optional timeout_ms in the shell tool schema

  • Replaced command.output().await with managed child execution

  • Added timeout handling via tokio::time::timeout

  • Added direct-child cleanup via kill_on_drop / start_kill

  • Added structured timeout responses with:

    • timed_out: true
    • timeout_ms
    • partial stdout/stderr
    • actionable hint
  • Added ToolErrorCode::ShellExecTimeout

  • Added ToolErrorCode::ShellExecTimeoutUnsupported

  • Rejects timeout_ms > 0 on DockerTarget with a structured unsupported-target error instead of silently ignoring it

  • Stabilized the Windows shell auto-repair test by using ver instead of echo

Why

Previously, HostTarget::exec_shell used command.output().await with no timeout or managed lifecycle. A hanging command could block the agent indefinitely, and cancelling a TUI turn could orphan the child process.

This PR fixes the highest-priority harness reliability issue from the opencode capability audit.

Validation

  • cargo fmt --check
  • cargo clippy --all-targets -- -D warnings
  • cargo test --lib target::
  • cargo test --lib tools::
  • cargo test --test tool_call_accuracy_ci
  • cargo test --lib repro
  • cargo test --test artifact_golden
  • python scripts/ci_release_readiness.py

All required validation passed.

Known limitations

  • Process-tree cleanup is best-effort direct-child cleanup only.
  • Grandchildren spawned through sh -c, cmd /C, or similar wrappers may continue until they exit.
  • DockerTarget does not yet enforce shell timeouts. It now rejects timeout_ms > 0 clearly instead of ignoring it.
  • Live output streaming is not included in this PR.

Follow-ups

  • Robust Unix process-group cleanup and Windows job-object cleanup
  • Real Docker shell timeout enforcement
  • Default shell timeout policy for autonomous runs
  • Live shell output streaming / TUI tail events

Replace the unbounded `command.output().await` host shell execution with a
managed child: piped stdout/stderr drained into shared buffers, `kill_on_drop`
enabled, and an optional per-call `timeout_ms`.

- ShellReq gains `timeout_ms` (u64; 0 = unbounded, preserving prior behavior).
  Exposed as an optional `timeout_ms` arg on the `shell` tool (catalog + schema
  + strict validation; integer >= 0 so negatives are unrepresentable).
- On timeout the direct child is killed and reaped, drain tasks get a short
  grace then abort (so a surviving grandchild holding the pipe can never block
  return), and a structured result is returned: ok=false, timed_out=true,
  timeout_ms, partial stdout/stderr, and an actionable hint.
- New ShellExecTimeout tool error code; classify_shell_target_error detects the
  timeout envelope before other shell error classification.
- kill_on_drop(true) means a cancelled turn no longer silently orphans the
  direct child.

Limitation: termination is best-effort on the direct child only; commands run
through a shell wrapper (sh -c / cmd /C) may leave grandchildren until they
exit. Robust process-tree cleanup (unix process groups / Windows job objects)
and docker-target timeout enforcement are follow-ups.

Tests: host command under timeout succeeds; exceeding timeout returns promptly
(<2s for a 200ms budget) with timed_out + hint; timeout_ms=0 stays unbounded.
Make the shell auto-repair test deterministic and give the docker target a
clear answer for timeout requests.

- tools/tests: the windows builtin auto-repair test used `echo`, which is
  frequently shadowed by an MSYS/Git `echo.exe` on PATH; the direct spawn then
  succeeds and the repair path is never exercised (the test failed on this
  machine and on base main). Switch to `ver`, a cmd builtin with no standalone
  executable counterpart, so the initial spawn deterministically fails and
  forces windows_cmd_c repair regardless of host PATH. Assertion still proves
  repair_attempted + windows_cmd_c strategy and real command output.
- target: DockerTarget::exec_shell now rejects timeout_ms > 0 with a structured,
  classifiable error (error=timeout_unsupported) instead of silently ignoring a
  requested bound. Returns before any docker invocation, so the new test is
  deterministic without a docker daemon. Shared run_container read/write/list/
  patch behavior is untouched.
- New ShellExecTimeoutUnsupported tool error code; classify_shell_target_error
  maps the docker rejection envelope to it with an actionable message.
@CalvinSturm
CalvinSturm merged commit 17c3454 into main Jun 30, 2026
1 check passed
@CalvinSturm
CalvinSturm deleted the feat/shell-command-lifecycle branch June 30, 2026 22:57
CalvinSturm added a commit that referenced this pull request Jul 1, 2026
Previously an omitted timeout_ms meant unbounded shell execution, so an
autonomous/unattended run could hang forever on a runaway command.

Introduce a target-aware default in resolve_shell_timeout_ms:
- explicit timeout_ms > 0: use it (host enforces; docker still rejects per #15)
- explicit timeout_ms == 0: unbounded opt-out (unchanged)
- missing timeout_ms on host: apply DEFAULT_SHELL_TIMEOUT_MS (120000 ms)
- missing timeout_ms on docker: resolve to 0 (unbounded), NOT a rejection

The docker branch is deliberate: DockerTarget cannot enforce timeouts and
rejects timeout_ms > 0, so a *missing* timeout must stay unbounded there rather
than silently becoming a rejection. Explicit docker requests are unchanged.

The default lives as a documented module constant in tools/exec_shell.rs (easy
to adjust; no reliability-profile/RunArgs/golden churn) and is reflected in the
shell tool description.

Tests: resolver covers missing->host-default, explicit>0 override, explicit 0
unbounded, and missing-on-docker stays unbounded; a behavioral execute_tool test
confirms a fast host command with no timeout still succeeds under the default.
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