Skip to content

fix(agent-runtime): decouple headless executor from leaked reader threads (#970) - #980

Closed
AndriiPasternak31 wants to merge 2 commits into
devfrom
AndriiPasternak31/autoplan-issue-970
Closed

fix(agent-runtime): decouple headless executor from leaked reader threads (#970)#980
AndriiPasternak31 wants to merge 2 commits into
devfrom
AndriiPasternak31/autoplan-issue-970

Conversation

@AndriiPasternak31

@AndriiPasternak31 AndriiPasternak31 commented May 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Phase 1 fix for #970 (P1). When an agent's headless task spawns grandchild processes (stdio MCP servers, hooks, subagents) that hold the stdout pipe FD open after Claude exits, the executor's stdout reader thread wedges in readline(). The drain's force-close fallback couldn't unblock it, so _run_headless_subprocess blocked until the full task timeout (~2h) — saturating execution slots and recording false timed out after 7200s failures even though Claude finished in seconds (evidence: 24 leaked drains over 47h on one agent, each 1:1 with a 7200s false-timeout).

This PR decouples the orchestrator from the leaked reader so the platform-visible hang is bounded to the drain budget (~90s).

Changes

  • subprocess_lifecycle.py_drain_bounded returns a DrainOutcome literal (completed/budget_exceeded/errored) instead of None, and captures + logs daemon-thread exceptions instead of the silent except Exception: pass (D20).
  • headless_executor.py
  • subprocess_pgroup.py — per-process leaked-reader gauge sourced from the real force-close leaked_count, lock-guarded (D21); Phase 1 forensic [Drain] INFO logging (D8).
  • claude_code.py — chat path calls _drain_bounded fire-and-forget; chat budget-exceeded recovery deferred (D18).

Implementation matches the 25-decision /autoplan + /plan-eng-review (incl. Codex outside-voice) plan. Phase 2 (make safe_close_pipes abandonable + emit outcome=hard_timeout) is a deferred follow-up ship.

Test Coverage

36 new/updated unit tests covering every new path:

  • test_drain_bounded.pycompleted/budget_exceeded/errored outcomes; errored is logged with traceback, not swallowed.
  • test_headless_drain_budget.py — drain-outcome plumbing, pre-drain stdout_exc precedence, compound timeout+wedge bounding, full-field snapshot isolation, snapshot retry + fallback, end-to-end JSONL recovery within the outer margin.
  • test_chat_drain_contract.py — pins the chat fire-and-forget contract (no binding/branching on the return).
  • test_subprocess_pgroup.py — leaked gauge increments from the real leaked_count.

Verification (Docker)

All runs in throwaway containers; the live stack was not touched.

  • Backend runtime image (trinity-backend:latest): 4 changed test files → 36 passed. Related-module regression sweep (15 files) → 143 passed, 0 failed.
  • Full unit suite, random order (mirrors CI: pytest unit/ -m "not slow", pytest-randomly): 1790 passed, 10 failed, 3 skipped. The 10 failures (test_git_pull_branch, test_orphaned_execution_recovery, test_reset_preserve_state_guardrails) are pre-existing/environmental (no Redis / git env in the throwaway container) — proven by running them on a clean origin/dev worktree where they fail identically. Unrelated to the touched modules.
  • Agent base-image runtime (trinity-agent-base:latest): smoke test confirms the fixed modules import and function in the actual agent runtime (leaked gauge, _drain_bounded literal, drain_budget_exceeded default, snapshot isolation, _DRAIN_BUDGET_SECONDS=90, claude_code imports) — no boot-crash regression.
  • tests/lint_sys_modules.py: PASS (no new violations; 7 retired).

Pre-Landing Review

Self + adversarial review found no issues: the errored/done event ordering is correct (errored fully settled once done is set), the pre-drain stdout_exc snapshot preserves the #285 fast-fail, the snapshot retry targets the right operation (model_copy dict-iteration; list() of a list can't raise), and the widened outer margin keeps the inner machinery first-to-fire while remaining backend-bound.

Plan Completion

Phase 1: all items DONE (D5, D6, D8, D16–D21 verified present in the diff). Phase 2 intentionally deferred to a follow-up ship per the plan.

Refs #970 — Phase 1 of 2; intentionally does not auto-close so #970 can track the Phase 2 follow-up (safe_close_pipes abandonable + outcome=hard_timeout). The 2h false-timeout class is resolved by this phase.

🤖 Generated with Claude Code

AndriiPasternak31 and others added 2 commits May 29, 2026 18:00
…eads (#970)

Phase 1 of #970. When an agent's headless task spawns grandchildren (stdio MCP
servers, hooks, subagents) that hold the stdout pipe FD open after Claude exits,
the reader thread wedges in readline(). The drain's force-close fallback could
not unblock it, so the orchestrator blocked until the full task timeout (~2h),
saturating execution slots and producing false "timed out after 7200s" failures
even though Claude finished in seconds.

- _drain_bounded returns a DrainOutcome literal
  ("completed"/"budget_exceeded"/"errored") instead of None, and captures+logs
  exceptions from the daemon thread instead of swallowing them (was the
  silent `except Exception: pass`).
- _run_headless_subprocess records ctx.drain_budget_exceeded from the outcome
  and snapshots the PRE-drain stdout_exc, so a leaked reader appending late
  can't mask the #285 permission-mode fast-fail (D16).
- _finalize_headless_result snapshots every field it reads (list copies +
  metadata.model_copy(deep=True)) on the budget-exceeded path so a still-leaked
  reader can't tear a read; _snapshot_for_finalize retries the deep-copy against
  the same race and falls back to the live ctx with a warning (D19).
- Outer asyncio.wait_for margin widened from +60 to +_DRAIN_BUDGET_SECONDS+30
  so the outer net can't fire mid-drain and mislabel a bounded drain (D6).
- Per-process leaked-reader gauge (get_leaked_reader_thread_total) sourced from
  the real force-close leaked_count, lock-guarded (D21); Phase 1 forensic
  [Drain] logging to characterize the natural-drain overrun (D8).
- Chat path calls _drain_bounded fire-and-forget; chat budget-exceeded recovery
  is a deferred follow-up (D18).

Phase 2 (make safe_close_pipes abandonable + emit outcome=hard_timeout) is a
follow-up ship. Tests: 36 new/updated unit tests; verified in the real agent
base-image runtime; full unit suite green (the only failures are
pre-existing/environmental, identical on origin/dev).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sync feature-flow docs with the #970 change (headless executor decoupled
from leaked reader threads):

- parallel-headless-execution.md: add a 2026-05-29 revision entry for the
  headless-executor-side changes — HeadlessRunContext.drain_budget_exceeded,
  pre-drain stdout_exc snapshot (D16), _finalize_headless_result snapshot via
  _snapshot_for_finalize on the budget-exceeded path (D19), and the widened
  outer asyncio.wait_for margin (D6). The drain-side _drain_bounded return
  contract was already documented in execution-termination.md by the #970
  commit itself.
- feature-flows.md: add a #970 changelog row, consistent with the sibling
  drain fixes (#586/#808/#912).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

@vybe

vybe commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Closing as superseded by #973 (merged to dev as b4c5bfd) after a root-cause adjudication against current dev.

Why: This PR implements issue #970's suggested fix #1 — "decouple the orchestrator from the leaked reader so the platform-visible hang is bounded to the ~90s drain budget." But that decoupling and that 90s bound already exist on dev: _drain_bounded runs the drain in a daemon thread and done.wait(timeout=90) returns without joining the stuck reader (subprocess_lifecycle.py:79-87, from #728). The actual 2h is spent earlier, in process.wait(timeout=effective_timeout) (headless_executor.py:588), before the drain ever runs. The ticket's own arithmetic confirms it: 7,215,000ms ≈ 7,200,000 (wait budget) + ~15,000 (already-bounded drain). So the changes here — while correct as robustness — do not shorten the 2h hang, which is why this PR (by its own note) couldn't auto-close #970. #973 attacks process.wait directly (early-completion on the result message + a stalled-tool watchdog) and resolves the false-timeout class.

Your work is not lost. Two improvements here are genuinely valuable and orthogonal to #973 — the daemon-thread exception capture (D20, replacing the silent except Exception: pass) and the finalize snapshot isolation (D19). I've filed #1025 to cherry-pick those from this branch. I've intentionally not deleted this branch so that's easy. Thank you for the thorough plan + test coverage — the snapshot-isolation work in particular is worth keeping.

Branch AndriiPasternak31/autoplan-issue-970 retained for the #1025 cherry-pick.

@vybe vybe closed this Jun 2, 2026
dolho added a commit that referenced this pull request Jun 12, 2026
…1025)

Salvages the two orthogonal robustness improvements from the closed PR #980
that the #973 fix did not include. Pure hardening of the already-bounded
drain/finalize path — defense-in-depth, not a behavioural change on the
clean path.

D20 — capture daemon-thread exceptions in `_drain_bounded`
  `subprocess_lifecycle._drain_bounded` swallowed every drain exception via
  `except Exception: pass`, making a drain that raised indistinguishable
  from a clean completion (and hiding the leaked reader). It now captures +
  logs the exception and returns a `DrainOutcome`
  (`completed` | `budget_exceeded` | `errored`) so the caller can tell the
  leaked-reader cases apart. Existing callers that ignore the return value
  are unaffected.

D19 — `_finalize_headless_result` snapshot isolation
  On the budget-exceeded / errored drain path a reader thread is leaked and
  may still be mutating ctx's shared buffers / metadata. The headless run
  context now carries `drain_budget_exceeded`; when set, finalize rebinds
  `ctx` to a deep snapshot (`list(...)` of each buffer +
  `metadata.model_copy(deep=True)`) via `_snapshot_for_finalize`, which is
  retry-guarded against the "changed size during iteration" race and falls
  back to the live ctx if every attempt loses. Clean drains keep the
  zero-copy fast path.

Out of scope (per the issue): the #980 asyncio.wait_for margin widening and
the #970 lsof/proc pipe-holder hunt — both correctly excluded.

Tests: `_drain_bounded` outcome matrix (completed/budget_exceeded/errored +
no-swallow logging) in test_drain_bounded.py; snapshot list/metadata
isolation, retry-exhaustion fallback, and default-off fast path in
test_headless_finalize_snapshot.py. Full related suite: 130 passed.

Related to #1025

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Jun 16, 2026
…1025) (#1078)

* fix(headless): harden drain/finalize against leaked reader threads (#1025)

Salvages the two orthogonal robustness improvements from the closed PR #980
that the #973 fix did not include. Pure hardening of the already-bounded
drain/finalize path — defense-in-depth, not a behavioural change on the
clean path.

D20 — capture daemon-thread exceptions in `_drain_bounded`
  `subprocess_lifecycle._drain_bounded` swallowed every drain exception via
  `except Exception: pass`, making a drain that raised indistinguishable
  from a clean completion (and hiding the leaked reader). It now captures +
  logs the exception and returns a `DrainOutcome`
  (`completed` | `budget_exceeded` | `errored`) so the caller can tell the
  leaked-reader cases apart. Existing callers that ignore the return value
  are unaffected.

D19 — `_finalize_headless_result` snapshot isolation
  On the budget-exceeded / errored drain path a reader thread is leaked and
  may still be mutating ctx's shared buffers / metadata. The headless run
  context now carries `drain_budget_exceeded`; when set, finalize rebinds
  `ctx` to a deep snapshot (`list(...)` of each buffer +
  `metadata.model_copy(deep=True)`) via `_snapshot_for_finalize`, which is
  retry-guarded against the "changed size during iteration" race and falls
  back to the live ctx if every attempt loses. Clean drains keep the
  zero-copy fast path.

Out of scope (per the issue): the #980 asyncio.wait_for margin widening and
the #970 lsof/proc pipe-holder hunt — both correctly excluded.

Tests: `_drain_bounded` outcome matrix (completed/budget_exceeded/errored +
no-swallow logging) in test_drain_bounded.py; snapshot list/metadata
isolation, retry-exhaustion fallback, and default-off fast path in
test_headless_finalize_snapshot.py. Full related suite: 130 passed.

Related to #1025

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(headless): address review findings on the drain/finalize hardening (#1025)

Self-review of the #1025 PR surfaced two correctness gaps in the hardening
itself, plus a dead assignment and a redundant Event. Fixed:

1. Leaked-reader outcome was missed (strongest finding). drain_reader_threads
   force-closes and *returns normally* when a grandchild-held reader won't EOF
   (its own outcome=leaked METRIC, #586), so a within-budget, non-raising drain
   did NOT prove the readers were dead — _drain_bounded returned "completed",
   finalize skipped the snapshot, and read buffers a confirmed-alive reader was
   still mutating. _drain_bounded now adds a "leaked" DrainOutcome: after a
   within-budget return it checks whether any reader thread it was handed is
   still alive (no dependency on drain_reader_threads internals). The headless
   flag is renamed reader_may_be_live and set on `drain_outcome != "completed"`
   so budget_exceeded / errored / leaked all trigger the snapshot.

2. Snapshot didn't isolate the auth-abort signal. finalize reads
   auth_abort_event.is_set() / auth_abort_reason[0], but the stderr reader (the
   thread that leaks) mutates them — a late auth match could flip a success to a
   spurious 503 (→ SUB-003 auto-switch). _snapshot_for_finalize now freezes both
   (a fresh Event mirroring is_set() via _freeze_event + a list copy), so the
   snapshot's "every field finalize reads" claim holds.

3. Dropped the dead reader_may_be_live assignment on the TimeoutExpired path —
   that path re-raises to HTTP 504 without finalizing, so nothing reads it
   (replaced with a comment).

4. Collapsed the redundant `errored` Event in _drain_bounded to a single
   write-once `outcome` cell set by the daemon thread and read after the `done`
   barrier — removes the load-bearing two-Event ordering invariant.

Tests: new "leaked"-outcome case (within-budget drain leaving a live reader)
and auth-abort freeze isolation; renamed the flag test. 21 passed locally.
Re-verified against a live agent-server: clean task still 200s through the
fast path with zero leaked/snapshot warnings.

Related to #1025

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.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.

2 participants