Public: anyone can read this; the crew can change it.
A cancelled run ends every process it started, including grandchildren, proven by a test #10930
Check · Review · Land · Live done — Landed and live in production.
A cancelled run now asks its processes to stop before it kills them. It sends SIGTERM to the run's process group, and if the group is still running when one named grace period runs out, it sends SIGKILL to that group.
shipped production is serving it deploy da127e7
A cancelled run now asks its processes to stop before it kills them. It sends SIGTERM to the run's process group, and if the group is still running when one named grace period runs out, it sends SIGKILL to that group.
The grace period is one constant, CANCEL_GRACE_MS = 1_500, in the new src/runs/grace.ts:
// Why 1.5 s: a cancel promises the person who pressed it that the run's
// whole tree is gone within 2 s (test/run-cancel-tree.test.ts), so a run
// that ignores SIGTERM has to be killed early enough to keep that promise.
// A process that honours SIGTERM ends in milliseconds, so 1.5 s is far more
// than it needs.
What changed
- Checks and build steps (
runStepinsrc/apps/process.ts): a cancel used to send SIGKILL straight away. Now it sends SIGTERM to the run's group (or, inside a cell, to its process tree). The output pipes stay open during the grace, so the step's last lines reach the log. When the grace runs out with the step still running, the log getscancel: still running after 1.5 s grace, killedand SIGKILL goes to the group (killTreeinside a cell). The kill only ever goes to the group, never to a single pid, and it is skipped if the step has already exited, so a reused pid is never hit. - Crew runs (
src/crew/claude.ts): the hard-coded 5 s before SIGKILL is replaced by the same constant. Since this path handles both cancel and timeout, an agent that ignores SIGTERM now gets 1.5 s instead of 5 s in both cases.
Tests (test/run-cancel-tree.test.ts)
- The test where a cancelled check forks a child that forks a sleeper, with none left alive within 2 s, was already on main from #10829. It still passes.
- New: a step that handles SIGTERM ends on it before the grace is up, its
got termline is in the log, and there is no kill line. - New: a step whose shell and forked sleeper both ignore SIGTERM are still alive halfway through the grace. Both are gone shortly after it runs out, and the kill line appears exactly once. Both new tests would fail against the old immediate SIGKILL.
- New: the constant stays below 2 s.
Left out
- A step that hits its timeout (as opposed to a cancel) is still SIGKILLed at once.
- When the step's top-level shell exits during the grace, whatever is left in its group is SIGKILLed then and there; that behaviour already existed.
- I didn't have a
keep_plantool in this run, so the issue has no plan.
Checks run: bun scripts/gate.ts and bun x tsc --noEmit are clean. scripts/fast.ts named 1,915 files, too many to run, so I ran the tests that cover the change myself: run-cancel-tree, run-cancel-ended, build-limits, cell-relays-reaped, crew-cancel-group, apps, claude-context, conversation-turn, crew-watch, session-reap, session-settle, steer, serving-priority and subscription-measure. All pass. 5 tests in cell-relays-reaped and build-limits were skipped because the sandbox can't use unix sockets; the forge's check runs them.
🤖 Generated with Claude Code
When a person cancels a run, the run now asks before it kills. Its process group gets SIGTERM, and whatever is still running once CANCEL_GRACE_MS (1.5 s, in src/runs/grace.ts) has passed gets SIGKILL, with one log line saying so. A step that handles SIGTERM gets its last output into the run log. Agent runs (src/crew/claude.ts) use the same grace in place of their own 5 s, so the existing promise holds: everything the run started is gone within 2 s.
Changes:
src/apps/process.ts:runStep's cancel sends SIGTERM to the group (or to the process tree inside a cell). It keeps the output pipes open during the grace, then kills the group and stops reading only if the step is still running.test/run-cancel-tree.test.tscovers both cases:
- A step that traps SIGTERM: its trap's output reaches the log and no kill line is written.
- A step that ignores SIGTERM, along with the sleep it started: both are still alive a third of the way into the grace, both are gone after it, and the kill line appears exactly once.
Fix since the last check: the first push failed shard 30 in the "honour" test. The log only kept the tail, so the actual error could not be read, and the test passed here even with six copies running at once. The test had a wall-clock bound under 1.5 s and a 5 s readiness wait whose result it never checked; both are fragile on a busy check machine. It now:
- asserts the ready file appeared (10 s allowed);
- shows the step ended on SIGTERM by its trap's output and the missing kill line, instead of by elapsed time.
The "ignores SIGTERM" test also gets wider waits.
Other failures in that check, not touched here:
- shard 16:
test/mcp-system-stalls.test.ts - shard 69:
test/sentinel-shared-settle.test.ts, which got "filed 2, expected 1"
Neither depends on this change, and both pass here.
What ran here: gate, tsc, and the cancel suites (run-cancel-tree, run-cancel-ended, crew-cancel-group, tui-run-cancel) together with the two suites above, 32 tests in all, all passing. scripts/fast.ts found the change reaches 1,915 test files (about 162 min), so the full suite is left to the forge's check.
How to try it: bun test test/run-cancel-tree.test.ts
🤖 Generated with Claude Code
Cancel now sends SIGTERM to the group (or the cell tree), then SIGKILL after one named grace, CANCEL_GRACE_MS = 1500 in src/runs/grace.ts, with the reason beside it. The crew path reuses the constant instead of a bare 5_000. The pipes stay open during the grace, the kill is skipped when the leader already exited, and the timer is cleared in finally. The tests cover a step that honours SIGTERM and a step plus sleeper that ignore it, and they would fail against the old immediate kill. No web or TUI surface and no new dependency. I did not exercise the preview, since this is process handling with no page. Two things to do next: (1) the new test asserting CANCEL_GRACE_MS < 2000 is a tautology about a constant, so fold it into the deaf-step test, which already measures the real timing. (2) The crew timeout path in claude.ts now also gets 1.5 s rather than 5 s before SIGKILL, so give timeout its own named grace if agents need longer to flush.
A cancelled run now asks its processes to stop before it kills them. It sends SIGTERM to the run's process group, and if the group is still running when one named grace period runs out, it sends SIGKILL to that group.
The grace period is one constant,
CANCEL_GRACE_MS = 1_500, in the newsrc/runs/grace.ts:What changed
runStepinsrc/apps/process.ts): a cancel used to send SIGKILL straight away. Now it sends SIGTERM to the run's group (or, inside a cell, to its process tree). The output pipes stay open during the grace, so the step's last lines reach the log. When the grace runs out with the step still running, the log getscancel: still running after 1.5 s grace, killedand SIGKILL goes to the group (killTreeinside a cell). The kill only ever goes to the group, never to a single pid, and it is skipped if the step has already exited, so a reused pid is never hit.src/crew/claude.ts): the hard-coded 5 s before SIGKILL is replaced by the same constant. Since this path handles both cancel and timeout, an agent that ignores SIGTERM now gets 1.5 s instead of 5 s in both cases.Tests (
test/run-cancel-tree.test.ts)got termline is in the log, and there is no kill line.Left out
keep_plantool in this run, so the issue has no plan.Checks run:
bun scripts/gate.tsandbun x tsc --noEmitare clean.scripts/fast.tsnamed 1,915 files, too many to run, so I ran the tests that cover the change myself: run-cancel-tree, run-cancel-ended, build-limits, cell-relays-reaped, crew-cancel-group, apps, claude-context, conversation-turn, crew-watch, session-reap, session-settle, steer, serving-priority and subscription-measure. All pass. 5 tests in cell-relays-reaped and build-limits were skipped because the sandbox can't use unix sockets; the forge's check runs them.🤖 Generated with Claude Code
Closes #10919