Two follow-ups from review of the salvaged fix:
- _reap_gateway_turn_processes now returns 0 for a blank task_id.
ProcessSession.task_id defaults to empty for sessionless callers, so a
blank turn id would match (and kill) every unrelated empty-task process
instead of the turn's own.
- The asyncio poll loop checks executor completion BEFORE the watchdog's
timeout flag. When both race in the same window, the completed run has
already persisted its real reply to session history; surfacing the
'agent inactive' diagnostic would contradict the stored transcript.
This matches _abandon_timed_out_gateway_turn's own worker-done-wins
tiebreak.
dbbb10d39 shipped without direct test coverage for its own new logic
— the same gap teknium's review flagged on the competing PR. Close it:
- _reap_gateway_turn_processes: skips when is_still_current() is
False, proceeds when True, fails open (reaps) if the check itself
raises rather than silently disabling the leak fix.
- _abandon_timed_out_gateway_turn: still marks the turn abandoned
(interrupt fires) even when the reap itself is skipped.
- api_server._reap_disconnected_agent_processes: reaps the
baseline-diff for an owned turn, no-ops when the agent never
recorded ownership markers.
- APIServerAdapter._run_agent: markers are populated with the right
task_id/baseline during the turn and cleared once it completes,
closing the same race window fixed in gateway/run.py for this
separate agent-lifecycle surface.
An agent turn can spawn a long-running background subprocess (e.g.
`next build`) and later be abandoned via inactivity timeout, /stop,
/new, or a client disconnect. Before this fix the gateway interrupted
the agent loop but never touched the subprocess: it kept running
inside the gateway's cgroup, unbounded, until memory pressure starved
the event loop and made every platform/cron look hung (#76115).
The process registry already knew how to kill a process tree — the
missing piece was per-turn ownership: nothing distinguished a process
that predates the turn (must survive), a process the turn started and
finished successfully (must survive), and a process an abandoned turn
left running (must be reaped).
- tools/process_registry.py: snapshot_running_ids() captures a turn's
starting baseline; kill_started_since() reaps only IDs created after
it, scoped to one task_id.
- gateway/turn_context.py: TurnContext carries process_task_id +
process_baseline so the timeout/interrupt paths can reach them.
- gateway/run.py: baseline is snapshotted right before the turn's
executor task starts; the inactivity-timeout path and the explicit
/stop|/new|disconnect interrupt path both reap via the same helper.
A daemon-thread watchdog backs up the asyncio-based timeout poll,
since a starved event loop is exactly the failure mode this bug
causes. The turn's own worker clears its ownership markers the
instant it finishes, closing a race where a /stop landing right
after normal completion could reap a background process the turn
deliberately left running.
Related but insufficient on their own: #37454 (cgroup ExecStopPost
reaper only fires on service restart) and #68915 (orphaned-pipe
grandchild detection, a registry bug not a turn-lifecycle gap).
Neither ties process cleanup to turn abandonment.