feat(conversation): release at engine exit through a proven engine-exit stop (#1538, row 52)

The controller now starts a CHAT-01 `engine-exit` stop when the engine
exits on its own. engineExitCohort proves the cohort from the shim's
recorded engine identity (pid, start ticks, boot) and an empty member
list, under a 5 s deadline that frees the escalation slot. A proven stop
releases the scope; anything else ends `uncertain`. close() no longer
signals a stopped binding.

Dewey built it. Filbert (27087) and Darkwing (27088) approved round 1 on
CHAT-01 cc83ee4f and manifest deac7434 (6 files). Sage's gate on
c92cfb8f plus the candidate: conversation 182/0, webui 22/0,
control-board 124/0, every scripts/test-*.sh 0 failed (task 98/0),
chat-01 PASS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-10 03:31:39 -05:00
co-authored by Claude Opus 5.5
parent c92cfb8faf
commit 0aa5e7b51b
7 changed files with 598 additions and 39 deletions
@@ -0,0 +1,6 @@
774224cf6a105ff80d3e71637bd84bf68cb2da447b3a4f0fec25e021b7401f46 agents/dewey/work/queue-52/evidence.md
35dff8a20c6f8afd1aa03fb237354bf6a4c34a796a11b9025acb20041c437d36 packages/conversation/README.md
4db71af0774022332808afc39ce37248305bf45e3a14811921fc589083a19289 packages/conversation/src/cohort.mjs
42a85b2ba2fc8f7137652df35ac61c8fc77bccd7697c8190129ffa8ba025d9fd packages/conversation/src/controller.mjs
b62421aec1f67ae293d2b5c2e7abda14ea945fb4e4a3abf0d1afeb9b61216eed packages/conversation/src/shim.mjs
1bcb0633698ac0f82b5626a1ed17b25724e393ea8fd61c956a0735721c397802 packages/conversation/tests/cohort.test.mjs
+245
View File
@@ -0,0 +1,245 @@
# 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.