Repository navigation
fix(storage): keep a session folder removed after gc in artifact writes and gc's rm - #212
Conversation
…lder The artifact tests fail because ensureArtifactsDir, the artifact manifest write and the snapshot write each create the session folder again after gc removed it (#198). The gc test fails because a manifest write that lands during gc's rm makes the rm fail with ENOTEMPTY and leaves session.json behind (#202). _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
…es and gc's rm ensureArtifactsDir now creates only the artifacts folder inside an existing session folder and reports SESSION_NOT_FOUND when gc removed that folder. The snapshot, asciicast and artifact manifest writes pass createParentFolders: false, and the ghostty-web backend writes the screenshot PNG and copies the video itself, because Playwright's path and saveAs options create missing folders. writeArtifactManifest checks the artifacts folder first, so record export --out still creates it. gc's rm retries 3 times, 10 ms apart: a manifest write that lands during the rm no longer leaves session.json behind. createTemporarySessionDir now creates the session folder, as every real session has one. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
The model still treats gc's rm as one step, and Scope now says why a manifest write during the rm no longer leaves session.json behind. It also says why the artifact commands cannot bring back a removed session folder, and that unit tests check it, not the model. replay-hold.mjs can hold a mkdir call, which replays the Playwright mkdir that brought the folder back. coverage.json is regenerated: only gcSessions and the model's GcRemoves comment changed since the last sync. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
|
@codex review |
|
@codex security review |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ 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. |
|
Codex Review: Didn't find any major issues. Keep it up! 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". |
🛡️ 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. |
|
Verification details for this PR. #198, CLI replay. Each case runs in its own isolated run folder (
On main, #202.
Gates on 3a32edf.
|
…id-capture When gc removes the session folder after ensureArtifactsDir, the screenshot's PNG write or its rename fails with a raw ENOENT, so the CLI prints a stack trace and no JSON envelope. A second test checks that finalizeVideo does not create the output folder again, as Playwright's saveAs did. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
…mid-capture captureScreenshotResult still removes the temp PNG on any failure. It now turns an ENOENT into SESSION_NOT_FOUND when the session folder is gone, so screenshot prints an error envelope and exits 3. Any other error stays as it was. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
The Scope section said unit tests check every artifact write. It now names the writes whose tests remove the session folder, and says that the asciicast write's test checks only its createParentFolders option. It also writes gc's rm options in full. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
|
Round 2 changes, verified on 2d70191. Screenshot mid-capture. I held the screenshot's temp PNG write with
With the hold on the rename into Tests. Two new unit tests remove the folder before the PNG write and before the rename. Both fail on 3a32edf with a raw Gates.
|
|
@codex review |
|
@codex security review |
|
Codex Review: Didn't find any major issues. 🎉 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". |
🛡️ 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. |
…ring a publish publishArtifactFile wrapped every file-system error in STORAGE_WRITE_ERROR, so captureScreenshotResult could no longer map the ENOENT from a removed session folder to SESSION_NOT_FOUND (#212). It now returns the sessionNotFoundIfRemoved result first. The storage README now says this, and it no longer says that ensureArtifactsDir is recursive or that writeTextFileAtomic always creates the parent directory. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
…r#216) * test(host): show the idle time-out leaves a SIGHUP-ignoring program running 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> * fix(host): kill the program's process group after an idle hang-up 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> * test(host): show a group kill that fails with EPERM crashes destroy 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> * fix(host): keep destroy and reconcile alive when a group kill gets EPERM 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> * test(host): check the manifest stays destroying after the host gives up 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> * test: share the process-group probes between integration tests 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> * test(cli): show destroy run by the program itself prints nothing 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> * fix(cli): print the result of a destroy run inside the program's group 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> * test(host): check the own process group lookup against ps 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> * refactor(host): stop the /proc read shadowing the stat import 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> * test(cli): show destroy typed into an interactive shell prints nothing 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> * fix(cli): rerun a destroy typed into the program's shell in a new session 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> * test(cli): show destroy run before the host names the program dies 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> * fix(cli): rerun a destroy that the program runs before its pid is recorded 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> * docs(skills): model the destroy reach fixes in the lifecycle check The lifecycle model no longer matched the code after the fixes for coder#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> * docs(skills): accept the group kill in any destroying block of handlePtyExit 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> * docs(skills): document the destroy reach guards in the lifecycle skill 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 coder#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> * docs(skills): record the lifecycle model sync for the destroy reach fixes The model describes the merged tree: the destroy reach fixes for coder#203 cases 3, 6 and 7, and coder#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> * test(host): cover a background process that ignores the idle hang-up 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`_ * fix(host): kill the program group when the program exits on the idle 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`_ * docs(skills): record the lifecycle model sync for the idle hang-up group 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`_ * test(host): cover a group whose killed processes stay zombies Under an init that never reaps orphans, such as a container's PID 1 without an init, a killed background process of the program stays a zombie, and kill(-pgid, 0) still finds it. A plain destroy then times out with HOST_TIMEOUT and the session stays destroying. The integration test runs create under a child subreaper that never reaps, and the unit tests feed the probe a fake /proc. Both fail on 90a630c. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * fix(host): read a group whose processes are all zombies as gone kill(-pgid, 0) succeeds while a zombie of the group is left. Under an init that never reaps orphans, the killed background process of the program stays a zombie for good, so every wait for the group gave up: a plain destroy ended with HOST_TIMEOUT and the session stayed destroying. On Linux, isProcessGroupAlive now also reads the members of the group from /proc and reads the group alive only while one is not a zombie. After EPERM a group with no member in view still reads alive, because hidepid hides the processes of other users. One parser of /proc/<pid>/stat now serves this scan and ownLinuxSessionId. macOS keeps the old probe, because launchd reaps orphans there. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * test(host): cover a destroy that lands during reconcile's group wait After its group kill, reconcileSession waits 100 ms or more for the group, and up to 5 s on EPERM. A destroy --force that records destroyed during that wait was overwritten with failed from reconcile's earlier copy, so destroy reported success and the session read failed. The test writes destroyed during the wait and fails on fa9cc4c. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * fix(host): re-read the manifest after reconcile's group wait reconcileSession built its record from the copy that it read before its group kill, then waited for the group. A destroy --force that recorded destroyed during that wait was overwritten with failed. Reconcile now reads the manifest again after the wait, returns when it is gone or terminal, and builds the record from that copy. A write that lands between this read and the write is still overwritten. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * test(host): cover a zombie group beside a process that refuses its read With hidepid=1, /proc lists the processes of other users but refuses their reads, so one of them can be a live process of the program's group, such as the root child of `sudo cmd &`. The scan skipped such a process, so a group with a zombie of ours read gone while that process ran. The test fails on bb26745. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * fix(host): count a process that /proc refuses to show as alive The group scan skipped a /proc entry whose read failed with EPERM. With hidepid=1 that is a process of another user, which can be a live process of the program's group. A group with a zombie of ours then read gone while that process ran, so destroy could record destroyed. Such a read now makes the group read alive, as EPERM from the kill does. With hidepid=2 such a process is not listed at all, so that case stays open. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * test(host): keep the zombie test's home registry in its temp folder create records its Home in the registry under XDG_STATE_HOME. The test now points that variable into its temp folder, so it leaves no entry in the real registry. _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 zombie probe and the reconcile re-read The model still describes the code. It has no zombies, and on Linux isProcessGroupAlive now reads a group of zombies gone, so the Scope bullet on reaping says so, with the hidepid limits and the zombie host that isProcessAlive still reads alive. ProbeCountsDeniedGroup now matches the EPERM branch that reads a group with no process in view alive; with that branch reading it gone, the current run turns UNEXPECTED on DestroyedProgramStopped. ReconcileRereads accepts the record built from the re-read after the group wait, and the model comment and a Scope bullet name the window that read cannot close. The timer paragraph gives the idle-exit kill its own reason. coverage.json records the new helpers at this commit. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * test(host): cover a group scan that a security module refuses with EACCES The group scan counts a process whose /proc stat read fails with EPERM (hidepid=1) as alive. A security module can refuse the same read with EACCES, and the scan then throws, which ends destroy or the host with a raw error. These cases fail now. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * fix(host): count a process that /proc refuses with EACCES as alive A security module can refuse a /proc stat read with EACCES where hidepid gives EPERM. Either way the scan cannot see the process, so it may be a live process of another user in the group, and the group reads alive. _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 EACCES scan read The group scan now counts a /proc read refused with EACCES as alive, as it does for EPERM. The model has no /proc scan, so it still describes the code. Scope names both refusals. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * test(host): show destroy fails on macOS when ps is not on PATH On systems without /proc, the check for a CLI inside the program's session runs `ps` through PATH. With a restricted PATH it throws ENOENT, so destroy ends with a raw error before it stops anything. This test fails now. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * fix(host): run /bin/ps for the inside-session check off Linux The check now calls ps by its absolute path, which macOS and the BSDs ship, so a restricted PATH no longer makes destroy fail. If ps cannot run at all, the check reads the CLI as outside the session, which is the destroy path from before the check. _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 /bin/ps lookup The inside-session check off Linux now runs /bin/ps and reads a failed run as outside the session. The model's destroy re-run step does not depend on how the process group id is read, so it still describes the code. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * test(host): show the zombie scan reads a group gone under hidepid=2 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`_ * fix(host): skip the zombie scan when /proc hides other users 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 coder#216. _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 hidepid=2 check 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`_ * test(host): fake a plain /proc mount in the group scan tests 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`_ * test(host): show the scan misses hidepid=ptraceable and a stacked /proc 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`_ * fix(host): read hidepid=ptraceable and the top /proc mount as hiding 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`_ * docs(skills): record the lifecycle model sync for the hidepid=ptraceable 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`_ --------- Signed-off-by: Thomas Kosiewski <tk@coder.com>
* test: cover repeat screenshot and snapshot captures at the same seq These tests fail on main. A repeat capture replaces the earlier file at the same name, and a failed manifest append then deletes that file while its manifest entry stays. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * fix(storage): keep earlier artifact files on repeat captures A repeat screenshot or snapshot at the same seq replaced the earlier file. When the manifest append then failed, the rollback deleted it, so the earlier manifest entry pointed at a missing file. publishArtifactFile moves the temp file into place with link, so it never replaces an existing file. Identical bytes reuse the existing file. Different bytes take the next free <name>-<n> file. The capture modules roll back only a file that the capture created. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * docs: describe artifact publishing in the capture and storage READMEs The screenshot and snapshot contracts no longer say that a repeat capture overwrites the earlier file or that the rollback deletes it. The storage contract gains bullets for publishArtifactFile. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * test: cover publishArtifactFile without hard links and with a vanished file These tests fail at 7d34252. A link failure on a filesystem without hard links fails every capture, and a file that disappears after EEXIST fails the publish with ENOENT. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * fix(storage): publish artifacts without hard links and retry a vanished file On a filesystem without hard links, link fails with EPERM, ENOTSUP, EOPNOTSUPP or ENOSYS. publishArtifactFile now falls back to copyFile with COPYFILE_EXCL, which still never replaces an existing file. When the file behind an EEXIST disappears before the byte comparison, for example after another capture's rollback, it retries the same name up to 3 times instead of failing. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * docs(storage): describe how publishArtifactFile picks a -<n> name The README and doc comment said different bytes take the first free -<n> name. The code also reuses a -<n> file with identical bytes. The README now also says that the vanished-file retry budget is 3 per call. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * test: cover concurrent identical captures and a symlinked artifact name These tests fail at 0046c90. A capture whose append fails can remove a file that a concurrent identical capture reused and recorded. A symlink at the artifact name is reused when its target has identical bytes. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * fix(storage): publish and append artifacts in one per-session queue A capture whose append failed could remove a file that a concurrent identical capture had already reused and then recorded. publishArtifactWithRollback now runs the publish step and the append in the same in-process queue, and both capture modules use it. publishArtifactFile opens an existing candidate with O_NOFOLLOW and rejects anything that is not a regular file, so a symlink at an artifact name is never reused. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * docs: describe artifact publishing in the src and cli/commands READMEs Both lines still said that screenshot renames its PNG into place and that offline captures append through appendArtifactWithRollback. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * docs(storage): say what O_NOFOLLOW guarantees in publishArtifactFile The comment said an artifact name can never resolve outside the session. O_NOFOLLOW only rejects a symlink as the final path component. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * test: cover a FIFO at an artifact name This test times out at 51c2981. Opening a FIFO read-only blocks until a writer appears, so the capture and the per-session queue hang. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * fix(storage): open a taken artifact name with O_NONBLOCK A FIFO at an artifact name made the read-only open block until a writer appeared, which hung the capture and every capture queued behind it. The open now adds O_NONBLOCK, so the regular-file check rejects the FIFO at once. O_NONBLOCK has no effect on reads from a regular file. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ * fix(storage): report SESSION_NOT_FOUND when gc removes the session during a publish publishArtifactFile wrapped every file-system error in STORAGE_WRITE_ERROR, so captureScreenshotResult could no longer map the ENOENT from a removed session folder to SESSION_NOT_FOUND (coder#212). It now returns the sessionNotFoundIfRemoved result first. The storage README now says this, and it no longer says that ensureArtifactsDir is recursive or that writeTextFileAtomic always creates the parent directory. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
…#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 asked to land the open follow-ups on main. Two of them leave a session folder behind after
gcremoved it. An artifact command that runs while gc removes its session brings the folder back with onlyartifacts/, and every later gc skips that folder for good (#198). A manifest write that lands during gc'srmmakes thermfail withENOTEMPTYand leavessession.jsonuntil the next gc (#202).What changed
ensureArtifactsDircreates onlyartifacts/inside an existing session folder, and reportsSESSION_NOT_FOUNDwhen gc removed the session folder. The snapshot, asciicast and artifact manifest writes create no folders (createParentFolders: false). The ghostty-web backend writes the screenshot and copies the video itself, because Playwright'spathandsaveAscreate missing folders. A screenshot whose PNG write or rename finds the folder gone reportsSESSION_NOT_FOUND.rmretries up to 3 times, 10 ms apart, so a retry deletes a latesession.json.verify-session-lifecycleScope describes both.replay-hold.mjscan hold amkdircall, andcoverage.jsonis synced.Scope
snapshot,screenshotandrecord export, with and without--out.SESSION_NOT_FOUND(see Blast Radius). The folder stays removed in every case.Blast Radius
Artifact commands and gc only. When gc removes the session folder during an artifact command, the command now fails and the folder stays removed. The result depends on when the removal lands:
SESSION_NOT_FOUND, exit 3.STORAGE_WRITE_ERROR, exit 8.EXPORT_ERROR, exit 7.--out, the user's file stays written when the command then reports exit 3.The CLI JSON shapes and error codes do not change. The Home Registry, evals and tools still create their folders.
Verification
REPLAY_HOLDon the command, andgcduring the hold: on main, all 10 cases exit 0 and leave a stub folder thatlist --allhides and gc skips. On this branch, all 10 exit 3SESSION_NOT_FOUNDand the folder stays removed. With the screenshot's temp PNG write held, the branch's first head printed a rawENOENTwith no JSON (exit 1). It now prints aSESSION_NOT_FOUNDenvelope and exits 3. Details are in PR comments.ENOTEMPTY) and pass here. A local run of the CI test shards, build and install smoke test exits 0, andcheck.shexits 0: 27 runs, 910,528 states. The unshardednpm run verifyfailed locally only on two load-sensitive tests, before any screenshot.Fixes #198
Fixes #202
Generated with
xum• Model:anthropic:claude-opus-5-5• Thinking:xhigh