fix: provider-runner timeouts kill the whole process GROUP; codex/gemini inherit the orphan-drain hardening

All three provider runners (claude/codex/gemini) killed only the direct
child on timeout: tool subprocesses the CLI spawned survived as orphans
holding our pipes open and burning shared API rate (observed: a 600s
timeout stretching past 1400s; a stalled run once burned a core for 15
hours). gstack-detach's watchdog had the same shape one level up — killpg
SIGTERM, 5s grace, then a direct-child proc.kill() that orphaned
grandchildren.

Fix: spawn provider children via node:child_process with detached (own
process group) and killProcessGroup(SIGKILL) in the timeout handler —
runShardChild's proven pattern, EPERM/ESRCH fallbacks included. The codex
and gemini copies also gain the reader.cancel() + stderr Promise.race
hardening only the claude copy had (they still carried the blocked-drain
hang it fixed). gstack-detach's watchdog now group-SIGKILLs after the
grace.

Regression net: test/session-runner-groupkill.test.ts drives the REAL
runSkillTest against a fake claude shim (PATH override) that spawns a
grandchild and wedges — the run must classify timeout within budget and
leave neither shim nor grandchild alive — plus source pins on all three
runners (detached + killProcessGroup, no bare timeout kill, no Bun.spawn
reversion).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-31 04:20:50 +00:00
co-authored by Claude Fable 5
parent 07c2452e6d
commit 2e53b18670
5 changed files with 215 additions and 34 deletions
+10 -2
View File
@@ -106,10 +106,18 @@ def child_run(args, log):
except Exception:
pass
time.sleep(5)
# Group SIGKILL after the grace, not a direct-child kill:
# eval runs spawn claude/codex grandchildren that survive a
# proc.kill() and burn cores + API for hours (the observed
# 15-hour-orphan class). ESRCH here just means the group
# honored the SIGTERM.
try:
proc.kill()
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except Exception:
pass
try:
proc.kill()
except Exception:
pass
code = "timeout"
else:
code = proc.wait()