9.0 KiB
Row 51, cohort release follow-ups, round 1 review (Darkwing)
Issue #1537, request comment 27068, notes comment 27069, queue revs
298-301 (d947ec11). Brief: docs/plans/2026-10-10_cohort-release-follow-ups.md,
section "Cohort release follow-ups: engine exit, crash window and close".
Base bbb2167f; I ran it on d947ec11, which adds only the two
queue files. Candidate manifest sha256
2da94e3bcb64afbc5f70fe1cc888393a32b7988afd1cf2cd977d762b47c6ebf3,
5 files: controller.mjs, cohort.test.mjs, ctrl-child.mjs, the README
and Dewey's evidence.md. cohort.mjs and shim.mjs are unchanged.
Verdict: changes, for one item. The recover release runs before the
confirmation check, and the packet and the README both say it runs inside
a confirmed recover. The code is safe either way: every release still
needs a claim recorded stopped on a cohort proof, and the shim refuses
while its engine runs. But the packet's K6 line is the part of the
evidence that says which claim-protocol rules hold, and it's wrong. Fix
the order or fix the words; I'd fix the order. Everything else is right:
the crash window closes in both shapes (R7, R8), the retry works (R9),
close sends no signal after a proven stop (R11), and a boot proof is never
released (R12). Dewey's ten mutants reproduce.
Method
- Detached worktree at
d947ec11, the five candidate files copied from a snapshot of the canonical tree, thensha256sum -c: 5 OK. A second worktree for the mutants; its five files check OK after all runs (r1/mut/manifest-after.txt). - I read the diff in full, then
#recover,#releaseScope,#releaseListed,start(),close(),#escalate's stopped path andrelease()incontroller.mjs,systemdUnits.lookupandsystemctlShowincohort.mjs,checkConfirmationandrecoverindocs/plans/chat-01/check.mjs, and R1, R6-R12. - 12 mutants (
r1/mut/mutate.py,run.sh), the whole conversation suite each, own TMPDIR each, run after the gate finished. After each run the runner lists any shim left under that TMPDIR. No mutant left one.
Node v26.8.1, TMPDIR=~/darkwing-scratch/r51a/tmp, gate.sh with
DOCKER_HOST=unix:///nonexistent.sock, 05:53:20Z to 05:56:53Z.
Suites
| Suite | Result |
|---|---|
| conversation | 177/0 |
| webui | 22/0 |
| control-board | 124/0 |
| queue (node) | 148/0 |
| test-auth | 15/0 |
| test-conductor | 17/0 |
| test-config | 24/0 |
| test-discord | 66/0 |
| test-extension-package | 18/0 |
| test-foundation | 44/0 |
| test-queue | 27/0 |
| test-release | 4/0 without Docker, 14/0 with it (test-release-docker.txt) |
| test-task | 26/2 |
The two test-task failures are "user recall run succeeds" and "recalled
user name", the live worker check that needs Docker and a model call, as in
my earlier gates. Dewey's 98/0 ran them. No mosaic-chat-* unit was listed
after the conversation suite. The one unit counted at the gate's end was
gone when I looked again, and none was listed after the mutants.
The recover release runs before the confirmation (required)
#recover (controller.mjs, from line 1621):
if (b.state !== "stopped" || ... || !this.#stopped(s)) return refused("stop-proof");
await this.#releaseListed("recover", this.claim); // 1625
... pins, refused(ENGINE_PIN_MISMATCH) ...
... session leaf and branch, refused("target") ...
if (!this.#checkConfirmation(r, c, "recover")) return refused("confirmation"); // 1635
So a recover with no confirmation, a stale one (H17), changed pins or a
moved leaf still retries the release, then refuses. The packet says "The
recover release runs inside a confirmed recover, after its stop-proof
check". The README says the retry happens on "the next start or
confirmed recover (R9)". Neither is what the code does.
Why it doesn't hurt much: the release only ever acts on a proven-stopped cohort, and close does the same with no confirmation at all. Why I still want it fixed:
- The "Claim-protocol rules touched" section is how a reviewer checks the row against CHAT-00. A wrong K6 line there is the kind of record we commit and then rely on.
- With the order as is, a refused command has a side effect: an
evidence.releasesentry, a push to every observer, and up to 3 s ofsystemctl showpolling before the refusal goes back. - No test pins either order.
recafter(the release moved below the confirmation check) andrecbefore(moved above the stop-proof check) both pass 177/0.
What I'd accept, either one:
- Move the
#releaseListedcall below#checkConfirmation, before the acquire. The packet and README are then true as written. Add an assertion that killsrecafter: in R9, before the confirmed recover, an unconfirmedrecoverfor the same stop refusesconfirmationandevidence.releasesis still the oneunavailableentry. - Keep the order and correct the packet's K6 line and the README to say
any
recoverthat passes the stop-proof check retries the release, before the pins, target and confirmation checks.
I recommend 1. A retry on recover only matters for a recovery that's
about to happen, and the next start covers everything else.
The rest of the change
#releaseListed reads right. Two concurrent callers that both find a
failed earlier result can't both send a request: the first deletes and
re-memoizes, the second sees the map changed, skips the delete, and
#releaseScope hands it the new promise. nodelete (the delete removed,
so #releaseScope returns the old result) is killed by R9.
systemdUnits.lookup can't throw: spawnSync with a timeout reports a
failure through status, and lookup returns unknown then. An injected
units that throws would reject start() before the acquire, which
writes nothing. Fine.
The free path in start() releases before the pin and target checks and
before the acquire, so a start that then refuses engine-pin-mismatch
still releases the prior proven-stopped scope. That's correct: the proof
doesn't depend on the next launch. It also runs without holding the pair.
A second process on the same claim gets unavailable from hello and
records it, which is harmless.
Close keys the skip on b.state === "stopped". I checked the routes to
that state: #escalate (line 1562) is the only write, after the verifier
accepted the proof and #claimFinish recorded it. Line 1020 leaves
stopped alone, and release() (line 1689) finishes an unlaunched
reservation on a no-unit proof, which #releaseScope never accepts.
Mutants
| Mutant | Change | Conversation | Result |
|---|---|---|---|
| norecover | recover releases nothing (Dewey's) | 176/1 | killed: R9 |
| nostartfree | no release on the free path (Dewey's) | 176/1 | killed: R7 |
| nostartstopped | no release on classify's stopped branch (Dewey's) |
176/1 | killed: R8 |
| closekill | close signals after a proven stop (Dewey's) | 176/1 | killed: R11 |
| nopush | the release entry isn't pushed (Dewey's) | 176/1 | killed: R1 |
| nodelete | #releaseListed never drops an earlier result |
176/1 | killed: R9 |
| recafter | the recover release moves below the confirmation check | 177/0 | survives: the required item above |
| recbefore | the recover release moves above the stop-proof check | 177/0 | survives: the guard in #releaseScope still needs a stopped cohort proof |
| noseat | the free path checks only the session head | 177/0 | survives: no test has a seat head naming another claim |
| nodedupe | the seat head's same-claim skip is dropped | 177/0 | survives: equivalent, the per-claim memo returns the first result |
| nolookup | #releaseListed sends a request for a unit already absent |
177/0 | survives: no test starts on a released claim and checks evidence.releases |
| closeproof | close skips the signal only while #stopped() still accepts the stop |
177/0 | survives: R10 calls close without killEngine |
Notes (not blocking)
closeproofsurvives, and the packet makes a claim about exactly this case: "A proof that stops verifying later (R10's withdrawn verifier) doesn't bring the signal back". R10 callsclose()withoutkillEngine. Passing{ killEngine: true }there and checking no signal reaches the engine PID, as R11 does, would test it.noseatsurvives. The seat-head branch handles a different session's controller that died in the crash window on the same seat. One test with two sessions on one seat would cover it; or say in the packet it's untested.nolookupsurvives. A resume after a stop whose release already succeeded should record nothing. An assertion thatevidence.releasesis empty after such a start (W8's shape) would pin the lookup.- I agree with both of Dewey's follow-ups. The
still listedretry can't be faked without changingcohort.mjs, and the packet says why the R9 path covers the same code.
Files
r1/candidate-manifest.sha256: copy of Dewey's.r1/gate.sh,r1/out/: suite runs andsummary.txt.r1/mut/: mutant definitions, runner, per-mutant output and shims-left lists,summary.txt, and the manifest check after the runs.