Files

28 KiB

Row 302 (#1809, Vikunja 588): the idle-stop race

Owner Gizmoduck. Reviewer Dewey. Gate owner Sage. Brief: docs/plans/2026-10-23_idle-stop-race-row.md, "Brief: the idle-stop race (Vikunja 588)". Base 0e30a85fa. This is the design, written static only. Nothing gets built until Dewey approves it (sage-3200).

The race, as the code has it at 0e30a85fa

  • packages/cli/src/launcher.mjs:703-717. The idle tick calls the threadIdle op. On released it adds the run to stopping, records thread-idle, and only then sends SIGTERM (:716).
  • packages/bus/src/broker.mjs:1044-1057. threadIdle releases the claim in the same synchronous call (#claimEnd(..., 'release', null), :1055). The comment at :1040-1043 says the order is deliberate: row 121 r2, Dewey's F3 in 28591.
  • broker.mjs:1002-1008. #agent is the only place a thread run's own call gets not-holder, because its session's latest claim row isn't a claim of its generation. message.receive (:5586-5591) and state (:5805-5807) both go through it, and so do message.send and message.read.
  • packages/harness/src/runner.mjs:55. SESSION_GONE holds unauthenticated, not-holder and run-ended. Four sites set code = EXIT.session (22) on any of them, without checking stopping: the landing state (:500-506), message.receive (:526-531), the turn's state (:545-552) and the reply (:575-582).
  • packages/seat/src/session.mjs:340 maps 22 to session-invalid.

So if a runner call reaches the broker after the release and before SIGTERM sets stopping, the run exits 22 by itself and ends session-invalid. A call already in flight when the release lands counts too. Row 292's diagnostic saw this once in about 100 idle stops. The clear test's run r846b8d083502 logged not-holder and then exiting 22.

Design: a distinct answer for a run the idle stop released (Mos's suggestion, adopted)

Broker (packages/bus/src/broker.mjs)

  1. Add a new in-memory set, #idled, keyed business:run, next to #ended, #received and #stops. threadIdle adds the run's key in the same synchronous call that releases it, and only when it returns {released:true}. Release and mark happen together, so no call can be answered between them.
  2. In #agent, the refusal becomes fail(this.#idled.has(s.business + ':' + s.run) ? 'idle-released' : 'not-holder'). Nothing else in #agent changes. A run the idle stop released now gets idle-released at every call after the release, whether it's a receive, state, a send or a read, and whether or not another run has claimed the session since. Every other path that takes a claim away still answers not-holder:
    • assistant.stop (:5975), instance.end (:6009), the chief lifecycle revoke (:4682) and role.revoke (:5230, :5235)
    • the session clear (:1146)
    • the limit stop (#limitStop, :4842-4855). It releases a thread claim and then refuses not-holder on purpose, so that the run exits 22.
    • another run's newer generation, where the run wasn't idle-released
  3. endLaunch (:1300) drops the key along with #ended.add. After the end, the run's caps are gone and its calls answer unauthenticated, as they do today. The set lives only in memory, like #runs. A broker that restarts loses #runs too, so an old run's calls answer unauthenticated there either way.
  4. threadIdle's own refusals don't change. A second threadIdle on a released run is still not-holder (threads.test:392). The answer comes from the #agent path only, for the run's own calls.

Why a new code and not a flag on not-holder: the wire carries only error: <code> (server.mjs:96-160), and the client accepts only ^[a-z-]{1,64}$ (client.mjs:42-43). A new code changes neither of them. Tools and docs that switch on not-holder keep their meaning, because not-holder still means "your claim was taken away".

Runner (packages/harness/src/runner.mjs)

  1. A new constant, IDLE_RELEASED = "idle-released". It is not added to SESSION_GONE. At each of the four sites, idle-released is checked before SESSION_GONE. The runner logs runner: session released by the launcher's idle stop, leaves code at EXIT.stopped (0), sets a local released, and leaves the loop the same way the site does for SESSION_GONE today.
  2. The role.release at exit (:589-595) is skipped when released. The claim is already released, so the call would only be refused and logged.
  3. If the runner didn't handle the code, the receive loop would count idle-released as an unreachable broker and retry (:532-537). The run would then end at SIGTERM (0), or after 30 polls (23). Every site handles it so that neither of those decides how the run ends.

How this meets mos-1411 condition 1: unauthenticated (the caps went at the end), run-ended, and every not-holder that isn't an idle release still exit 22 and end session-invalid. Only the broker's own record that threadIdle released this run maps to stopped/0. The launcher's stopping set and SIGTERM play no part in the mapping. That also covers a poll that lands before the signal, and a call that was in flight when the release landed.

Launcher and session.mjs

  1. No change. The runner exits 0 by itself, and SIGTERM's start-time check (:716) then finds no process, which the code already handles. endReason(0) is stopped.

Tests

The forcing test (mos-1411 condition 2): new file packages/cli/tests/idle-stop-race.test.mjs

This is a thread launch on the same fixture shape as thread-launch.test.mjs (start(t, prepared, { ops }) replaces launch ops, :78-101), with sessionDefaults.pollInterval set to 100. The test replaces the threadIdle op:

threadIdle: async (m) => {
  const r = runtime.threadIdle(m.record);
  if (r.released) window.set(m.record.run, await held(m.record.run)); // returns only after the runner's own exit, or 20 s
  return r;
}

held(run) waits for two things, in order. First, an action.refused event whose actor is run and whose seq is after the release's thread_claims row. That proves a runner call reached the broker inside the window. Second, the runner's own exit (the run's runner: exiting N line in runner.log, or its pid gone), with no SIGTERM sent. The launcher sends SIGTERM only after the op returns, so the window stays open until the runner has decided how it ends. The test then asserts all of the following:

  • held saw a refusal from the run after the release, and the runner exited before the op returned.
  • The refusal's code is idle-released.
  • The run's launch end event is ['stopped', 0, null].
  • runner.log has the idle-released line and no session stopped working: line.

Against the code today, the refusal is not-holder, the runner exits 22 inside the window, and the end is ['session-invalid', 22, null]. The test fails, and fails every time, not at some rate: the window is held open until a call has landed in it. If the runner fix is dropped, the runner retries and doesn't exit inside the window, so held times out and the "exited before the op returned" assertion fails. If the broker fix is dropped, the end is 22.

A control test in the same file: after an idle release, a role.revoke the human approved on another thread's run (or assistant.stop) still ends that run ['session-invalid', 22, null]. That shows a real not-holder still exits 22.

Bus: new tests in packages/bus/tests/idle-release.test.mjs (a new file, so no existing assertion moves)

  • After threadIdle releases a-run, a-run's message.receive, state, message.send and message.read each refuse idle-released. Each writes an action.refused event with code idle-released, and none of them delivers a message or writes a receipt.
  • not-holder still comes back for: a run released by assistant.stop, a run released by a session clear, a limit stop (stop then receive), a run whose session another run claimed without any idle release, and a second threadIdle on a released run. After endLaunch, the idle-released run's calls get unauthenticated, as today (its caps go at :1310-1311).
  • A role run's role.release followed by its own receive still gets not-holder (no idle).

Harness: no new runner.test case

runner.test.mjs's setup (:105) binds role runs only, and its claims sit in role_claims (:876), so it can't reach threadIdle. The runner's own exit at receive is covered by the cli forcing test above, which runs the real runner. The forcing test reaches only the site that lands in the window, which in practice is the receive poll. The landing state, the turn's state and the reply sites get the same four lines, but no test forces each one. Q4 asks whether Dewey wants a per-site case. The existing 22 tests (:539-560, :630-647, :772) stay unchanged and pass unchanged.

The one existing assertion that changes (goes to Mos through Sage, per the brief)

packages/bus/tests/threads.test.mjs:394, assert.throws(() => call('a', 'message.receive'), { code: 'not-holder' }). This is a receive by the run that threadIdle released at :388, so under this design it answers idle-released. The assertion is changed, not loosened: the new pin is { code: 'idle-released' }, and the property right below it (:395, the message stays pending for the next holder) stays. I've found no other existing assertion that pins not-holder for an idle-released run's own call. The build reruns git grep -n "not-holder" 'packages/*/tests/*' against every idle path before in-review, and names any other hit for Mos first.

Docs

  • packages/bus/README.md:320-326 (threadIdle). Add: after a release, the run's own calls answer idle-released, not not-holder. Also say why.
  • packages/harness/README.md:295-302, the exit table. Row 0 becomes "stopped, by a signal, or because the launcher's idle stop released the session (idle-released)". Row 22 is unchanged.
  • packages/bus/README.md:207 (assistant.stop): no change. It still gives not-holder.

Open questions for Dewey

  • Q1. landRelease (broker.mjs:4144-4165, launcher.mjs:876-889) has the same shape for a role run's land-relaunch: it releases, then the launcher sends SIGTERM, so a poll in between ends the run 22. I recommend folding it in. That means one line in landRelease (add to #idled on release), one bus assertion, and one cli forcing case like the one above. It falls outside this brief's words, so it needs Sage's yes. If the answer is no, I'll write it up as a follow-up row with the same evidence.

  • Q2. The code name idle-released. It names the cause, and if Q1 is yes it covers land-relaunch too, since that is also a release made by the launcher. A more general name like launcher-released would fit both. I prefer released-by-launcher only if Q1 is yes. Dewey picks.

  • Q3. A limit stop on a run that was already idle-released. #limitStop (:4842-4855) finds no claim to end and refuses not-holder, so the run ends 22. I'd leave that alone: a limit hit is a real stop and ends session-invalid, as it does today. It's possible only if the limit checker answers inside the window.

  • Q4. Per-site coverage in the runner. Only the receive site is forced. I can add a cli case per site (hold the window until the run's landing state, or a turn's state, lands in it), at about one fixture each. I'd add them only if Dewey asks, since all four sites share one helper in the build.

Gate (brief)

Dewey approves this design, then the manifest. Runs: the new forcing test, then compactions-launch.test.mjs whole, then the bus, cli and harness suites whole, then every scripts/test-*.sh. The mos-1411 point 4 rerun rule doesn't apply to this row's own gate.

Mutants. Each one must die on the forcing test, with the bus test also named where it applies:

  • M1. threadIdle doesn't add to #idled. Dies on the forcing test (end 22) and the bus test.
  • M2. #agent ignores #idled. Same deaths as M1.
  • M3. The runner drops the idle-released branch at receive. Dies on the forcing test (no exit inside the window).
  • M4. The runner maps idle-released to EXIT.session. Dies on the forcing test (end 22).
  • M5. #claimEnd marks every release, not just threadIdle's. Dies on the bus not-holder controls and the cli control.
  • M6. endLaunch doesn't drop the key. This is not a correctness mutant: caps are gone, so the answer is unauthenticated either way. I'll drop it unless Dewey wants an assertion on it, and record it as an equivalent mutant.

Limits will be set in the run plan after the design round (mos-1275: timeout -k 10 at three times or more the longest run).

After Dewey's d1 review (dewey-0875, 05:32Z): APPROVE on D1, folded here before any build

D1 (required). role.claim's thread branch (broker.mjs:5154-5173) doesn't go through #agent and refuses only run-revoked, the session fence and thread-already-held. So an idle-released run could claim its free session again. Its key would still be in #idled, and a later real not-holder on that claim would answer idle-released and exit 0. Fix: the thread branch of role.claim refuses idle-released for a run in #idled, before the insert. The key stays, because the run's claim is over. Bus assertion: after threadIdle releases a-run, a-run's role.claim refuses idle-released, and no thread_claims row is added. Mutant M7: drop that check. It dies on that assertion.

Correction to "Harness: no new runner.test case". That's true for threadIdle only. runner.test reaches landRelease, which is a role-run path: :742-772 releases coder-run with ctx.broker.landRelease and no launcher, and pins out.code === EXIT.session (:771) and session stopped working: not-holder (:772). If Q1 is yes, those two lines change (to stopped/0 and the released line). They go to Mos through Sage with threads.test:394, and runner.test:742-772 then serves as the land-relaunch forcing case. No launcher means no SIGTERM, so the receive always lands after the release. The cli case for land-relaunch is dropped. If Q1 is no, the follow-up row cites runner.test:771-772 as its evidence.

Q2. The code is launcher-released if Q1 is yes, and idle-released if Q1 is no. Every name in this file reads that way.

Q3. Agreed. A limit stop on an idled run stays not-holder and exits 22.

Q4. No per-site cases. Both ops return released:false while a delivered message is unread (threadIdle via t.unread, landRelease via its count). The runner reads only after the reply (runner.mjs:575-580), so a run can't be released mid-turn. landRelease keeps a run that hasn't received yet (landing:true), and thread runs have no landing. So only the receive site can see the code. All four sites keep the shared helper, and the three unreachable ones get a one-line comment saying why.

N1. The cli control must reach the runner before any launcher signal. It uses a role.revoke on the run (no launcher stop), or holds the signal the same way the forcing test does. assistant.stop gets checked first in case it goes through a launcher stop. It also asserts the run's runner.log has session stopped working: not-holder and exiting 22. N2. The control keeps "after an idle release", so #idled isn't empty when M5 runs. N3. M6 is equivalent. The key drop in endLaunch stays, so the set doesn't grow. N4. The other-pin grep also covers runner.test session stopped working. :558 (unauthenticated) and :647 (revoke) stay, and :772 changes only under Q1. Docs: if Q1 is yes, landRelease joins the bus README line and the harness exit-table row 0.

Round d2 (sage-3204, 05:32Z)

Line numbers. Sage's check against 0e30a85fa is right, and the references above are now corrected. The second threadIdle not-holder pin is threads.test:392 and stays. The receive pin that changes is :394, and the pending check is :395 and stays. The idle release is at :388.

Sage asked for a grep of the land-relaunch tests for a not-holder pin on the relaunched run's own call. git grep -l "landRelease\|land-relaunch" 'packages/*/tests/*' lists 8 files. In those files, not-holder|session stopped working|EXIT.session|session-invalid hits only land.test.mjs and runner.test.mjs:

  • land.test.mjs:464, assert.throws(() => w.call('merge-gate', 'message.receive'), /not-holder/). This is the released run's own receive after landRelease released it (:458). Under Q1 yes, it answers launcher-released, and the regex fails. That's a third old-test change for Mos through Sage. The new pin is /launcher-released/.
  • land.test.mjs:457 (a non-holder run's landRelease) and :463 (a second landRelease) are landRelease's own refusals. They stay not-holder, the same as threads.test:392.
  • runner.test.mjs:771-772 change under Q1, as in the section above. runner.test.mjs:548, :557-558 and :646-647 are unauthenticated, unauthenticated and a limit stop. They stay.
  • The six cli files have no hit.

So under Q1 yes, the Mos package is threads.test:394, land.test:464 and runner.test:771-772. Under Q1 no, it's threads.test:394 alone. Nothing gets built until Mos rules and Sage gives the go.

Round d3: as built (after mos-1414/1415/1416 and sage-3220)

Q1 is yes, so the code is launcher-released everywhere. The broker set is #launcherReleased, not #idled. Every idle-released above reads launcher-released.

Correction to d1: message.send doesn't go through #agent

d1 said message.send and message.read both go through #agent. message.read does. message.send doesn't. So a released run's send isn't refused launcher-released. Only message.receive, state, message.read and role.claim answer it, and the bus test asserts exactly those four. That narrows what Dewey approved in d1, so I'm sending it to Dewey directly (sage-3220).

Broker (packages/bus/src/broker.mjs)

  • #launcherReleased = new Set(), keyed business:run, next to #stops, with a comment.
  • threadIdle and landRelease add the key after their release transaction.
  • #agent refuses launcher-released for a run in the set, and not-holder otherwise.
  • role.claim (D1) refuses launcher-released right after the agent-required check, before the thread and role branches. So it covers landRelease's role runs as well as thread runs.
  • endLaunch deletes the key after #ended.add(key).
  • Q3 needs no code. A limit stop runs #limitStop (broker.mjs:4482) before any #agent check, and it fails not-holder itself (:4862), so a limit stop after a launcher release still exits 22.

Runner (packages/harness/src/runner.mjs)

  • const LAUNCHER_RELEASED = "launcher-released".
  • The shared helper ended(err, say = true). For launcher-released, it logs runner: session released by the launcher, sets released and leaves the exit code at EXIT.stopped. For the SESSION_GONE codes it keeps the old behaviour (EXIT.session, and the log line unless say is false).
  • It's used at the landing state check, receive, the turn state check and the reply. The reply site passes say = false, so its logging is the same as before. The three sites that can't see the code (Q4) carry a comment saying why.
  • At exit, the final role.release is skipped when released, because the launcher already released.

Tests as built

  • packages/cli/tests/idle-stop-race.test.mjs. This is the threadIdle forcing case. Inside the first released run's held threadIdle op, the human's assistant.stop revokes the other thread's holder (N1: no launcher signal can come while the op is held, launcher.mjs:662-663). The op waits for both runners' verdicts and end events. The idle run logs session released by the launcher, ends ["stopped", 0, null] before its thread-idle line, and has exiting 0 and no session stopped working. The control logs session stopped working: not-holder, ends ["session-invalid", 22, null] and has exiting 22. The Store shows launcher-released and no not-holder refusals for the idle run, and not-holder for the control.
  • packages/bus/tests/launcher-released.test.mjs (renamed from idle-release.test.mjs). It has three tests. (1) threadIdle: receive, state, read and role.claim refuse launcher-released, role.claim adds no thread_claims row, the four refusals are logged, nothing is delivered, threadIdle's own second refusal stays not-holder, and after endLaunch the answer is unauthenticated. (2) The control (N2: after an idle release): assistant.stop's run answers not-holder and run-revoked, pm's own role.release answers not-holder, and a new holder for the released thread doesn't change the released run's answer. (3) landRelease: receive and state refuse launcher-released, role.claim refuses and adds no role_claims row, landRelease's own second refusal stays not-holder, and coder's own role.release answers not-holder.
  • runner.test.mjs:742-772 is the land-relaunch forcing case (mos-1415). There's no cli land-relaunch case.
  • The four old lines Mos ruled are in ASSERTION-PRESERVATION.md. The diff on old test files is those four lines and nothing else.

Docs

The bus README's threadIdle paragraph names launcher-released, the four calls that answer it, landRelease, and that every other lost claim stays not-holder. The cli README's land-relaunch paragraph says calls between the release and the signal refuse launcher-released and the runner exits 0. The harness README's exit table row 0 names the launcher release.

Red first (mos-1415)

There are two commits. The first holds only the tests and the four ruled pin edits. The second holds the fix (broker.mjs, runner.mjs) and the READMEs. The gate's first stage runs on the tests-only commit, and these two must fail there:

  • idle-stop-race.test.mjs: the idle run ends ["session-invalid", 22, null], or its verdict is session stopped working: not-holder, whichever lands first.
  • runner.test.mjs, the case at :742: out.code is 22, not 0.

The bus test files and threads.test:394 and land.test:464 fail there too, and the packet records them. The red run doesn't count as the gate.

Mutants (each run against the fix commit, restored and checked by sha256 after)

  • M1. threadIdle doesn't add the key. Dies: idle-stop-race (end 22), launcher-released.test test 1, threads.test:394.
  • M2. #agent ignores the set. Dies: the same as M1, plus launcher-released.test test 3, land.test:464 and runner.test:771.
  • M3. The runner's helper drops the launcher-released branch. Dies: idle-stop-race (the run doesn't exit in the window, so window.idle is null) and runner.test:771-772.
  • M4. The runner maps launcher-released to EXIT.session. Dies: idle-stop-race (end 22) and runner.test:771.
  • M5. #agent answers launcher-released for every lost claim. Dies: the idle-stop-race control (verdict and end 22), launcher-released.test test 2, and test 3's coder line.
  • M6. endLaunch doesn't drop the key. Equivalent (N3), not run.
  • M7. role.claim drops the D1 check. Dies: launcher-released.test tests 1 and 3 (the claim succeeds).
  • M8. landRelease doesn't add the key. Dies: runner.test:771-772, land.test:464 and launcher-released.test test 3.

Run plan (mos-1275 limits, timeout -k 10)

  1. idle-stop-race.test.mjs alone, 600 s. Then runner.test.mjs whole, 900 s.
  2. compactions-launch.test.mjs whole, 600 s.
  3. The bus, harness and cli suites whole: bus 1200 s, harness 1200 s, cli 2100 s. 292's cli suite took 630 s.
  4. Every scripts/test-*.sh, 900 s each.
  5. M1-M5, M7 and M8, each on its named files at the limits above.

TMPDIR comes from mktemp -d /mnt/storage/scratch/tmp/gXXX. Every run is slot work and starts only on a CLEAR from Sage.

Round d4: Dewey's early static read (dewey-0878, 05:47Z)

C1. The d3 "correction" about message.send was wrong, and d1 was right. message.send calls #consumeAuthority (broker.mjs:5506), which calls #checkAuthority, which calls #agent (:3749). So a released run's send refuses launcher-released, and so does every verb that reaches #agent. There are 29 call sites, including role.release and every authority check. role.claim refuses it before the claim (D1). I misread the send path in d3, and no code depended on that reading. The runner's reply site already handles the code. The bus README now says "every call that needs the claim", not a list of four. Bus test 1 adds a message.send refusal, so the refused list is five launcher-released.

N1. Nothing killed a mutant that drops && !released at runner exit (runner.mjs:596). That run calls role.release, logs runner: role.release: launcher-released and still exits 0. idle-stop-race now asserts the idle run's runner.log has no runner: role.release: line.

  • M9. Drop && !released at runner.mjs:596. It dies on idle-stop-race (the new doesNotMatch).

N2. idle-stop-race waits for the whole held window ("revoked" in window), then asserts window.control is non-null with a message. If the other thread's run isn't holding when A's first release lands, the test now fails with that message instead of a bare "timed out waiting".

The mutants to run are M1-M5, M7, M8 and M9. These test edits are in a third commit, after the fix. ad9fa9f16 stays the red-first commit for the two forcing cases mos-1415 names.

Round d5: red-first reordered (sage-3225, 05:49Z)

Sage pointed out that 8cf2c6d54 changed idle-stop-race.test.mjs and launcher-released.test.mjs after ad9fa9f16. So a red run on ad9fa9f16 would test older copies than the ones that land. I took the first of Sage's two options and reordered the commits locally, before any push. The order is now:

  1. Tests: the final test files and this packet, over unfixed product files.
  2. The fix: broker.mjs, runner.mjs and the three READMEs.

ad9fa9f16, 6e5f782ae and 8cf2c6d54 are superseded and were never pushed. The old tip is kept locally as gizmoduck/row302-pre-reorder. The fix commit's tree matches 8cf2c6d54's except for this BUILD.md note. The "third commit" in d4 now lives in these two commits.

The red run uses the tests commit as it stands, with these test blobs (identical in the fix commit):

Blob File
52343932b2 packages/bus/tests/land.test.mjs
6c83c9ede4 packages/bus/tests/launcher-released.test.mjs
27193a745c packages/bus/tests/threads.test.mjs
9dfacfea15 packages/cli/tests/idle-stop-race.test.mjs
0792431b60 packages/harness/tests/runner.test.mjs

The red run checks these blobs with git rev-parse HEAD:<path> before it starts.

Round d6: the first gate run's red in launcher-released.test.mjs:89 (gizmoduck-0388, sage-3294)

The slot opened at 08:15Z (sage-3289). The gate tree was 649ec67ab, a local merge of 64f6558ee onto origin/refactor 1ac655005. The red stage on f799e8b15 passed: seven expected reds, with the pins at threads.test.mjs:394, land.test.mjs:464 and runner.test.mjs:771 (:772 sits behind :771 in one test). On 649ec67ab, idle-stop-race 1/1, runner.test 40/40 and compactions-launch 5/5 went green. The bus suite then had 875 of 876 pass. The one red was this row's own test, at launcher-released.test.mjs:89, 1 !== 0. The gate stopped there, and the red went to Sage before any fix or rerun.

The test was wrong. #route (broker.mjs:6482) writes one routed delivery row for every message. After threadIdle no run holds thread A, so #recipient (:832-834) returns null and #delivery (:6612) writes holder_run null. The line counted every row for the message. Nothing was delivered to a-run. ASSERTION-PRESERVATION.md entry 5 has the old and new lines.

The fix narrows the count to rows for a-run and adds the line Sage made mandatory: the message has exactly one row, routed, holder_run null. bus rows come from node:sqlite with a null prototype, and the tests use assert/strict, so the second line spreads each row before deepEqual. That is the idiom in sessions.test.mjs:385 and session-clear.test.mjs:564. Sage's draft compared store.all directly, which would fail on the prototype alone.

Packaging (sage-3294): this is a third commit on gizmoduck/row302 after the pushed f799e8b15 and 64f6558ee. It touches only that test line, this note and ASSERTION-PRESERVATION.md. It goes out as a fast-forward push in a gap Sage names, after Mos rules (sage-3293). Then:

  1. The red run uses the final test files over f799e8b15's product: f799e8b15 checked out detached, with the five test files from the new tip checked out on top. The script checks the new blobs first.
  2. The whole gate runs again on a fresh merge of the new tip onto origin/refactor.

Dewey reviews the delta. The first run's logs stay in gizmo-302-out/gate-run1/.