Expand description
The process-global child reaper.
waitpid(-1) is process-global — it reaps any child (including
PR_SET_CHILD_SUBREAPER orphans), so two threads each calling it would steal
each other’s children (the robbed one then waits forever for an exit another
thread already collected).
The fix is dispatch by pid: exactly one place drains waitpid(-1) and
routes each reaped pid to the owning Supervisor’s channel, so any number of
Supervisors run concurrently without stealing from each other.
Two operations, both under a global pid→route registry mutex:
spawn_trackedforks the child under the lock, then registers its pid → the owner’s reap channel — so registration is atomic with the fork and the reaper can neverwaitpida not-yet-registered child.reap_and_dispatch(called from each Supervisor’s tick) drainswaitpid(-1, WNOHANG)and sends each reaped pid to its owner; an unowned pid (an adopted orphan, or a foreign child such as an MCP-server /execprocess that reaps itself) is simply dropped — already reaped, no owner.
There is no dedicated reaper thread: reaping happens only while a
Supervisor is active (its 200 ms tick drives it). That is deliberate — a
continuous waitpid(-1) would steal the children of components that
spawn-and-wait their own (the exec self-tool, the MCP client).
What waitpid(-1) reaps (the real coexistence contract). Because it is
process-global, reap_and_dispatch reaps every exited child in the process,
not just tracked ones — including a daemon’s long-lived MCP-server children, a
warm session’s subagent, and adopted orphans — and it does so from whichever
supervisor happens to tick, possibly concurrently with the main thread. That
is safe not because these never overlap (in the daemon they do, once a
served async run is in flight) but because every such component detects its
child’s death via its own channel (MCP stdout EOF, the warm/async AgentMsg
channel) and its Drop tolerates ECHILD — none of them needs the reaped
exit status, which is what waitpid(-1) consumes. The one component that
does consume a child’s status, the exec tool, runs only on a subagent’s
single agentic-loop thread where no reactor ticks concurrently. A foreign
child.wait() is waitpid(specific_pid) and so can never steal a tracked
supervised child (a different, still-live pid).
Functions§
- deregister
- Drop a pid’s route without reaping it — for a
Subagenthandle dropped before the reaper dispatched its exit (an abandoned run, which then reaps the child itself). Harmless if the pid is absent (a foreign / already-reaped pid). - reap_
and_ dispatch - Drain
waitpid(-1, WNOHANG)and dispatch each reaped pid to its owning Supervisor. Unowned pids (orphans / foreign self-reaping children) are dropped. Called from each Supervisor’s tick; the lock keeps the singlewaitpid(-1)serialized across concurrent Supervisors. - spawn_
tracked - Spawn a supervised child and register its pid →
reap_txatomically with the fork (both under the routes lock), so the reaper can neverwaitpida child before it is registered.spawn_fndoes the fork and returns theSubagentwhosepid()is the registry key. - spawn_
tracked_ pid spawn_trackedfor a plainstd::process::Child— an instance-tier child, which is a full daemon with no control channel. Same contract: the fork happens under the routes lock so the reaper can neverwaitpidthe pid before it is registered.