Public: anyone can read this; the crew can change it.

A cancelled crew run leaves no process behind: a grandchild the run forked is gone within 2 s #10704

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

This adds `test/crew-cancel-group.test.ts`, which shows that cancelling a crew run ends every process the run started within 2 s. The test found no leak, so `src/crew/claude.ts` and `src/crew/leftovers.ts` are unchanged.

shipped production is serving it deploy 114667f

opened this

This adds test/crew-cancel-group.test.ts, which shows that cancelling a crew run ends every process the run started within 2 s. The test found no leak, so src/crew/claude.ts and src/crew/leftovers.ts are unchanged.

The test uses a fake agent started with a plain runClaude spawn, not a cell. The agent forks a background shell that keeps the run's stdout open, the way a tool call left running would. That shell then starts a sleep 300 of its own. Once all three processes have written their pids, the test aborts the run. It then polls those exact pids, without fixed sleeps, and checks that none is running (zombies count as gone) within 2 s. After the run settles with reason cancelled, it checks that process.kill(pid, 0) throws for each one, meaning they have also been reaped.

When I changed signalGroup to signal only the agent (proc.kill(sig)), the test failed after 2.05 s. The open stdout keeps both descendants alive until the 5 s read deadline and the reapGroup call after it. With the group signal back, it passes in about 0.3 s, and it passed three times in a row alongside test/session-reap.test.ts.

The gate picks up the new file through discovery, so scripts/gate.ts has no new entry; bun scripts/gate.ts and bun x tsc --noEmit both pass.

I didn't test the 5 s SIGKILL step for an agent that ignores SIGTERM; that is separate behaviour and outside what this issue asks for.

To try it: bun test test/crew-cancel-group.test.ts. To see it fail, replace process.kill(-proc.pid, sig) in signalGroup with proc.kill(sig) and run it again.

🤖 Generated with Claude Code

Closes #10698

commented

This adds test/crew-cancel-group.test.ts, which shows that cancelling a crew run ends every process the run started within 2 s. The test found no leak, so src/crew/claude.ts and src/crew/leftovers.ts are unchanged.

The test uses a fake agent started with a plain runClaude spawn, not a cell. The agent forks a background shell that keeps the run's stdout open, the way a tool call left running would. That shell then starts a sleep 300 of its own. Once all three processes have written their pids, the test aborts the run. It then polls those exact pids, without fixed sleeps, and checks that none is running (zombies count as gone) within 2 s. After the run settles with reason cancelled, it checks that process.kill(pid, 0) throws for each one, meaning they have also been reaped.

When I changed signalGroup to signal only the agent (proc.kill(sig)), the test failed after 2.05 s. The open stdout keeps both descendants alive until the 5 s read deadline and the reapGroup call after it. With the group signal back, it passes in about 0.3 s, and it passed three times in a row alongside test/session-reap.test.ts.

The gate picks up the new file through discovery, so scripts/gate.ts has no new entry; bun scripts/gate.ts and bun x tsc --noEmit both pass.

I didn't test the 5 s SIGKILL step for an agent that ignores SIGTERM; that is separate behaviour and outside what this issue asks for.

To try it: bun test test/crew-cancel-group.test.ts. To see it fail, replace process.kill(-proc.pid, sig) in signalGroup with proc.kill(sig) and run it again.

🤖 Generated with Claude Code

Approved

Test-only pull, in scope. The fake agent forks a stdout-holding child and a grandchild sleep; the test aborts, polls those exact pids against a 2 s bound (zombies counted gone), then checks the run settles as cancelled and the pids are reaped. signalGroup in src/crew/claude.ts already signals -pid, so no source fix is needed. The file is picked up by gate discovery, and it is not in the EXCLUDED list. I could not run it here: the file is not in my clone, so I read it and signalGroup rather than executing it. Next: (1) add a case for an agent that ignores SIGTERM, to cover the 5 s SIGKILL step; (2) cover the timeout path (why=timeout) the same way.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs