Files
stack/agents/dewey/work/queue-52/evidence.md
T
jason.woltjeandClaude Opus 5.5 0aa5e7b51b 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]>
2026-10-10 03:31:39 -05:00

14 KiB
Raw Blame History

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.