fix(agent-server): resolve #728 drain-deadlock — sweep orphans before pipe close, bound all close paths - #1718
Conversation
…p (f5d69be) Public main's enterprise gitlink referenced 630cca9 — the tip of the feature/78-portal-history-hardening branch (portal voice mode, ElevenLabs TTS + Scribe STT), which was added AFTER the Abilityai#106 umbrella squash-merged to enterprise main and was never itself landed on the enterprise trunk. That left main pointing at a dangling, off-trunk enterprise commit and inverted vs dev (dev=f5d69be is on enterprise main; main=630cca9 was ahead). This reverts main to f5d69be — the enterprise-main tip that already carries the full Client Portal (Abilityai#79 exposure seam, Abilityai#104 roster/sessions/chat, Abilityai#106 history hardening + chat sessions + cross-chat search) minus voice mode. Voice mode stays on its feature branch, unshipped, per decision to hold it. dev already points at f5d69be, so this restores dev == main on a valid on-trunk pointer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pages One page per topic (getting started, agents, chat/sessions, credentials, scheduling, collaboration, channels, MCP/API, operations, sharing, deployment, security, advanced, troubleshooting) plus a generated question index. Answers derived from user docs and verified against code; .claude pointer picks up the generate-user-docs FAQ step. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… authentication Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-submodule-pointer fix(enterprise): align main's submodule pointer to on-trunk f5d69be (revert dangling 630cca9)
Create research/ as the home for Ability AI's open research work: - Quantum-Steered Cognition (qrng-agent-consciousness-whitepaper, 2026-07-14) - A Measurably Self-Improving Multi-Agent Forecasting System (closed-loop-forecasting, 2026-07-11) Each paper lives in its own subfolder as a dated PDF of the final manuscript; README.md carries the index and naming conventions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n-papers-main docs(research): add open research folder with first two papers (main)
Links the new animated architecture explainer (https://youtu.be/XDLOq1crF9w) in the README "Watch more" list and at the top of the Platform Overview section of docs/user-docs/videos.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
docs: add llms.txt for agent-native discovery
…plainer-video-main docs: add architecture explainer video to README and video library (main)
…ns before pipe close, bound all close paths When an agent has a persistent MCP connection, `claude --print` completes its work but a setsid'd grandchild (or the connection) keeps its stdout write-end open past SIGKILL. The stdout reader thread is parked in a blocking readline() holding CPython's TextIOWrapper buffer lock, so a synchronous safe_close_pipes() -> TextIOWrapper.close() deadlocks on that same lock. Worse, kill_cgroup_orphans() — the only thing that can kill the pipe-holder and let the reader hit EOF — ran in a finally block AFTER the deadlocked close, so it never executed. Result: the drain coroutine never returned, the 90s budget was exhausted, the process was SIGKILLed, the reader thread leaked, and the model's already-produced output was discarded (empty result + circuit-breaker trip), even though the MCP tool calls had succeeded. Fixes: - drain_reader_threads: run the cgroup orphan sweep BEFORE the force-close (so the pipe-holder dies, the reader EOFs and releases the lock, and the close is a fast no-op), and bound the close itself via _bounded_safe_close_pipes (asyncio.wait_for + asyncio.to_thread). Keeps the finally backstop sweep and the Abilityai#531 natural-drain-before-kill ordering. - HeadlessRunContext.terminate(): the second deadlock-exposed call site. Use a new sync _bounded_safe_close_pipes_sync (daemon thread + join(timeout)) instead of asyncio.run(_bounded_safe_close_pipes(...)) — the latter's loop teardown joins the leaked non-daemon to_thread worker for up to THREAD_JOIN_TIMEOUT (300s on CPython 3.13), silently defeating the 5s bound and pinning a shared executor thread. - claude_code.py / codex_runtime.py: their `await loop.run_in_executor(None, _safe_close_pipes, process)` calls were fully unbounded (never resolve if the escapee is alive). Route through the bounded async helper (5s). Tests (tests/unit/test_subprocess_pgroup.py): a fork-based test where a setsid'd grandchild holds stdout open for 20s (past post_kill_grace) — FAILS on pre-fix code ("drain took 20.01s"), PASSES post-fix (~1s); plus TestBoundedSafeClosePipes covering the sync + async bounded helpers under a genuinely-wedged close. Related: closes the gap behind the band-aid in Abilityai#1502; addresses the deadlock tracked as Abilityai#728 (and reported in discussion Abilityai#1696). Follow-ups (not in this PR, to keep it focused): add the cgroup sweep at the claude_code/codex_runtime close sites too; a JSONL-recovery safety net so output is never lost even on a pathological drain. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Retargeted `main` → `dev` (per SDLC, feature/fix PRs land on `dev`; `main` is release-cut only). That retarget surfaces a conflict, but only from branch history — the branch was cut from `main`, which has diverged from `dev` (124 commits ahead / 11 behind). The change itself applies cleanly:
Could you rebase onto `dev` and force-push? I could not do it for you — the fork is org-owned, so GitHub's maintainer-push does not apply and I get a 403. ```bash Everything else validated: required checks green, security scan clean, closing keyword present (`resolve #728` → auto-promotes on merge), tests cover the non-happy paths. |
|
Resolve by running |
AndriiPasternak31
left a comment
There was a problem hiding this comment.
Validated via /validate-pr. The engineering here is the strongest thing I've reviewed this week — the root-cause analysis is correct and the test is a real reproduction, not a mock. Requesting changes on process only; I have no objection to the fix itself.
What holds up
The lock-ordering diagnosis is right. safe_close_pipes() needs the same TextIOWrapper buffer lock the reader thread holds while parked in a blocking read, so calling it synchronously while the pipe-holder is still alive deadlocks the coroutine — and a deadlocked coroutine never reaches the trailing finally, which is why the #817 sweep that could have freed the lock never fired. Moving _sweep_cgroup_orphans() ahead of the force-close in subprocess_pgroup.py attacks the actual causal chain rather than widening a timeout.
The asyncio.run() catch in HeadlessRunContext.terminate() is the kind of thing that normally ships undetected: loop.shutdown_default_executor() joins the leaked non-daemon to_thread worker for up to THREAD_JOIN_TIMEOUT (300s on CPython 3.13), so the "5s bound" would have been a 5-minute bound in exactly the pathological case it exists for. Using a daemon thread in _bounded_safe_close_pipes_sync is the correct escape.
test_drained_via_sweep_before_close_when_grandchild_survives_entire_window is the test the previous fixes in this lineage were missing — a real setsid()'d grandchild outliving the entire drain window, failing pre-fix at 20.01s and passing post-fix in ~1s, with the sweep stubbed only to stay cross-platform while still performing a real SIGKILL of the real holder. The #1502 and #1661 follow-ups exist because the earlier tests couldn't reach this branch.
Blocking
1. The PR closes no open issue. The closing keyword targets #728, which is closed and is a different bug (agent-server.py spins at 90% CPU on OAuth token auth failure). The whole lineage is closed — #728 → #1502 → #1661 — and #1661 is titled "#728 fix incomplete", which is what this PR is actually resolving. As written the keyword is a no-op and the work traces to nothing open. Please reopen #1661 (or file a fresh bug for this deadlock) and re-point the reference at it, so the status automation and the release notes pick it up.
2. No CI has run. gh pr checks reports no checks at all on this branch — this is a fork PR, so the workflows need a maintainer to approve the run. A change to the agent base-image runtime can't merge on a local test run alone. A maintainer will need to approve the workflow; given docker/base-image/** is touched, this also wants a /verify-local pass with the agent stage (base image rebuild + real agent boot), not just the backend stages.
Non-blocking
3. The async helper still pins a shared pool thread. _bounded_safe_close_pipes uses asyncio.to_thread, so on timeout it abandons a non-daemon worker from the loop's default ThreadPoolExecutor — the exact hazard you correctly avoided in the sync variant. The inline comment argues it's harmless because the app loop is long-lived and never torn down, and that's true for the bound; the cost is different. That pool is capped at min(32, cpu_count + 4) and is shared process-wide with every other to_thread/run_in_executor(None, ...) caller in the agent server — the git auto-sync maintenance cycle (#1595) among them. So each wedge permanently consumes a slot from a small shared pool, and the failure mode isn't a leaked thread, it's unrelated subsystems stalling once the pool is exhausted. A leaked reader thread is a plain thread and costs nothing shared; this one isn't. Routing both helpers through the same daemon-thread primitive would make the async path leak as cheaply as the sync one.
4. Diff display is misleading. GitHub renders this as 32 files / +2035, including the .claude and src/backend/enterprise submodule pointers and two research PDFs. That's a stale merge-base artifact from the branch being cut off main before those commits reached dev — verified locally, the true delta against current dev is 7 files / +482. A rebase onto dev would make the diff reviewable at a glance and is worth doing before this gets more eyes.
Happy to re-review as soon as the issue reference points somewhere open and CI has actually run.
…r as the error cause (#1849) Mirror the CLI's own consumer: drop `[ede_diagnostic]` entries from `result.errors`, join the rest. Applied at BOTH `errors[0]` reads — the pre-existing max_turns branch (#361) reads the same array from the same emitter and has the same defect shape. When a result carries no real cause at all, the diagnostic's key=value payload is kept as clearly-labelled context (minus the `[ede_diagnostic]` token, which is unsearchable against this repo and misdirects investigators) rather than dropped — nothing else on this path is recorded. It also stays in the existing WARNING log; not DEBUG, since the agent server is pinned to INFO and DEBUG would emit nothing. The `#1673:` comment block is EXTENDED, not replaced — a sibling test asserts only `"#1673" in src`. Also fixes four latent defects in the same two expressions: - a malformed `errors` shape (a dict, an int, a bool) made `errors[0]` RAISE inside the parser, and the swallowed raise fell through to the #160 `context: fork` placeholder — HTTP 200, status=success; - a bare-string `errors` was indexed character-by-character ("boom" -> "b"); - a blank/None entry could join to "" and re-introduce the empty error_message #1673 fixed; - a leading newline slipped a marker past the prefix test. No new imports; no change to headless_executor / jsonl_recovery / claude_code (#1870 / #1853 / PR #1718 territory) or to the backend predicate, which is the acceptance criterion rather than the fix site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r as the error cause (#1849) (#1938) * test(agent-server): failing regression tests for the [ede_diagnostic] header (#1849) TDD red. 24 cases (19 test functions, one parametrized 5 ways) driving the real parser, the real 502 finalizer, and the REAL backend predicate (routers/sessions.py::_is_resume_not_found, loaded via spec_from_file_location rather than `from routers.sessions import ...` — the package import raises ImportError under CI's randomized seeds). 18 fail against unmodified source, all behaviourally: - errors[0] is Claude Code's own [ede_diagnostic] header, so the marker wins over the real cause at errors[1..]; - the acceptance test proves impact #3 (resume self-healing defeated) is real, not inferred: _is_resume_not_found(detail) is False today; - a malformed `errors` shape RAISES inside the parser (KeyError: 0 for a dict, TypeError for an int/bool) — the swallowed raise lands as HTTP 200 success; - a bare-string errors is indexed character-by-character ("boom" -> "b"). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(agent-server): never surface Claude Code's [ede_diagnostic] header as the error cause (#1849) Mirror the CLI's own consumer: drop `[ede_diagnostic]` entries from `result.errors`, join the rest. Applied at BOTH `errors[0]` reads — the pre-existing max_turns branch (#361) reads the same array from the same emitter and has the same defect shape. When a result carries no real cause at all, the diagnostic's key=value payload is kept as clearly-labelled context (minus the `[ede_diagnostic]` token, which is unsearchable against this repo and misdirects investigators) rather than dropped — nothing else on this path is recorded. It also stays in the existing WARNING log; not DEBUG, since the agent server is pinned to INFO and DEBUG would emit nothing. The `#1673:` comment block is EXTENDED, not replaced — a sibling test asserts only `"#1673" in src`. Also fixes four latent defects in the same two expressions: - a malformed `errors` shape (a dict, an int, a bool) made `errors[0]` RAISE inside the parser, and the swallowed raise fell through to the #160 `context: fork` placeholder — HTTP 200, status=success; - a bare-string `errors` was indexed character-by-character ("boom" -> "b"); - a blank/None entry could join to "" and re-introduce the empty error_message #1673 fixed; - a leading newline slipped a marker past the prefix test. No new imports; no change to headless_executor / jsonl_recovery / claude_code (#1870 / #1853 / PR #1718 territory) or to the backend predicate, which is the acceptance criterion rather than the fix site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(agent-server): record the [ede_diagnostic] fix and its bug class (#1849) - tests/registry.json: entry for tests/unit/test_1849_ede_diagnostic_filtered.py. - learnings.md: new entry for the durable class — a cross-surface contract carried by third-party FREE TEXT has no schema and breaks silently when the vendor reformats. Broken twice in six weeks (#1673 -> #1849), green CI both times, because no test drove producer -> transforms -> the real consumer predicate. Also covers the "raise inside a swallowing parser is a false success" and "mirror a vendor's own consumer-side filter" corollaries. - parallel-headless-execution.md: revision-history row, AND a fix to the stale "How Errors Are Classified -> Source 1" section, which quoted pre-#1673 code and attributed it to claude_code.py — a file that has not owned this since the #122 extraction. Appending a row above a wrong snippet would leave the doc self-contradictory, and that snippet is exactly what a future contributor reads before "simplifying" the helper. - session-tab.md: hazard clause on the resume-fallback step. The contract is restored, not changed — but it was documented without noting that it depends on an agent-side free-text string surviving filter -> join -> sanitize -> [:300], which is why it has silently broken twice. No requirements/ or architecture.md change: this is a bug fix (Rule #4), and architecture.md documents nothing about result.errors parsing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: correct the #1849 test count (19 tests / 23 cases, not 24) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(docs,tests): correct the AUTH_INDICATORS UUID false-positive rate to ~1.73% (#1849) The plan cited 1.09% "measured over 200,000 UUIDs". Re-measured here over 500,000 `uuid4()`s: 1.727% contain a bare "401" or "403" (0.879% / 0.854% individually) — ~1 in 58, not ~1 in 92. uuid4 pins '4' at string index 14, which lifts the rate above the naive 30-position estimate. The fixed prose around the UUID ("Execution error: No conversation found with session ID: ") contributes no match, so the UUID is the only source. Material because this number is the whole cost argument for shipping #1849 before the `\b401\b` word-boundary hardening — it belongs in the PR body and release notes correct. Also: assert the WHOLE diagnostic payload survives, not the "stop_reason=null" fragment (uses the previously-unreferenced _MARKER_PAYLOAD constant). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * review: correct the max_turns SUB-003 disclosure and two known-bad pins (#1849) Four review corrections, no behaviour change. 1. Disclosure defect (the substantive one). The plan justified joining at the max_turns branch with "verified safe: max_turns text lands only in an informational 422 and is substring-matched by nothing". That is wrong: `classify_switch_failure` returns "auth" for ANY >=400 body that trips `is_auth_failure`, and the `except httpx.HTTPError` handler calls `is_auth_failure(error_msg)` independently of status code — so the 422 detail is matched at the same two sites as the 502. The R1 exposure therefore covers BOTH branches. Practically inert (a max_turns result with real `errors[]` entries is unobserved; marker-only/absent still yields the fixed "Task stopped after N turns"), but a spurious switch there re-runs a turn that legitimately hit its limit, so the misfire costs tokens rather than a fast-failing duplicate. Recorded in the feature-flow row, since the whole R1 disposition rests on the exposure being measured and disclosed. 2. `errors[0]` did NOT raise on all four malformed shapes. Verified against the pre-fix expression: dict/int/bool raise; an explicit `null` falls through the `if errors` guard to the old bare literal, and a nested list assigns a raw `list` into the str-typed `error_message` field. The commit message and the flow row already said "a dict, an int, a bool"; the test file's docstrings said "a nested list" and "on a non-sequence raises". Corrected so the artifact matches what was measured. 3. The casefold/fixed-length-slice interaction is now stated as an invariant instead of left to be re-derived: full case folding never shortens a string, so the 16 folded chars came from at most 16 original ones and the slice starts at or after the real payload — the token cannot leak. Worst case, confirmed with a "st"-ligature marker, is one dropped payload character on input Claude Code cannot emit. 4. `test_multi_error_truncation_limit_is_known` pins a KNOWN-BAD residual, and its failure message read as if the failure were the regression. It now says outright that a failure means truncation was FIXED, names the flip, and asserts the marker really is truncated away so the pin can't pass vacuously. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Eugene Vyborov <eugene@beingluminous.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: trinity-ability <trinity@ability.ai>
Problem
When an agent has a persistent MCP connection (a remote streamable-HTTP MCP server, native
type:httpor via annpx mcp-remotestdio bridge),claude --printcompletes its work — the MCP tool calls succeed (the remote returns HTTP 200s) — but asetsid'd grandchild (or the live connection) keepsclaude's stdout write-end open past SIGKILL. Every such execution then hangs the drain, gets SIGKILLed after the 90s budget, discards the model's already-produced output (empty result), and trips the agent circuit breaker. With an empty.mcp.jsonit works fine, which is what pins the trigger to a lingering pipe holder.Observed signature (base image 0.8.0):
Root cause
A lock-ordering deadlock in
drain_reader_threads:readline(), holding CPython'sTextIOWrapperbuffer lock.safe_close_pipes()→TextIOWrapper.close()was called synchronously in the async coroutine;close()needs that same lock → deadlock.kill_cgroup_orphans()— the only thing that can SIGKILL the pipe-holder so the reader hits EOF and releases the lock — ran in afinallyafter the deadlocked close, so it never fired.Fix
drain_reader_threads: run the cgroup orphan sweep before the force-close (holder dies → reader EOFs → lock released → close is a fast no-op), and bound the close itself via_bounded_safe_close_pipes(asyncio.wait_for+asyncio.to_thread). Thefinallybackstop sweep and the bug: drain_reader_threads closes stdout pipe before reader can drain backlog — silently loses final result line on long agentic tasks #531 natural-drain-before-kill ordering are preserved.HeadlessRunContext.terminate()(second deadlock-exposed site): switched fromasyncio.run(_bounded_safe_close_pipes(...))to a new sync_bounded_safe_close_pipes_sync(daemon thread +join(timeout)).asyncio.run()'s loop teardown joins the leaked non-daemonto_threadworker for up toTHREAD_JOIN_TIMEOUT(300s on CPython 3.13), silently defeating the 5s bound and pinning a shared executor thread — a daemon thread is never joined at teardown, so the true bound holds.claude_code.py/codex_runtime.py: theirawait loop.run_in_executor(None, _safe_close_pipes, process)calls were fully unbounded (never resolve if the escapee is alive). Routed through the bounded async helper (5s).Tests
tests/unit/test_subprocess_pgroup.py:setsid'd grandchild holds stdout open for 20s (pastpost_kill_grace) — the exact condition existing tests never covered. FAILS on pre-fix code (drain took 20.01s), PASSES post-fix (~1s).TestBoundedSafeClosePipes— the sync + async bounded helpers each return within their timeout under a genuinely-wedgedsafe_close_pipes.pytest tests/unit/test_subprocess_pgroup.py tests/unit/test_drain_bounded.py tests/unit/test_headless_executor_pipe_drop.py tests/unit/test_headless_executor_970_timeout.py tests/unit/test_codex_runtime.py -q→ 167 passed, 1 skipped (pre-existing Linux-only skip; ran on macOS).Context
Closes the gap behind the
#1502band-aid; addresses the deadlock tracked as #728 and reported in discussion #1696.Deferred follow-ups (kept out to stay focused)
claude_code/codex_runtimeclose sites (they currently only pgid-kill before closing).🤖 Generated with Claude Code