# Row 52 (#1538): CHAT-01 engine-exit stop and release at engine exit Dewey, 2026-10-10. Brief: `docs/plans/2026-10-10_cohort-release-follow-ups.md`, section "CHAT-01 engine-exit stop and release at engine exit" (rev 297). Base 945440db (row 51 landed). The change has two parts, CHAT-01 first in its own commit: - CHAT-01 (commit cc83ee4f, not pushed): the four `docs/plans/chat-01/` files. - Conversation (candidate, uncommitted): `src/cohort.mjs`, `src/controller.mjs`, `src/shim.mjs`, `tests/cohort.test.mjs` and the package README. `tests/ctrl-child.mjs` is unchanged. ## CHAT-01 hashes (each approval names all four) ``` a41fc4e2edb11371c275e3167774c162254e1457fd737121630dedd665431889 docs/plans/chat-01/README.md ccceaf2653b279f5890748b40d16fae7493f199a33cecee035f82b92e80243af docs/plans/chat-01/check.mjs 941675de949c52c7fe7c4b7bcce3aff48bb26ccc1120e090574c5d25f9d9e22e docs/plans/chat-01/contracts.schema.json c75c2b9f731bb70d0e033e8aa56f2c28accfa09384ec964fd3ecbd45974b2a7d docs/plans/chat-01/fixtures.json ``` `node docs/plans/chat-01/check.mjs`: 100 shape cases, 76 reference cases, 22 lifecycle sequences, PASS. `node docs/plans/chat-00/check.mjs`: 48 checks PASS. ## Darkwing's points 1–4 | Point | Model (`check.mjs`) | Conversation | |---|---|---| | 1. Never supersede an unfinished force stop | `engine-exit` returns `stop-owned` while the current stop is a force stop (any state) or an engine exit. Sequence `engine-exit-refused-during-force-stop` walks all four force-stop states. | `#engineExit` starts nothing while `escalating` is held or the current stop is a force stop or engine exit: a running force stop (E3, second half) and one that ended `uncertain` before it closed the execution (E5). | | 2. Binding follows at lines 121, 334, 337 | All three use `ending(mode)` (force stop or engine exit). Sequence `engine-exit-binding-follows-stop`: `stopping` at the fence and on each advance, `uncertain` with the stop, `stopped` only with it. | `#startStop` and `#advanceStop` use the same `ending` (E3 sees `stopping` at `engine-exit-recorded`). | | 3. The proof names the engine; not `members: []` | `stopped(w, s)` refuses an `engine-exit` proof with no member. The empty-list case is in `engine-exit-live-unreadable-or-empty-cohort-uncertain`. | `engineExitCohort` returns the engine as the one member: PID and start ticks the shim recorded, boot, and the shim's reap time as `terminatedAt`. `#stopped` also refuses an engine-exit proof with no member. E1 checks the members exactly; E6 checks that another PID or start ticks is refused. | | 4. The proof's deadline frees the slot | The model leaves the slot to the implementation (README says so). | The observation runs under `within(…, exitProof)`. A miss ends the stop `uncertain`, and `finally` frees `escalating`. E4 SIGSTOPs the shim: the stop ends `uncertain` at the deadline, `escalating` is `null`, and a client force stop then proves and releases. | ### Engine identity comes from a shim op Sage's condition: if the engine identity can't come from a shim op, stop before relying on `members: []`. It comes from one. The shim reads the engine's start ticks from `/proc` right after spawn (the exec chain keeps the PID and start time), and its `exit` handler records `{ code, signal, at, pid, startTicks, boot }`. `hello` returns that as `engineExit`. `engineExitCohort` refuses, as `unavailable`, an `engineExit` whose PID or start ticks differ from the claim's recorded engine, whose start ticks aren't a positive integer, or whose boot differs from the shim's. Nothing relies on an empty member list: the force stop's proof (R4, now in E4) is unchanged and still lists no member when the engine has already gone. ## CHAT-01 change - Stop mode `engine-exit` (schema enum). The server starts it like a revocation fence: `request: null`, no confirmation. - `server(w, 'engine-exit')`: refuses `stop-owned` under a force stop or another engine exit; otherwise `startStop` (closes admission, recovers queued drafts, marks pending decisions uncertain, supersedes an unfinished interrupt or revocation, emits `stopping`). Dispatched or acknowledged input moves to `delivery-unknown`; `working` input keeps its state. - `confirm-stopped` accepts it under the same `stopped(w, s)` check as a force stop, plus at least one member. - Recover after it needs its own confirmation bound to the stop (K6, K17, K18): an unissued one, one issued but not confirmed, and the binding's stop check are all in `engine-exit-empty-cohort-stopped`. - Fixtures: `valid-engine-exit-stop`, `refuse-stop-mode-eof`, and five sequences: `engine-exit-empty-cohort-stopped`, `engine-exit-live-unreadable-or-empty-cohort-uncertain`, `engine-exit-stale-confirmation-dispatched-receipt`, `engine-exit-refused-during-force-stop`, `engine-exit-binding-follows-stop`. - README: a paragraph under "Control loss, approvals and stopping", a pointer near the top, and the R3 counts. "SIGTERM, EOF, abort acknowledgment or idle does not prove death" stays; K15 and K2 are unchanged. ### Model mutants (`check.mjs` on a scratch copy) | Mutant | Change | Result | |---|---|---| | norefuse | `engine-exit` never refuses `stop-owned` | killed | | optionB | refuses only while the force stop is unfinished | killed | | bind121 | `startStop` moves the binding only for a force stop | killed | | bind334 | `advance-stop` the same | killed | | bind337 | `confirm-stopped` accepts only a force stop | killed | | emptyok | no member-count check for an engine exit | killed | | noreceipt | dispatched input keeps its state | killed | | nostale | no current-stop check on a confirmation | killed | ## Conversation change - `#onEnd` (EOF): after `#transport` (which settles in-flight input `delivery-unknown`/`transport-unknown` as before, H19, N18), starts `#engineExit`. - `#engineExit`: only for the current execution with a recorded engine, no held slot and no current force stop or engine exit. Starts the `engine-exit` stop, takes `escalating`, pauses at `engine-exit-recorded` (test barrier), runs `engineExitCohort` under the `exitProof` deadline (5000 ms; `exitSettle` 2000 ms for EOF before the reap), then goes to `uncertain` or through `#stopProven`. - `#endStopUncertain` and `#stopProven` are `#escalate`'s former `fail` and proof tail, shared. The force-stop path is unchanged: same order, same claim writes, `resumed` still in its evidence. Evidence and the release now carry `stop.mode` instead of a literal `force-stop`. - `engineExitCohort` (cohort.mjs): reads only (`hello`, `events`, `members`), never freezes or kills. Proven when the shim has reaped the recorded engine, `engine` reads `populated 0` and no member is listed. - The claim: no `stopping` claim write for an engine exit. The claim goes `uncertain` at EOF (`#transport`, as before) and `stopped` only through `#claimFinish` with the proof. A restart in between finds an ordinary `uncertain` orphan, never an engine-exit stop to resume. ## Tests (`cohort.test.mjs`, needs a systemd user manager) R4 is folded into E4; E1–E6 are new. - E1: the engine exits with no other member. One `engine-exit` stop, `request: null`, superseding the prior stop, `stopped`; the proof's members are exactly the engine (PID, boot, start ticks, reap time from the shim's `hello`); both claim keys stopped on it; the release is `engine-exit`/`released`/`absent` and the unit is gone. A recover confirmation issued before the exit is refused; a fresh one is `recovery-eligible`. - E2: the engine exits while a tool child runs. The stop ends `uncertain` (evidence `engine-exit`/`uncertain`), no release, the unit and the child live, no proof. A force-stop confirmation issued before the exit is refused (H17). A fresh force stop supersedes the engine-exit stop, kills the child, proves and releases. - E3: one slot, both orders. The engine-exit stop held at `engine-exit-recorded`: a client force stop is refused `fenced`, and the engine exit then proves. A force stop held at `force-stop-recorded`: the engine's EOF starts no engine-exit stop, and only the force stop is in the evidence. - E4: the shim SIGSTOPped. The engine-exit stop misses its deadline, ends `uncertain` (reason names the deadline), `escalating` is `null`; after SIGCONT a force stop proves with no member (the R4 assertion) and the scope goes. - E5: a force stop whose `stopping` claim write fails once ends `uncertain` before it closes the execution, so the engine's later EOF still reaches `#onEnd`. No engine-exit stop starts; the force stop stays current (point 1 after the slot is free). - E6: held at `engine-exit-recorded`, `engineExitCohort` with another PID or other start ticks is `unavailable` ("the shim reaped a process that isn't the recorded engine"); the recorded identity is `proven`, and the controller's stop then proves. Conversation suite on the draft at 945440db: 182/182 (was 177: R4 out, E1–E6 in). ## Mutation check Twenty mutants on a fresh copy of the candidate, full conversation suite each (`~/dewey-scratch/r52/mut-tools`). Run 1 (07:11–07:33Z) was on the tests before E5, E6 and the E2 evidence check, and left `noowncheck`, `noident` and `evmode` alive with the five equivalents. I added those tests and ran all twenty again (run 2, 07:33–07:54Z). Run 2 base: 182/182. No shim, engine or `mosaic-chat-*` unit was left after any run. | Mutant | Change | Run 2 | Killed by | |---|---|---|---| | noexit | `#onEnd` doesn't start `#engineExit` | killed | E1, E2, E3, E4, E6 | | noslottake | the engine-exit stop doesn't take `escalating` | killed | E3 | | nofree | `finally` doesn't free the slot | killed | E2, E4 | | noslotcheck | EOF ignores a held slot | survives, equivalent | – | | noowncheck | EOF ignores a current force stop or engine exit | killed | E5 | | nostopcheck | both checks gone | killed | E3, E5 | | nodeadline | the observation has no deadline | killed | E4 | | bindstart | `#startStop` moves the binding only for a force stop | killed | E3 | | bindadvance | `#advanceStop` the same | survives, equivalent | – | | emptyguard | `#stopped` accepts an engine-exit proof with no member | survives, equivalent alone | – | | emptyproof | the proof lists no member | killed | E1 | | emptyboth | both: an empty-list proof that verifies | killed | E1 | | noident | no PID or integer check on the shim's `engineExit` | killed | E6 | | nopopulated | a populated `engine` cgroup proves | survives, equivalent | – | | nomembers | a listed member proves | survives, equivalent | – | | nolive | neither is read | killed | E2 | | shimstart | the shim keeps no start ticks | killed | E1, E3, E6 | | relwhy | the release is recorded as a force stop's | killed | E1 | | evmode | uncertain evidence names a force stop | killed | E2 | Why the five survivors are equivalent: - noslotcheck: `escalating` is set only right after `#startStop` made a force stop or engine exit the current stop, and the binding is then `stopping`. Interrupt and revocation need `b.state === "active"` (controller.mjs `#interruptLocked`, the revocation in the connection close), so no other stop replaces it while the slot is held. `ending(…)` covers every state the slot check covers. The check stays as a guard. - bindadvance: the binding is already `stopping` from `#startStop`; `#uncertain` keeps `stopping`; `#endStopUncertain` and `#stopProven` set the binding themselves. The `ending` in `#advanceStop` stays to match the model's line 334. - emptyguard: a proven `engineExitCohort` always lists the engine, so `#stopped`'s guard is reachable only through a second fault. emptyproof and emptyboth test point 3. - nopopulated, nomembers: the shim's `members` walks `engine` and its descendants, so `populated 1` with an empty list (or `populated 0` with a member) needs a process to start or exit between the two reads. Both reads stay; each guards the other. Killing tests are from each mutant's `failing tests:` list. Run 1 and run 2 tables: `~/dewey-scratch/r52/mut-tools/results-run1.txt` and `results-run2.txt`. ## Gate Run in a scratch worktree at cc83ee4f with the candidate applied (`~/dewey-scratch/r52/gate/candidate.diff`), 2026-10-10T07:56:23Z to 08:01:22Z, sequential, each output kept. | Suite | rc | Result | |---|---|---| | `node docs/plans/chat-01/check.mjs` | 0 | PASS: 100 shape, 76 reference, 22 lifecycle | | conversation | 0 | 182 pass, 0 fail | | webui | 0 | 22 pass, 0 fail | | control-board | 0 | 124 pass, 0 fail | | `test-auth.sh` | 0 | 15 passed, 0 failed | | `test-conductor.sh` | 0 | 17 passed, 0 failed | | `test-config.sh` | 0 | 24 passed, 0 failed | | `test-discord.sh` | 0 | 66 passed, 0 failed | | `test-extension-package.sh` | 0 | 18 passed, 0 failed | | `test-foundation.sh` | 0 | 44 passed, 0 failed | | `test-queue.sh` | 0 | 148 node pass, suite 27 passed, 0 failed; `queue verify` and `render --check` skipped (not the canonical root) | | `test-release.sh` | 0 | 14 passed, 0 failed | | `test-task.sh` | 0 | 98 passed, 0 failed | The `test-queue.sh` skip is that suite's own guard for a non-canonical checkout; Sage's rerun in the canonical checkout covers it. After the gate and the mutant runs no `mosaic-chat-*` unit is listed (`systemctl --user list-units --all 'mosaic-chat-*'`) and `core.hooksPath` is unset. ## Not tested, follow-ups Nothing here blocks the row; I'd file them together if Sage wants them. - The boot comparison in `engineExitCohort` (`exit.boot !== hello.boot`) is untested: both come from the same shim process, so only a faked `hello` reaches it. - An unreadable `engine/cgroup.events` or a failed `members` during the engine-exit observation (the K15 shapes) is untested for this path. The tests reach `unavailable` only through the deadline (E4). - On the process-group fallback a natural exit now starts an `engine-exit` stop that ends `uncertain` at once ("a pgroup cohort can't be enumerated completely"). The binding ends `uncertain` as it did before row 52, but no test runs it. - The five equivalent mutants above are guards I kept on purpose; no change proposed. - Row 51's untested `still listed` retry and the noseat and recafteracq survivors are #1539, not repeated here.