Skip to content

fix: stop finite timeouts from killing legitimate work - #63

Merged
asto18089 merged 43 commits into
pinvou3-cleanfrom
fix/timeout-audit
Sep 22, 2026
Merged

asto18089 merged 43 commits into
pinvou3-cleanfrom
fix/timeout-audit

Conversation

@asto18089

@asto18089 asto18089 commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

A timeout-behaviour audit (21-area sweep of the engine, judged against real usage: model inference streams for minutes, tool executions run tens of minutes, users walk away from approval prompts) found finite wall-clock caps sitting on paths whose work legitimately runs long, human decisions being auto-expired, and local waits with no bound at all. This PR fixes the high-impact set, and closes the review gaps found in two follow-up self-reviews (see "Review round" and "Second review round" below).

Fixes

Kills live work (worst class)

  • External tool approvals no longer auto-deny after 300s: the decision wait is unbounded (human-paced) and now ends on turn interrupt, engine death, or runtime shutdown, emitting approval.decided{interrupted:true} so clients clear pending UI. The in-process engine approval wait was already unbounded and excluded from the turn wall clock; the runtime-thread path now matches it.
  • request_user_input waits unboundedly instead of answering the model with ToolError::Timeout after 300s — a human-paced answer is not a stuck request — and, like the approval wait, its human wait is excluded from the per-turn wall-clock budget so an answer past the budget is not collected and then dropped with the turn.
  • Background-task idle detection now treats a running tool as progress: the journal only records tool calls at start/completion, so the 2-minute idle deadline fired during any silent build/test/MCP call. In-flight tools are tracked by the journal item id on both lifecycle edges, and the steady-state loop refreshes progress while any tool is running — not only on event arrival. The wall-time budget remains the spend backstop for a hung tool.
  • MCP tool calls: TUI pool default execute timeout 60s → 1800s; the per-request response-read wait is widened to at least that request's own budget, so raising execute_timeout actually governs a tools/call while the read_timeout knob keeps bounding quick requests (resources/read, discovery); the HTTP transport's client-level total is a separate ceiling of max(read, execute); engine-side stdio proxy gets a dedicated 1800s tools/call budget instead of the 120s generic request cap; headless runtime dispatch backstop 300s → 1800s; MCP OAuth browser-callback wait 300s → 900s.
  • js_execution, code_execution, and plugin scripts: 120s → 600s, and the interpreter child is verified killed on timeout (the Child is held explicitly and reaped synchronously; see "Review round" and the corrected kill narrative in "Third review round"), and the call returns promptly even when a grandchild holds the pipes — on the timeout and the clean-exit path alike (see "Second" and "Third" review rounds).
  • image_analyze: the 120s total reqwest deadline on a multi-MB upload + long non-streaming vision generation is replaced with a 30-minute total envelope around send, retry, and body consumption (connect still bounded at 10s). reqwest's read_timeout was evaluated and rejected for this path: its request-phase timer starts at send() and never resets, so it silently re-introduces a total deadline from send — exactly the bug being fixed.
  • RLM child completions now honor the advertised sub_query_timeout_secs (it was accepted, clamped, stored — and never read; the bridge always used its 120s const), and the knob now follows the rlm_query recursion into nested bridges; inline repl rounds 180s → 900s.
  • web fetch hard cap 60s → 300s (10 MB bodies need more than ~1.3 Mbps); SSRF pre-flight DNS lookup bounded at 10s so a hung resolver cannot stall the fetch envelope per redirect.
  • Sub-agent tool timeout default 300s → 1800s at its single source of truth (the heartbeat floor derives from it automatically).
  • The OpenSandbox external backend's hardcoded 30s client total on the exec request — which is the remote command — is raised to 600s under a named constant (matches the Bash tool's foreground cap); a remote build or test run was previously cut off at the HTTP layer with no way to raise it.

Hangs with no bound

  • Non-streaming model requests get a 30-minute total envelope: the retry loop is wrapped in one outer timeout, and each attempt's request carries the same budget as a reqwest per-request total covering connect through body end (a slow-drip body previously had no bound even after the header envelope resolves). The Anthropic dialect's non-streaming Messages request — a direct send with no retry loop and no bound at all — gets the same budget. Streaming paths keep their own open-cap + per-chunk idle protection; their opens go through a dedicated entry point that sets no per-request total, because reqwest's total wraps the response body and would otherwise ride on the returned body and hard-cut a live stream (see "Second review round").
  • Skill/plugin installs get connect (10s) + total (600s) timeouts; previously none — a stalled connection hung installs and 8-way registry sync forever.
  • Snapshot git subprocesses bounded at 300s with degrade-to-disabled instead of blocking the turn pipeline on a wedged git (NFS/FUSE, hung hooks). The pipes are drained concurrently while the child runs, and the timeout path detaches the readers (grandchildren can hold the pipes); a SIGKILLed git add -A/checkout can no longer leave an index.lock that permanently and silently poisons every later snapshot — a lock older than an hour is cleared with a warning on open.
  • The app-server OpenAI-compatible proxy (non-streaming upstream forward) gets connect (10s) + total (1800s) timeouts; its client previously had none, so an accept-and-stall provider wedged the handler forever.
  • Plugin tools keep model-invoked-interpreter parity (600s + effective kill); provider PKCE browser sign-in callback 300s → 900s (same human-paced reasoning as the MCP OAuth callback); self-update asset download 300s → 600s (tens-of-MB binaries at ~100 KiB/s); image_ocr bounded at 300s (spawn_blocking + timeout, shared by read_file's image path — a wedged backend keeps its blocking thread until it returns, but the tool call is bounded) and pandoc_convert converted to a tokio Command with explicit kill at a 600s bound; previously both ran unbounded sync children on the async executor. The task-gate child is now killed on gate timeout instead of being left running orphaned.

Review round (what the first follow-up self-review caught and this PR now fixes)

  • task_manager idle fix was doubly ineffective: started/completed edges were keyed by different id namespaces (tool-use id vs journal item id) so the tracking set never drained, and progress was only refreshed on event arrival — the exact silent window the fix targets has no events. Rebuilt properly, with both edges pinned by tests (silent tool survives the idle deadline; completion restores it; a hung tool still hits the wall budget).
  • vision read_timeout claim was false: reqwest's request-phase read timer is a hidden total-from-send() deadline, so the "connect + per-read idle" change still killed generations at ~120s. Replaced with the envelope pattern and a corrected comment; wiremock tests pin both the stalled-provider cutoff and the in-budget happy path.
  • snapshot run_git would have deadlocked: read-after-exit cannot drain ls-tree -r/diff output; rebuilt with concurrent reader threads (mirroring the sandbox exec plumbing) and a large-output regression test under a tightened test-only timeout.
  • MCP max(read, execute) clamped the documented read_timeout knob and extended 30-minute liveness to non-tools/call requests. Replaced with per-request widening; three tests pin the knob, the transport ceiling, and the widening rule; docs/MCP.md (en + zh) now documents the defaults and the split.
  • Approval wait had two dead exits: a crashed engine left the pending approval suspended forever, and nothing ever cancelled the runtime token. The wait now probes the engine's op channel (non-consuming) and RuntimeThreadManager::shutdown() cancels the token; both exits are tested and emit approval.decided{interrupted:true}.
  • SSE projection dropped the interrupted flag (and kept a null timeout): fixed, with the legacy approval.timeout arm kept for old-journal replays; docs/RUNTIME_API.md documents the full resolution contract.
  • kill_on_drop through Command::output() was misdiagnosed as not killing: the minimal reproduction of the earlier round was wrong — a five-line reproduction against the pinned tokio shows the kill (and reap) does fire when the future is dropped; the original observation was a reaper race. All three interpreter sites (js, code, plugin) still hold the Child and kill explicitly on timeout — kept because the reap is synchronous and testable and the call can return at child exit instead of coupling the deadline to pipe EOF — with pid-liveness regression tests; see "Third review round".
  • Non-streaming bodies and the Anthropic dialect were still unbounded: per-attempt reqwest totals now cover connect through body end, and the Anthropic non-streaming request gets the same budget; a wiremock test pins the envelope.
  • Residual outliers closed: plugin tools (120s + orphan), PKCE login (300s), self-update download (300s), image_ocr/pandoc_convert (unbounded).
  • Docs: MCP.md defaults/semantics (en + zh), RUNTIME_API.md approval resolution contract.
  • Two stale config expectations that the original commit's compile break had masked (the sub-agent heartbeat floor moved with the new 1800s default: /config subagents status now resolves 1830, and a 900s api timeout no longer dominates the tool-timeout floor) were caught by CI's full-workspace run and fixed with the dominance documented and a maxed-api-timeout case added.

Second review round (what an independent full re-review caught and this PR now fixes)

  • The per-attempt reqwest total leaked onto the streaming open and hard-cut live streams at 30 minutes. send_with_retry applied the total to every attempt — but it is also the transport for the default ChatCompletions dual-stack stream open and every Responses open, and reqwest wraps the response body in the total deadline: the caller's 45s open budget only bounds the open, and the total rode on the returned body, truncating any generation 30 minutes after send (the exact failure class this PR removes). Stream opens now go through a dedicated no-total entry point, with wiremock tests pinning that an open answering past the envelope succeeds and that a caller-pinned total (list_models' 30s — previously silently overwritten by the envelope) survives; the loop envelope now dominates the per-attempt total it wraps.
  • request_user_input burned the default 1-hour turn wall clock while the user thought. Only the approval wait paused the budget (begin_human_wait); the user-input wait's comment claimed to mirror it but did not, so an answer submitted past the budget was collected and then discarded with the failed turn at the next provider boundary. The wait now pauses the budget like the approval wait; an engine test pins that a 1s budget plus a 1.2s-delayed answer completes the turn (fails with "budget exhausted" without the pause).
  • Timeout arms joined pipe-drain tasks that grandchildren can keep alive. Killing the direct child closes only its write ends; a grandchild that inherited the pipes keeps them open, so read_to_end never sees EOF and joining hung the caller past the 600s/300s bound (the old "joins cannot hang" comments asserted the opposite of real pipe semantics). All three interpreter timeout arms abort the drain tasks instead (handing grandchildren an EPIPE), run_git detaches its readers on the timeout path, and a js regression test spawns a pipe-holding grandchild: the join implementation measured 30.0s against the 5s test budget. run_git's comment also cited a nonexistent helper (run_sandboxed_exec) and now names the real one.
  • Two same-class holes outside the original sweep: the OpenSandbox backend's hardcoded 30s exec total (raised to a named 600s constant matching the Bash foreground cap) and the app-server proxy's completely unbounded upstream forward (now connect 10s + total 1800s, mirroring the TUI non-streaming envelope).
  • A timed-out snapshot git could permanently and silently break snapshots: SIGKILL skips git's lock cleanup, so a killed git add -A/checkout left index.lock and every later snapshot failed fast on it with only a log line. Stale locks (older than an hour, far past the command bound) are now cleared with a warning on open; a fresh lock is left alone.
  • Small gaps closed: task-gate child killed on gate timeout (was orphaned, no kill_on_drop); fetch_url schema still said "max 60,000" after the cap moved to 300s; nested rlm_query bridges now inherit sub_query_timeout_secs; plugin gained the pid-liveness test the PR claimed (plus the same 5s test-only timeout as js/code); shutdown()'s doc now states it resolves only approvals and has no production caller yet; SSE projection regression coverage for the interrupted flag and the legacy approval.timeout replay arm.

Third review round (what an independent 13-agent re-review caught and this PR now fixes)

  • The idle-watchdog fix never reached the production path. The in-flight-tool suppression only refreshed drive_engine_turn's own guard; the worker supervisor (run_task) wraps the executor in a second guard fed exclusively from the task-event channel, where a silent tool produces nothing after ToolStarted — automation tasks still died at the 2-minute idle deadline, and the earlier tests (all driving drive_engine_turn directly) could not see that layer. The in-flight window is now published as a ToolHeartbeat task event the supervisor counts as progress, never persisted or surfaced; the seam is pinned from both sides, each test failing if its half reverts.
  • The interpreter rewrite's central kill narrative was false (see the corrected "Review round" bullet): kill_on_drop through a dropped output() future does kill on the pinned tokio. The explicit structure stays for the right reasons; pandoc and the task gate, which use the stock idiom, were never broken.
  • The interpreter success path could still hang past the deadline. The budget wrapped only child.wait(); after a normally-exiting child the drain joins were unbounded, so a grandchild inheriting the pipes (npm run dev & then exit) held the call — the pre-PR timeout(output()) bounded this. All three sites now bound the post-exit drain with a short grace, returning the captured output with a note on stderr, and the three near-identical blocks are extracted into one shared tools::process::run_bounded_child (which also aborts the plugin stdin writer on the timeout path instead of detaching it). New grandchild tests fail if the grace is inflated back to unbounded.
  • Two snapshot git calls were never bounded, and the module comment claimed they were. git init and the date-pinned commit-tree (reachable from the per-turn prune path) ran bare .output(); both route through the bounded core now, whose success-path readers get the same grace treatment and whose kill reaps on a detached thread — on a hard-wedged mount git dies only when its uninterruptible syscall returns, and a blocking wait() would hang the turn pipeline exactly like the wedged git would. A timed-out git also reaches the user's stderr through the once-per-workspace snapshot notice: it leaves a fresh index.lock that fast-fails snapshots for about an hour, and that must not be log-only.
  • The app-server runtime bridge client had no timeouts — the same builder-without-deadlines shape the previous round fixed for the upstream proxy, one module away; the bridge lock is held for the whole turn, so one request to a live-but-wedged runtime child queued every later JSON-RPC message forever. Connect 10s, POST total 1800s, and a re-arming 60s SSE chunk-idle bound (the runtime emits 15s keepalives), with no total on the live stream.
  • The MCP send leg sat outside every budget. A server wedged without draining stdin blocked write_all forever; the send now shares the request budget and routes through the guarded error path, because a timed-out partial write desyncs the line protocol. Two wiring tests pin the widened read wait and the bounded send at call_method level rather than only in the pure functions. The 900s OAuth wait's tool description, two comments, and the synthetic-tool test still said five minutes — all synced.
  • Smaller gaps closed: the engine code-execution pid test is now #[cfg(unix)]-gated (libc is unix-only; the Windows test leg would not compile); the heartbeat operator docs resolve 1830 through both floors instead of a stale 630; RUNTIME_API.md states that runtime shutdown is a latent resolution path until a host wires shutdown() up; the OpenSandbox exec client gets the 10s connect family; PANDOC_TIMEOUT no longer swallows the tool's rustdoc; the OCR disclosure covers the orphaned tesseract child; the injected client test envelope went 250ms → 2s so unrelated non-streaming tests cannot flake against the injection window under CI load.

Fourth review round (what the second external review caught and this PR now fixes)

  • The snapshot git readers leaked a thread per pipe whenever a grandchild held a pipe. Both exits left reader threads pinned on pipes whose write ends live on in a grandchild: the drain-grace expiry on the success path discarded the JoinHandles, and the timeout path detached the readers outright, so a hook or subprocess that daemonized left one OS thread per pipe per git call blocked in read for its own lifetime — the prior regression only backgrounded a sleep 30, so the leak self-healed and the hole went unnoticed. The drain loop is now cancellable on both platforms — a non-blocking, polled read on Unix, a PeekNamedPipe probe loop on Windows — and both exits cancel and join the readers, so the joins are bounded by one poll interval and the call provably leaves no reader threads behind. Cancelling also drops the read ends, handing the grandchild an EPIPE on its next write, the same contract as the interpreter runner. A live-reader counter makes the join observable: the new regression drives two calls against a never-exiting grandchild and fails with "4 still running" if the join reverts to a detach (red-green verified against a simulated revert).
  • The CodeQL gate went red on a moved, pre-existing flow — traced and fixed at the source. Routing run_git through the bounded drain core relocated the flagged argv sinks, and code scanning re-registered the flow as a new alert instead of matching base alert TUI QoL: make /config searchable and grouped Hmbown/Codewhale#198 (PR-ref alerts cannot be dismissed, and Rust has no inline suppression support upstream yet). The alert's path resolved to a single origin: the /v1/snapshots/{id}/restore route, whose request-path id reached git as a treeish. Two changes close it: (1) the side-repo paths ride git's documented GIT_DIR/GIT_WORK_TREE environment interface — the same options by definition, same never-touch-the-user's-repo guarantee — so they are out of argv; (2) the restore endpoint now resolves the requested id against the snapshots the side repo actually knows and 404s on anything else, so an unknown id never reaches a git command line and the allowlist contains is the scanner's modeled trust boundary. Verified locally with CodeQL 2.27.0 (build-mode none): the fixed tree reproduces the base's pre-existing alerts at identical locations and reports no alert for the snapshot flow.

Fifth review round (what an independent ten-agent fresh re-review caught and this PR now fixes)

  • ToolHeartbeat was persist-urgent. It was missing from the execution_event_persist_urgent exclusion list, so every ~200 ms heartbeat during a silent build rewrote the whole task record under the manager-wide state lock — contradicting the variant's own "never persisted" contract. Now excluded, pinned by classification and wiring-level assertions (heartbeat applies unpersisted, a real lifecycle edge still flushes).
  • MCP request-leg timeouts left the connection Ready. A timed-out send can leave a partial line in the pipe, and a timed-out outer read wait can still be answered late; finish_guarded_error does not tear the connection down, so the pool handed the broken transport right back out even though the code comment claimed the caller reconnects. Both arms now mark the connection Disconnected (matching the inner read timeout), the comment states the real behavior, and wiring assertions pin the state on both paths.
  • The snapshot stderr notice never fired for its motivating case. It was wired only to open_or_init errors, so the documented "wedged git add -A times out and leaves a fresh index.lock" scenario stayed log-only. Session and prune failures now route through the same once-per-workspace notice; the TimedOut gate keeps it scoped.
  • git init inherited ambient GIT_DIR/GIT_WORK_TREE. It was the one git invocation not cleared of a shell-exported GIT_DIR, which could redirect the one-time init away from the hashed side-repo path. Now env_removed, and the stale comment still describing flag-based routing is corrected.
  • Plugin scripts could pass truncated output as a success. The drain-grace truncation note lands on stderr, which the plugin surface discards on a successful exit — an unparseable, possibly cut-off output became a silent success=true. The note is now a shared constant with a probe, and the plugin arm fails loudly when the output does not parse and the drain was truncated.
  • A queued approval decision could be eaten by a same-instant interrupt/shutdown. The biased select favored the cancel token, so a decision already in the oneshot was discarded and reported as an interrupted deny — contradicting the documented "a decision the user actually made never carries interrupted". Both exit arms now rescue a queued decision via try_recv; a deterministic test pins the race.
  • The bridge's SSE header wait was unbounded. A runtime child that accepts the event-stream GET but never writes response headers held the bridge lock forever — the queue-forever scenario surviving through the headers phase. The header wait now shares the 60s chunk idle deadline; the live stream keeps no total.
  • Smaller gaps: the OpenSandbox comment claimed the 600s budget "matches the Bash tool's foreground cap" (the Bash cap is effectively unbounded; the real parity class is the interpreter tools' 600s cap); the human-wait wall-clock test margins went 1s/1.2s → 2s/3s against CI load; SUBAGENTS.md (en + zh) no longer implies a resolvable tool_timeout_secs config key (only api_timeout_secs exists; the tool timeout is a compile-time constant); RUNTIME_API.md documents the restore endpoint's exact-match/404 contract; the in-flight tracking set now comments the disclosed compaction blind spot; and the first commit's author/SOB email is normalized to the account address (whole-chain replay: trees byte-identical, range-diff all-equal).

Sixth review round (what an independent 15-area fresh re-review, run after rebasing onto the merged #67, caught and this PR now fixes)

  • Headless exec could park forever on request_user_input. The exec event loop auto-resolves approvals but had no UserInputRequired arm, and the engine's human wait is unbounded now — a headless run whose model asked a clarifying question parked forever where the old 300s cap resumed it. The loop now cancels the request (the tool gets a cancelled error, the turn continues) and says so once on stderr.
  • A timed-out git init permanently wedged snapshots. needs_init keyed on directory existence alone; a killed init leaves .git without HEAD, so init was skipped forever and every later snapshot failed with "not a git repository" (the config failures on that path were swallowed too). open_or_init now uses open_existing's readiness predicate; git init is idempotent, so re-initializing the partial directory is safe.
  • A dead monitor stranded pending approvals. settle_claimed_turn_failure settled user inputs and dynamic tools but not approvals: the map entry leaked and no approval.decided was published, contradicting the documented resolution contract. It now settles this turn's approvals the same way the interrupt exit does (deny + interrupted).
  • A delivered decision could still be mislabeled interrupted. deliver_external_approval removes the map entry before sending, so a user decision could land in the oneshot after both rescue checks saw Empty. The interrupted exit now makes one final try_recv and routes a found decision through the decision path; a send landing after that finds a dropped receiver and honestly reports not-delivered.
  • Heartbeats took the manager-wide state lock for a no-op and set dirty. Each ~200ms tick entered apply_execution_event, acquired the lock, matched => {}, and marked the record dirty — deferring real mutations' persistence past the debounce window for the whole silent tool. process_execution_event now short-circuits before the lock (the supervisor guard is already refreshed by the progress classification).
  • The vision envelope broke the error taxonomy and the permit wait sat outside every bound. The envelope expiry surfaced as execution_failed instead of ToolError::Timeout (every other bounded tool classifies); the runtime-chat inference permit was acquired before the envelope, so a contested permit could park the tool unbounded — the acquire now lives inside the envelope, still held for the whole request.
  • Envelope-exceeded requests skipped the recovery probe. maybe_probe_recovery ran on the exhausted-retries path only; a provider that just wedged past 30 minutes is exactly when the /models health probe matters.
  • Three app-server adjacencies: a stream that dies mid-turn (idle/header timeout, transport error) now leaves a best-effort interrupt, so a retried message no longer fails with "already has an active turn" until the orphan completes; the /health boot probe bounds each attempt by the remaining deadline (a child that accepts but never writes headers used to hang it forever and respawn a child per message); the proxy drops the config read guard once the endpoint is resolved instead of holding it across the (now up to 30-minute) upstream round trip.
  • The interpreter runner's wait()-error exit leaked the drain tasks (the one exit that was neither timeout nor clean exit); aborts now run before propagating. The RLM sub-query builder floors at 1s (0 built an instantly-firing timeout that would kill every child query), the session default references CHILD_TIMEOUT_SECS instead of a duplicate literal, and the inline-round comment no longer implies a single minutes-long sub-query survives a path where the bridge still bounds each one.
  • Docs/comments corrected: the stale-lock comment no longer claims a lock older than the command bound cannot belong to a live writer (a git wedged in uninterruptible I/O can; removal stays benign and the comment says why); the bounded-git comment names the real sandbox-CLI gap instead of claiming to mirror it; the OpenSandbox comment matches its own rationale (the Bash foreground cap is effectively unbounded); the fetch-envelope comment discloses the pre-envelope SSRF pre-flight lookup; the self-update comment states the real ~58 MiB at 100 KiB/s; the MCP execute-timeout comment discloses the per-leg worst case; the 1800s definitions in core, subagent-limits, MCP, and the dynamic-tool wait now cross-reference their family; RUNTIME_API.md's restore contract is accurate (any snapshot the side repo knows, not just the listed window; the guarantee is that a non-matching id never reaches a git command; real 40-hex example id; approval.timeout marked legacy in the common-events list; "every resolution" scoped to forced resolutions).
  • Test hardening: the fetch hard cap is pinned to 300,000 ms so the schema's advertised max cannot drift; the reader-leak regression asserts its own reader delta against a pre-loop baseline instead of a process-global zero, so unrelated concurrent git calls cannot fail it.

Testing

  • cargo check --tests clean for codewhale-tui, codewhale-mcp, codewhale-core; cargo clippy --workspace --all-features --locked -D warnings clean (CI's allow-list); cargo fmt --check clean.
  • Targeted suites all green: runtime_threads 159, task_manager 48, tools::web 192, mcp 76 (+231 in the TUI pool), core 82, vision 12, js_execution 10, repl 31, snapshot 57, skills::install 20, client 165, tools::plugin 19, tools::file 119, rlm::bridge 10, codewhale-mcp 76, plus the full workspace suite (cargo test --workspace --all-features --locked).
  • Pre-existing, unrelated to this PR: 2–3 remote_control restart/recovery tests flake under full-suite parallel load on the base commit too (verified by rerunning the same suite on pinvou3-clean); isolated runs pass.
  • New regression tests: approval pends-until-interrupt/shutdown/engine-death; silent-tool idle suppression + wall-budget backstop; snapshot large-output drain + stale index.lock cleanup; MCP read-knob/transport-ceiling/per-request widening; vision envelope (stall + happy path); client non-streaming envelope + stream-open-no-total + pinned-total survival; interpreter kill (pid liveness, js + code + plugin) + prompt return past a pipe-holding grandchild; snapshot readers cancelled and joined (repeated permanent-grandchild calls leave no reader threads, pinned by a live-reader counter); user-input human wait excluded from the turn wall clock; RLM sub-query budget; SSE interrupted/legacy-approval.timeout projection.
  • The old approval_timeout_denies... test is replaced by approval_pends_until_interrupt... pinning the new semantics (no auto-deny; interrupt resolves the pending approval and clears state so the next turn can start).
  • retrieval_defaults_are_coherent... updated to assert the fetch hard cap dominates the search cap (page fetches stream 10 MB bodies; search APIs answer small JSON) instead of strict equality.
  • Known pre-existing, unrelated: running the full tools::subagent:: test filter stack-overflows on this host with or without this PR (CI runs the suites with a 16 MiB stack; local runs need RUST_MIN_STACK=16777216 for some broad filters).
  • Full-suite parallel-load flakes, verified against the base commit (3 base runs fail only the documented remote_control pair): on this branch's head, three additional global-state tests each failed once across repeated full-suite runs — model_inventory::ollama_default_prefers_live_local_tags..., shell_dispatcher::test_env_lock::uncontended_read..., and client::tests::deepseek_anthropic_translate_uses_messages_endpoint — all pass in isolation and on rerun, all live in files this PR does not touch (provider-lake / merged-catalog global state races), and all are the same test-robustness class as the remote_control pair; noted as follow-up hardening, not smuggled fixes.

Notes for reviewers

  • The Desktop (pinvou3-app) side ships a companion PR (Speculative cached-prefix turn branches with verifier selection Hmbown/Codewhale#532) with the app-side waits (shell_env hook budget, pre-turn checkpoint bound, connector probes, relay pong watchdog, stop button, OAuth window, ingest/monitor bounds). Merge order with Verifier chains over cached repo context beyond RLM Hmbown/Codewhale#530 (gitlink advance) needs coordination so the pinned gitlink moves once.
  • Known remaining gaps, disclosed rather than smuggled: the standalone codewhale sandbox run CLI (run_sandbox_command) still joins its pipe readers unconditionally after the child exits — pre-existing on the base branch and untouched here (a grandchild holding the pipes delays only that standalone process); and on Windows tokio backs child pipes with its shared blocking pool, so an aborted interpreter drain leaves the pool thread parked on the read until the grandchild closes the pipe (the pool is bounded and reused; the Unix reactor path, where the interpreter tests run, has no such reservation). the isolated-request path (send_with_isolated_retry) has no envelope of its own — its only caller wraps it in a 4–300s outer timeout; a timeout-bounded image_ocr cannot kill the native macOS Vision FFI mid-call (the tool returns, the blocking thread returns when the backend does); grandchildren spawned by interpreted scripts are not chased (kill covers the direct child, matching every other process-kill site in the repo). Since the approval/user-input waits became unbounded, an automation task blocked on a human answer now occupies its worker until the task execution wall_time (default 30 minutes) instead of failing at the old 300s cap — and that clock does not pause for human waits, so a slow answer still ends the task; pausing it safely needs reliable wait-window detection and is left for a follow-up; and the idle watchdog still cannot see a long-running compaction (its journal item carries no tool marker), so a background task mid-compaction remains exposed to the 2-minute idle deadline.
  • From the fifth-round re-review, disclosed rather than fixed: with the approval wait unbounded, a vanished deciding client now leaves an interactive turn parked indefinitely where the base self-released at 300s — shutdown() remains latent until a host wires it, the pending-approval map (unlike the dynamic-tool map) has no cap, and LRU protection pins parked turns. A reconnected client can still answer, interrupt and engine death still resolve, and automation tasks remain bounded by wall_time; production wiring of the shutdown path (or a pending cap) is follow-up work. Relatedly, the 1800s tool timeout now exactly equals the default sub-agent child wall-time and the task wall_time backstop, so one maximal tool call can consume an entire child/task budget with nothing left for the surrounding steps — documented trade-off, not changed here.
  • Fourth parallel-load flake of the already-disclosed class: client::anthropic::tests::anthropic_stream_open_error_is_not_retried failed once under full-suite load and passes in isolation and on rerun (same global-state class as the three below; the file is this PR's but the flake is load-order, not semantic).
  • Known follow-up debt, not smuggled in (verified by the fifth-round audit): run_git's drain core and the sandbox exec plumbing are two copies of the same sync spawn/drain/bounded-join shape and should share one helper — and the sandbox twin still carries the grandchild-wedge and blocking-reap hazards this PR fixed on the git side, which makes it the priority; the 1800s budget is defined in nine places across four crates that comments keep in sync — one shared constant home would remove the drift risk; the pid-liveness poll loop is triplicated across the three interpreter test modules; the Anthropic envelope test cannot inject a shrunk budget because anthropic.rs uses the raw constant instead of the test accessor; and MCP OAuth metadata/login clients plus the image_analyze upload cap remain unbounded (pre-existing). (The three near-identical interpreter blocks were extracted into the shared tools::process::run_bounded_child in the third review round.)
  • The desktop app pins 92427bd8d (advanced by the merged Verifier chains over cached repo context beyond RLM Hmbown/Codewhale#530); this branch is rebased on the current pinvou3-clean, so the gitlink can advance in one step once this merges.

No-Issue: fix wave from a repo-wide timeout-behaviour audit; no single tracking issue exists yet.

@JensenChen28 JensenChen28 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

发现一个会绕过本 PR 所设总预算的阻塞问题。

js_execution 只把 child.wait() 放进 600 秒 timeout;父进程正常退出后,成功分支又在 timeout 之外无期限等待 stdout/stderr drain task。脚本可以启动一个继承 stdout/stderr 的后台孙进程后立即正常退出,此时 child.wait() 立刻成功,但 drain 要等孙进程关闭管道,甚至可能永远不返回。现有回归只覆盖“父进程一直运行直到被 timeout 杀掉”的分支,未覆盖父进程先成功退出的情形。plugin 使用了相同结构,snapshot::run_git 的成功分支也会在 wait_timeout 返回后无界 join reader thread。

请让总预算覆盖“等待父进程 + 收集两条管道”的整个生命周期,或在父进程退出后按剩余预算等待 drain,超时则关闭/abort reader;并补一个“父进程启动继承管道的后台孙进程后立即退出”的回归。其余检查中,git diff --check 以及 PR 的 check/gate/DCO/CodeQL 均通过;sync 失败来自镜像同步,不改变上述代码结论。

@JensenChen28 JensenChen28 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

上轮 JS/plugin 与 snapshot 成功路径的无界等待已改为有界返回;共享 async runner 的 2 秒 drain grace 能中止任务,原阻塞点已解决。但 snapshot 的同步实现引入了新的资源泄漏:run_bounded_git 在 drain grace 超时后于 snapshot/repo.rs:1166-1167 直接丢弃 JoinHandle。Rust 线程不会因此取消,reader 仍永久阻塞在管道 read;仓库 hook、clean filter 或子进程若启动长期后台孙进程并继承 stdout/stderr,每次 snapshot/git 调用都会遗留最多两个 OS 线程。现有回归只后台 sleep 30,所以泄漏会在 30 秒后自行结束,覆盖不到永久持管道及重复调用导致的线程耗尽。请让 reader 可被主动终止/关闭,或改用可取消的异步/非阻塞 drain,并增加永久持管道后重复调用仍不增长线程/任务的回归。

Comment thread crates/tui/src/snapshot/repo.rs Fixed
@asto18089

Copy link
Copy Markdown
Collaborator Author

Thanks for both reviews — round 1 was fixed in the previous push, and your round 2 finding is confirmed and fixed in 7add69d (two commits on top).

Round 1 (drain joins) — already resolved before this push, for the record. The success-path drains on all three interpreters now go through the shared run_bounded_child with a 2s grace that aborts on expiry, and snapshot::run_bounded_git bounds its post-exit drain the same way; each site carries the "grandchild spawned after a clean exit" regression you asked for.

Round 2 (reader-thread leak) — confirmed exactly as described. Both exits left reader threads blocked in a pipe read whose write end lives on in a grandchild: the drain-grace expiry dropped the JoinHandles, and the timeout path detached outright — one leaked OS thread per pipe per git call for a daemonizing hook/subprocess. The sleep 30 regression self-healed and never saw it. Fixed on both platforms:

  • The drain loop is now cancellable: a non-blocking, poll-driven read on Unix (O_NONBLOCK on our read end only), and a PeekNamedPipe probe loop on Windows (std pipes have no non-blocking mode there; data is read only when already buffered). A cancelled reader exits within one 50ms poll interval and drops the read end, handing the grandchild an EPIPE on its next write — the same contract as the interpreter runner.
  • Both exits now cancel and join the readers, so the joins are bounded by the poll interval and the call provably leaves no reader threads behind — including the timeout path, which previously detached.
  • The new regression (repeated_calls_with_a_permanent_grandchild_do_not_leak_reader_threads) drives two calls against a never-exiting grandchild and asserts a live-reader counter drains back to zero. Red-green verified: with the join reverted to a detach it fails with exactly 4 still running.

One nuance for completeness, since you reviewed the interpreter runner too: on Unix, tokio's child pipes are reactor-backed (O_NONBLOCK + PollEvented), so the async runner's abort() genuinely frees everything. On Windows, tokio backs child pipes with its blocking pool, so an aborted drain leaves a pool thread parked on the read until the grandchild closes the pipe — inherent to tokio, and the pool is bounded and reused, so it does not grow the way the detached std::threads did. Both behaviors are now disclosed in the PR notes.

The CodeQL gate, for transparency: it went red on this PR not because of a new flow but because routing run_git through the bounded core relocated the --git-dir/--work-tree argv sinks, and code scanning re-flagged the unchanged flow as a new alert — the identical flow is already two open alerts on the base branch. Since PR-ref alerts can't be dismissed and Rust has no inline suppression support yet upstream (github/codeql#21638 is still open), both paths now pass the side-repo paths through git's documented GIT_DIR/GIT_WORK_TREE environment interface — the same options by definition, same never-touch-the-user's-repo guarantee — so the paths are out of argv and the flow stops re-flagging on refactors. Post-merge, the base's two stale alerts retire with the code they pointed at.

Disclosed, not fixed (out of scope): the standalone codewhale sandbox run CLI (run_sandbox_command in lib.rs) still joins its readers unconditionally after the child exits — pre-existing on the base branch and untouched by this PR; happy to file it as a follow-up.

All 59 snapshot:: tests pass locally (including the two bounded-git regressions), cargo clippy --workspace --all-features --locked -D warnings and cargo fmt --check are clean.

@JensenChen28 JensenChen28 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已复核当前 head 7add69d3 及上一轮 reader-thread 泄漏修复。Unix 路径将 pipe fd 设为 nonblocking 并以 50ms poll 检查取消;Windows 路径以 PeekNamedPipe 做同等有界轮询;成功 drain-grace 超时与命令 timeout 两个出口都会设置 cancel 并 join reader,因此不再丢弃 JoinHandle。新增永久持管道孙进程的重复调用回归通过 live-reader 计数验证 reader 回到 0。随后将 side-repo 路径改走 Git 官方 GIT_DIR / GIT_WORK_TREE 环境接口,保持原有隔离语义。

上一轮阻塞项已解决,未发现新的代码级阻塞问题。git diff --check 通过,当前 head 的 required checks(DCO、Gitleaks、check、gate)全部通过;非 required 的 CNB sync / CodeQL 汇总失败不改变本次代码结论。

@asto18089

Copy link
Copy Markdown
Collaborator Author

Follow-up on the CodeQL gate — the picture changed after my previous comment, and the earlier "stops re-flagging" claim needs correcting.

The env routing did remove the --git-dir/--work-tree argv sinks, but the analysis re-flagged the remaining .args(args) sink — and this time I traced the alert's path properly (CodeQL 2.27.0, build-mode none, locally): the taint originates at the /v1/snapshots/{id}/restore route, where the request-path id flowed straight into git checkout <id> as a treeish. That remote flow is exactly what base alert Hmbown#198 covers, so every refactor of run_git re-registered it as a new alert instead of matching — PR-ref alerts can't be dismissed and Rust has no inline suppression upstream (github/codeql#21638 is still open), so no amount of reshuffling the sink would keep the gate green.

Fixed at the source in c88ceb0: the restore endpoint now resolves the requested id against the snapshots the side repo actually knows (repo.list → contains) and returns 404 for anything else, so unknown ids never reach a git command line. The allowlist membership check is also the scanner's modeled sanitizer shape, and it is a real hardening rather than an analyzer trick — previously any string from the request path was handed to git as a treeish (it would have failed loudly, but still).

Verified locally before pushing: the fixed tree reproduces the base branch's four pre-existing command-line-injection alerts at identical locations (operate.rs:720/722/724, native_memory.rs:148) and reports no alert for the snapshot flow. A regression test pins the 404 (restore_snapshot_endpoint_helper_rejects_unknown_snapshot_id).

Net effect of the two review rounds, for the record: the reader-thread leak is fixed with cancellable drains and a red-green regression, and the restore endpoint now validates its input against the snapshot allowlist.

@asto18089
asto18089 force-pushed the fix/timeout-audit branch 2 times, most recently from dd4c98c to f782064 Compare September 20, 2026 12:08
@asto18089

Copy link
Copy Markdown
Collaborator Author

第五轮全新复审(10-agent 独立舰队)与修复

按“以全新视角重新审阅”的要求,本 PR 在最新 pinvou3-clean(c4e6caf)上由 10 个独立审查代理做了全量重审:按区域划分 8 个(审批/用户输入等待链、任务空闲看门狗、MCP 超时族、client 信封与 vision、解释器 runner、snapshot git 有界核、sandbox/bridge/proxy、杂项工具与文档),外加逐提交夹带审计和对抗性跨域红队各 1 个。全部 PR 正文声明逐条对照代码核实,reqwest 语义对 Vendored 0.13.4 源码验证。

核实为真并已修复(7 项,提交 028fd0e2a..dfbcc89ce):

  1. MAJOR — ToolHeartbeat 被判为 persist-urgent:不在排除名单,静默构建期间每 ~200ms 全量重写任务 JSON 并持有全局锁,与变体自身 "never persisted" 契约矛盾。已排除 + 分类/接线双层钉测试。
  2. MAJOR — MCP send/外层 read 超时不毒化连接:超时的写可能留下半行、超时的外层读可能晚到,两条路径都未置 Disconnected,连接池把坏传输原样再借出(与注释声称的"调用方会重连"不符,is_mcp_stale_session_error 也不匹配)。两臂均已毒化,wiring 断言钉死。
  3. MAJOR(声明与接线不符)— snapshot 通知未覆盖 motivating case:stderr 通知只接了 open_or_init,"git add -A 超时留 index.lock" 场景仍 log-only。session/prune 失败已接入同一 once-per-workspace 通知。
  4. git init 未清除环境中的 GIT_DIR/GIT_WORK_TREE(唯一没做 env 隔离的调用点),已 env_remove。
  5. plugin 成功路径丢弃截断注释:截断输出可伪装成静默成功;注释改为共享常量 + 探测,解析失败且截断时显式报错。
  6. 审批 select 的 biased 顺序会在取消与决策同瞬间丢弃用户已排队决策(违反"a decision the user actually made never carries interrupted");两个出口臂均先 try_recv 抢救,含确定性红绿测试。
  7. bridge SSE GET 的 headers 等待无界(accept-but-no-headers 子进程可永久持有 bridge 锁);现已共享 60s chunk idle 界。

另有小型修正:OpenSandbox 注释"Bash foreground cap"事实错误(实际是解释器 600s 同类)、user-input 墙钟测试余量 1s/1.2s→2s/3s、SUBAGENTS.md(en+zh)不再暗示不存在的 tool_timeout_secs 配置键、RUNTIME_API.md 补 restore 精确匹配/404 契约、compaction 空闲盲区在代码处披露、首提交 author/SOB 邮箱归一(整链 commit-tree 重放,树逐字节一致,range-diff 全等号)。

红队六向量四向 sealed,两向产出上列 #1/#6;新增披露(见正文 Notes):审批无限等待下决策方客户端消失会让交互 turn 无限滞留(base 300s 自愈;shutdown() 仍无生产接线、pending map 无上限),以及 1800s 工具超时与子代理/任务 wall-time 相等碰撞——均为记录在案的后续项,不在本 PR 夹带。

审阅结论:夹带审计三重验证(文件集闭包/hunk 级重放/逐提交对账)零夹带;两笔自认债务(沙箱 drain 孪生、1800s×8)核实属实且延期合理,沙箱孪生仍带本 PR 已修的 hazard,已标注为最优先跟进;无造轮子(reqwest total-rides-body 与 read_timeout 语义均对 vendored 源验证)。本地 clippy -D warnings、cargo fmt --check、全 workspace 测试(RUST_MIN_STACK=16777216)全绿;一次 anthropic_stream_open_error_is_not_retried 失败为正文已披露的并行负载全局状态 flake 同类第 4 例,隔离重跑通过。CI 的 "Sync to CNB" 失败为全分支存量基建问题(与代码无关)。

请 @JensenChen28 有空时重批。

A timeout-behaviour audit found several finite wall-clock caps sitting on
paths whose work legitimately runs minutes to hours, auto-expiring human
decisions, and local waits with no bound at all. Fix the high-impact set:

- External tool approvals no longer auto-deny after 300s: the decision
  wait is unbounded (human-paced) and ends on turn interrupt or runtime
  shutdown instead, emitting approval.decided{interrupted} so clients
  clear pending UI. The engine's own approval wait was already unbounded;
  request_user_input now waits unboundedly too instead of answering the
  model with a ToolError::Timeout after 300s.
- Background-task idle detection now treats a running tool as progress:
  the journal only records tool calls at start/completion, so the 2-minute
  idle deadline fired during any silent build, test suite, or MCP call.
  The wall-time budget remains the backstop.
- MCP tool calls: the TUI pool default execute timeout 60s -> 1800s and
  the response-read wait derives from max(read, execute) so raising the
  documented knob actually governs; the engine-side stdio proxy gets a
  dedicated 1800s tools/call budget instead of the 120s generic request
  cap; the headless runtime dispatch backstop 300s -> 1800s; MCP OAuth
  browser-callback wait 300s -> 900s.
- js_execution and code_execution: 120s -> 600s and the child is killed
  on timeout instead of being left running orphaned.
- image_analyze vision calls: replace the 120s total deadline with
  connect + per-read idle timeouts; a multi-MB upload plus a long
  non-streaming generation legitimately exceeds two minutes.
- RLM child completions now honor the advertised sub_query_timeout_secs
  (it was stored but never read; the bridge always used its 120s const),
  and inline repl rounds 180s -> 900s to match the model-thinking
  reality the RLM path already accommodates.
- web fetch hard cap 60s -> 300s and the SSRF pre-flight DNS lookup is
  bounded at 10s so a hung resolver cannot stall the fetch envelope.
- Skill/plugin installs get connect + total timeouts (previously none:
  a stalled connection hung installs forever); snapshot git subprocesses
  are bounded at 300s with degrade-on-timeout instead of blocking the
  turn pipeline on a wedged git.
- Non-streaming model requests get a 30-minute total envelope around the
  retry loop (all attempts + honored Retry-After included). The shared
  client intentionally has no client-level total timeout, so compaction,
  translate, and similar non-streaming callers previously had no bound
  against a provider that accepts and stalls, nor against a gateway
  answering 429 + Retry-After: 3600 forever.
- Sub-agent tool timeout default 300s -> 1800s (single source of truth;
  the heartbeat floor derives from it automatically).

Verified: cargo check --tests on tui/mcp/core; targeted suites for
runtime_threads (157), task_manager (46), web (192), mcp (76), core (82),
vision, js_execution, repl, snapshot, skills install, client all pass.

Signed-off-by: asto18089 <asto18089@126.com>
The timeout-audit fix for background-task idle detection tracked running
tools with mismatched keys and only refreshed progress on event arrival:

- item.started carries the engine tool-use id under "tool", but
  item.completed/item.failed only carry the journal item id under
  "item", so the tracking set was never drained: after the first tool
  call the watchdog was disarmed for the rest of the turn (only the
  wall-time budget remained).
- note_progress was evaluated per event, but the very scenario being
  fixed is a tool whose execution window produces no journal events at
  all, so the 2-minute idle deadline still fired during silent builds,
  test suites, and MCP calls.

Track both lifecycle edges by the journal item id, and refresh progress
from the steady-state loop (which wakes at least every catch-up poll)
for as long as any tool item is in flight. The wall-time budget stays
the backstop for a tool that never completes.

Pin both edges with tests: a silent tool survives well past the idle
deadline and the deadline resumes after completion, and a hung tool
still hits the wall budget.

Signed-off-by: asto18089 <asto18089@126.com>
The audit change replaced the 120s total deadline with connect(10s) +
read_timeout(120s), but reqwest's read_timeout is not a per-read idle
bound for the request phase: its timer starts at send() and is never
reset until the response headers arrive, so it silently re-introduced a
~120s total deadline on the multi-MB upload plus the full non-streaming
generation - the exact healthy work the change meant to protect (the
per-read idle semantics only apply once the response body is streaming).
The accompanying comment described the opposite of the actual behavior.

Bound only the connect handshake on the client and put a 30-minute
envelope (matching the non-streaming model request budget) around the
retry loop and response body consumption, reporting a clear timeout
when it fires. Wiremock tests pin both directions: a stalled provider
is cut off by the envelope with a timeout error, and a prompt answer
arriving in time still flows through.

Signed-off-by: asto18089 <asto18089@126.com>
run_git waited for the child to exit before reading stdout/stderr, so
any invocation producing more than the 64 KiB OS pipe buffer deadlocked
on its own output and died at the 300s command timeout. That is not a
corner case: ls-tree -r -z --name-only on the restore path lists one
path per workspace file, and diff --name-only after a large refactor
grows the same way, so medium-size workspaces would have seen /undo and
snapshot listing stall 300s and then fail - the exact turn-pipeline
blocking the audit meant to remove (the old .output() call drained
concurrently and had no such cliff; the removed 'quiet-output' comment
was wrong about the tree-listing and diff commands).

Spawn two reader threads (mirroring the sandbox exec plumbing in
crate::run_sandboxed_exec) so the pipes are drained while the child
runs, join them on both the success and the kill path, and use a
tighter test-only command timeout so the large-output regression test
fails in seconds rather than hanging.

Signed-off-by: asto18089 <asto18089@126.com>
The audit fix set the connection-level read timeout to
max(read, execute) so a raised execute_timeout actually governs a
tools/call. That traded one bug for two more: the read_timeout knob
documented in docs/MCP.md was silently disabled (a user-configured read
budget of 30s or 180s was raised to the 1800s execute default, so fast
requests on a wedged server waited out 30 minutes before failing), and
every non tools/call request on the connection - resources/read,
prompts/get, notifications - inherited the same 30-minute liveness
regression for detecting a dead server.

Restore the connection read timeout to the user's knob and widen the
read wait per request instead: call_method now reads for at least its
own outer budget (so a silent server is not declared dead mid tool
call), while everything else keeps the configured read budget. The HTTP
transport's client-level total stays a separate ceiling computed as
max(read, execute) because it must cover the longest request the
transport carries; both decisions are extracted into named pure
functions and pinned with tests.

Signed-off-by: asto18089 <asto18089@126.com>
…untime shuts down

The unbounded external-approval wait only ended on turn interrupt or
runtime-shutdown cancellation, but two of its documented exits were
not actually reachable. A crashed engine dropped its op receiver while
the wait kept polling for a decision that could never come, suspending
the turn and the pending approval forever (the pre-PR 300s timeout was
accidentally masking this); and nothing in the tree ever cancelled the
manager token, so the shutdown exit was dead code.

Probe the engine's op channel with tx_op.is_closed() - a non-consuming
check - in the wait's poll arm, and give hosts an explicit
RuntimeThreadManager::shutdown() that cancels the token the wait (and
turn monitoring) already observe. Both exits resolve the pending
approval through the same deny+interrupted path as a turn interrupt,
emitting approval.decided so clients clear pending UI.

Pin both exits with tests.

Signed-off-by: asto18089 <asto18089@126.com>
…ment resolution semantics

The compat SSE projection forwarded only the legacy timeout field of
approval.decided, so external HTTP/SSE clients could not distinguish a
deny forced by an interrupt or shutdown from a deny the user actually
chose; the projection silently dropped the interrupted flag this PR
introduced. Forward it alongside timeout (which now only ever arrives
from legacy journal replays - as does the approval.timeout event, whose
arm is kept for exactly that reason).

Document the full resolution contract in docs/RUNTIME_API.md: approvals
wait unbounded for a human, and every forced resolution carries
decision deny plus interrupted true while a real user decision never
does.

Signed-off-by: asto18089 <asto18089@126.com>
The audit normalized tool execution, human-paced waits, and download
budgets, but four outliers of the same classes survived in code the
audit's own arguments apply to:

- Plugin script tools: the model-invoked interpreters kept the 120s
  cap the audit raised to 600s for js/code_execution, and a timeout
  left the script running orphaned (no kill_on_drop). Align the budget
  and kill the child on timeout, matching the interpreter surfaces.
- Provider PKCE browser sign-in: the callback wait stayed at 300s while
  the identical human-paced argument moved the MCP OAuth callback to
  900s. Apply the same budget.
- Self-update asset download: the total stayed at 300s, which needs an
  implausible >1.6 Mbps to fetch tens-of-MB release binaries - tighter
  than the 600s the audit gives 20 MiB skill tarballs. Use 600s and
  state the arithmetic.
- image_ocr and pandoc_convert ran sync child processes (and blocking
  Vision FFI) directly on the async executor with no wall-clock bound:
  a wedged tesseract/pandoc stalled the executor and the turn
  indefinitely. Bound OCR at 300s (spawn_blocking + timeout, shared by
  the image_ocr tool and read_file's image path) and convert pandoc to
  a tokio Command with kill_on_drop and a 600s timeout so a wedged
  converter is killed instead of orphaned.

Signed-off-by: asto18089 <asto18089@126.com>
…Messages request

The 30-minute envelope only resolved at response headers: the shared
client has no client-level total timeout, so after a successful response
a provider that trickles the body (a byte every few minutes) still
wedged non-streaming callers - compaction, translate, list-models,
provider-native search - with no per-chunk protection on this path.
Give every attempt in the non-streaming retry loop the same budget as a
reqwest per-request request timeout, which covers connect through body
end, so both the attempt and the whole retry loop are bounded.

The Anthropic dialect's non-streaming Messages request bypasses
send_with_retry entirely (direct send plus a one-shot JSON decode) and
was left completely unbounded - the same accept-and-stall wedge the
envelope fixed elsewhere. Apply the same budget there.

A wiremock test pins the envelope by injecting a short budget through a
test-only override and asserting a stalled provider fails with a
timeout instead of hanging (serialized through the shared test-env lock
because the injected budget is process-global).

Signed-off-by: asto18089 <asto18089@126.com>
The kill_on_drop fix relied on the timeout dropping the Command::output()
future to kill the interpreter, but that is not how tokio behaves:
output() moves the Child into wait_with_output(), and dropping that
future mid-wait does not run the kill - a minimal reproduction (spawn
node with kill_on_drop(true), wrap output() in a timeout, let it elapse)
leaves the child alive. So js_execution, code_execution, and the plugin
runner still orphaned their interpreters on timeout; the audit claim
'the interpreter child is killed on timeout' did not hold. Only a plain
drop of an owned Child handle kills via kill_on_drop.

Restructure all three sites: spawn explicitly, keep the Child handle,
drain stdout/stderr with concurrent tasks (so a child blocked on a full
pipe buffer cannot turn the timeout into a guaranteed kill), and kill +
reap explicitly when the deadline fires. kill_on_drop stays as the
cancel-path backstop, where the owned handle really is dropped.

Both interpreters get regression tests that capture the child's pid and
assert it is gone after the timeout (previously these fail: the child
survives the kill).

Also pin the RLM sub_query_timeout_secs wiring with a hanging-child
test asserting the configured budget governs the deadline.

Signed-off-by: asto18089 <asto18089@126.com>
The minimal example still showed the retired 60s execute default, and
neither the field list nor the Chinese reference explained how the
budgets interact: execute_timeout bounds a whole tool call (a server is
silent until its tool finishes, so slow tools raise this knob), while
read_timeout bounds the response wait of quick requests and a tools/call
wait is automatically widened to at least its execute_timeout.

Signed-off-by: asto18089 <asto18089@126.com>
Signed-off-by: asto18089 <asto18089@126.com>
… tool-timeout floor

Raising the sub-agent tool timeout default to 1800s moved the
tool-timeout heartbeat floor (tool_timeout + 30) above two stale test
expectations that predate it: a 900s api timeout no longer dominates
the floor (1830 wins, and that is the point - cleanup must never fire
before a 1800s tool finishes), and the /config subagents status shows
1830 for the api-timeout-0/heartbeat-1 scenario. Update both, keep the
'heartbeat follows a genuinely long api timeout' scenario by adding a
maxed-out api timeout case (3600s api floor wins up to the heartbeat
clamp), and document the dominance in comments.

These surfaced only now because the original commit's compile break
masked every test behind it; CI's full-workspace run caught them.

Signed-off-by: asto18089 <asto18089@126.com>
git init and the date-pinned commit-tree ran as bare .output() calls
with no timeout — commit-tree is reachable from the per-turn prune
path — while the stale-lock doc comment claimed every git invocation
in this module is bounded. Both now go through the same bounded core as
run_git, making that comment true.

The core itself closes two ways the 300s bound could be escaped: the
success-path readers are joined under a short grace (a hook or gc pack
worker that inherited the pipes could otherwise hold read_to_end, and
with it the turn pipeline, indefinitely — partial output is returned
with a note on stderr), and the kill's reaping wait moves to a detached
thread, because on a hard-wedged mount git dies only when its
uninterruptible syscall returns and a blocking wait() would hang the
pipeline exactly like the wedged git would.

Readers stream into shared buffers so a grace expiry keeps what was
captured instead of losing it with the thread. A timed-out git also
reaches the user's stderr through the once-per-workspace snapshot
notice now — the fresh index.lock it leaves behind fast-fails
snapshots for about an hour, and that must not be log-only.

Signed-off-by: asto18089 <asto18089@126.com>
The bridge client was built from the platform builder with no timeouts
at all — the same shape commit b32fc69 fixed for the upstream proxy
in this crate, one module away. The inner bridge lock is held for the
whole turn, so one request to a live-but-wedged runtime child queues
every later JSON-RPC message behind it forever.

The client gets a 10s connect budget; the bridging POSTs (enqueue and
return by design) get a 1800s total matching the TUI non-streaming
envelope; and the SSE event stream gets a 60s per-chunk idle bound that
re-arms every chunk — the runtime emits 15s keepalives, so this catches
a wedged stream without total-capping a live one (a per-request total
would ride the body and hard-cut long turns, the same trap the model
client's stream-open path avoids).

Signed-off-by: asto18089 <asto18089@126.com>
The heartbeat docs still resolved the default to 630s from
api_timeout_secs alone and never mentioned the tool-timeout floor the
code has applied since the sub-agent tool timeout moved to 1800s;
operators reading SUBAGENTS.md or CONFIGURATION.md compute 630 and
observe 1830 from /config with no documented rule. Both languages now
state both floors.

RUNTIME_API.md presented runtime shutdown as an active approval
resolution path, but RuntimeThreadManager::shutdown has no production
caller yet — the contract is now stated as latent until a host wires
it up. The MCP OAuth comment in handlers.rs still said five minutes
after the wait moved to fifteen.

Signed-off-by: asto18089 <asto18089@126.com>
call_method awaited self.send outside every budget: a server that
wedged without draining stdin blocked write_all forever — the same
liveness hole the per-request read budget closed on the receive side.
The send now shares the request budget; a timed-out partial write
desyncs the line protocol, so it routes through finish_guarded_error
like the read-side timeout and the caller reconnects.

Two wiring tests pin the receive leg's widening and the send bound at
the call_method level, not just in the pure functions: a server that
answers slower than a small read knob but well inside a raised
request budget must survive, and a stalled write side must hit the
budget with a send-specific error.

Also make the 900s OAuth browser wait consistent everywhere: the tool
description and two comments still said five minutes, and the
synthetic-tool test pinned the old wording.

Signed-off-by: asto18089 <asto18089@126.com>
The non-streaming envelope tests inject a process-global 250ms budget;
tests that issue real non-streaming requests without holding the test
env lock can run concurrently with that window, and a localhost round
trip over 250ms under CI load fails them spuriously. Inject 2s with
scaled server delays instead — the pins are unchanged, only the
safety margin.

Signed-off-by: asto18089 <asto18089@126.com>
The 600s total arrived without a connect timeout, so a black-holed
host ate the whole exec budget stalling in TCP connect while every
other client this PR bounds fails on the 10s connect family.

Signed-off-by: asto18089 <asto18089@126.com>
The PANDOC_TIMEOUT constant was inserted between PandocConvertTool and
its doc comment, leaving the tool undocumented and the rustdoc lost.
The image_ocr timeout disclosure now also mentions the orphaned
tesseract child a timed-out call leaves behind, not just the blocked
thread.

Signed-off-by: asto18089 <asto18089@126.com>
A drain-grace expiry on the success path dropped the reader
JoinHandles, and the timeout path detached them outright: each reader
thread stayed blocked in a pipe read for as long as a grandchild held
the write end, so a hook or subprocess that daemonized leaked one OS
thread per pipe per git call. The regression only backgrounded a
sleep, so the leak self-healed after 30s and the coverage hole went
unnoticed.

The drain loop is now cancellable on both platforms — a non-blocking,
polled read on Unix, a PeekNamedPipe probe loop on Windows — and both
exits cancel and join the readers, so the joins are bounded by one
poll interval and the call provably leaves no reader threads behind.
A live-reader counter makes the join observable to the new regression,
which drives two calls with a never-exiting grandchild and fails
('4 still running') if the join reverts to a detach.

Signed-off-by: asto18089 <asto18089@126.com>
The refactor that routed run_git through the bounded drain core moved
the --git-dir/--work-tree sinks, and code scanning re-flagged the
unchanged flow as a new command-line-injection alert, turning the PR
gate red on a finding that already exists as two open alerts on
pinvou3-clean. Both paths now ride git's documented GIT_DIR /
GIT_WORK_TREE environment interface — the same mechanism and the same
never-touch-the-user's-repo guarantee, with the paths out of argv so
the accepted flow stops re-flagging on every refactor.

Signed-off-by: asto18089 <asto18089@126.com>
CodeQL's PR analysis kept re-flagging run_git because the flow it
reports starts at the /v1/snapshots/{id}/restore route: the request
path id reached git as a treeish, so every refactor of the sink
re-registered the finding instead of matching base alert Hmbown#198. The
restore endpoint now resolves the id against the snapshots the side
repo actually knows and 404s on anything else — the allowlist contains
is the scanner's modeled trust boundary, and unknown ids no longer
reach a git command line at all.

Signed-off-by: asto18089 <asto18089@126.com>
ToolHeartbeat was missing from the persist-urgent exclusion list, so
every ~200ms heartbeat during a silent build rewrote the whole task
record while holding the manager-wide state lock — contradicting the
variant's own 'never persisted' contract. Also comment the disclosed
compaction blind spot at the in-flight tracking set.

Signed-off-by: asto18089 <asto18089@126.com>
A timed-out send can leave a partial line in the pipe, and a timed-out
outer read wait can still be answered late — both desync the line
protocol for the next request, yet neither path marked the connection
Disconnected, so the pool handed the broken transport right back out.
Both arms now mark it dead (matching the inner read timeout) and the
comment states the real behavior; wiring tests pin the state.

Signed-off-by: asto18089 <asto18089@126.com>
The once-per-workspace snapshot notice was wired only to open_or_init
errors, so the motivating case — a wedged 'git add -A' timing out and
leaving a fresh index.lock — stayed log-only, the exact silence the
notice exists to prevent. Session and prune failures now reach it too;
the TimedOut kind gate keeps it to the cases that matter.

Signed-off-by: asto18089 <asto18089@126.com>
git init was the one command not cleared of an ambient GIT_DIR /
GIT_WORK_TREE exported by the launching shell, which could redirect the
one-time init away from the hashed side-repo path; every later command
would then fail against the intended repository. Also fixes the stale
comment that still described flag-based path routing.

Signed-off-by: asto18089 <asto18089@126.com>
The drain-grace truncation note lands on stderr, but the plugin surface
reports stdout only, so an unparseable, possibly cut-off output passed
as a silent success. The note is now a shared constant with a probe,
and the plugin arm fails loudly when the output did not parse and the
drain was truncated.

Signed-off-by: asto18089 <asto18089@126.com>
The wait's biased select favors the cancel token, so a decision already
queued at the instant of an interrupt or shutdown was discarded and
reported as an interrupted deny — contradicting the documented contract
that a decision the user actually made never carries interrupted. Both
exit arms now rescue a queued decision via try_recv; a test pins the
race deterministically.

Signed-off-by: asto18089 <asto18089@126.com>
A runtime child that accepts the bridge's event-stream GET but never
writes response headers held the bridge lock forever — the queue-forever
scenario the client bounds were meant to remove, surviving through the
headers phase. The header wait now shares the 60s chunk idle deadline;
the live stream itself keeps no total.

Signed-off-by: asto18089 <asto18089@126.com>
The 600s constant claimed to match the Bash tool's foreground cap, but
the Bash cap is effectively unbounded — the real parity class is the
interpreter tools' 600s execution cap. State that, and note the bound
is a transport backstop rather than a per-command policy.

Signed-off-by: asto18089 <asto18089@126.com>
The 1s budget against a 1.2s answer left the engine's pre-pause work a
hair of headroom on a loaded CI host. The margins move to 2s/3s; the
exclusion semantics the test pins are unchanged.

Signed-off-by: asto18089 <asto18089@126.com>
The MCP OAuth login comment still said five minutes (now 900s), and
SUBAGENTS.md presented tool_timeout_secs as a resolvable config key
when only api_timeout_secs exists — the tool timeout is a compile-time
constant. RUNTIME_API.md now states the restore endpoint's
exact-match/404 contract.

Signed-off-by: asto18089 <asto18089@126.com>
@asto18089
asto18089 merged commit d349f25 into pinvou3-clean Sep 22, 2026
12 checks passed
@asto18089

Copy link
Copy Markdown
Collaborator Author

Round-6 fresh review: rebase onto merged #67 + 15-area independent re-review + fix wave

Rebased onto 6f780290f. One organic merge conflict: the vision test module, where base #67 added the pixel-dimension/BMP-degrade tests in the same region this PR rewrites — both sides are kept, and the PR's two envelope tests now reuse the base tool_with_base_url helper instead of a duplicate config builder (all 15 module tests pass; base test bodies byte-identical). The rebased head ran a fully green CI pass (including check, CodeQL, and the sync conflict gate clearing) before the fix wave below was pushed on top.

Verdict: the core thesis survives a fully fresh adversarial pass. Fifteen parallel review tracks re-derived the claims from source and ran the targeted suites (approval/runtime-threads 9, task_manager 51, snapshot/repo 38, tools::process 2 + js 10 + plugin 19, mcp 214, vision 15, client 165, rlm 67, app-server 100, web 91, runtime_api 185 — all green). No BLOCKERs. The pass found one real regression, one real correctness hole, and a batch of contract/comment inaccuracies; all are fixed in the 11 commits now pushed. Two cross-area findings re-confirmed as disclosed debt rather than defects.

Fixed in this push

  • e4c47e237 exec regression (the one true blocker-class find): the headless event loop auto-resolves approvals but had no UserInputRequired arm; with the wait now unbounded, a headless run whose model asked a clarifying question parked forever. It now cancels the request (cancelled error to the tool, turn continues) with a one-time stderr note.
  • 7b82f3dcc partial git init wedged snapshots forever: needs_init keyed on directory existence; a killed init leaves .git without HEAD on exactly the wedged-mount scenario this PR targets, and every later snapshot fast-failed. open_or_init now uses open_existing's readiness predicate; re-init is idempotent.
  • 484c1dc6f two approval-exit hardenings: (1) a dead monitor task stranded the pending approval — the map leaked and no approval.decided fired, contradicting the documented contract; settle_claimed_turn_failure now settles this turn's approvals like the interrupt exit. (2) deliver_external_approval removes the map entry before sending, so a real decision could land after both rescue checks saw Empty and be reported as an interrupted deny; the interrupted exit now makes a final try_recv and routes a found decision through the decision path.
  • a689b703d heartbeats took the manager state lock for a no-op and set dirty, deferring real mutations' persistence past the debounce window for the whole silent tool; short-circuited before the lock.
  • 54e1f88e4 vision: envelope timeout is now ToolError::Timeout (taxonomy parity with every other bounded tool; the existing "timed out after" assertions still pass), and the runtime-chat inference permit is acquired inside the envelope — the ownership window is part of the bounded call, so a contested permit can no longer park the tool outside every total bound.
  • cf10b3cbc client: the envelope-exceeded branch now runs maybe_probe_recovery; a provider that just wedged for 30 minutes is exactly when the /models health probe matters.
  • a0f109364 app-server, three adjacencies: a stream that dies mid-turn leaves a best-effort interrupt (retries no longer hit "already has an active turn" until the orphan completes); the /health boot probe bounds each attempt (an accept-and-never-write-headers child used to hang it forever, respawning a child per message); the proxy drops the config read guard before the upstream round trip instead of holding it (write-preferring lock) for up to 30 minutes.
  • 04bd7968a tools::process: the wait()-error exit aborted nothing; drains and the stdin writer now abort before propagating. Module doc states the EPIPE contract is Unix-only.
  • 83dc99763 rlm: with_sub_query_timeout_secs(0) built an instantly-firing timeout (now floored at 1s); the session default references CHILD_TIMEOUT_SECS instead of a duplicate literal; the inline-round comment no longer implies a single minutes-long sub-query survives a path where each one is still bridge-bounded.
  • bc447c1f0 docs/comments: stale-lock doc no longer overclaims safety vs a D-state holder (and names why removal stays benign); bounded-git comment names the real sandbox-CLI gap instead of claiming to mirror it; OpenSandbox comment matches its own rationale; fetch-envelope comment discloses the pre-envelope SSRF pre-flight; update.rs arithmetic is honest (~58 MiB at 100 KiB/s); MCP execute-timeout comment discloses the per-leg worst case; the 1800s definitions in core / subagent-limits / MCP / dynamic-tool now cross-reference their family; RUNTIME_API.md restore contract corrected (any known snapshot, non-matching id never reaches a git argv, real 40-hex example id, approval.timeout marked legacy, "every resolution" scoped to forced resolutions).
  • 7a03db628 test pin: HARD_MAX_TIMEOUT == 300_000 ms asserted beside the coherence invariant, so the fetch schema's advertised max cannot drift; the reader-leak regression now asserts its own reader delta against a pre-loop baseline instead of a process-global zero (unrelated concurrent git calls can no longer fail it).

Re-confirmed as disclosed debt (unchanged)

  • Background tasks still bound human waits with the 30-minute task wall_time, which auto-denies a pending decision at expiry — disclosed in the body; this pass additionally confirmed the new heartbeat suppression means base's 2-minute idle kill no longer fires either, so the wall clock is the only remaining bound there. Fixing it safely needs wait-window detection in ExecutionGuard; left as the stated follow-up.
  • The three coincident 1800s budgets (task wall / sub-agent child wall+tool timeout / MCP execute) resolve by outermost-wins; inside tasks a raised MCP execute_timeout above 1800s is clipped and the SUBAGENTS.md heartbeat-floor guarantee does not hold. The body documents the equal-budget trade-off; the MINOR-1-style clamping idea (effective inner budget = min(configured, remaining outer)) is a real follow-up candidate but changes budget semantics, so not smuggled in here.
  • Snapshot pre-git work is still unbounded blocking on a wedged workspace mount (workspace.canonicalize(), first-init size walk) — the hang predates this PR, but the new "degrades instead of hanging" claim covers only the git calls. The honest scope is now stated in the code; a bounded-canonicalize helper is the natural follow-up.
  • Anthropic non-streaming envelope still has no direct regression test (the body's old wording implied a weaker test existed; wording now corrected via the round section). The full-suite parallel-load flakes and the remaining documented gaps in "Notes for reviewers" stand as written.

Verification

  • cargo fmt --check, cargo clippy --all-features --locked -- -D warnings clean for both touched crates; cargo check --tests --locked clean.
  • All targeted suites listed above green after the fixes; the 9 approval tests confirm the rescue restructure keeps approval_decision_queued_before_shutdown_is_honored_not_interrupted passing, and the snapshot suite confirms the new init predicate plus the reader-test rescope.
  • Cosmetic, for a future rebase only: f7921abe1 carries its Signed-off-by trailer mid-body; DCO passes, but the trailer should close the message.

asto18089 added a commit that referenced this pull request Sep 23, 2026
#77)

The bounded-child regression test in tools/process.rs is #[cfg(unix)]
(it drives a real `sh` and a backgrounded grandchild holding the pipes),
but the module-level `use super::*` and the `shell_command` helper it
feeds on stayed ungated. Under cfg(test) on Windows the test is compiled
out and the leftovers trip `-D warnings` (unused import + dead code),
which is how pinjou-agent#595's windows-rust-test caught it after the
2026-09-22/23 batch landed (#63 introduced the module).

Gate both items with #[cfg(unix)] so the module is empty rather than
broken on Windows; no behavior change on unix targets.

Signed-off-by: asto18089 <asto18089@126.com>
asto18089 added a commit that referenced this pull request Sep 27, 2026
A durable task whose turn hit an approval or user-input prompt was still
bounded by the 30-minute task wall_time, which interrupted the turn at
expiry and denied the pending decision on the human's behalf. The window
is now excluded from the wall clock (like the engine-side turn budget),
the idle clock pauses and restarts fresh when the window closes, and the
exclusion is bounded by a fail-safe cap. Three design holes close the
gaps the first landing shipped with:

- Answerability: the pause applies only where a host can actually
  deliver the decision. The runtime API's task threads are served by
  HTTP decide_approval/submit_user_input and pause; the TUI's private
  task runtime has no such channel, so a prompt there never arms the
  window and the wall clock releases the worker at its own deadline
  (before, it parked the worker for the 24h cap).
- Aggregate bound: the cap now bounds the windows summed across the
  whole task, not only each open window — a task chaining short prompts
  can no longer sum its way past the cap once per prompt.
- Park-poll backoff: while a window is parked the journal poll backs
  off from the 200ms catch-up cadence, doubling up to 2s; an arriving
  decision broadcasts on the subscription and wakes the loop early, so
  the backoff only removes the empty polls.

Pinned by red-green tests: an unanswerable surface keeps the wall clock
running (mutation check fails without the gate), chained short waits
sum to the cap and terminalize between windows (fails without the
hoisted aggregate check), and every wait-writer surface that arms the
window still completes on a late answer.

Revert "revert(tasks): move the human-wait clock pause to its own PR"

This reverts commit ce5a01fe93fde5a17e94f44b9127d3c00d0782c2.

feat(app-server): resume a dropped event stream before interrupting

A dropped SSE connection is not evidence the turn died — the runtime's
lifecycle task keeps it running — yet the bridge interrupted the turn on
first stream failure. That killed healthy turns: an idle stall on the
child's event stream (bounded at 60s) or any transient transport error
ended the turn even though the model was fine.

stream_turn_events now reports how the connection ended via StreamDrop:
the last consumed seq and whether the failure was the streaming writer
itself. message_thread reconnects with since_seq=last_seq — the runtime
treats since_seq as exclusive, so the replay picks up exactly the events
that never arrived, and no delta is ever replayed to the client — for up
to MAX_STREAM_RESUMES (2) further connections. A writer failure (the
peer is gone; no resume can ever deliver an event to it) or an
exhausted resume budget falls through to the existing best-effort
interrupt, which remains the only cure for a genuinely wedged child.

Tests pin all three endings against a scripted-drop mock runtime: a
mid-stream drop resumes and completes with no duplicate deltas and no
interrupt, exhausted resumes interrupt the orphan exactly once, and a
writer that dies after response_start skips the resume entirely.

fix(tools): kill the child's process group on timeout, not just the child

The observed leak: a timed-out run killed only the direct child, so a
command the shell had forked (`... & sleep 60`) outlived the call — every
timed-out gate run left a live orphan, and so did js_execution, plugin,
and code_execution through the shared runner.

- run_bounded_child now spawns each child with process_group(0) (the
  same shape the shell tool uses) and its timeout path SIGKILLs the
  whole group via kill(-pgid), then reaps. The group is only signalled
  on the timeout path; a successful run never touches it, so an
  intentionally-detached `&` process survives — pinned by test.
- The core of the runner is now run_bounded_child_observed, which also
  harvests the pipes after the kill: the partial output the interpreter
  produced before the budget is returned with the timed-out result
  instead of being discarded. run_bounded_child stays a thin wrapper
  reporting ToolError::Timeout, so the existing callers keep their
  semantics.
- The gate task had its own runner — timeout(cmd.output()) +
  kill_on_drop, which could not take out a forked command. It now runs
  on the shared bounded runner: timed-out gates die as a whole group and
  carry the output printed before the kill, a Unix group-killed child
  reports the conventional 137 exit code, and spawn_error is surfaced in
  the result metadata so a gate that cannot even spawn remains
  structured evidence instead of a raised tool error.

Regression tests assert the grandchild itself is dead after the timeout
(in the runner and in the gate), that the pre-kill output survives the
group kill, and that a detached success run leaves its background
process alive.

fix(runtime): close the approval no-resolution windows

Three windows could strand a decided approval without a published
resolution, or let the settler race a late-delivered decision:

- deliver_external_approval dropped the entry even when the waiter's
  receiver was already closed, so a decision posted a moment after the
  turn settled would be reported delivered while the settler had
  already emitted its own deny+interrupted. The entry now stays for the
  settler to reclaim; delivery to a closed receiver reports false and
  the count stays put.
- The settler ran the in-loop rescue concurrently with delivery; a
  decision sent between close() and try_recv() could be observed by
  neither side. Rescue now happens at a single point after the loop,
  ordered after any concurrent delivery, and the entry is cleaned up
  only when its sender is closed (cancel_closed_pending_approval), so a
  live approval reusing the id can never be removed by a stale waiter.
- The Decision(Err(_)) and Interrupted arms folded into one: both mean
  "no decision reached", and both emit exactly one deny+interrupted
  resolution.

Covers the contract with red-green pins: a dead-receiver delivery
never counts, the pre-close decision is honored by the rescue, the
post-settle send on a reused id never removes the live approval, and
settlement publishes exactly one resolution. RUNTIME_API.md now states
the delivery contract outright.

fix(snapshot): heal every half-written side repo a killed init leaves

The HEAD-based readiness predicate still missed two states an
interrupted `git init` produces (reproduced with git 2.54):

- HEAD written but `objects/` not yet created: the predicate called the
  repo ready, so no re-init ran and every snapshot failed with "not a git
  repository". Readiness now also requires `objects/` and `refs/`; a
  re-init fills in whatever is missing.
- A `HEAD.lock` left by a kill during the HEAD write: `git init` then
  fails with "cannot lock ref 'HEAD'" on every attempt. It is swept on
  the same staleness rule as `config.lock`.

`list()` also mapped every failed `git log` to "no restore points", so a
broken side repo read as an empty history in `/undo`, `/restore`, export
and the runtime API. Only an unborn HEAD (`rev-parse --verify` exit 1) is
empty now; anything else is an error.

The timed-out-init hint no longer promises a re-init on the very next
attempt: a fresh lock left by the killed init blocks it until the
stale-lock sweep may clear it.

docs: complete the 1800s family roster

The anchor comment claimed the full roster but missed three members with
the same 1800s value: the background-task wall clock
`TaskExecutionLimits::wall_time`, the sub-agent `default_wall_time_secs`,
and the fleet `builder` role preset. Both wall clocks preempt the tools
in the family, so a family-wide bump that skipped them would change
nothing. Also note that `STREAM_MAX_DURATION_SECS` is the one member user
config can override.

test(client): hold the env lock on the Anthropic endpoint tests

The Anthropic dialect now resolves its non-streaming budget through the
process-global `non_streaming_request_envelope()`, which
`NonStreamingEnvelopeGuard` overrides (a neighbouring test injects 1s).
Two tests on the same path did not take `lock_test_env`, so a parallel
injection could cut their request off mid-flight.

fix(tools): abort the interpreter drains on the cancel path too

`abort_drains` promised that every exit that stops waiting on the child
calls it, but the most common non-success exit cannot: on a turn
interrupt the whole tool future is dropped, and dropping a `JoinHandle`
detaches its task instead of aborting it. The drain tasks then kept the
pipe read ends (and their growing buffers) alive for as long as a
pipe-inheriting grandchild held the write ends — the exact leak the
module doc describes.

Own the three handles in a `DrainTasks` guard that aborts in `Drop`, so
the cancel path behaves like every other exit; the grace-expiry exit
still aborts explicitly so the abort is ordered before the buffer
snapshots.

fix(snapshot): stop a failed re-init from disabling snapshots silently

A killed `git init` writes `core.repositoryformatversion` under
`.git/config.lock` before it creates HEAD, so the interruption that
leaves a HEAD-less side repo can also leave that lock. Git never ages
lockfiles out, so every later `git init` failed with "could not lock
config file" and the HEAD-based re-init could never succeed. Sweep a
stale `config.lock` before the init, on the same age rule as
`index.lock`.

That failure carried `ErrorKind::Other`, and the degraded-snapshot notice
only passes size-gated and `TimedOut` errors, so a permanent loss of undo
history reached only the tracing log. Give init failures their own notice
arm and hint, and build the hint router's needles from marker constants
exported by `snapshot::repo`, so the hint tests really pin the producer
text instead of hand-copied literals.

Run the first-init size walk only when `.git` does not exist at all:
while a repair kept failing, `needs_init` stayed true and every snapshot
attempt re-paid a walk of up to 200k entries.

`run_bounded_fs` now spawns with `Builder::spawn` (it abandons a thread
per expiry, so thread exhaustion is the failure it is most likely to meet
and `thread::spawn` would panic), reports a panicking probe as such rather
than as a wedged mount, and renders sub-second bounds as milliseconds.
`run_bounded_git`'s doc comment, which the `git_stderr_tail` insertion had
attached to the wrong function, moves back. The reader-leak test samples
a settled baseline so transient readers from parallel tests cannot mask a
real leak.

fix(app-server): run the orphan-turn interrupt on every turn surface

The post-stream-failure interrupt only ran when `registered_turn` was
Some, which is built only for interruptible (stdio `thread/message`)
turns — the one surface that already has a manual `thread/interrupt`.
HTTP `/thread` Message, `/prompt` and stdio `prompt/*` turns have no
client-reachable interrupt at all, so after a dropped stream their
thread rejected every later message with "already has an active turn"
until the orphan finished on its own.

Everything the interrupt needs is in scope on every surface, so build it
unconditionally; only the registry insert stays stdio-only, because only
that surface can ask for a cancel by thread id.

revert(tasks): move the human-wait clock pause to its own PR

The task wall/idle clock pause (9e667d1fc) is a task-lifecycle behavior
change rather than a review fix: it adds a terminal reason, two public
execution events and a public limits field, and it changes what
`wall_time` bounds. As landed it also regresses the TUI: TUI tasks run on
a private runtime-thread manager that no client can deliver an approval
or user-input answer to, so an unanswered prompt now holds a worker for
the 24h cap instead of releasing it at the 30-minute wall.

Take it out of this follow-up wave so it can be reviewed and fixed on its
own. The heartbeat short-circuit (1feed0794) stays, with its comment
corrected: a spurious dirty mark only arms a redundant flush. The
SUBAGENTS docs paragraph now states the real bounds on a long tool: the
child's own wall time and, inside a task, the task wall clock, which runs
while a prompt waits on a human.

fix(snapshot): bound the safety classifier's re-canonicalization

The open path bounded its workspace probe but then ran
unsafe_workspace_snapshot_reason inline, which re-canonicalizes the
workspace and HOME (twice) with plain syscalls — another unbounded
pre-git window on the same mount, exactly where the workspace probe
just bounded. Any post-probe wedge in that window parked the open
forever, so the 30s boundary the PR establishes never actually
closed.

Put the whole classifier behind one run_bounded_fs bound via
snapshot_safety_reason_bounded; the classification semantics are
untouched. A regression test pins that the bounded classifier agrees
with the inline one on every anchored classification (HOME, Desktop
collection, unrelated dir); the timeout leg is the pass-through
already pinned by bounded_fs_probe_times_out…, since a genuinely
wedged mount cannot be induced in a unit test.

docs: list the model stream cap in the 1800s family roster

STREAM_MAX_DURATION_SECS shares the family's value and generation but
was absent from the roster the family's comments keep in sync, so a
family-wide bump could silently leave it behind. List it at the anchor
and cross-reference the roster from the constant.

test(tools): widen the gate-kill deadline for login-shell startup

The 800ms deadline raced the login shell's profile sourcing on slow
runners: the child could be killed before echo landed its pid and the
expect would panic. Five seconds still sits far below the sleep-60
payload the deadline exists to cut off.

fix(snapshot): key the wedge hints to their producer text

The degraded-snapshots notice selected its remedy hint inline; a
timed-out git init (which leaves no index.lock and now heals by
re-initing on the next attempt) was advised to wait out a stale lock
for about an hour. Extract the selection into snapshot_failure_hint,
give the init timeout its own re-init hint, and pin every branch
against the producer message text with unit tests.

fix(snapshot): honor cap=0 and keep the init-timeout kind

- The first-init size walk ran even when cap_bytes was 0, the
  documented max_workspace_gb=0 opt-out, so a workspace too large to
  walk within SIZE_WALK_TIMEOUT failed init with a misleading
  wedged-filesystem error and re-paid the failing walk on every later
  attempt. Skip the walk, timeout included, when the gate is disabled.
- A timed-out git init was re-wrapped with io::Error::other, dropping
  the TimedOut kind the turn pipeline's degraded-snapshots notice
  gates on — the exact swallow the size-walk re-wrap eight lines up
  documents as the contract. Preserve the kind.
- Extract the stderr-tail construction into git_stderr_tail and pin
  the truncation semantics (last N characters, ellipsis, multibyte
  safety) with unit tests.

fix(runtime): make the approval exits race-free by construction

- The interrupt exit's last-chance rescue dropped the oneshot receiver
  after a final empty check, but a send racing that window could still
  land on the alive receiver, report delivered to the HTTP layer, and
  then be discarded by the waiter in favor of the interrupted deny.
  Close the receiver before the final try_recv: a racing send now fails
  immediately (reported not-delivered) while a value that landed before
  the close stays readable, so a decision is either rescued or never
  promised.
- settle_claimed_turn_failure scanned the pending-approval ids under
  the lock and removed them one by one afterwards; a concurrent
  delivery could steal an entry between the two steps, fail its own
  send on the monitor-dead receiver, and leave the approval with no
  resolution event at all. Collect and remove in one lock hold, and
  pin with a test that the publish fires even when the deny cannot
  deliver.

docs: align the timeout family and fix claims

- The four 1800s-family comments each listed a different subset and the
  anchor itself was missing two members. Anchor the full roster at the
  core dispatch backstop's comment and point the other three at it.
- The heartbeat short-circuit's second claimed benefit was not real:
  the run loop rebuilds the debounce timer on every event, so real
  mutations wait out the same starvation with or without the spurious
  dirty marks. Say what the short-circuit actually buys (no manager
  lock per tick, no redundant flushes) and state the limitation.
- Document the merged-window premise of the human-wait guard: windows
  only ever serialize today because the engine blocks inside one
  prompt at a time; nested prompts need paired windows first.
- Correct the sandbox transport comment: local foreground Bash clamps
  at the same 600s, the contract bash timeout can run far longer, and
  the external-backend path never consumes the tool timeout at all.

refactor(tools): extract the shared drain aborts

The five-line abort block was copied across all three exits of
run_bounded_child, and the wait-error exit is exactly where a copy was
missed before this PR fixed it. Fold the aborts into one helper so the
next exit path cannot forget them, and replace the module doc's pointer
at the PR-level Windows disclosure with the substance inline — the
lingering blocking-pool read ends when the grandchild exits, which
bounds the leak by the grandchild's own lifetime.

test: pin the timeout follow-up fixes

- client: the envelope-exceeded exit must fire the /models recovery
  probe once the connection has degraded — two stalled requests then
  exactly one probe, verified end to end (the probe used to leave
  health bookkeeping untouched).
- runtime_threads: monitor-death settlement must publish the stranded
  approval as deny+interrupted, clear the pending map, and resolve the
  decision channel.
- snapshot: HEAD-less side repos re-init and snapshot cleanly (see the
  previous commit).
- rlm: the sub-query budget setter floors 0 at one second.
- vision: pin the ToolError::Timeout variant, not just the message —
  the pre-fix execution_failed form shares the "timed out after"
  substring, so the old assertion survived a classification regression.
- web: bind the fetch_url schema text's "max 300,000" to
  HARD_MAX_TIMEOUT; the constant alone never caught schema drift.
- vision: reword the envelope comment — retries do fire for slow
  failures, they just rarely fit before the shared budget expires.

fix(snapshot): report wedge failures as errors

Three wedge paths were quieter than the follow-up claimed:

- open_existing swallowed a timed-out workspace probe into Ok(None),
  which both read-only consumers render as "no restore points" — the
  exact silent omission their Unreadable arm exists to avoid. Surface
  the probe failure as Err so users see "could not be opened" plus
  the reason.
- The first-init size-walk timeout was re-wrapped with
  io::Error::other, dropping the TimedOut kind the turn pipeline's
  degraded-snapshots notice gates on; the user-visible stderr warning
  was lost to tracing. Preserve the kind.
- That notice's hint always blamed a stale index.lock, which no pre-git
  probe ever created. Pick the hint by failure stage.
- The wait-error exit of the bounded git core now kills the child too
  (std handles have no kill_on_drop).
- Pin the HEAD-less side-repo recovery with a regression test: a .git
  without HEAD must re-init and snapshot cleanly again.

fix(runtime): close the approval delivery race

The interrupted-approval exit checked the oneshot one last time and
then walked two real await points before dropping the receiver, so a
decision delivered in that window still reported delivered=true while
the monitor published the interrupted deny over it. Drop the receiver
as soon as the final check comes up empty: the arms below never read
it again, and a send that lands afterwards now fails on the dropped
receiver and reports not-delivered, making the documented invariant
literally true.

Also point the dynamic-tool wait comment at the anchored 1800s family
roster instead of re-listing a stale subset.

docs: disclose the per-leg MCP budget and the task wall clock

- MCP.md (en+zh): execute_timeout applies per leg (send, then read),
  so a server that drains its input barely within the budget can push
  the total toward twice the configured value.
- SUBAGENTS.md (en+zh): inside a durable task the heartbeat-floor
  guarantee is additionally bounded by the task wall_time — the wall
  clock pauses for pending human prompts (24h fail-safe per prompt)
  but otherwise wins over an in-flight sub-agent, so raising the tool
  or execute budget above the remaining task wall has no effect.

test: harden the timeout regression pins

- client: widen the two sub-3x timing margins (the stream-open probe
  answers at 4s against the 2s injected envelope; the pinned-total
  probe answers at 4s against a raised 12s pinned budget), so a slow
  runner cannot blur red-state detection.
- rlm: give the hanging-child budget test an outer 5s deadline so a
  regression back to the 120s const fails in seconds instead of
  hanging the suite.
- tasks: regression test that the gate child is actually killed and
  reaped at the gate deadline (the kill_on_drop claim previously
  rested on static reasoning); removing the flag fails the test.
- mcp: pin the engine-side stdio proxy's budget wiring (tools/call at
  1800s, separate from the 120s generic request budget) — the proxy
  has no behavioral timeout tests, so the constants are the seam.

fix(client): route the Anthropic budget through the test seam

The Anthropic dialect's non-streaming request read the raw
NON_STREAMING_REQUEST_ENVELOPE constant, so no test could shrink the
budget and the cutoff was pinned by nothing (the PR body's earlier
round implied a weaker test existed where none did). It now goes
through the same injectable accessor as every other non-streaming
completion, with a wiremock test pinning that a provider answering
past the envelope is cut off and that the failure reads as a timeout.

fix(mcp): state the send budget instead of the elapsed display

The send-timeout error embedded tokio's Elapsed display ("deadline
has elapsed") next to the already-stated budget; drop it and keep the
budget figure, which is the actionable part.

fix(runtime-api): forward the approval posture to compat streams

The auto-review fail-closed producer emits posture: auto_review, but
the compat projection dropped it, so those clients could not tell an
execution-policy denial from a plain auto-deny (the raw event stream
always carried it). Forward it and pin it in the projection test.

fix(runtime): publish the channel-closed approval exit

The Decision(Err) arm — the decision channel closing mid-delivery —
resolved the wait by denying the engine without emitting
approval.decided, the one exit that made the documented
every-resolution-published contract false. It now publishes like any
other forced resolution (deny + interrupted), and the doc sentence
softened for it in the previous round is restored to the universal
claim, which now holds.

fix(snapshot): bound the pre-git probes and carry the wedge evidence

The bounded git core never ran before two plain blocking calls had
already parked the turn pipeline: workspace canonicalize (twice —
once in open_or_init, again inside snapshot_dir_with_home) and the
first-init size walk, each unbounded, on the very wedged NFS/FUSE
mount the bounds exist for. Both probes now run on a helper thread
under hard bounds (30s path resolution, 120s size walk); a wedge
errors the open into the usual degraded-snapshots notice, the
availability surface reads it as "no snapshots" instead of hanging,
a fast canonicalize failure still falls back to the raw path exactly
as before, and the module claim now covers the whole open path.

Two diagnosability gaps in the bounded core close alongside: the
TimedOut error now carries the last 500 characters of what the child
managed to say (hook and clean-filter diagnostics are exactly what
explains a timeout), and the restore commit-message label slices the
id through str::get instead of a bare byte range — defensive only,
ids are git-produced hex today.

fix(tasks): pause the wall and idle clocks for human waits

A durable task whose turn hit an approval or user-input prompt was
still bounded by the 30-minute task wall_time, which interrupted the
turn at expiry and denied the pending decision on the human's behalf
— the exact failure the timeout audit set out to remove, and the
headline guarantee silently not holding on the background path.

The turn loop now recognizes the human-wait windows from the journal
(approval.required / user_input.required through their paired closing
events) and excludes them from the wall clock exactly like the
engine-side turn budget does, publishing the window as
HumanWaitStarted/Ended so the worker supervisor's guard follows in
lockstep. Silence during a human decision is expected, so the idle
clock pauses too and restarts fresh when the window closes. The
exclusion is bounded by a per-prompt fail-safe cap (24h default,
configurable with the other execution limits) with a dedicated
HumanWaitTimeout terminal reason, so a prompt nobody will ever answer
still releases the worker.

Tests: the wall clock survives a 2x-budget wait and completes on the
late answer; the fail-safe cap releases an unanswered prompt; the
supervisor guard completes a task whose executor waited past the wall
budget.

fix(tests): pin the fetch hard cap against schema drift

Dropping the strict-equality assertion left no test binding the
fetch_url schema's "max 300,000" to HARD_MAX_TIMEOUT; pin the
absolute value beside the coherence invariant.

(cherry picked from commit 7a03db628674e88ba38e216587c40543149f7be8)

docs: correct timeout claims and cross-reference the 1800s family

Comment and contract-accuracy fixes from a fresh review pass:

- The snapshot stale-lock doc no longer claims a lock older than the
  command bound cannot belong to a live writer (a git wedged in
  uninterruptible I/O can), and names why removal stays benign; the
  bounded-git comment names the real sandbox-CLI gap instead of
  claiming to mirror it.

- The OpenSandbox comment matches its own rationale (the Bash
  foreground cap is effectively unbounded), the fetch-envelope
  comment discloses the pre-envelope SSRF pre-flight lookup, the
  self-update comment states the real ~58 MiB at 100 KiB/s, and the
  MCP execute-timeout comment discloses the per-leg worst case.

- The 1800s definitions in core, the sub-agent limits, the MCP
  defaults, and the dynamic-tool wait now cross-reference their
  family, and subagent_limits documents that a task wall_time
  preempts the tool timeout in background tasks.

- RUNTIME_API.md: the restore id must match any snapshot the side
  repo knows (not just the listed window) and the guarantee is that
  a non-matching id never reaches a git command; the example id is a
  real 40-hex SHA; the common-events list marks approval.timeout as
  legacy; and the every-resolution claim is scoped to forced
  resolutions.

(cherry picked from commit bc447c1f06f9a57efdfb09dafccd285deb8fcf3d)

fix(rlm): floor the sub-query budget and share the 120s default

with_sub_query_timeout_secs accepted 0, which builds a tokio timeout
that fires immediately and kills every child query; floor at one
second. The compatibility session's 120s default now references
CHILD_TIMEOUT_SECS instead of duplicating the literal, and the inline
round comment no longer implies a single minutes-long sub-query
survives on a path where the bridge still bounds each one.

(cherry picked from commit 83dc99763a4fdcfc5e8ee75a031acd252f10ebc6)

fix(tools): abort the drains on the wait-error exit too

The child.wait() error path propagated before aborting the pipe-drain
tasks and the stdin writer, letting pipes outlive the call on the one
exit that was neither a timeout nor a clean exit. The module doc also
states the EPIPE contract is Unix-only now.

(cherry picked from commit 04bd7968ac86d7d46cc25661f8f5f48aa3b474e1)

fix(app-server): interrupt orphaned turns and bound the health probe

Three adjacencies the new bounds made reachable or left behind:

- A stream that dies mid-turn (idle/header timeout, transport error)
  left the runtime turn running unattended; a retried message was
  rejected with "already has an active turn" until the orphan
  completed. Leave a best-effort interrupt behind, reusing the same
  bounded POST as the interrupt handler.

- The /health boot probe only checked its deadline after send()
  resolved, so a child that accepts but never writes headers hung
  the probe forever and respawned a child per message. Bound each
  attempt by the remaining deadline.

- The proxy handler held the config read guard across the upstream
  round trip (now up to 30 minutes), blocking config writes and
  every later resolve. Drop it once the endpoint is resolved.

(cherry picked from commit a0f109364dd0e73ca148c54658fad0e84c627226)

fix(client): probe recovery after the request envelope is exceeded

The envelope-exceeded branch marked the failure and returned without
maybe_probe_recovery, so connection health stayed degraded until the
next real request succeeded — exactly the wedged-provider case where
the /models health probe matters.

(cherry picked from commit cf10b3cbcc30270708858e7826cf02bf9f5fd1a3)

fix(vision): classify the envelope timeout and bound the permit wait

The envelope expiry surfaced as execution_failed instead of
ToolError::Timeout, breaking the error taxonomy every other bounded
tool follows; receipts and severity now classify it as a timeout.
Acquire the runtime-chat inference permit inside the envelope: the
ownership window is part of the bounded call, so a contested permit
can no longer park the tool outside every total bound.

(cherry picked from commit 54e1f88e4672dace81c2f2fd1da3f19b09ded41b)

fix(tasks): short-circuit liveness heartbeats before the state lock

ToolHeartbeat never mutates the record, but each tick still took the
manager-wide state lock for a no-op and set dirty=true, which deferred
the persistence of real mutations past the debounce window for the
whole silent tool. Return before the lock; the supervisor guard is
already refreshed by the progress classification.

(cherry picked from commit a689b703dd3c381f49430661c06cc1293e595ca1)

fix(runtime): settle stranded approvals and the delivery race

Two exit-hardening fixes to the external approval wait:

- When the monitor task itself dies, nothing resolved the pending
  approval: the map entry leaked and clients never got the promised
  approval.decided. settle_claimed_turn_failure now settles this
  turn's approvals the same way the interrupt exit does.

- deliver_external_approval removes the map entry before sending, so
  a user decision can land in the oneshot after both rescue checks
  saw Empty and then be reported as an interrupted deny. The
  interrupted exit now makes one final try_recv and routes a found
  decision through the decision path; a send landing after that
  finds a dropped receiver and reports not-delivered.

(cherry picked from commit 484c1dc6f1239efd98e5b70d69fa625e0c2d4b06)

fix(snapshot): re-init after a timed-out partial git init

A killed git init can leave the side-repo .git directory created
without a HEAD, and needs_init keyed on directory existence alone
skipped init forever, fast-failing every later snapshot with "not
a git repository". Use the same readiness predicate as open_existing
git init is idempotent, so re-initializing the partial directory is
safe.

(cherry picked from commit 7b82f3dcc075ab0e7d12cdbe0ecb362866d038f5)

fix(exec): resolve user-input requests in headless runs

The engine's request_user_input wait is unbounded now that it is
recognized as human-paced, but the headless exec event loop only
auto-resolves approvals and never touches user-input requests: a
run whose model asks a clarifying question parked forever where the
old 300s cap used to resume it. Cancel the request instead — the
tool returns a cancelled error and the turn continues — and say so
once on stderr.

(cherry picked from commit e4c47e237835c25b8b250b70c2c448b5ba2f311f)

fix(tui): recognize restore carriers by provenance (replaces #74) (#76)

* fix: recognize restore carriers by provenance

* fix: correct restore residual docs and pin the anchor order

Review follow-ups on the restore-by-provenance change:

- The loose fallback fires on the structural absence of a stamped
  carrier, not on session age: it also covers histories that never
  compacted, where checkpoint is None and a pasted header is dropped
  outright. State that in the doc comment instead of equating the
  branch with pre-provenance saves.
- Point the history_recognition doc at the live system-prompt scanners
  (extract_compaction_summary, strip_summary_text);
  is_compaction_summary_text has no callers.
- Add the mirror-order regression test: a pasted full summary BEFORE
  the real carrier is the anchor-theft order, so the test pins the
  authoritative summary landing at the real carrier's index, with
  distinct carrier/checkpoint texts so leaving the old carrier in
  place cannot pass.

---------

Co-authored-by: asto18089 <asto18089@users.noreply.github.com>
fix(tools): gate the unix-only process test helpers for windows builds (#77)

The bounded-child regression test in tools/process.rs is #[cfg(unix)]
(it drives a real `sh` and a backgrounded grandchild holding the pipes),
but the module-level `use super::*` and the `shell_command` helper it
feeds on stayed ungated. Under cfg(test) on Windows the test is compiled
out and the leftovers trip `-D warnings` (unused import + dead code),
which is how pinjou-agent#595's windows-rust-test caught it after the
2026-09-22/23 batch landed (#63 introduced the module).

Gate both items with #[cfg(unix)] so the module is empty rather than
broken on Windows; no behavior change on unix targets.

refactor(tui): extract checkpoint recognition to leaf module (#73)

* refactor: extract checkpoint recognition to leaf module

* docs(tui): point the moved recognition docs at their consumers

The leaf has no consumers of its own, so "every consumer here" was
vacuous; name the compaction/runtime_handoff consumers instead. The
module doc's no-reverse-edge claim gains its production qualifier and
names the disclosed test-module residue.

---------

fix(computer-use): bind the region origin in screenshot rasters (#72)

* fix: bind region origin in screenshot rasters

* test: pin screenshot raster-origin binding

* fix(computer-use): aim screenshot regions in global screen points and bind measured pixels

Review of the #554 first cut surfaced three gaps this closes:

- darwin derived scale from _spdisplays_resolution, which is the POINT
  resolution on Retina panels — every region shot bound scale 1 while the
  PNG carries 2x, so clicks still landed 2x off (verified on real
  hardware). The binding now measures the actual PNG (sips, as zoom
  already does) and the metadata fallback uses the real pixel field
  _spdisplays_pixels.
- darwin treated -R as display-space with -D; the weight of evidence says
  screencapture crops -R in GLOBAL screen points. A region now pins the
  crop itself (no -D) and binds the region verbatim — which also makes
  region shots work on displays whose origin macOS will not report — and
  clamps only against a complete display union, since clamping on a
  partial union could pull another display's region into the main one.
- linux rejected negative regions although grim/scrot/import crop in
  global layout points that go negative on exactly the layouts the
  virtual-screen binding exists for; and it hardcoded scale 1 while grim
  renders at the highest output scale, so virtualScreen now carries the
  contributing scale. An unbound shot no longer advances the backend's
  last raster — the crop rides along as 'path' so the previous binding
  stays whole and a follow-up zoom cannot slip past the foreign-source
  guard with a stale frame.

The server stops guessing: a screenshot with neither points nor origin
keeps the previous binding (or none — coordinate targets then fail closed
with no_raster) instead of binding (0,0), and over ssh the backend's own
note is joined with the scp hint instead of overwritten. The screenshot
schema now describes the global frame and the clip.

* test: pin global region frames, measured pixels, and the unbound-raster contract

Six mutation checks revert each binding fix and fail the suite: the raw
-R passthrough, resolution-only metadata scale, the linux n >= 0 gate,
the linux scale-1 hardcode, the server (0,0) fallback, and the darwin
file-swap under a stale frame. The Retina fixtures use the real
system_profiler field pair (resolution = points, pixels = backing).

* fix(computer-use): aim win32 screenshot regions in global points

Review round on #72 surfaced the last cross-platform coordinate
mismatch: the shared schema, darwin, and linux all define the
screenshot region in GLOBAL screen points — the frame list_displays
and cursor_position report, negative on multi-monitor layouts — but
win32 required non-negative x/y and consumed them as offsets from the
VirtualScreen top-left. On layouts whose VirtualScreen origin is not
(0,0) the same tool arguments cropped a different position, and a
region on the left/upper display (negative global coordinates, e.g.
[-1500,100,400,300] with VirtualScreen x=-1920) was rejected outright.

The win32 backend now consumes global points like the other backends:
the script derives the VirtualScreen-relative crop offset itself
(rx = region.x - bounds.X; node cannot know the bounds before the
script echoes them), clamps it against the virtual screen, and copies
at bounds + offset as before. Node recomputes the same clip with
clampRegion and restores global points, so the bound points remain the
crop actually taken. Tests pin the contract from global-coordinate
inputs — including a negative region aiming at the left display and a
region clamping past the left/top edge — and the malformed-region gate
now covers junk, arity, and degenerate sizes without the negativity
clause.

---------

fix: relativize context source labels to repo root (#70)

Chain-segment ("<!-- scoped instructions: ... -->") and
<project_rule source=...> labels carried absolute paths into the
pinned system prompt, so a checkout move or recase changed the prompt
prefix and could emit a spurious <context_update> history append.
Render both labels repo-relative (git-root-relative, forward
slashes), falling back to workspace-relative for rules outside any
checkout and to the absolute spelling only for paths outside the
label root (unreachable by construction).

Pins Pinvou/pinvou-agent#514.

fix(tui): close the fresh-review residuals (rlm, registry pin, echo) (#71)

* fix: add activation hints to rlm handle_read text

* fix: add the fourth side to the registry cap pin

* test: cover every submission-echo self-start path

* fix(rlm): teach the activation path on the stdout preview and truncation marker

The two same-class residuals registered by the first round of this PR
are now fixed instead of tracked: the handle-route preview line ("N
chars; retrieve via handle_read", emitted on every eval output past the
handle threshold) and the preview_output truncation marker both embed
the shared HANDLE_READ_ACTIVATION_HINT, each with a runtime pin.

* test(registry): pin the registry_sync description wording and sync the side list

The enumeration comment now names all four text sides and both
compile-time pins, and the registry_sync tool description gains the
missing runtime "eight" pin; the mcp-discovery skill markdown remains
the one side reached only by the tripwire message.

* test(forkguard): make the composer-shell accidental-dispatch defense real

The composer shell test pre-advances the model-client call counter so
every provider request blocks, making the documented bounded-timeout
defense true (the first request used to be absorbed by the canned
completion); the goal continuation test documents why its host-status
sync stays even though the constructor already armed the objective.

---------

fix(mcp): stream session boot and make tool_search boot-aware (#69)

* fix(mcp): stream session boot, make tool_search boot-aware

The session-boot connect pass returned only after every pending server
settled, and only then stored results into the pool. Two slow remote
401s could therefore hold the local command servers' tools off the
shelf for the whole boot window (~88s in the field), while tool_search
consulted a turn catalog built from the empty pool — and a bare empty
result is indistinguishable from "no server configured", so a
forced-capability request issued shortly after launch collapsed into a
definitive "capability unavailable" verdict even though the capability
came up moments later.

Three coordinated seams:

- boot: `spawn_pending_connects` returns the unfinished JoinSet and the
  session-boot task applies each result under a short pool lock the
  moment it settles — first-settled-first-available — sending Progress
  per settle; `connect_all` keeps the drained batch wrapper.
- turn loop: while boot is in flight, each tool batch folds servers
  that became ready since the turn started into the live catalog
  (append-only, deferral mirrored from the turn build), so an in-turn
  tool_search retry can find them. The boot update channel is left to
  the idle seam, which owns the failure briefing — it must never be
  appended mid-tool-loop.
- tool_search: while boot is in flight the result carries an `mcp_boot`
  block naming the servers still connecting, and the tool description
  states the retry contract, so a miss reads as "not yet", never as
  "absent".

A settled boot keeps every existing contract byte-compatible: the
payload carries no booting block, the refresh is a no-op, and the
drained batch shape remains for `connect_all`.

Tests use a real stdio fixture server: fast-server tools become
snapshot-able while a hung server is mid-timeout (mutation-checked
against the batch-return shape); the refresh append/deferral/
no-duplicate contracts; the connecting-status truthfulness boundaries;
and the settled-boot payload shape.

* fix(mcp): narrow boot appends and trust pool diagnosis

Review of 79853e30e surfaced four MAJOR seams the first cut left open;
this closes them without changing the streaming shape.

- The boot-window catalog refresh now applies the same narrowing the
  turn build does: the MCP feature and tool-security gates, the turn's
  ToolSurfacePolicy allow/deny predicates, and apply_mcp_tool_deferral
  itself for the deferral treatment. A tool the build stripped can no
  longer re-enter mid-turn, where a search hit would defeat the
  denied-name concealment and a declared activation would serialize an
  operator-excluded schema into the request.
- An always_load append now resolves eager and joins the active set,
  with the caller declaring the growth through the existing
  tool_surface re-pin; a turn starting while boot is in flight stamps
  "mcp-session-boot" up front, so the next turn's snapshot picking up a
  newly settled tool is a declared re-pin instead of undeclared drift
  tripping the C5 guard (the stamp is inert when nothing changed).
- The connecting status reads the pool's live truth: catalog_authorized
  servers count as answered (wider than is_ready, matching all_tools)
  and connect_backoff entries count as diagnosed, so a server that
  already settled with a failure is never reported "still connecting"
  before the queued boot update is drained; collect_pending_connects
  drops the backoff entry when it re-pends a server, preserving
  "backoff entry => no attempt in flight".
- The booting note and comments now describe what the code does, the
  JoinError attribution lives in one helper, and the misspelled test
  name is fixed.

New tests drive the turn loop end to end (search finds the refreshed
tool and carries the boot note; the boot-window turn start declares its
surface change), plus policy/security, always-load, pool-side-diagnosis
and drain-lag regression tests.

* fix(mcp): keep the retry ladder and close gate gaps

Round-2 review of the branch surfaced one consumer regression and two
boot-window gate gaps; this closes them without changing the streaming
shape.

- collect_pending_connects marked a re-pended server by dropping its
  backoff entry, which also reset the failure ladder: every automatic
  reconnect cycle restarted a dead server at the 30s base instead of
  climbing toward the 600s cap, and mcp_tools' per-turn connect_all
  paid a full spawn+handshake for each reset. The entry now survives
  the re-pend and a connects_in_flight set marks the attempt, so
  settled_failure_names still splits "diagnosed, cooling down" from
  "connecting right now" — and the ladder climbs.
- The tool_search seam re-folds the live pool before searching: a
  server that settled while earlier tools of the batch were executing
  used to produce a bare miss with no note at all, because the status
  correctly stopped naming it while the batch-start fold had not
  picked it up yet.
- mcp_boot_search_status now applies the same MCP feature and
  tool-security gates as the refresh: a tool-security turn's catalog
  has no MCP surface, so its search must not carry a connecting note
  for servers that turn can never reach.
- The boot re-pin declaration is first-set wins across all three boot
  stamps (turn start, Finished, drain): an already-pending `resume` or
  `tool_surface` reason keeps ownership instead of being washed into
  `mcp-session-boot`.
- allows_tool is promoted out of #[cfg(test)] so the refresh and the
  build spell the narrowing in one place, the batch wrapper's doc
  describes its actual caller, and two overreaching comments (the
  backoff entry's "exactly while no attempt is in flight" claim and
  drop_all_connections' settle guarantee) are rewritten around what
  the code actually does.

Four new tests pin the behavior fixes and were mutation-checked:
restoring the entry drop, deleting the search-time re-fold, removing
the status gate, and overwriting an already-declared reason each turn
the matching test red.

---------

fix: 评估 #67 与上游重叠面——采纳上游更合理的四处做法 (#68)

* fix(tui): 恢复历史隐藏内部运行时 handoff,保留 MCP system cell 例外

吸收上游 6362e1e84 的通用过滤:history_cells_from_message 在
restored checkpoint 分支之后接入 is_internal_runtime_handoff,
background shell completion 等运行时 handoff 恢复后不再渲染为
User cell(fork 同样存在该 bug)。过滤仅作用于显示层,持久化与
模型侧 api_messages 逐字节不变。MCP briefing/recovery 在更早分支
已按 fork 决策返回 System cell,不会落入新过滤器,行为保持。

测试按上游语义改写并新增:
- apply_loaded_session_never_restores_background_shell_event_as_composer_draft
  覆盖 current/legacy 两种 provenance、保存与会话消息字节不变、
  恢复后 cell 序列精确为 [User, Tool, Assistant];
- 新增 apply_loaded_session_keeps_user_authored_shell_event_lookalikes:
  无 provenance 的用户手打 envelope 形似物仍可见;
- 新增 restored_background_shell_completions_yield_no_cells_but_user_text_survives:
  单元级钉住两种 provenance 隐藏、普通用户文本幸存;
- backtrack 回归测试追加 handoff 不计为用户发言的断言。

红→绿:改写后的 resume 回归、单元级隐藏断言与 backtrack 断言在
改动前均失败,改动后通过;forkguard MCP system cell 测试全程通过。
验证:cargo fmt -p codewhale-tui;cargo clippy -p codewhale-tui
--lib -- -D warnings 通过;history::/ui::/runtime_handoff::/
session_manager::/session_peek::/compaction:: 共 924 项通过;全量
lib 11849 项通过(4 个 remote_control/runtime_threads 用例因共享
runtime 目录在并行下环境性失败,单独运行通过,与本次改动无关)。

* fix(tui): read 结果按预算自限后不再被上下文压缩器二次截断

移植上游 e7f7c71e2 的 context 压缩层豁免守卫。read 工具按字节预算自我
截断并以页脚声明"从 offset 续读",但压缩器硬限 12,000 字符会把打满预算
的 read 结果再做一次 head/tail 压缩,内容被切、续读契约丢失。

- compact_tool_result_for_route:metadata 带 read_budget_bytes 且 raw
  字节长度不超过预算时原样放行(位置与上游一致:evidence_available
  早退之后、subagent 摘要器之前);超出自身预算仍走常规压缩。
- read 工具两条物理路径在受预算约束的成功结果上写入 read_budget_bytes:
  execute_contract_read(read 原语,50KiB 预算)与 render_line_window
  (read_file 遗留阅读器,16KiB 预算)。值取最终输出实际字节长度:
  窗口预算约束的是内容,续读页脚与 <file> 包装叠加其上,只有按最终
  payload 声明,守卫条件才对打满预算的结果成立。
- 测试:engine 层验证带 metadata 原样放行、无 metadata 与超预算仍压缩;
  file 层两条路径各验证打满预算的结果携带 metadata 且过压缩器后页脚
  完整存活(端到端)。

验证:cargo fmt;cargo clippy -p codewhale-tui --lib -- -D warnings 通过;
cargo test -p codewhale-tui --lib -- core::engine::tests tools::file
531 通过 0 失败;contract_read read_budget budgeted_read 过滤 7 通过。
改动前 3 个新测试均红(被二次压缩 / 缺 metadata)。

* fix(tui): 行摘要器输出有界的 child 路由回执

上游 0c03b5a81 在 emitter 侧让每行 compact status 携带有界化的 typed
child_route,fork 的 subagent 行摘要器此前丢弃该字段,unscoped fleet
监视面上 child 实际使用的 provider/model 路由到不了模型。

- summarize_subagent_snapshot 每行在 receipt 可打印时输出 route 行:
  provider/model,附 route_source 与 resolved_profile_id(连接身份);
  字段缺失或形状不符时静默跳过,不打印空占位。
- 有界:各字符串字段 preview 截 64 字符,整行硬性守卫 200 字符,不
  依赖 emitter 侧 compact_child_route 的 ≤1024 字节保证。
- 兼容三形状:对象 receipt、裸 "provider/model" 字符串、snapshot
  wrapper 包裹(wrapped 行优先,envelope 兜底,与 transcript_handle
  的 fallback 规则一致)。

验证:cargo fmt -p codewhale-tui;cargo clippy -p codewhale-tui --lib
-- -D warnings 通过;RUST_MIN_STACK=16777216 cargo test -p codewhale-tui
--lib -- core::engine::tests 381 通过(新增
forkguard_fleet_summary_rows_carry_bounded_child_route 与
forkguard_subagent_projection_child_route_reaches_summary_row,
先红后绿)。

* style: 自检微调——守卫注释改 fall-through 表述,route 行缺失段不再打占位

---------

feat(subagent): list host-presented profiles in agent roster action (T7) (#65)

* feat(subagent): list host-presented profiles in agent roster action (T7)

The agent tool's action=roster only ever returned the 8 builtin Fleet
roles, while the base contract tells hosts to present exact prompt-only
profile ids themselves — leaving embedder-injected profiles (Pinvou's
expert cards) undiscoverable by the model. The roster payload now adds
host_profiles (sorted by member id, capped at 48 with
host_profiles_truncated and host_profile_count), listing exactly the
members profile=<member_id> can resolve: the acceptance contract
(config origin, prompt-only, no built-in role-token shadow, bounded
selector) is single-sourced in is_spawnable_host_profile and shared by
the listing and spawn resolution, so the roster never advertises an id
spawn would refuse. Builtin members keys are unchanged and the payload
stays stable when no host profiles exist.

Tests: forkguard_agent_roster_lists_only_spawnable_host_profiles pins
that the listing mirrors spawn acceptance (mixed origins, route pins,
role-token shadows, oversized selectors);
agent_roster_lists_host_presented_profiles_spawn_resolves;
agent_roster_truncates_host_profiles_at_the_listing_cap; the existing
roster action test pins the new empty-shape keys.

* feat(subagent): let roster profile_query filter host profiles (T7)

The roster host-profile listing caps at 48 rows and the agent schema has
no pagination, so embedding hosts presenting more spawnable profiles than
the cap left their tail permanently undiscoverable: the model could see
host_profiles_truncated=true but had no way to reach the dropped ids, and
profile=<member_id> requires knowing the exact id. Host-review follow-up
on the swarm phase 2 parent PR.

Add an advertised, roster-scoped profile_query keyword: a bounded,
case-insensitive substring match over member ids and the same description
the listing displays (matched before output bounding). The filter narrows
the spawnable pool before the cap; host_profile_count and
host_profiles_truncated describe the filtered set, so a zero-match answer
stays honest. Blank or non-string values filter nothing. The advertised
surface grows 12 -> 13 fields; budgets, routing and workspace knobs stay
off (pins updated).

Tests: the 13-field schema pin, the roster dependentSchemas branch
advertising profile_query, and a discovery regression that builds 50
spawnable profiles, proves exp-host-049 is cut from the default listing,
found by description and id keywords, honest on zero matches, ignored on
blank input, and resolvable at spawn via profile=.

* test(subagent): close review-pinned coverage gaps (T7)

Fresh-review follow-ups on the roster/profile_query wave: the forkguard
shadow mirror now resolves against a roster that actually contains the
renamed role-token member, so FleetRole::from_str-over-roster precedence
is load-bearing (a roster without the id takes the role path under any
ordering); the discovery regression pins partial-id substring matching
so an exact-equality regression on the id leg fails; the ROSTER_PROFILE_
QUERY_MAX_CHARS doc now states the truncate-not-refuse convention it
actually implements; docs/FLEET.md cross-reference follows the advertised
surface to 13 fields.

* fix(fleet): dedupe host ids that get() conflates

[fleet.profiles] is a byte-order BTreeMap, so case-variant and
whitespace-variant keys (Expert / expert / ' expert') are distinct legal
entries while FleetRoster::get resolves all of them to the first member
in order. The host roster built by from_host_config listed every key
verbatim, so the roster action could advertise an id whose selection
silently spawned a different profile's prompt — every other profile
layer (directory loaders, ambient merge) already fails closed or shadows
on exactly this collision.

Collapse each duplicate to the member get actually resolves to, and pin
the listing/spawn invariant with a case/whitespace collision regression:
every advertised member_id must resolve back to itself.

* fix(fleet): collapse conflatable config ids at load too

The roster listing and the spawn path build the [fleet.profiles]
layer through two independent constructions. The listing reads the
engine-installed config.fleet_roster, built by FleetRoster::load,
whose merge_member folds each config key into the merged roster
last-wins (byte-order-last key replaces its conflatable siblings in
place). The spawn path re-resolves profile=<id> against a roster
rebuilt per spawn by refresh_spawn_route_sources via
FleetRoster::from_host_config, which 0576deb2c teaches to collapse
ids get() conflates (trimmed, ASCII case-insensitive) FIRST-wins.
For case-variant config keys such as 'Expert' and 'expert' — both
legal, distinct TOML keys — load advertised the last key's member
while get() on the spawn roster resolved every spelling to the
first one: the roster could list a profile whose prompt silently
differs from the one a selection actually spawns.

The invariant was previously established only on the from_host_config
side; both regressions (agent_roster_never_lists_ids_that_spawn_a_
different_profile, from_host_config_collapses_ids_that_get_resolves_
together) constructed their rosters through from_host_config
directly, so the listing/spawn pairing gap could not surface.

Extract the first-wins collapse (and the config-entry-to-member
mapping) into shared helpers and apply the same collapse to the
config-extracted profile list in load before merge_member, so the
last-wins layer merge never sees two conflatable config keys.
Cross-source precedence is preserved exactly: config entries still
override built-ins and are still overridden by plugin, personal, and
workspace layers; only intra-config duplicates are removed, in the
same direction from_host_config already pins.

Regressions:
- agent_roster_listing_and_spawn_agree_on_conflatable_config_ids
  builds the listing side the way the engine does (FleetRoster::load)
  and drives the spawn side through the real refresh_spawn_route_sources,
  asserting the listed row is the member spawn resolves, with the same
  id, description, and prompt overlay. Verified to fail on the
  pre-fix code.
- load_collapses_conflatable_config_ids_like_from_host_config pins
  the load-side direction at the roster unit level.
- roster_host_profile_listing_cap_is_pinned_at_48 literal-pins
  ROSTER_HOST_PROFILE_LIMIT to the 48-row cap the app-side roster
  contract teaches.

---------

fix: stop finite timeouts from killing legitimate work (#63)

* fix: stop finite timeouts from killing legitimate work

A timeout-behaviour audit found several finite wall-clock caps sitting on
paths whose work legitimately runs minutes to hours, auto-expiring human
decisions, and local waits with no bound at all. Fix the high-impact set:

- External tool approvals no longer auto-deny after 300s: the decision
  wait is unbounded (human-paced) and ends on turn interrupt or runtime
  shutdown instead, emitting approval.decided{interrupted} so clients
  clear pending UI. The engine's own approval wait was already unbounded;
  request_user_input now waits unboundedly too instead of answering the
  model with a ToolError::Timeout after 300s.
- Background-task idle detection now treats a running tool as progress:
  the journal only records tool calls at start/completion, so the 2-minute
  idle deadline fired during any silent build, test suite, or MCP call.
  The wall-time budget remains the backstop.
- MCP tool calls: the TUI pool default execute timeout 60s -> 1800s and
  the response-read wait derives from max(read, execute) so raising the
  documented knob actually governs; the engine-side stdio proxy gets a
  dedicated 1800s tools/call budget instead of the 120s generic request
  cap; the headless runtime dispatch backstop 300s -> 1800s; MCP OAuth
  browser-callback wait 300s -> 900s.
- js_execution and code_execution: 120s -> 600s and the child is killed
  on timeout instead of being left running orphaned.
- image_analyze vision calls: replace the 120s total deadline with
  connect + per-read idle timeouts; a multi-MB upload plus a long
  non-streaming generation legitimately exceeds two minutes.
- RLM child completions now honor the advertised sub_query_timeout_secs
  (it was stored but never read; the bridge always used its 120s const),
  and inline repl rounds 180s -> 900s to match the model-thinking
  reality the RLM path already accommodates.
- web fetch hard cap 60s -> 300s and the SSRF pre-flight DNS lookup is
  bounded at 10s so a hung resolver cannot stall the fetch envelope.
- Skill/plugin installs get connect + total timeouts (previously none:
  a stalled connection hung installs forever); snapshot git subprocesses
  are bounded at 300s with degrade-on-timeout instead of blocking the
  turn pipeline on a wedged git.
- Non-streaming model requests get a 30-minute total envelope around the
  retry loop (all attempts + honored Retry-After included). The shared
  client intentionally has no client-level total timeout, so compaction,
  translate, and similar non-streaming callers previously had no bound
  against a provider that accepts and stalls, nor against a gateway
  answering 429 + Retry-After: 3600 forever.
- Sub-agent tool timeout default 300s -> 1800s (single source of truth;
  the heartbeat floor derives from it automatically).

Verified: cargo check --tests on tui/mcp/core; targeted suites for
runtime_threads (157), task_manager (46), web (192), mcp (76), core (82),
vision, js_execution, repl, snapshot, skills install, client all pass.

* fix(tasks): keep the idle watchdog honest about in-flight tools

The timeout-audit fix for background-task idle detection tracked running
tools with mismatched keys and only refreshed progress on event arrival:

- item.started carries the engine tool-use id under "tool", but
  item.completed/item.failed only carry the journal item id under
  "item", so the tracking set was never drained: after the first tool
  call the watchdog was disarmed for the rest of the turn (only the
  wall-time budget remained).
- note_progress was evaluated per event, but the very scenario being
  fixed is a tool whose execution window produces no journal events at
  all, so the 2-minute idle deadline still fired during silent builds,
  test suites, and MCP calls.

Track both lifecycle edges by the journal item id, and refresh progress
from the steady-state loop (which wakes at least every catch-up poll)
for as long as any tool item is in flight. The wall-time budget stays
the backstop for a tool that never completes.

Pin both edges with tests: a silent tool survives well past the idle
deadline and the deadline resumes after completion, and a hung tool
still hits the wall budget.

* fix(vision): bound image_analyze with a real total envelope

The audit change replaced the 120s total deadline with connect(10s) +
read_timeout(120s), but reqwest's read_timeout is not a per-read idle
bound for the request phase: its timer starts at send() and is never
reset until the response headers arrive, so it silently re-introduced a
~120s total deadline on the multi-MB upload plus the full non-streaming
generation - the exact healthy work the change meant to protect (the
per-read idle semantics only apply once the response body is streaming).
The accompanying comment described the opposite of the actual behavior.

Bound only the connect handshake on the client and put a 30-minute
envelope (matching the non-streaming model request budget) around the
retry loop and response body consumption, reporting a clear timeout
when it fires. Wiremock tests pin both directions: a stalled provider
is cut off by the envelope with a timeout error, and a prompt answer
arriving in time still flows through.

* fix(snapshot): drain git pipes while the child runs

run_git waited for the child to exit before reading stdout/stderr, so
any invocation producing more than the 64 KiB OS pipe buffer deadlocked
on its own output and died at the 300s command timeout. That is not a
corner case: ls-tree -r -z --name-only on the restore path lists one
path per workspace file, and diff --name-only after a large refactor
grows the same way, so medium-size workspaces would have seen /undo and
snapshot listing stall 300s and then fail - the exact turn-pipeline
blocking the audit meant to remove (the old .output() call drained
concurrently and had no such cliff; the removed 'quiet-output' comment
was wrong about the tree-listing and diff commands).

Spawn two reader threads (mirroring the sandbox exec plumbing in
crate::run_sandboxed_exec) so the pipes are drained while the child
runs, join them on both the success and the kill path, and use a
tighter test-only command timeout so the large-output regression test
fails in seconds rather than hanging.

* fix(mcp): widen the read wait per request instead of clamping the knob

The audit fix set the connection-level read timeout to
max(read, execute) so a raised execute_timeout actually governs a
tools/call. That traded one bug for two more: the read_timeout knob
documented in docs/MCP.md was silently disabled (a user-configured read
budget of 30s or 180s was raised to the 1800s execute default, so fast
requests on a wedged server waited out 30 minutes before failing), and
every non tools/call request on the connection - resources/read,
prompts/get, notifications - inherited the same 30-minute liveness
regression for detecting a dead server.

Restore the connection read timeout to the user's knob and widen the
read wait per request instead: call_method now reads for at least its
own outer budget (so a silent server is not declared dead mid tool
call), while everything else keeps the configured read budget. The HTTP
transport's client-level total stays a separate ceiling computed as
max(read, execute) because it must cover the longest request the
transport carries; both decisions are extracted into named pure
functions and pinned with tests.

* fix(runtime): resolve pending approvals when the engine dies or the runtime shuts down

The unbounded external-approval wait only ended on turn interrupt or
runtime-shutdown cancellation, but two of its documented exits were
not actually reachable. A crashed e…
asto18089 added a commit that referenced this pull request Sep 27, 2026
#63 (d349f25) merged mid-review, so its final review wave never
reached `pinvou3-clean`. This PR carries that wave plus the fixes
from the subsequent review rounds onto the current base, as one
commit; per-area detail lives in the PR description.

- exec: headless runs resolve `request_user_input` via
  `cancel_user_input` instead of parking forever once the engine
  wait became unbounded; the cancellation contract is pinned.
- snapshot: every pre-git window on a wedged mount is bounded
  (workspace canonicalize, the safety classifier's
  re-canonicalization, the first-init size walk, honoring
  `max_workspace_gb = 0`); a killed `git init` heals instead of
  wedging (HEAD-less, half-written, leftover config.lock/HEAD.lock);
  wedge failures surface as errors with stderr evidence and
  stage-keyed remedy hints; drain captures cap at 16 MiB per stream.
- runtime: every approval exit publishes `approval.decided` and is
  race-free by construction (delivery races, monitor-death
  settlement, channel-closed exit); compat streams forward the
  approval posture.
- tasks: liveness heartbeats short-circuit before the manager-wide
  state lock. The human-wait clock pause lives in #78 instead: as
  first landed it changed task-lifecycle behavior and regressed the
  TUI, so it was taken back out.
- vision: the envelope timeout classifies as `ToolError::Timeout`,
  and the inference permit is acquired inside the envelope.
- client: envelope-exceeded requests mark connection health and run
  the recovery probe on every dialect arm, including the Anthropic
  transport arm; the non-streaming budget got an injectable test
  seam.
- app-server: a dropped stream resumes from the last consumed seq
  (bounded reconnects) before falling back to a best-effort
  interrupt, on every turn surface; the `/health` boot probe bounds
  each attempt.
- tools: drain aborts are one helper covering every exit including
  cancel/drop; timeouts SIGKILL the child's process group so forked
  commands no longer outlive the call.
- rlm: the sub-query budget is floored at 1s and the session default
  references `CHILD_TIMEOUT_SECS`.
- mcp: the send-timeout error states the budget instead of tokio's
  `Elapsed` display.
- docs: the 1800s timeout family anchors one complete roster;
  MCP/SUBAGENTS disclose the per-leg budget and wall-clock
  interactions; the RUNTIME_API.md restore contract is corrected.
- tests: every fix above has a regression pin (mutation-verified
  red-green), timing margins are widened, and the Anthropic
  endpoint tests hold the env lock.

Deliberately not fixed here (pre-existing on base unless noted,
follow-up scale): Windows job-object group-kill parity; the
contested inference-permit shape in web_search/voice/
prompt_suggestion; the pandoc.rs old-shape timeout; the two bounded
runners' parallel drain implementations; the engine-side MCP stdio
proxy's unbounded send leg; debounce starvation while heartbeats
stream; the `CODEWHALE_MAX_OUTPUT_TOKENS` test-isolation race.

Verified: `cargo fmt --check`; clippy `-D warnings` on
codewhale-tui, codewhale-app-server, codewhale-core and
codewhale-mcp; `cargo test -p codewhale-tui --lib` 11969/0 and
`-p codewhale-app-server --lib` 103/0 on the final tree; the
load-bearing pins were each mutation-checked to fail red.

No-Issue: review-fix replay for the merged #63; no separate
tracking issue exists. The full findings ledger is the round-6
review comment on #63.

Signed-off-by: asto <asto18089@126.com>
asto18089 added a commit that referenced this pull request Sep 29, 2026
…ise the cap

`execute_js_execution_tool` ran the interpreter as `timeout(120s,
cmd.output())`: when the 120 seconds elapsed, tokio dropped the wait
future WITHOUT killing the spawned child, so Node kept running detached
— holding CPU, files, and pipe write-ends — while the tool reported a
timeout. The 120s cap itself was also short for legitimate scripts
(builds, report generation).

Spawn the child under our ownership instead: it leads its own process
group on Unix so the timeout kill reaches scripts that spawned their
own children, `kill_on_drop` remains the backstop when the whole tool
future is dropped (turn interrupt), and stdout/stderr are drained
concurrently with the wait so a script blocked on a full pipe buffer
still exits. On timeout the process group is SIGKILLed, the child is
reaped, and the drain tasks are aborted. The cap is raised 120s → 600s.

Tests pin the kill itself: a Node child that reports its pid and sleeps
is verifiably dead after the timeout error, and a grandchild holding
the pipes cannot make the call hang past the budget.

Adapted from the Pinvou fork's timeout audit (Pinvou/CodeWhale
d349f25, PR #63 review series).

Signed-off-by: asto18089 <asto18089@126.com>
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.

3 participants