Repository navigation
fix(host): close the idle, EPERM and in-session gaps of destroy - #216
Conversation
…unning The idle time-out only sends SIGHUP. A program that ignores it keeps running, and the session stays running forever. This test fails on main: the session is still running 10 s after a 300 ms idle time-out. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ Signed-off-by: Thomas Kosiewski <tk@coder.com>
The idle time-out only sent SIGHUP. A program that ignores SIGHUP kept running, so the session never ended. The idle time-out now hangs up the program the way shutdown does: SIGHUP now, and a kill of the program's process group after the 2 s grace. Both call sites share one helper and one timer. The host clears the timer when the PTY exits, so a program that exits on SIGHUP keeps its leftover background processes, as before. The final status of an idle time-out stays exited. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ Signed-off-by: Thomas Kosiewski <tk@coder.com>
When every process left in the program's group belongs to another user, kill(-pgid) fails with EPERM. The group helpers throw it. The host dies from the throw in its kill timer or in handlePtyExit, and destroy, destroy --force and the reconcile after a host crash end with a raw Node stack and no JSON envelope. A preload fixture makes every group signal fail with EPERM, which stands for such a group. The integration tests fail on main with an empty stdout. The unit tests show the probe and the kill throw EPERM, and that reconcile records the session failed although its group survived the kill. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ Signed-off-by: Thomas Kosiewski <tk@coder.com>
When every process left in the program's group belongs to another user, kill(-pgid) fails with EPERM. The group helpers threw it, so the host died in its kill timer or in handlePtyExit, and every CLI path printed a raw Node stack instead of a JSON envelope. EPERM now means "a process of the group exists" for the probe, and the kill ignores it as it ignores ESRCH. A kill that returns 0 is not proof either: with `sudo cmd &` the group holds sudo, which we may signal, and a root child, which survives. So every writer of destroyed or of childPid null first waits until the group is gone: - handlePtyExit records destroyed only after the wait. If the wait gives up, the manifest stays destroying and the host still shuts down. - reconcileSession returns without a write while the group lives, so a later reconcile retries. - destroy --force needs no change: its wait now times out with HOST_TIMEOUT. `wait` read the exit code from the session record, which handlePtyExit now fills in later for a destroyed session. It reads the PTY's exit code instead, so `wait --exit` during a destroy still reports it. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ Signed-off-by: Thomas Kosiewski <tk@coder.com>
The test read the manifest as soon as destroy returned. The host gives up on the group at about the same time as destroy, so a host that wrote destroyed after a failed wait could still pass. The test now waits for the host to exit first. It fails if handlePtyExit records destroyed without the wait, or after a wait that gave up. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ Signed-off-by: Thomas Kosiewski <tk@coder.com>
The idle time-out test had its own copy of the group probe that the lifecycle tests define. The case 7 tests need it too, outside the describe block that held it. One copy now lives in test/helpers.ts. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ Signed-off-by: Thomas Kosiewski <tk@coder.com>
A program that runs `agent-tty destroy "$AGENT_TTY_SESSION_ID"` runs the CLI inside its own process group. The destroy kills that group, or the kernel hangs it up when the session leader exits, so the CLI dies before it prints. With --force the session also ends exited instead of destroyed. Both tests fail on main with no output. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ Signed-off-by: Thomas Kosiewski <tk@coder.com>
A program that runs `agent-tty destroy "$AGENT_TTY_SESSION_ID"` starts the CLI inside its own process group. --force kills that group, and a plain destroy ends the program, whose exit makes the kernel hang up the group. Either way the CLI died before it printed, and with --force the session ended exited instead of destroyed. destroy now compares its own process group id with the manifest's childPid. When they match, it runs the same command again in a new session, outside the group, waits for it and exits with its exit code. That copy takes the normal path and prints the result. Linux reads the group id from /proc/self/stat, other POSIX systems ask ps, and Windows has no process groups. Rejected: print first and kill the own group last. A plain destroy shows that the CLI dies from the kernel's SIGHUP when the session leader dies, before any self-kill could run, and that order would write destroyed before the group is gone. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ Signed-off-by: Thomas Kosiewski <tk@coder.com>
The case 7 integration test cannot tell the group id from the parent pid or the session id: in that test all three are the program's pid. This test compares the lookup with `ps -o pgid=` in the test process, where they differ. It also runs the ps branch on Linux, since no CI job runs the tests on macOS. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ Signed-off-by: Thomas Kosiewski <tk@coder.com>
lifecycle.ts imports stat from node:fs/promises. The own-group lookup named its /proc/self/stat text stat too, which hid that import inside the function. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ Signed-off-by: Thomas Kosiewski <tk@coder.com>
Bash runs a typed command as a job in its own process group. So a destroy that a user types into the session's interactive shell does not share the program's group, and the group check does not see that it runs inside the program. The CLI still dies when the shell hangs up its jobs or the shell's exit hangs up the foreground job. The job's session id is the program's pid, though. The new integration tests run only on Linux, where destroy can read the session id. They fail at the group check with no output. The unit test now expects the Linux lookup to match the caller's session. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ Signed-off-by: Thomas Kosiewski <tk@coder.com>
…sion A shell with job control puts each typed command in a process group of its own. So a destroy typed into the session's interactive shell did not match the program's group, and it died without output as before. The program leads its session, so every process inside that session has the program's pid as its session id. On Linux, destroy now compares its own session id, the field after pgrp in /proc/self/stat, with the manifest's childPid. That covers both a CLI in the program's group and a job of the program's shell. The helper is now cliRunsInsideProgramSession, and the rerun is rerunInNewSession. Other POSIX systems still compare the process group through `ps -o pgid=`, because POSIX ps has no session id field. So macOS still misses a destroy typed into an interactive shell's job. That gap is documented here, not fixed. Windows is never inside. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ Signed-off-by: Thomas Kosiewski <tk@coder.com>
The program gets AGENT_TTY_SESSION_ID at spawn, but its pid reaches the manifest only with the host's start-up write. A destroy that the program runs before that write reads childPid null, so cliRunsInsideProgramSession says it runs outside and destroy does not re-run itself. With --force it kills the host, the PTY hangs up the program's group, and the CLI dies before it prints. The session then reads failed. The integration test holds the host's start-up rename with replay-hold, so the destroy always runs in that window. It fails with no output. The unit test expects the variable to mark a caller inside the session. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ Signed-off-by: Thomas Kosiewski <tk@coder.com>
…orded The program has AGENT_TTY_SESSION_ID from its spawn on, before any manifest names its pid. So cliRunsInsideProgramSession now returns true when that variable equals the session id, and only then compares the manifest's childPid with the CLI's session id or process group. A destroy that the program runs before the host's start-up write now re-runs in a new session, prints its result, and records destroyed. The id comparison stays: `create --env AGENT_TTY_SESSION_ID=x` overrides the injected value. A needless re-run is harmless, because the check only routes the CLI and never decides a kill. As a side effect, macOS now also sees a job of the program's interactive shell as inside, as long as the job kept the variable. The re-run copy gets an environment without AGENT_TTY_SESSION_ID. Without that, the copy would match the variable again and start yet another copy, without end. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ Signed-off-by: Thomas Kosiewski <tk@coder.com>
The lifecycle model no longer matched the code after the fixes for #203 cases 3, 6 and 7. KillsProgramGroup read FALSE because recordDestroyed moved out of handlePtyExit's destroying block, so `current` failed. The model now has a process of another user in the program's group, which our SIGKILL does not reach. The host, reconcile and destroy --force wait for the group, and those waits can give up. A destroy can also run inside the program's session. New guards: ProbeCountsDeniedGroup, HostWaitsForGroup, ReconcileWaitsForGroup, ReRunsInNewSession and InsideCheckReadsEnv. New rules: R13 LiveGroupStaysNamed and R14 DestroyOutlivesProgram. R11 now counts the process of another user. Without KillsProgramGroup the waits read the group gone, as with the pid waits before the group kill, so its mutant still breaks R11. coverage.json is not updated here, so check.sh exits 3 until the sync commit. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ Signed-off-by: Thomas Kosiewski <tk@coder.com>
…PtyExit handlePtyExit has two blocks under `currentStatus === 'destroying'`: the group kill and, after the wait, recordDestroyed. The KillsProgramGroup pattern kept only the last block it visited. So a scratch edit that left recordDestroyed under the bare status check also turned KillsProgramGroup FALSE, and that run did not show HostWaitsForGroup on its own. The pattern now reads TRUE when any such block kills the group. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ Signed-off-by: Thomas Kosiewski <tk@coder.com>
The skill now lists R13 and R14, the new covered code, the five new guards with the race each one prevents and a replay of it, and the run counts. Scope says what the model assumes for #203 cases 3, 6 and 7, and it keeps cases 1, 2, 4 and 5 as open. Each replay was run against this tree and showed the safe result in its last comment. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ Signed-off-by: Thomas Kosiewski <tk@coder.com>
origin/main is at ce85b3f. #212 changed the Scope section of the lifecycle skill: gc's rm now retries, and the artifact commands no longer bring back a removed session folder. The conflict was in the "It leaves out" paragraph. The resolution keeps this branch's open cases of #203 and takes #212's artifact item and its new paragraph. The gc rm bullet and the GcRemoves comment merged cleanly. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ Signed-off-by: Thomas Kosiewski <tk@coder.com>
…ixes The model describes the merged tree: the destroy reach fixes for #203 cases 3, 6 and 7, and #212's gc rm retries, which the model still takes as one step. Each of the five new guards was broken once in a scratch edit of the code, and each time `current` turned UNEXPECTED on the rule that the guard protects. This commit becomes the sync commit. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ Signed-off-by: Thomas Kosiewski <tk@coder.com>
|
@codex review |
|
@codex security review |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
Security findingsAdvisory findings (2)
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9fbd31ed2f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
When the idle time-out hangs up a program that exits on SIGHUP at once, a background process in its group that ignores SIGHUP keeps running after the session ends exited. This test reproduces that case and fails on 9fbd31e. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
…hang-up handlePtyExit cleared the 2 s group kill and recorded exited, so a background process that ignored SIGHUP outlived the idle time-out. The exit after an idle hang-up now kills the group, as the destroying branch does. A program that exits on its own still keeps its background processes. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
…oup kill handlePtyExit now kills the program's group when the program exits on the idle hang-up. The model still describes the code: that kill only ends processes, and the normal exit drops them in the model anyway. The Scope bullet, the timer paragraph and the model comments now name the kill, and coverage.json records runHost at this commit. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
|
@codex review |
|
@codex security review |
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
There was a problem hiding this comment.
🛡️ Codex Security Review
Here are some automated security review suggestions for this pull request.
Reviewed commit: bbc95b7bcb
ℹ️ About Codex security reviews in GitHub
This is an experimental Codex feature. Security reviews are triggered when:
- You comment "@codex security review"
- A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review
Once complete, Codex will leave suggestions, or a comment if no findings are found.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bbc95b7bcb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
With /proc mounted hidepid=2 (or hidepid=invisible), the scan cannot list the processes of other users. A group that holds a zombie of ours and a hidden live process of another user then reads gone, so destroy records destroyed while that process runs. These cases fail now. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
hidepid=2 leaves other users' processes out of the /proc listing, so the scan cannot tell a zombie-only group from one with a hidden live process of another user. The probe now reads the /proc mount options and, under hidepid=2 or an unreadable mountinfo, counts the group alive as it did before the scan. Codex security review raised this on #216. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
The probe now skips its zombie scan when /proc hides other users, so a group with a zombie reads alive there, as it does on macOS. The model has no zombies and no /proc scan, so it still describes the code. Scope says so, and the covered list gains procHidesOtherUsers. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
|
@codex review |
|
@codex security review |
|
Codex Review: Didn't find any major issues. Delightful! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
There was a problem hiding this comment.
🛡️ Codex Security Review
Here are some automated security review suggestions for this pull request.
Reviewed commit: b158e2bd2a
ℹ️ About Codex security reviews in GitHub
This is an experimental Codex feature. Security reviews are triggered when:
- You comment "@codex security review"
- A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review
Once complete, Codex will leave suggestions, or a comment if no findings are found.
The zombie scan tests faked /proc but read the host's real mountinfo. On macOS that file does not exist, so the probe skipped the scan and three cases failed in quality-gates-macos. They failed the same way on a Linux host whose /proc has hidepid=2. The fake /proc now comes with a plain /proc mount line, and a read of mountinfo with no fake line fails ENOENT, so no case depends on the host. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
hidepid=ptraceable (hidepid=4) also leaves processes of other users out of the /proc listing, and with two /proc mounts the last one is in effect. The probe reads only hidepid=2 and the first /proc line, so in both cases a zombie of ours beside a hidden live process reads gone. These cases fail now. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
hidepid=ptraceable (hidepid=4) also leaves processes of other users out of the /proc listing, so the probe now skips its zombie scan there too. With several /proc mounts the last mountinfo line is the one in effect, so the probe reads that one instead of the first. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
…ble check The probe now also skips its zombie scan under hidepid=ptraceable and reads the top /proc mount. The model has no zombies and no /proc scan, so it still describes the code. Scope names both modes. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
|
@codex review |
|
@codex security review |
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 248dad38a9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…#221) * feat(host): record the program's session token in program.json Every group kill trusts the number in session.json, which can name a group that the kernel has since given to another program. On Linux, setsid() gives the program's session an autogroup id that never repeats within a boot, and fork() passes it on, so the boot id and that id prove which session a process belongs to. The host now captures both right after it spawns the program and writes them, with the pid, to a private program.json before its first manifest write and before it awaits anything. Nothing reads the record yet. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * test(host): show kills that trust a stale or missing childPid Reconcile and destroy --force kill whatever group the manifest's childPid names. These tests point the manifest and program.json at an unrelated group that took the program's group id, and reconcile and destroy --force kill it. A host that dies before its first manifest write leaves a program that no manifest names, and destroy --force leaves it running, although program.json names it. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * fix(host): kill a program group only when program.json proves it Every group kill now asks one module whether the group is the program's. A record with a token proves it when a live member of the group has the program's pid as its session id and the recorded autogroup of the same boot. Without that proof the kill does not happen, so reconcile and destroy --force leave an unrelated group that took the id alone. The record also names a program whose host died before its first manifest write. The module reads the witness again right before the kill. A record without a token, or no record, keeps the kill of the manifest's childPid. The raw group kill moves into the module, so no other code can call it. The kill after the program exits on an idle hang-up goes through the module too. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * test(cli): show destroy leaves the processes of a program that exited A program that exits on its own can leave a process in its group. destroy of the exited session records it destroyed and leaves that process running, although program.json proves it is the program's. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * fix(cli): stop the processes left by a program that exited on destroy destroy of a session whose program exited, failed or ended during the destroy now kills the program's group first when program.json proves the group is the program's, and waits for it to end. If it does not end in the destroy wait, destroy fails with HOST_TIMEOUT and does not record the session destroyed. Without a token destroy kills nothing, as before, and it never kills the hostPid of a finished session. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * refactor(storage): make the sync record write state createParentFolders: false Since coder#212 the shared writers create a missing parent folder unless the caller passes createParentFolders: false. writeValidatedJsonFileSync never creates one, so its type now requires that option, and the program.json write states it like the other session-folder writes. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * docs: describe what destroy stops and where its proof ends docs/USAGE.md gets a destroy section: destroy and reconcile kill only a group that program.json proves is the session's, and they keep the plain childPid kill where no token exists. It names the limits: processes that leave the group with setsid or job control, platforms and sessions without a token, the microseconds between the check and the kill, the startup gap before program.json exists (3 to 6 ms median, up to 80 ms under load in this measurement) and gc removing the record. The host and storage contracts list the new module, path and sync writer. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * refactor(host): name the proven-group read and drop restating comments Destroy of a finished session now reads its group through readProvenProgramGroup instead of passing a null childPid, and the helper that kills leftovers before the destroyed write says so in its name. ownLinuxSessionId reuses parseProcStat, parseAutogroup refuses text that names no autogroup, and comments that restated the code or claimed the program stays unreaped go. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * test(host): show an unexpected autogroup file crashes capture and kill checks A kernel that prints the autogroup file in another format, or a member whose file cannot be read, makes parseAutogroup throw. That throw would end the host at start-up and make destroy or reconcile fail. The tests expect such a file to carry no token instead. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * fix(host): read an autogroup file it cannot parse as no token parseAutogroup threw on text in an unexpected format, so a kernel that prints the file differently would end the host at start-up and make destroy and reconcile fail. Now such text, and a file that cannot be read, names no autogroup. At start-up the record then has no token and today's kill applies. At a check the member is no witness, so the group reads unproven and gets no kill. The capture also stops as soon as the child leads its session without a readable autogroup, instead of polling for the rest of its 250 ms. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * test(cli): show a corrupt program.json hides the session and fails destroy A program.json that does not parse, or that another version wrote, makes reconcile and destroy --force throw MANIFEST_VALIDATION_ERROR. list then skips the session, and destroy --force cannot record it. The tests expect such a record to prove nothing: no kill, no throw. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * fix(host): treat a program.json that does not validate as unproven A record that does not parse, or that a newer version wrote, made reconcile and destroy throw, so list hid the session and destroy could not record it. Such a record proves nothing about the group, so it now gets no kill and no throw. A missing record still falls back to the manifest's childPid, as before program.json existed. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * docs: list program.json among the files of a session folder The host now writes program.json into every session folder, so the README line that lists those files names it too. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * test(host): show a reconcile kills a stale childPid after gc removed the folder When gc removes the session folder between the re-read of session.json and the read of program.json, the record reads missing, and the kill falls back to the childPid of the copy that reconcile read before. By then the group can be gone and its id can name an unrelated group. The test expects no kill once the session's files are gone. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * docs(skills): model the program's session token in the lifecycle check The model now has program.json and its token (rec), an environment step that gives the group id of an ended group to an unrelated group (reused), a ghost for a kill that reaches that group, the processes a program leaves when it exits on its own, and the window between the spawn and the record. It adds the record step of the host, the kill of proven leftovers in destroy of a terminal session, and the program.json read before each CLI group kill. R15 NoUnrelatedGroupKilled says no kill reaches the unrelated group. R16 DestroyedSessionStopped says a destroyed session runs no process of the program's group, also none it left, unless the host died before program.json named the program. Four guards protect them: RecordsProgramSync, ProvesGroupOwnership, DestroyStopsLeftovers and FallbackNeedsFolder. The last one reads FALSE on this code: the model found that a kill without a token can use a childPid that gc's removal left stale. facts.mjs follows the group helpers into programGroup.ts. With a token the token check, not the EPERM probe, reads a process of another user as alive, so the ProbeCountsDeniedGroup mutant now passes. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * fix(host): kill a group that only a childPid names only while its folder exists Without a token, readProgramGroup falls back to the childPid of a manifest read made before it. When gc removed the session folder in between, program.json reads missing too, and the kill used a childPid whose group can have ended and whose id can name an unrelated group by then. A session with a record names its childPid only after the record, so a record missing from a folder that still exists marks a session that never had one. The fallback now carries the session folder, and stopProgramGroup and isProgramGroupAlive check it in the same synchronous block as the kill or the probe. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * docs(skills): read the record and the stop of the leftover helper apart A scratch break that dropped the stop from stopLeftoversThenRecordDestroyed also turned DestroyRecordsDestroyed FALSE, though the helper still records destroyed. DestroyRecordsDestroyed now asks only that the helper ends with recordDestroyed, and DestroyStopsLeftovers asks for the stop before it. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * docs(skills): let a program leave a process only while its group runs mutant-CreateRecordsHostPid ended in a TypeOK error: destroy --force killed the group of a host it could not name, and the host's exit step then made up a leftover in a group whose id another group had taken. A program that exits on its own leaves a process now only when one of its group still runs. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * docs: describe the unproven record and the folder check of the fallback kill A program.json that does not validate now proves nothing instead of failing reconcile and destroy, and a group that only a childPid names gets the kill only while its session folder exists. The host README and the destroy section of USAGE.md say so. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * refactor: drop comments that restate the code of the program group A review of the comments that this branch adds found several that only restate the code next to them, and one that called a write durable without an fsync. Those go. The tests name the platform gate once as capturesToken, clean up a group through one helper that ignores only ESRCH, and check that the unrelated group is not a zombie instead of relying on init to reap it. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * docs(skills): record the lifecycle model sync for the program's session token The check passes at this commit: 42 runs, 4,304,893 states of the current model, every result as expected. The scratch breaks of RecordsProgramSync, ProvesGroupOwnership and DestroyStopsLeftovers, and the code before the folder check of the fallback kill, each turned the current run UNEXPECTED on the rule that the guard protects. SKILL.md lists R15 and R16, the covered code of programGroup.ts, one race section per new guard with a replay against the real CLI, and the limits that stay: the microsecond gap between the check and the kill, the start-up gap before program.json names the program, case 4, and sessions without a token. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * test(host): show the token check crashes on a refused read and trusts a zombie under hidepid=2 PR 1 counts a /proc read refused with EPERM or EACCES as alive, and under hidepid=2 it does not let a zombie prove the group gone. The token check throws on a refused read in the scan and in the capture, and reads a group with only a zombie in view as gone while /proc hides other users, so a process of another user that /proc hides outlives a destroy that records destroyed. The tests fake /proc and fail on this code. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * fix(host): give the token check the probe's refused-read and hidepid=2 rules A /proc read that hidepid=1 or a security module refuses now hides that process from the token scan and the capture instead of throwing, so it never witnesses the token. When no live member of the group is in view, a zombie or a hidden process of another user can stand for one, so the token check then reads the group as isProcessGroupAlive does: alive while kill(-pgid, 0) finds a process and /proc hides or refuses other users, gone when every process in view is a zombie. A live member in view still decides alone, because the members of a group share one session. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * test(cli): show destroy times out on a member that outlives an idle exit The coder#216 review found that an idle time-out records exited while a group member that the kill cannot stop runs, and that a later destroy then records destroyed. The EPERM preload stands for that member. The session still ends exited, and on Linux the token now makes destroy wait for the member and fail with HOST_TIMEOUT instead. On PR 1's code the same test fails: destroy reports ok and records destroyed. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * docs(skills): check the lifecycle model without the program's session token too The model took the token as always captured, so it no longer checked the childPid fallback that macOS, kernels without autogroups and sessions of older versions use, and mutant-ProbeCountsDeniedGroup passed. A Token constant now picks the path. current keeps every rule with the token. current-no-token checks R1 to R14 without it, because R15 and R16 are what the token adds. There the host kills and probes the program's pid as it is ("bare"), and readProgramGroup falls back to the childPid. The EPERM probe and the group kill, host wait and reconcile wait mutants break R11 and R13 on that path again, and six witnesses show it reaches the fallback. facts.mjs also covers the /proc reads of the token check. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * docs: give one measured start-up gap for program.json USAGE.md said 3 to 6 ms median and up to 80 ms, and the skill said p50 2.5 ms and at most 74 ms, from two different measurements. Both now give one measurement with its method: 150 sequential create calls on Linux, timed with performance.now() in a scratch build of runHost from the return of node-pty's spawn to the renamed program.json, at 1-minute load averages of 38 to 83. The median was 2.8 ms, the 90th percentile 4.3 ms and the longest 66 ms. The skill adds that captureProgram took a median of 1.3 ms and at most 7.3 ms of it. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * docs(skills): record the lifecycle model sync for the token check's hiding rules The model now runs with and without the program's session token, and the token check reads a group with no live member in view as the probe does. check.sh ran all 52 runs as expected: current has 4,304,893 distinct states and current-no-token 5,218,828. coverage.json records the new hashes and is formatted, because --update-coverage writes arrays that oxfmt joins. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * test(host): wait for the killed group in the token kill test The test read the group right after stopProgramGroup sent SIGKILL. The process is still running at that moment: 198 of 200 sleeps still showed a live state in /proc right after kill(-pgid, SIGKILL). Locally the full /proc scan before the read took long enough, so the test passed, but it failed in CI test-unit. It now waits until the group reads gone. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * test(host): capture with a boot id that /proc refuses to show A hardened /proc can refuse the boot id with EACCES or EPERM while it shows the autogroups. captureProgram then throws, and the host dies at start-up, so every create fails with HOST_UNREACHABLE. This test fails until the capture records no token instead. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * fix(host): record no token when /proc refuses to show the boot id readBootId rethrew EACCES and EPERM, so on a hardened /proc that refuses the boot id but shows the autogroups, captureProgram crashed the host at start-up and every create failed with HOST_UNREACHABLE. A refused read now reads as no boot id, so the capture records no token and the session falls back to the childPid kill, as on a kernel without autogroups. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * test(host): a member whose token cannot be read reads alive When /proc refuses the autogroup of a live member that leads the program's group and session, or refuses the boot id, the token check cannot tell whether the group is ours. Today it reads that group gone, so destroy of an exited session records destroyed while the process that the program left still runs. These tests fail until such a group gets no kill and reads alive, so the wait ends in HOST_TIMEOUT. A member whose autogroup reads as another id still reads gone. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * fix(host): read a group whose token cannot be read as alive judgeProgramGroup gave unproven both to a group that shows another autogroup or boot id and to one whose token /proc refuses to show. The second can be the program's own group, and isProgramGroupAlive read it gone, so destroy or a reconcile could record a terminal status while a process of the session ran. A new verdict, unknown, covers a boot id that cannot be read and a member of the program's session without a readable autogroup. It gets no kill and reads alive, so the wait ends in HOST_TIMEOUT. A member whose autogroup or boot id reads as another one stays unproven and reads gone. facts.mjs now finds the boot id check as its own early return. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * test: run the token tests only where the host can capture a token The tests that need program.json to hold a token ran on every Linux. On a kernel without autogroups, or under a /proc that refuses the autogroup or the boot id, the host rightly records no token, so those tests expected a kill or a time-out that production skips, and 17 of them failed. They now run only where /proc/self/autogroup names an autogroup and the boot id reads, through one capturesToken flag in test/helpers.ts. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * test(host): a zombie leader with a live thread stays alive When the main thread of the program calls pthread_exit while another thread runs, /proc/<pid>/stat shows Z, but /proc/<pid>/task shows the running thread. Both the token check and the scan without a token skip such a process as a zombie, so destroy never kills it and ends with HOST_TIMEOUT while the status stays destroying. These tests fail until a zombie leader with a live thread counts as live, so the token check can take it as the witness. The fake /proc of the unit tests now lists the threads of each process. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * fix(host): count a zombie leader with a live thread as live /proc/<pid>/stat shows Z for a process whose main thread called pthread_exit while another thread runs. judgeProgramGroup and the scan without a token skipped it as a zombie, so with a token stopProgramGroup never killed it, and destroy ended with HOST_TIMEOUT while the program ran on. readProcStat now gives such a process the state of its first live thread from /proc/<pid>/task, so it is a member that can carry the token and gets the kill. A thread read that /proc refuses, or that vanished, counts as a refused or vanished stat read does. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * test(host): use the shared stat tail in the fake process table The thread fake added STAT_TAIL, and procShowsGroup kept a local alias of it. Both helpers now use the constant directly. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * test(host): destroy ends a zombie-leader child that outlives the program The worse form of the zombie leader: `sh -c 'python3 zombie-leader.py & wait'`. The SIGHUP of destroy ends sh, the python child ignores it and shows Z while its thread runs, so the group read empty. Before the fix, destroy reported destroyed while that process still ran. The test runs both forms, the zombie-leader program and the child that outlives it, and waits until /proc shows Z before the destroy. On 0679782, before the fix, both fail: the first with HOST_TIMEOUT, the second because the group outlives a destroy that reported success. Both pass since 9e5fc6a. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * docs(skills): sync the lifecycle model with the unknown verdict and live threads The model has no refused /proc reads and no zombies, so its actions stay as they are. The comments of ProvenKill and GroupSeen, and the Scope, now say what the code does when /proc refuses the boot id or a member's autogroup (unknown: no kill, reads alive, destroy ends with HOST_TIMEOUT) and that a process whose main thread exited counts as live while another of its threads runs. facts.mjs finds the boot id check as its own early return and covers readBootId and readLiveThreadState. docs/USAGE.md and src/host/README.md describe the new verdict, the no-token cases and the thread reads. check.sh --update-coverage: 52 runs, all as expected, exit 0. current has 4,304,893 distinct states and current-no-token 5,218,828. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
Why
The operator wants every follow-up of #203 landed.
destroymust stop what the session started, and it must not reportdestroyedwhile the program's process group still runs. This PR fixes three of the seven cases in #203 (3, 6 and 7). A second PR covers the other four.What changed
exited.destroyedor clearchildPid. Until then,destroyfails with the existingHOST_TIMEOUT.destroythat runs inside the program's session now runs again in a new session (case 7). This covers adestroyrun by the program and one typed into its shell. It prints its result, and the session endsdestroyed.LiveGroupStaysNamedand R14DestroyOutlivesProgram, plus five guards.Scope
destroytyped into a shell job whose environment lostAGENT_TTY_SESSION_IDstill dies there, because macOS has no portable way to read a session id. Linux compares the session id.Tradeoffs
destroyedwhile the group still runs.Blast Radius
The change touches
destroy, reconcile (list,inspectandgc) and the idle time-out. A session whose group holds a process of another user now staysdestroyingorrunning. Each reconcile of such a session takes about 5 seconds. Adestroywhose environment carries the target'sAGENT_TTY_SESSION_IDruns again in a new session even when the caller is outside the session, which adds about 400 ms. The re-run prints its result to the caller's stdout. When the program itself runsdestroy, that stdout is the session's terminal. CLI JSON and error codes stay the same.Verification
exitedby SIGKILL at about 3 seconds.destroyreturns aHOST_TIMEOUTenvelope.destroyprinted nothing. Now it printsok: trueanddestroyed: true.Refs #203
Generated with
xum• Model:anthropic:claude-opus-5-5• Thinking:xhigh