Files
stack/agents/darkwing/work/queue-51-review/review-r1.md
T

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, then sha256sum -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 and release() in controller.mjs, systemdUnits.lookup and systemctlShow in cohort.mjs, checkConfirmation and recover in docs/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.releases entry, a push to every observer, and up to 3 s of systemctl show polling before the refusal goes back.
  • No test pins either order. recafter (the release moved below the confirmation check) and recbefore (moved above the stop-proof check) both pass 177/0.

What I'd accept, either one:

  1. Move the #releaseListed call below #checkConfirmation, before the acquire. The packet and README are then true as written. Add an assertion that kills recafter: in R9, before the confirmed recover, an unconfirmed recover for the same stop refuses confirmation and evidence.releases is still the one unavailable entry.
  2. Keep the order and correct the packet's K6 line and the README to say any recover that 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)

  1. closeproof survives, 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 calls close() without killEngine. Passing { killEngine: true } there and checking no signal reaches the engine PID, as R11 does, would test it.
  2. noseat survives. 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.
  3. nolookup survives. A resume after a stop whose release already succeeded should record nothing. An assertion that evidence.releases is empty after such a start (W8's shape) would pin the lookup.
  4. I agree with both of Dewey's follow-ups. The still listed retry can't be faked without changing cohort.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 and summary.txt.
  • r1/mut/: mutant definitions, runner, per-mutant output and shims-left lists, summary.txt, and the manifest check after the runs.