Skip to content

fix(agent-runtime): cgroup-walk sweep catches env-stripped orphans (#817 follow-up) - #857

Merged
vybe merged 7 commits into
devfrom
fix/817-cgroup-sweep
May 16, 2026
Merged

fix(agent-runtime): cgroup-walk sweep catches env-stripped orphans (#817 follow-up)#857
vybe merged 7 commits into
devfrom
fix/817-cgroup-sweep

Conversation

@obasilakis

Copy link
Copy Markdown
Contributor

Closes #817 follow-up

PR #827 caught one class of leak via an env-tag sweep. Eugene's 2026-05-13 production capture on cornelius-m proved it incomplete:

env-tag scan: 0 processes found with TRINITY_EXECUTION_ID

…while the orphan burned 136–160% CPU for 100+ minutes and blocked every subsequent execution at the CB layer. Total orphan survival: ~2.5h. Recovery: container restart.

The escape vector that defeated #827 is environment scrubbing (env -i / sudo / re-exec into a clean shell). There is no signal we can plant in env that the orphan can't strip.

The new mechanism

Cgroup membership is the inescapable boundary. A process whose CPU the kernel attributes to this container is, by definition, in its cgroup. This PR collapses the prior three reactive cleanup passes into one exclusionary mechanism:

read /sys/fs/cgroup/cgroup.procs (cgroup v2 unified)
SIGKILL every PID not on the allowlist

Allowlist composes:

Tier Source Examples
Hard-protected computed PID 1, agent-server + parent chain, sweep caller, every sshd-named process
Platform essentials cmdline patterns in resolver tail -f /dev/null, sudo /sbin/sshd*, guardrail-config writer
Active executions ProcessRegistry + ppid walk every registered claude PID and its descendants
User-configured daemons ~/.trinity/persistent-processes.allow cornelius-m adds *moltbook-http-mcp*, *moltbook-mcp*

When the sweep runs

Trigger Path Production scenario it covers
Task end (success or failure) drain_reader_threads finally block The default case — every execution closes
External termination ProcessRegistry.terminate() after pgid kill Backend CB / user cancel kills task mid-run
Background loop orphan_sweeper service (90s startup grace, 30s interval) Eugene's exact scenario: no execution ever completes (CB fast-fails everything), no per-task cleanup ever runs

What was deleted

Symbol Status Why
kill_processes_by_env_tag (#827) deleted subsumed; env-strip defeats it
_kill_orphan_pipe_writers (#618/#728/#808) deleted subsumed; </dev/null defeats it
_set_idle_priority, _read_proc_field, _read_proc_cmdline deleted only existed for the deleted code
The _drain_reader_threads_inner split (#817) deleted the try/finally split it enabled is now trivial
_ORPHAN_LOG_DETAIL_CAP, _ORPHAN_SCAN_WALL_SECONDS deleted constants for deleted scans
EXECUTION_TAG_NAME retained still set on every Claude Popen for log identity & /proc/<pid>/environ correlation during incident response; no longer load-bearing

subprocess_pgroup.py: 631 → 331 lines (-300). drain_reader_threads: 411 → 138 lines (-273). Three new single-purpose modules add 788 lines but collectively replace the entire reactive-cleanup pyramid plus give us the periodic-sweep capability that wasn't possible before.

Validation

Reproduced Eugene's exact failure shape locally against cornelius-m:

PID 311  PPID 1  PGID 310  SID 310  CPU 99.9%  cmd: bash -c while :; do :; done
no TRINITY_EXECUTION_ID in /proc/311/environ
not in claude's pgid
not a descendant of agent-server
no shared pipes with claude

This orphan survived all three prior passes (pre-fix). Post-fix, validated end-to-end across all three sweep paths:

Per-task path (PID 734):

17:24:00  inject orphan during sleep 50 task
17:24:15  task ends as success
17:24:16  [OrphanSweep] SIGKILL cgroup orphan: pid=734 pgid=733
17:24:16  [Subprocess] Cgroup sweep killed 1 orphan(s) after drain

External-termination path (PID 668):

17:42:52.328  [ProcessRegistry] Sending SIGINT to execution LQB8rnXT4TOD5cqDqLidZw
17:42:52.908  [ProcessRegistry] Execution LQB8rnXT4TOD5cqDqLidZw terminated gracefully
17:42:52.909  [OrphanSweep] SIGKILL cgroup orphan: pid=668 pgid=667
17:42:52.909  [ProcessRegistry] Cgroup sweep killed 1 orphan(s) after terminating ...

Periodic-sweep path (PID 311, no execution running):

16:18:53  inject orphan (container idle, no claude task)
16:20:01  [OrphanSweep] SIGKILL cgroup orphan: pid=311 pgid=310
16:20:01  [OrphanSweeper] periodic sweep killed 1 orphan(s)

68s from spawn → death, exactly startup_delay (90s) + first_sweep after container start. Eugene's 100-minute production scenario reduced to 30s upper bound (the periodic interval) for any future occurrence.

Moltbook daemon (PID 301) was preserved by the allowlist across every test run.

CI

New pytest test_817_cgroup_sweep_catches_no_env_tag_orphan in tests/test_817_subprocess_leak.py exercises the per-task path deterministically (PASS in 49s).

External-termination and periodic-sweep paths validated manually but not in CI yet — adding them as @pytest.mark.slow is the obvious follow-up; left out of this PR to keep the diff focused.

Operator note

Templates that ship deliberate long-lived daemons (cornelius-m, future templates) MUST list their cmdline patterns in ~/.trinity/persistent-processes.allow or the periodic sweep will reap them on first iteration. Default file is empty — a permissive default would defeat the point. For cornelius-m, the patterns are:

*moltbook-http-mcp*
*moltbook-mcp*

Files

  • NEW docker/base-image/agent_server/utils/orphan_sweep.py (229 lines) — kill_cgroup_orphans, cgroup_available
  • NEW docker/base-image/agent_server/utils/orphan_allowlist.py (345 lines) — three-tier resolve_allowlist
  • NEW docker/base-image/agent_server/services/orphan_sweeper.py (214 lines) — periodic background task
  • docker/base-image/agent_server/utils/subprocess_pgroup.py — drop env-tag + pipe-writer kernels, simplify drain_reader_threads
  • docker/base-image/agent_server/services/process_registry.py — migrate to direct kill_cgroup_orphans with explicit preserve list
  • docker/base-image/agent_server/services/gemini_runtime.py — same migration (three call sites)
  • docker/base-image/agent_server/main.py — wire schedule_orphan_sweeper(app)
  • tests/test_817_subprocess_leak.py — add test_817_cgroup_sweep_catches_no_env_tag_orphan

🤖 Generated with Claude Code

…ocess orphans (#817)

The existing cleanup path has two passes:
  1. terminate_process_group: kills the claude pgid via killpg
  2. _kill_orphan_pipe_writers: scans /proc for processes holding the
     stdout pipe inode

A subprocess that escapes BOTH passes — by calling setsid() (new pgid)
AND redirecting stdin/stdout/stderr to /dev/null (no FDs back to our
pipes) — survives indefinitely. In production this manifests as
runaway CPU after a scheduled task ends in pending_retry, eventually
starving the agent-server event loop, opening the agent_client CB,
and cascading the scheduler CB into the dormant state described in
the ticket.

Fix: tag every Claude subprocess with TRINITY_EXECUTION_ID=<uuid> in
its env. Env vars are inherited at every fork/exec/setsid AND survive
double-fork daemonization (the new parent doesn't reset env), so the
tag follows every descendant regardless of how it tries to detach.

A new pass (kill_processes_by_env_tag) scans /proc/<pid>/environ and
SIGKILLs every process carrying the matching tag. Wired into:
  - terminate_process_group (used by all timeout paths)
  - drain_reader_threads (runs on every task end — success or kill)
  - ProcessRegistry.terminate (the /api/cancel path triggered when
    the backend's task call times out)

Critically, the sweep is local to the agent container — it does not
depend on the backend reaching the agent. So even when the dormant
CB has the agent isolated, the cleanup still fires when claude
itself eventually exits.

Test: tests/test_817_subprocess_leak.py engineers an agent template
(config/agent-templates/test-leak-hook/) that injects a UserPromptSubmit
hook spawning eight setsid'd, FD-detached CPU burners then sleeping
120s. The schedule's timeout_seconds=30 + max_retries=1 produces
pending_retry deterministically. Pre-fix: 10 burner PIDs survive,
test fails with full ps dump in the message. Post-fix: 0 surviving
PIDs across 3 consecutive runs.
Gemini runtime spawned subprocesses without TRINITY_EXECUTION_ID env or
any post-wait orphan cleanup, leaving the same leak class open as the
pre-fix Claude path. Mirrors the #817 fix mechanism in both Gemini
entry points (chat + headless task):

- Inject TRINITY_EXECUTION_ID into Popen env so descendants are tagged
- After process.wait() returns, call kill_processes_by_env_tag() to
  reap any setsid'd, FD-detached orphans
- Error path in headless task also runs the sweep so a failing Gemini
  task does not leave leaked descendants

Chat path generates execution_id if caller did not provide one
(matching execute_claude_code's behavior).

Production has not reported a Gemini-specific instance of #817 — this
is defensive parity, not a fix for an observed bug. No test added; the
mechanism is identical to the Claude path which the slow integration
test already validates.
Adds TestKillProcessesByEnvTag covering branches the integration test
(tests/test_817_subprocess_leak.py) does not exercise:

- exact-match semantics (tag value 'abc' must not match 'abcdef')
- name-scoped match (different env var with same value must not match)
- exclude_pids parameter
- calling-PID always excluded (guards against killing agent-server itself)
- /proc unreadable returns 0 cleanly without raising
- count returned matches number of distinct processes killed
- non-tagged and differently-tagged processes left alive

All tests are Linux-only (require /proc/<pid>/environ); marked
@pytest.mark.skipif(sys.platform != "linux", ...) matching the existing
convention for tests in this file. Verified all 9 pass on Linux via
docker run python:3.11-slim.
 follow-up)

PR #827 added an env-tag sweep (kill_processes_by_env_tag) that caught
subprocess orphans which escape both terminate_process_group (different
pgid via setsid) AND _kill_orphan_pipe_writers (no shared pipe FDs).
Eugene's 2026-05-13 production capture on cornelius-m proved that fix
incomplete: the surviving orphan had no TRINITY_EXECUTION_ID anywhere
in /proc/<pid>/environ, so the env-tag scan returned zero hits while
the orphan burned 136-160% CPU for 100+ minutes and blocked every
subsequent execution at the CB layer.

The leak class is "process scrubs its environment after spawn"
(env -i / sudo / re-exec into a clean shell). That defeats env-tag
identification on principle — there is no signal we can plant in env
that the orphan can't strip.

Cgroup membership is the inescapable boundary. A process the kernel
attributes to this container's CPU/memory is, by definition, in its
cgroup. This change collapses the prior three reactive cleanup passes
(pgid kill #407, pipe-writer sweep #618/#728/#808, env-tag sweep #827)
into one exclusionary mechanism: read /sys/fs/cgroup/cgroup.procs
(cgroup v2 unified) and SIGKILL every PID not on the allowlist.

The allowlist composes three layers:
  - Hard-protected: PID 1, agent-server + parent chain, sweep caller,
    every sshd-named process (operator SSH sessions survive)
  - Platform essentials by cmdline pattern: tail -f /dev/null
    (startup.sh keep-alive), sudo+sshd wrapper, guardrail-config writer
  - Active execution descendants: ProcessRegistry-registered claude
    PIDs + pgids, expanded via ppid walk
  - User-configured persistent daemons: cmdline glob patterns from
    ~/.trinity/persistent-processes.allow (templates with deliberate
    long-lived daemons like cornelius-m's moltbook-http-mcp list them
    here)

The sweep runs on three paths:
  1. drain_reader_threads finally block (every execution end, success
     OR failure). Catches the production-success-path case where claude
     spawned a leaking grandchild then exited cleanly.
  2. ProcessRegistry.terminate() after pgid SIGKILL. Catches the
     production-external-termination case where the backend CB or a
     user cancel kills the execution before drain_reader_threads can
     run.
  3. Periodic background task in orphan_sweeper (Eugene's suggested
     fix #2). Default 90s startup grace + 30s interval. Catches the
     "no execution ever completes" production case where the CB
     fast-fails every subsequent task and no per-task cleanup runs.

  drain_reader_threads: 411 lines -> 138 lines
  subprocess_pgroup.py:  631 lines -> 331 lines (deleted env-tag and
                                                  pipe-writer kernels)

Validated end-to-end against the live cornelius-m repro from Eugene's
ticket:
  - per-task path: orphan PID 734 (setsid + env -i + FD detached, no
    relation to claude) killed at exec end. Same shape orphan
    survived all three prior passes pre-fix.
  - external-termination path: orphan PID 668 injected mid-task, killed
    by ProcessRegistry.terminate's cgroup_orphans pass after SIGINT.
    Log attribution unambiguous: SIGINT -> 580ms graceful exit ->
    cgroup sweep -> orphan dead, all on the [ProcessRegistry] code path.
  - periodic-sweep path: orphan PID 311 injected with no execution
    running, killed at T+90s by the periodic sweeper. Eugene's exact
    100-minute production scenario.

CI: new pytest test_817_cgroup_sweep_catches_no_env_tag_orphan in
tests/test_817_subprocess_leak.py covers the per-task path
deterministically (PASS in 49s). External-termination and periodic-
sweep paths validated manually here; can be added to CI as @slow tier
in a follow-up.

EXECUTION_TAG_NAME is retained on every Claude Popen for log identity
and operator incident-response correlation (env grep on /proc), but
cleanup no longer depends on it.

Default allowlist file ships empty. Templates running deliberate
long-lived daemons via SessionStart hooks must add their cmdline
patterns to ~/.trinity/persistent-processes.allow or the periodic
sweep will reap them on first iteration. This is intentional — a
permissive default would defeat the point of the mechanism.

Closes the regression Eugene captured in
#817 (comment)
@obasilakis
obasilakis marked this pull request as draft May 15, 2026 17:56
The cgroup-walk refactor in the previous commit deleted three internal
helpers from subprocess_pgroup.py:

  - kill_processes_by_env_tag (#827)         - subsumed by cgroup sweep
  - _kill_orphan_pipe_writers (#618/#728)     - subsumed by cgroup sweep
  - _set_idle_priority (#808)                 - only existed for _kill_orphan_pipe_writers

The unit tests in tests/unit/test_subprocess_pgroup.py imported and
exercised those symbols directly. With the symbols gone, pytest
collection errored on every seed run, which the regression-diff CI
job flagged as a new failure.

Removes the four test classes that targeted deleted code:

  - TestDrainOrphanKillerTimeout (covered the _kill_orphan_pipe_writers
    daemon-thread budget inside drain_reader_threads)
  - TestKillOrphanPipeWriters (covered _kill_orphan_pipe_writers
    directly against engineered npx-style orphans)
  - TestSetIdlePriority (covered _set_idle_priority no-raise on every
    platform)
  - TestKillProcessesByEnvTag (covered kill_processes_by_env_tag's
    /proc/<pid>/environ scan)

Retained tests that target still-existing symbols:

  - TestTerminateProcessGroup (pgid SIGTERM/SIGKILL of full tree)
  - TestDrainReaderThreads (reader unwind via pgid kill + natural drain)
  - TestSafeClosePipes (best-effort pipe close)
  - TestSignalProcessTree (signal propagation to group)

Dedicated unit coverage for the new cgroup-walk modules
(orphan_sweep, orphan_allowlist, orphan_sweeper) will land in a
follow-up commit on this PR — kept separate so the deletion vs. the
new-tests addition are reviewable independently.
…tests

tests/unit/test_subprocess_pgroup.py imports subprocess_pgroup flat:
it adds docker/base-image/agent_server/utils/ to sys.path then runs
`import subprocess_pgroup` with no package context. The previous
module body deliberately documented this contract:

    "This module is kept free of package-relative imports so it can
     be unit-tested without loading the rest of agent_server."

The #817 cgroup-walk refactor broke that contract by adding:

    from .orphan_sweep import kill_cgroup_orphans

which fails at collection time with:

    ImportError: attempted relative import with no known parent package

That left head's pytest collection erroring on a single test, which
the regression-diff CI job correctly flagged as a new failure under
HEAD (1 collection error vs base's 1510 collected tests).

Restores the contract with a try/except that preserves the
package-relative form for normal use (inside agent_server) and falls
back to flat name on ImportError (unit-test sys.path-injection path):

    try:
        from .orphan_sweep import kill_cgroup_orphans
    except ImportError:
        from orphan_sweep import kill_cgroup_orphans

Same fix applied to orphan_sweep.py's import of orphan_allowlist —
that module also needs to be flat-importable so the unit tests can
load its dependency chain without the agent_server package.

Verified locally: collect-only succeeds (11 tests), full run passes
(11 passed, 2.87s).
@obasilakis
obasilakis marked this pull request as ready for review May 15, 2026 18:39

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM. Cgroup boundary is the right primitive — env-tag can be stripped, cgroup membership can't. Three-layer allowlist is well-reasoned, race conditions handled correctly, all three sweep paths validated against the exact production failure shape. Net -300 lines with better coverage.

Two housekeeping items to do post-merge:

  1. Close #817 manually (the PR body header isn't a GitHub closing keyword so it won't auto-close)
  2. Track the slow-tier CI tests for external-termination and periodic-sweep paths as a follow-up issue

@vybe
vybe merged commit f11ae1b into dev May 16, 2026
9 checks passed
AndriiPasternak31 added a commit that referenced this pull request May 17, 2026
Two conflicts:

  - docker/base-image/agent_server/utils/subprocess_pgroup.py: dev's
    PR #857 (cgroup-walk sweep, #817 follow-up) removed the explicit
    `_kill_orphan_pipe_writers` /proc scan and replaced it with
    `kill_cgroup_orphans()` called in `finally`. Took dev's structure
    verbatim and re-added the #586 [METRIC] drain_outcome emissions at
    the two reachable branches (natural-drain + force-close/leaked).
    `orphan_kill_count` becomes vestigial (always 0) since the explicit
    killer is gone — the actual orphan count is logged separately by
    the cgroup sweep and can be correlated by pid. Tests assert
    `orphan_kill_count=0` so the contract holds.

  - docs/memory/feature-flows.md: both branches added entries to the
    Recent Updates log. Kept all three in chronological order
    (#862#586#831).

Adjacent updates (no conflict but stale after merge):

  - tests/unit/test_subprocess_pgroup.py: monkeypatch target switched
    from removed `_kill_orphan_pipe_writers` to `kill_cgroup_orphans`.
    All 13 tests pass locally.
  - docs/memory/feature-flows/execution-termination.md: Site B
    description now points at `kill_cgroup_orphans()` instead of the
    removed `_kill_orphan_pipe_writers`; `orphan_kill_count` description
    notes its vestigial role since #817.
  - docs/TRINITY_COMPATIBLE_AGENT_GUIDE.md: stop-hook authoring section
    refers to the cgroup sweep instead of the removed function.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AndriiPasternak31 added a commit that referenced this pull request May 17, 2026
…871)

- agent-monitoring: fleet check covers stopped agents (#675/#853) + per-pass log
- parallel-headless-execution: cgroup-walk sweep for env-stripped orphans (#857)
- platform-settings: new Feature Flags section (session tab, voice, workspace #860)
- public-agent-links: SITE-001 reverse-proxy retirement (#865)

Co-Authored-By: Claude <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