Conversation
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.
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.
4 tasks
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
fix: subagent hangs — bash timeouts, process-group kill, watchdogs
Emergency diagnosis (RC1–RC4, verified)
internal/tool/implementations.go:370-373ran the command with the raw context and no deadline;internal/tool/shell_command_unix.go:31used bareexec.CommandContext, which kills only the direct child. A grandchild that inherited bash's stdout/stderr pipes keptCombinedOutput'sWaitblocked forever — even after cancellation. Nothing bounded a never-exiting command at all.spawn_subagenttool 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.Timeout: 0.internal/client/client.goset no client timeout; a provider that accepted the request and then streamed nothing (half-open connection, stalled backend) blockedscanner.Scan()for the OS TCP lifetime, indistinguishable from "thinking".exec.Commandin 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.gowas unbounded exec.internal/orchestrator/base.gosent every event (streaming deltas, transient statuses) with a blocking channel send, so a stalled TUI consumer wedged the whole run loop.The four fixes
fix(tool)(eda9326) —newShellCommandmirrors the provenhooks.gopattern: 10-minute default timeout (test-overridable viaSetShellTimeout),Setpgid+ whole-process-group SIGKILL on cancel (unix),taskkill /T /F(windows),WaitDelay = 5sso pipe-holding descendants can never blockWait. Timeout surfaces partial output as a real error.fix(orchestrator)(b35a927) — the runner bounds each subagent with a new--subagent-timeoutflag (default 24h,0= unlimited; also configurable via thesubagent-timeoutconfig.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.fix(client)(21371b6) — a 120s stream idle watchdog (test-overridable viaSetStreamIdleTimeout) cancels a silent stream so the failure surfaces through the normal retry tier; non-streamChatCompletion/Completion/HealthCheckget a 5-minute bound (SetRequestTimeout).chore(451bd0b) — every git exec gains a 60s bound plusGIT_TERMINAL_PROMPT=0/GIT_ASKPASS=echoso credential lookups fail fast instead of hanging; the MCP server subprocess lifetime is documented as intentionally unbounded (owned by the SDK transport); child streamingContentEvents and transient "thinking" statuses are now non-blocking with a drop counter reported at turn end, while terminal status events andChildAddedEventstay 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_subagenttool result becomes: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). Written0600next to the child's own history (cache-dir fallback), so the parent can read it and resume without redoing the work.Test evidence
internal/tool/shell_timeout_test.go::TestShellTool_CancelReturnsDespiteGrandchildHoldingPipes—(sleep 300 &)leaves a pipe-holding grandchild; pre-fixExecuteblocked forever after cancel, post-fix it returns < 5s. PlusTestShellTool_TimeoutKillsHangingCommandandTestShellTool_TimeoutLeavesNoProcesses(pgrep-verified process-group cleanup).context.DeadlineExceeded.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.-race.Environmental plugin flake note (pre-existing, unrelated)
late/internal/pluginfails with 4 known environmental tests on this machine — reproduced identically on a cleanmaincheckout (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 ininternal/plugin). One full-suite run additionally showedTestHandleCommand_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.