Repository navigation
fix(seats): a supervised kill reaches the CLI's whole process tree; a handled SIGTERM keeps the turn - #2726
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the
|
lilyshen0722
left a comment
There was a problem hiding this comment.
Reviewed at 034edeb (process-group.js, supervisor.js, seat-stop.js, agent.js, adapters).
The group and tree kill is right. Only a marked, unreaped child is signalled by negative pid. Each descendant is re-read for ppid and start time before it is signalled, leaves go first, and the host-owned SIGTERM path yields to the host through the listenerCount check.
One blocker: daemon-spawned seats get a deadline nothing enforces. createSeatStop arms groupKillDeadlineMs(readExitTimeOut(plist)) on every SIGTERM. When there is no plist it falls back to 20 s and kills the groups at 15 s. But a daemon seat is stopped by daemon-supervisor.js with seat.child.kill('SIGTERM') (stopSeat, and the ready === 'changed' restart), and the daemon never follows that with a SIGKILL. So no deadline is coming. Today the in-flight turn finishes. With this PR, its MCP servers and tool shells are SIGKILLed 15 s after any reassignment or config change, which cuts the turn this SIGTERM path exists to protect. Fix: arm the deadline only when readExitTimeOut found a plist, meaning launchd is the parent. Otherwise rely on onExit, which already kills the groups once the turn ends. One test each way.
On the other two questions:
- Ordering (walk first, then group kill) is acceptable. A pid reparented to 1 during the walk, or a child that calls setsid after the snapshot, is missed. Write that down as a known residual in the process-group.js header. Don't add a second pass.
- SIGINT and SIGHUP are covered. SIGINT goes to the host's requestStop with no deadline, and onExit kills the groups. SIGHUP has no host listener, so the module's own handler kills the groups and re-raises. No change.
GATE: HOLD
lilyshen0722
left a comment
There was a problem hiding this comment.
Re-gate at 68ced27. That's one commit on 034edeb, which I reviewed earlier.
The blocker is fixed. createSeatStop now arms the deadline only when readSeatPlist finds the seat's plist. A daemon seat has no plist, so it arms nothing and relies on onExit. A reassignment or config change no longer cuts its in-flight turn. If a plist exists but has no usable ExitTimeOut, it still falls back to launchd's 20 s, which is correct because launchd will SIGKILL that seat. The real-process test covers both branches, and the arm-regardless mutant fails exactly the two no-plist cases. The two residuals of the walk are now written in the process-group.js header.
Non-blocking, worth a row: a seat moved from launchd to the daemon that still has its old plist on disk would arm the deadline, even though nothing will SIGKILL it. Removing the plist at migration time fixes that. Checking that launchd actually has the job loaded (launchctl print gui/$UID/me.commonly.seat-<name>) would too. That's a stricter version of the same check, not a reason to hold this PR.
GATE: PASS
killChildren() sent SIGKILL to the direct child only, so an agent CLI's own children (MCP servers started through npx, the native binary behind codex's node launcher) survived the kill and were orphaned on the shared host. - process-group.js: spawnInOwnGroup starts a child detached and marks it (LEADS_PROCESS_GROUP). killProcessTree signals -pid only for a marked child that has not been reaped, and otherwise falls back to child.kill. Leading a group is never inferred. - The supervisor passes ctx.ownProcessGroup = true and kills through killProcessTree. - The claude and codex adapters spawn through spawnInOwnGroup and ask for a group only when ctx.ownProcessGroup === true. Unsupervised, the options object reaches spawn untouched, so live seats spawn exactly as before. - Wrapper exit: a detached group is outside the seat's launchd group. While a supervised group is live, the wrapper kills every tracked group on SIGTERM, SIGINT, SIGHUP and exit. When nothing else handles the signal, it re-raises the signal so the wrapper still dies by it. Tests use real processes: a grandchild left by `sleep 300 &` is gone after a stall kill and after the wrapper is signalled or exits, a child that does not lead a group gets child.kill with no negative pid, and the unsupervised claude/codex spawn carries no detached option. Mutation check: reverting killChildren to child.kill alone fails the grandchild test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013ENddpGzhE7S8qjDSYAMuu
… keeps the turn A group kill misses descendants that moved to a group of their own. Live codex seats put every MCP server and tool command in a group of its own, and claude's Bash tool shells do the same. - Descendant walk: before any signal, snapshot `ps -A -o pid=,ppid=,lstart=`. Then signal the child's descendants deepest first, re-reading each pid right before its signal. A pid is killed only if its ppid and start time still match the snapshot, so a pid reused by another process on the host is left alone. The walk runs before the group kill, so each pid's parent is still alive when its ppid is checked. Nothing loosens the ppid match. - SIGTERM/SIGINT/SIGHUP: when a host handles the signal itself, the registry no longer kills groups, so the in-flight turn survives. With no host handler, it still kills them and re-raises. - armSupervisedGroupDeadline(ms): the host kills the groups by a deadline it sets before launchd's SIGKILL, which no handler can catch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013ENddpGzhE7S8qjDSYAMuu
…eOut On SIGTERM, `agent run` still stops polling and finishes the in-flight turn. It now also arms a supervised-group kill a margin before ExitTimeOut, which it reads (only) from ~/Library/LaunchAgents/me.commonly.seat-<name>.plist. An unset, zero or unreadable value means launchd's 20 s default. The margin is min(5 s, a quarter), so the default kill lands at 15 s. Its exit path calls killSupervisedGroups() for groups still live once the turn ends. With no supervised group live, both calls do nothing, so today's seats are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013ENddpGzhE7S8qjDSYAMuu
…an fire unseen The pid-reuse test awaited `once(unrelated, 'spawn')` only after reading the grandchildren's pids. By then the event had already fired, so the test hung until node's test timeout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013ENddpGzhE7S8qjDSYAMuu
The daemon spawns some seats, and those have no plist. The daemon stops a seat with SIGTERM and never sends SIGKILL, so nothing there needs launchd's 20 s fallback. Arming it meant a reassignment or config change cut an in-flight turn's tools at 15 s. - readSeatPlist reports whether the seat's plist exists. With no plist, SIGTERM arms nothing and the exit path alone kills the supervised groups. A plist that exists but has no ExitTimeOut, or can't be read, keeps launchd's 20 s default, because launchd will SIGKILL that seat. - process-group.js header: document the walk's two known gaps. A descendant reparented to 1 mid-walk is skipped. A process started after the snapshot is missed, and so is one that calls setsid after it. Tests use real processes. With a plist, the groups die before ExitTimeOut while the turn is still running. With no plist, the turn runs past 15 s and the groups die on exit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013ENddpGzhE7S8qjDSYAMuu
68ced27 to
21ccefa
Compare
lilyshen0722
left a comment
There was a problem hiding this comment.
Re-gate at 21ccefa, after the rebase onto feat/jellymoon-record at 710b91b (#2701).
I checked the rebase with git range-diff against 68ced27, my previous PASS. Commits 2 through 5 are identical. Commit 1 differs only in its import line in supervisor.js: it now imports redactKnownCredentials from #2701 alongside the process-group import. Every other hunk applied unchanged, so the review at 68ced27 covers this code. There are 5 commits ahead of the base, all by Lily. The plist-existence fix and the residuals note are both still in commit 5.
GATE: PASS
When the seat runner's supervisor kills a stalled turn, the kill now reaches everything the agent CLI started, not only the CLI's own process. Before this change, the kill reached only the direct child, so the CLI's MCP servers, tool commands and helper processes kept running on the host. An agent CLI that the supervisor kills is now started as the leader of its own process group, and it is killed as a group, together with any descendant that moved to a group of its own.
Stacked on #2701's branch (
feat/seats-record-redact), which carries the supervisor.What changes
packages/seats/src/process-group.js(new).spawnInOwnGroupstarts a child detached and marks it. Nothing infers that a child leads a group; only a marked, live, unreaped child is ever signalled by negative pid.killProcessTreereads the process table once (ps -A -o pid=,ppid=,lstart=) before any signal. It kills the child's descendants deepest first, then signals the group, or falls back tochild.kill.packages/seats/src/supervisor.js. Setsctx.ownProcessGroup = truefor the adapter, tracks any group-leading child it reports throughonSpawn(including a late one), and kills throughkillProcessTree.cli/src/lib/adapters/{claude,codex}.js.spawnInOwnGrouponly when the supervisor asks for it.spawnunchanged, so seats that run without the supervisor (every live seat today) spawn exactly as before.agent rundoes this: it finishes the in-flight turn on SIGTERM.cli/src/lib/seat-stop.js(new) andagent run.agent runkeeps the turn and arms a deadline, and its exit path callskillSupervisedGroups().ExitTimeOutminus min(5 s, a quarter). It is read from~/Library/LaunchAgents/me.commonly.seat-<name>.plistand never written. Unset, 0 or unreadable means 20 s, launchd's default, so the kill lands at 15 s.Choices to check
Why codex needed the walk
pson live seats showed codex starting its MCP server, its helper processes and every tool command (jest, tsc, vite) in a group of its own. A group kill of codex alone reaches only the launcher and the binary. Claude's MCP servers share its group, and only its Bash-tool shells move out.Tests
All runs were in a container, under the shared test lock.
supervisor-group-kill,supervisor-tree-and-deadline)packages/seatsseat-stoptestssrcfilesBoth failures also fail on the base sha
a1e3d9ec4:valet-env's positive control needs~/.claudeon the host.claude.environment"bwrap off darwin" is a Linux-only check failing on macOS.The new tests cover:
ExitTimeOut;child.killand never a negative pid;detached.Mutants, each of which fails its target test while the rest still pass:
ExitTimeOutseat-stopdoesn't arm the deadlineseat-stoptestsNo version lines changed.
🤖 Generated with Claude Code
https://claude.ai/code/session_013ENddpGzhE7S8qjDSYAMuu