Skip to content

fix: subagent hangs — bash timeouts, process-group kill, watchdogs - #130

Open
Emasoft wants to merge 11 commits into
mlhher:mainfrom
Emasoft:fix/subagent-hang
Open

Emasoft wants to merge 11 commits into
mlhher:mainfrom
Emasoft:fix/subagent-hang

Conversation

@Emasoft

@Emasoft Emasoft commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

fix: subagent hangs — bash timeouts, process-group kill, watchdogs

Emergency diagnosis (RC1–RC4, verified)

  • RC1 — ShellTool bash unbounded and cancel-immune. internal/tool/implementations.go:370-373 ran the command with the raw context and no deadline; internal/tool/shell_command_unix.go:31 used bare exec.CommandContext, which kills only the direct child. A grandchild that inherited bash's stdout/stderr pipes kept CombinedOutput's Wait blocked forever — even after cancellation. Nothing bounded a never-exiting command at all.
  • RC2 — parent blocked with no notification channel. The parent blocks synchronously inside the spawn_subagent tool call; its only notification is the runner's return. When a child hung (or was killed while its bash had spawned a grandchild), the parent had no budget, no termination-cause classification, and no way to learn what the child had been doing.
  • RC3 — LLM clients had Timeout: 0. internal/client/client.go set no client timeout; a provider that accepted the request and then streamed nothing (half-open connection, stalled backend) blocked scanner.Scan() for the OS TCP lifetime, indistinguishable from "thinking".
  • RC4 — bare exec.Command in git/mcp and blocking child event sends. internal/git/* shelled out with no bound; a credential prompt on an interactive terminal or an askpass helper hung the git tools forever. internal/mcp/client.go was unbounded exec. internal/orchestrator/base.go sent every event (streaming deltas, transient statuses) with a blocking channel send, so a stalled TUI consumer wedged the whole run loop.

The four fixes

  1. fix(tool) (eda9326)newShellCommand mirrors the proven hooks.go pattern: 10-minute default timeout (test-overridable via SetShellTimeout), Setpgid + whole-process-group SIGKILL on cancel (unix), taskkill /T /F (windows), WaitDelay = 5s so pipe-holding descendants can never block Wait. Timeout surfaces partial output as a real error.
  2. fix(orchestrator) (b35a927) — the runner bounds each subagent with a new --subagent-timeout flag (default 24h, 0 = unlimited; also configurable via the subagent-timeout config.json entry, explicit flag > config > default), classifies the termination (time budget exhausted / cancelled or killed / crashed) and, on abnormal termination, writes a pruned transcript and returns a tool result (see below) instead of an opaque error.
  3. fix(client) (21371b6) — a 120s stream idle watchdog (test-overridable via SetStreamIdleTimeout) cancels a silent stream so the failure surfaces through the normal retry tier; non-stream ChatCompletion/Completion/HealthCheck get a 5-minute bound (SetRequestTimeout).
  4. chore (451bd0b) — every git exec gains a 60s bound plus GIT_TERMINAL_PROMPT=0 / GIT_ASKPASS=echo so credential lookups fail fast instead of hanging; the MCP server subprocess lifetime is documented as intentionally unbounded (owned by the SDK transport); child streaming ContentEvents and transient "thinking" statuses are now non-blocking with a drop counter reported at turn end, while terminal status events and ChildAddedEvent stay blocking (guaranteed consumers — the TUI state machine and the child event-forwarder setup depend on them).

Transcript feature — what the parent receives

On abnormal termination (budget exhausted, killed, or crashed) the parent's spawn_subagent tool result becomes:

The <type> subagent terminated abnormally (<cause>).
Full pruned transcript: /abs/path/to/<child-id>-transcript.md
Last actions:
<last assistant action or tool call, 500-char preview>

The transcript is pruned: system→500 B, user→2000 B, assistant→4000 B, tool results→2000 B head with a […truncated…] marker, tool-call args→500 B, reasoning content dropped, image/attachment parts → placeholders (no base64 ever), and a 64 KB total cap that keeps the header + first 2 + last 6 messages (the tail is what resuming needs). Written 0600 next to the child's own history (cache-dir fallback), so the parent can read it and resume without redoing the work.

Test evidence

  • Grandchild-pipe regression: internal/tool/shell_timeout_test.go::TestShellTool_CancelReturnsDespiteGrandchildHoldingPipes(sleep 300 &) leaves a pipe-holding grandchild; pre-fix Execute blocked forever after cancel, post-fix it returns < 5s. Plus TestShellTool_TimeoutKillsHangingCommand and TestShellTool_TimeoutLeavesNoProcesses (pgrep-verified process-group cleanup).
  • Stream watchdog: silent-server abort (~5s, not OS-lifetime) and a slow-but-active stream (300 ms lines across a watchdog tick) is never killed; non-stream silent server returns via context.DeadlineExceeded.
  • Orchestrator: TestBaseOrchestrator_ExecuteDoesNotDeadlockWhenEventConsumerStalls — 150-chunk stream with no event consumer; progress sends drop (counted) instead of deadlocking on the 101st send, terminal sends still delivered, full content still accumulated.
  • Client idle-watchdog + transcript pruning table tests (image strip, per-role caps, reasoning drop, 64 KB head/tail overflow, rune-safe truncation, path fallback) all pass under -race.
  • A 30x repeated-run stress of the regression suite was not executed in this session; the race-detector full suite was run three times across the change (see flake note) with the new tests green every time.

Environmental plugin flake note (pre-existing, unrelated)

late/internal/plugin fails with 4 known environmental tests on this machine — reproduced identically on a clean main checkout (de0d922) via a throwaway worktree: TestRunHook_ProcessGroupKillsChildrenOnCancel, TestHandlePluginRemove_PurgesStaleSkillSymlink, TestHandlePluginRemove_PreservesSiblingSkillSymlink, TestRegisterPluginSkills_PreservesSameNamedSkills (HOME/XDG + process-spawn timing in this environment; the branch touches nothing in internal/plugin). One full-suite run additionally showed TestHandleCommand_ConcurrentWithWriters (load-timing) which did not reproduce in two package-alone runs.

Independence from #127

This branch is main-based; its fixes are independent of PR #127's changes. No rebase or conflict with #127 is expected on either side.

Update: subagent-timeout is now config.json-configurable (subagent-timeout), default 24h.

CombinedOutput waited on inherited pipes held by grandchildren, so a hung command (or a killed subagent whose bash spawned one) blocked the calling agent forever even after cancellation; and nothing bounded a never-exiting command. Mirrors the proven hooks.go pattern: 10-minute default timeout (test-overridable), Setpgid + group-SIGKILL cancel (unix), taskkill /T /F (windows), WaitDelay 5s. Regression tests: grandchild-pipe cancel hang, timeout kill, process cleanliness.
The parent blocks synchronously in the spawn_subagent tool call; its only notification is the runner's return. The runner now bounds each subagent with --subagent-timeout (default 30m, 0 = unlimited), classifies the termination (budget exhausted / cancelled or killed / crashed) and, on abnormal termination, writes a PRUNED transcript (images, reasoning and long tool outputs omitted; 64 KB cap keeping the tail) and returns its absolute path plus a last-actions preview as the tool result — the parent can resume without redoing the work.
A provider that accepted the request and streamed nothing blocked scanner.Scan() for the OS TCP lifetime. A 120s idle watchdog (test-overridable) cancels the stream so the failure surfaces through the normal retry tier; non-stream requests and the llama.cpp fallback get a 5-minute bound.
git exec commands gain a 60s bound and GIT_TERMINAL_PROMPT=0/GIT_ASKPASS=echo (credential prompts hung the git tools forever); the MCP server lifetime is documented as intentional; child ContentEvent and transient status sends are non-blocking with a drop counter so a stalled UI consumer can never hang an agent, while terminal events and ChildAddedEvent stay blocking (guaranteed consumers).
The old SYSTEM DIRECTIVE banner trained the pattern that authoritative-looking text inside tool output is a binding instruction — exactly the shape an attacker can forge from untrusted tool output (repo files, build logs). The note is now attributed to the late harness, keeps the delegation-boundary guidance (stop and report back when the fix exceeds the delegated task), and drops the imperative styling. Coder-only gating unchanged; unit test pins the attribution.
@mlhher

mlhher commented Sep 20, 2026

Copy link
Copy Markdown
Owner

Thank you for your contribution. I will take a look at it and evaluate it.

The note was appended inside the shell tool-result string, adjacent to
untrusted stdout — an attacker-controlled output could forge the same
shape. The note is now a separate user-role history message attributed
to the late harness, delivered in the LLM context next turn (guaranteed
read, not terminal-only), and the shell tool result stays clean.
IsShellFailureResult keeps the prefix contract; the no-middleware
fail-closed shell guard was verified (registration is *tool.ShellTool,
guard assertion matches, regression test covers it).
…do not

The timeout path returned an error wrapped by ExecuteToolCalls' own prefix, which IsShellFailureResult did not match — a timed-out command (the highest scope-creep risk) never produced the harness note. It now matches. A user stop (ctx.Canceled → 'signal: killed') no longer produces a 'report back' note.
The 10-minute shell bound existed only as a package var with no CLI surface. --bash-timeout (0 = unlimited) wires it. Cancel now ignores ESRCH (unix) and is best-effort (windows) so a command finishing exactly at cancellation cannot surface 'process already finished' as the tool error.
Under 4-package parallel -race load the 5s post-cancel bound measured 5.21s once (WaitDelay is 5s and the scheduler adds jitter); the real hang guard is the separate 10s Fatalf, which never tripped. 9s keeps the bounded-return proof while removing the load flake.
…load

Under back-to-back 30x -race load, bash startup + echo could exceed the
300ms test timeout, so the group kill fired before 'start' was written
and the partial-output assertion failed (8/30 under load, 30/30
isolated). A 2s test timeout keeps the kill proof and the strong
partial-output assertion while removing the load sensitivity.
The 30m default was hardcoded in the flag registration. The budget is now a config.json entry (subagent-timeout, time.ParseDuration, 0/negative = unlimited, invalid values warn and fall back) with the established precedence: explicit flag > config > default. Default raised to 24h — long autonomous subagent runs (overnight autopilot, large refactors) were the motivating use case for the budget feature, and 30m killed legitimate runs.
Emasoft added a commit to Emasoft/late-cli that referenced this pull request Sep 21, 2026
Union of PR mlhher#131 (feat/subagent-control @ 2d96f26, main-based) into the
local/full stack (mlhher#127 resilient streaming + mlhher#129 OTP gate + mlhher#130 bash
timeouts). Both feature sets are kept and both test suites pass.

Per-file union decisions:

- cmd/late/main.go: adopted incoming's budget resolution stack (24h
  DefaultSubagentTimeout, config.json subagent_timeout precedence via
  flag.Visit + ResolveSubagentTimeout, per-spawn timeout override via
  effectiveSubagentBudget) and the nested-spawn busy tracking
  (BeginNestedSpawn/EndNestedSpawn + parent heartbeat around
  child.Execute, SetContext(runCtx), child SetIdlePolicy). Kept
  local/full's transcript writer + cause classification: the
  classification now uses the EFFECTIVE budget and gained an idle-kill
  cause (child IdleKillReason() non-empty) that renders as
  "idle: killed by the harness idle watchdog (<probe summary>)" ahead of
  the user-cancel case. Flags: union --subagent-timeout (default 24h
  from appconfig), --subagent-idle-timeout, --subagent-idle-kill-after
  plus all local/full flags (--bash-timeout, --continue-project, OTP,
  ...); SetIdlePolicy applied to root and children.

- internal/orchestrator/base.go: union of event hardening (non-blocking
  progress sends via trySendProgress + droppedEvents counter, blocking
  terminal/ChildAdded sends) with the activity-aware idle watchdog
  (lastActivity/inFlightTools/nestedSpawns/oldestToolStartAt,
  activityMiddleware outermost, two-stage tool-kill then agent-kill,
  injectable tick, SetIdlePolicy/MarkActivity/IdleKillReason).
  MarkActivity fires in both Execute and run() stream callbacks; the
  idle event is emitted non-blocking (select/default).

- internal/executor/executor.go: harness-note delivery kept; per-call
  toolCtx + SetInFlightToolCancel/Clear and the "tool cancelled by the
  harness idle watchdog" result path added. inFlightKill is captured
  BEFORE the per-call toolCancel() and gates the harness note, so a
  watchdog tool-kill (failure-shaped "Command failed with exit code -1"
  from the shell SIGKILL) never asks the coder to report back; a real
  shell failure or a per-call timeout still gets the note.

- internal/tool/implementations.go: single ShellTool.Execute with
  per-call timeout resolution (resolveShellTimeout: absent=global,
  0/negative=unlimited, invalid=error result), global 10m default wired
  to --bash-timeout via SetShellTimeout, timeout message uses the
  effective bound. Kept local/full's harness-note-free error path (no
  duplicated coder sandwich) and the otp_code parameter; Parameters now
  declare both otp_code and timeout.

- internal/tool/shell_command_unix.go, shell_command_windows.go:
  identical mechanism on both sides (process-group kill + WaitDelay);
  incoming's comments adopted.

- internal/config/config.go: PermissionMode constants + SubagentTimeout
  entry coexist; ResolvePermissionMode and ResolveSubagentTimeout both
  kept.

- internal/common/interfaces.go: RetryEvent + RecoveryEvent (local/full)
  and SubagentIdleEvent + ActivityMarker (incoming) kept together.

- internal/tui/update.go: SubagentIdleEvent status-line case added to
  the hardened update loop.

- internal/tool/shell_timeout_test.go: local/full's de-flaked timeout/
  grandchild/pgrep tests plus incoming's per-call timeout and
  resolveShellTimeout tests.

- docs/quickstart.md: incoming Subagent Control section; Common Flags
  row updated to the 24h default.

Integration test added:
TestExecuteToolCalls_WatchdogToolKillDoesNotAttachNote guards the union
of the in-flight hook with the harness note.

Gates: go build ./... ok, go vet ./... ok, go test ./... -race -count=1
all packages ok, gofmt clean on touched files, ./install-dev.sh check ok.
Emasoft added a commit to Emasoft/late-cli that referenced this pull request Sep 23, 2026
…, compaction

Union of both lineages (merge base de0d922):

Incoming (feat/info-bar-compaction @ 5992020):
- 2c1f9ee-lineage: retry status updates, render artifacts, queuing/retry races
- fleet LLM limiter + exported AcquireLLMSlot wrapper (compaction scoring)
- 429 throttle tier (independent retry budget)
- TUI info bar (model/context/subagent/skill stats, uptime, /infobar)
- [HH:MM:SS] transcript timestamps (/timestamps)
- Jev-scored context compaction (off/shadow/enabled) + expand tool
- retry UI: partial output retained during backoff, 'connection regained'
  toasts with duration; retry/status refinements from 8361a17/2ee31ba/2c1f9ee
- internal/compaction package + pipeline wiring in main/executor

Kept from local/full (a515e5a):
- mlhher#129 OTP gate (force-revaluate mode, 3-flag exclusive ResolvePermissionMode)
- mlhher#130 fleet limiter (deduped to one copy + incoming's exported wrapper)
- stream idle watchdog + non-stream request timeout (client)
- event hardening: trySendProgress/droppedEvents/reportDroppedEvents
- idle watchdog: SetIdlePolicy/MarkActivity/withActivityMiddleware/
  SubagentIdleEvent + ActivityMarker interfaces, --subagent-idle-* flags
- hang fix: isRunning cleared on every run-loop exit; queued messages
  preserved across a crashed run (incoming's pendingMsgs=nil on run exit
  dropped in favor of preserve-queued semantics; Cancel() still clears)
- harness-note delivery (coder error note as separate message; timeouts
  get the note, user stops do not) — supersedes incoming's inline sandwich
- subagent time budget: DefaultSubagentTimeout 24h + config subagent_timeout
  + explicit-flag precedence via flag.Visit
- in-flight tool kill (SetInFlightToolCancel) + bash process-group kill
- todos pane default-open (show-todo-pane) + plugin de-flakes
- bounded newGitCmd env for worktree/RepoRoot

Tests: both sides' suites pass (base_idle/base_events/base_retry/base_hang,
infobar, timestamps, compaction, coder_note, inflight, retry, limiter,
throttle); go build/vet clean, go test ./... -race -count=1 green.
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.

2 participants