feat(queue): queue as data A1, journal, lock, CLI and verify (#1508)

packages/queue, scripts/queue-commit.sh, scripts/git-hooks and
scripts/test-queue.sh, plus docs/plans/BRIEF-TEMPLATE.md. There is no
queue.json yet, so verify skips until the genesis commit after A2.

Darkwing built it, and Filbert reviewed R0 (6933b885, changes requested)
and r1 (e464be6c, approved). The 20 files match manifest 85a8a453. The
nine suites passed on an index export, including the new queue suite.
test-queue.sh joins the suite list in AGENTS.md. Lead decisions 20, 23
and 26.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-26 19:07:48 -05:00
co-authored by Claude Opus 5.5
parent 91df7d6b54
commit 34a72af912
32 changed files with 11592 additions and 1 deletions
@@ -0,0 +1,20 @@
76833a3bf536bb9592a0040cdb10d9a8351cf0b3bd2828feee928d3415136a79 docs/plans/BRIEF-TEMPLATE.md
5d4b4c7a4624ef267d76d4c032dbcddf3a3d7e7b73a5cfed06953307992bbde7 packages/queue/package.json
576d8ed44a19e1cca96fd7128ca34fcc62d840580b2c0936fa3b296df2e72ddf packages/queue/README.md
188ade96cabb73e06b6b8fbe3d30e8d4d174843877f3a151dcf08082c92d3068 packages/queue/src/cli.mjs
7a851814dfff6f392de814fc31f8dc8cbe9c79cd40ef71313dca1939115ee879 packages/queue/src/errors.mjs
b18e120cb9ddba4c5576d7bc7f7f378ed86e6ec1e084d436474b80765261d2e5 packages/queue/src/io.mjs
52d9f68f01f29e84943fc359fdb1d1ddfaf58d1650c6b15b253b83f1daba9927 packages/queue/src/lock.mjs
c11235a6b99acf6baf1257c63eced410060065ef4f21f79a18860adfa19571cf packages/queue/src/queue.mjs
756cbc9ab13de85757cc24f903d02e8a1d20bb45bb8020c5e1d91e26fbaacaa4 packages/queue/src/store.mjs
6005da4c809cb9045f9480e1e29077b8e7ab185c3ed91567717e8cc13d67b5b0 packages/queue/tests/commit.test.mjs
d29d58427c712a69e8a818d8c74ce724d519ddea8780cc0b5f0b0035cec0f498 packages/queue/tests/data.test.mjs
5cccea50d5a40e07891a090dea001c095a26f4a6c2e0af40c144b1d00f239b5e packages/queue/tests/fixtures/kill-at.mjs
59cc8092fbcddbe9854da7d5014f706f4ffb86573f62ad909aa0b0e09c6d99b9 packages/queue/tests/fixtures/lock-child.mjs
5769b3618918fe36398a75e449d644932332ad5a60c3127098a1d011eda2c182 packages/queue/tests/helpers.mjs
9f98a388ce91438c3238be36049bcd5171b365c68d4b7bf1ae5ff910f4b7b1e8 packages/queue/tests/lock.test.mjs
2a3d2be8cb25b6e7cd18ba56393a284415c66e7ee26a3148fa39f32885efbd35 packages/queue/tests/store.test.mjs
74378acbd41ef21a0b171b08aa85677e4c471966d2ffd8cfb310adb0e044b246 packages/queue/tests/write.test.mjs
3cbd40575dc728dc5407c5029f4f5fff747ce93a5508807233f4362ad37d7f9c scripts/git-hooks/pre-commit
2632078bea45e0249b3fdd9a335100bade7c9106222931603ede9d414c404f54 scripts/queue-commit.sh
92cea23b9ada2edb1b0482ca2daf5864666cc1f546e1f2e6558c3826f1ddf9e7 scripts/test-queue.sh
@@ -0,0 +1,20 @@
20363f5dafbb1be8b7380d7603fd04cf38f5284457b634c9a98ce5a6d8e4832a docs/plans/BRIEF-TEMPLATE.md
5d4b4c7a4624ef267d76d4c032dbcddf3a3d7e7b73a5cfed06953307992bbde7 packages/queue/package.json
9ebdb6a3f239051a39e63fcf8f59c8fba540bb3cc63b7880d15e6c6f1e549a09 packages/queue/README.md
711db25594d78e0ba603a9e91221f6c32241ce1d9217b01bd4da3a99967ae871 packages/queue/src/cli.mjs
7a851814dfff6f392de814fc31f8dc8cbe9c79cd40ef71313dca1939115ee879 packages/queue/src/errors.mjs
b18e120cb9ddba4c5576d7bc7f7f378ed86e6ec1e084d436474b80765261d2e5 packages/queue/src/io.mjs
1095cb6611f2d8d38f535930bbe03208e854b4a57176cea813f7ea0487b3c4f1 packages/queue/src/lock.mjs
8e9230901f550b829ef55e754819c9cd5703e98e447fcebdba6ce5dbef11b0e9 packages/queue/src/queue.mjs
172cf529b2c0e915fbd4a130faa5dbec9023bb19160012246801b16482b4ffaa packages/queue/src/store.mjs
74d04d0de9f4e068fe66bb465ed1575fb38b6ea6592fca6cd6fbacdd553fe9e1 packages/queue/tests/commit.test.mjs
e3f774f823bfb1bcb6025cc3688d72ec110144d5df064557d1645a62472fd663 packages/queue/tests/data.test.mjs
5cccea50d5a40e07891a090dea001c095a26f4a6c2e0af40c144b1d00f239b5e packages/queue/tests/fixtures/kill-at.mjs
59cc8092fbcddbe9854da7d5014f706f4ffb86573f62ad909aa0b0e09c6d99b9 packages/queue/tests/fixtures/lock-child.mjs
5769b3618918fe36398a75e449d644932332ad5a60c3127098a1d011eda2c182 packages/queue/tests/helpers.mjs
a2dbe3dc69b9d53c42246e41e61b9b7d2395697a53ca12ef3481965b43321ff6 packages/queue/tests/lock.test.mjs
34f4b0e3eeadc882ffcc6ba7f0b439651957f2f0c41c9387cf8fb51901f46202 packages/queue/tests/store.test.mjs
70e8a068efda8da8fe1e1628cc1cd5b7fa7796a475941bf7349747244c002f1b packages/queue/tests/write.test.mjs
3cbd40575dc728dc5407c5029f4f5fff747ce93a5508807233f4362ad37d7f9c scripts/git-hooks/pre-commit
2632078bea45e0249b3fdd9a335100bade7c9106222931603ede9d414c404f54 scripts/queue-commit.sh
92cea23b9ada2edb1b0482ca2daf5864666cc1f546e1f2e6558c3826f1ddf9e7 scripts/test-queue.sh
+186
View File
@@ -0,0 +1,186 @@
# Queue A1 build (#1508), candidate for review
Darkwing built this on 2026-09-26 from section 8 of
`agents/filbert/work/queue-as-data-plan-2026-09-26.md` (sha256 282fabbb,
the only spec), split as Sage approved: render is in A1, `move in-review`
needs `--candidate` until piece D, and a round's issue is the row's first
issue. Filbert reviews the code; Sage commits after the suites. Base is HEAD
3a209eea. Nothing is committed, staged or pushed.
A stray pkill at 22:02:32Z stopped my first turn with only
`src/errors.mjs` and `src/io.mjs` on disk. I reread both against what I had
meant them to be. They match: the exit-code class, and the file layer with
`realIo` as the only layer the CLI uses. Everything else was written after
the restart.
## Files
`build-manifest.sha256` pins the 20 files. `build.patch` (sha256 419804f2)
adds all 20 as new files with their modes. It applies cleanly to 3a209eea,
and the applied tree matches the manifest and passes `scripts/test-queue.sh`.
- `packages/queue/src/`: `errors.mjs`, `io.mjs` (the fault-injectable file
layer), `lock.mjs` (lock and unlock gate, 8.4), `queue.mjs` (serialization,
replay, the transition matrix, `next`, render), `store.mjs` (canonical
checks, the write path, witness, views, snapshot, verify), `cli.mjs`.
- `packages/queue/tests/`: data 19, lock 17, store 18, write 20, commit 21
tests, plus `helpers.mjs` and two child fixtures.
- `packages/queue/package.json` and `README.md`. The package has no
dependencies.
- `scripts/queue-commit.sh` (0755): the 8.12 procedure and
`--install-hook`.
- `scripts/git-hooks/pre-commit` (0755, POSIX sh): the queue guard.
- `scripts/test-queue.sh` (0755): the suite, in the style of
`test-discord.sh`.
- `docs/plans/BRIEF-TEMPLATE.md`: the 8.13 template.
## Which path runs `verify` once genesis is in
`scripts/test-queue.sh` runs `node packages/queue/src/cli.mjs verify` when
`git cat-file -e HEAD:docs/plans/queue.json` succeeds. At HEAD today there is
no `queue.json`, so it prints `skip queue verify: HEAD has no
docs/plans/queue.json (before the genesis commit)` and stays green. A2 moves
that call to `scripts/mosaic queue verify` when it adds the dispatch.
`queue-commit.sh` also calls `node packages/queue/src/cli.mjs` directly
(`snapshot`, then `verify --snapshot` from HEAD's archive) until A2.
## Sage's five conditions
1. Nothing ran against the canonical `.git`. After all runs, `.git/hooks`
holds only the samples, `.git` has no `mosaic-queue*` file, and
`git config --show-scope --get-all core.hooksPath` returns nothing in any
scope (rc 1). Every hook install, genesis, lock and gate test runs in a
scratch repo under the system temp directory. Suite runs used a
`--shared` clone at `/tmp/qa1-verify`.
2. `docs/plans/QUEUE.md`, `AGENTS.md` and `docs/TOOLS.md` are unedited.
`docs/SESSIONS.md` shows as modified in the working tree, but that was
someone else's edit before my session began; I didn't touch it.
3. `scripts/test-queue.sh` is green at HEAD with no `queue.json`: 19 checks
passed, `verify` skipped as above.
4. H is recorded before the canary. `commit.test.mjs` has "H recorded before
the canary": a shim commits on the first `git hook run`, and
`queue-commit.sh` exits 1 with `refs/heads/<branch> moved since <H>;
nothing published`. A second test moves HEAD after `commit-tree` with the
same result. Mutation M1 (read H after the canary) fails the first test.
5. The fault file layer is reachable only from tests. Faults enter through
the options the API takes (`io`, `proc`, `hook`, `now`, `readOrder`,
`lockWaitMs`); `cli.mjs` passes none. The only `process.env` read in
`src/` is the default `env` in `store.mjs`'s context.
## Bugs found while building
- A nested `node --test` inherits `NODE_TEST_CONTEXT` and exits 0 whatever
its tests do. Step 4's run of HEAD's archived tests therefore passed with a
failing test in the archive. `queue-commit.sh` and `test-queue.sh` now run
it under `env -u NODE_TEST_CONTEXT`, and a test commits an archive with a
failing test and expects a refusal (mutation M4). Other suites in this repo
that nest `node --test` may have the same blind spot. I haven't checked
them.
- git 2.55 does not hold `index.lock` while the commit editor is open. The
plan expected a paused `git commit -e` to block step 8. It doesn't: the
paused commit loses later at its own HEAD update with `cannot lock ref
'HEAD': is at C but expected H`. The test now asserts that outcome.
Nothing is lost, but the reason differs from the plan's.
- ext4 hands a freed inode number straight back. My first test for release's
inode check wrote a byte-identical lock after unlinking the original and
got the same inode back, so it proved nothing. It now writes a copy and
renames it over the lock, which guarantees a new inode.
## Choices the spec left open
- Verb names `release` and `set`. `add` requires `--gate`. A null brief is
allowed only on rows that genesis creates as done. `sync --op` is
optional.
- Reads need no actor. `next` with no seat and no `$MOSAIC_AGENT_NAME`
refuses.
- `accept-history` accepts a stale table but not an unknown one. When a
write succeeds but the table write is skipped, the CLI warns and exits 0.
- An invalid witness is treated as absent.
- `GIT_DIR`, `GIT_WORK_TREE` and `GIT_COMMON_DIR` refuse in the CLI.
`queue-commit.sh` also refuses `GIT_INDEX_FILE`, `GIT_OBJECT_DIRECTORY`
and `GIT_ALTERNATE_OBJECT_DIRECTORIES`.
- Leftover temp files are unlinked. Genesis uses `link`, so it cannot
replace an existing file.
- `unlock` works on a missing or invalid lock file.
- `note` on a blocked row edits `blockedReason`.
- Messages already name `scripts/mosaic queue`. The lost-history refusal
names `sync` when the file holds genesis alone.
- `--candidate` auto-detects: an existing file is a manifest, anything else
is a commit reachable from `refs/heads` or `refs/tags`.
- Only `--install-hook` is privileged in `queue-commit.sh` (jason or sage).
The commit itself relies on the protocol that the lead runs it.
- After the snapshot, `queue-commit.sh` checks the genesis entry's branch
and root against the current branch and root. For `--genesis` it also
checks that `H:<map>` is the map blob genesis read.
- Step 8 compares the index's two queue entries with H's before it looks
for `index.lock`, so "someone staged a queue path" is reported ahead of a
lock.
## Deferred
- 8.12's test of `verify-commit` on a prospective tree belongs to piece D,
which adds `verify-commit`. It is not in A1.
- A2 holds the real migration map, the QUEUE.md markers and header, the
row-7 pointer, the golden render, `scripts/mosaic` dispatch and
`docs/TOOLS.md`.
- Sage adds `queue` to the suite list when A1 lands (lead decision 20).
- Bootstrap happens after A2's map and markers land:
`scripts/queue-commit.sh --install-hook --by sage`, then `queue genesis`,
then `scripts/queue-commit.sh --genesis -m MSG`.
## Verification
In `/tmp/qa1-verify` (HEAD 3a209eea plus the 20 files):
| Suite | Result |
|---|---|
| config | 24/24 |
| task | 90/90 |
| foundation | 43/43 |
| conductor | 17/17 |
| release | 14/14 |
| auth | 15/15 |
| discord | 63/63 |
| extension-package | 18/18 |
| queue | 19 checks; `node --test` 95/95 |
The queue tests take about 18 s and were stable over two runs. A combined
`node --test` run over every package came to 474/474.
I also ran the post-genesis path in a scratch repo: install the hook,
genesis, `--genesis` commit, then `test-queue.sh`. `verify` printed `ok
verify rev 1: file valid, witness matches, view current`. After a hand edit
to one table cell it failed with `view unknown`.
### Mutations
Each mutation went into the verify clone, the queue tests ran, and the
original was restored. Every one was caught; the number is how many tests
failed.
| Id | Mutation | Failing tests |
|---|---|---|
| M1 | read H after the canary | 1 |
| M2 | drop the step-7 guard recheck | 2 |
| M3 | drop step 8's entry comparison | 1 |
| M4 | keep `NODE_TEST_CONTEXT` | 1 |
| M5 | drop step 1's staged-path check | 1 |
| M6 | `update-ref` without the old value | 2 |
| M7 | skip the canary | 2 |
| M8 | guard hook always passes | 21 |
| M9 | drop step 8's `index.lock` check | 1 |
| M10 | drop the exec-bit check | 2 |
| M11 | drop the `core.hooksPath` check | 1 |
| M12 | drop the map-blob check | 1 |
| S1 | drop the "unchanged since read" check | 1 |
| S2 | swallow the directory fsync error | 2 |
| S3 | drop the recheck under the lock for unlocked reads | 2 |
| S4 | drop the unlock-gate check | 2 |
| S5a | release ignores the inode | 1 |
| S5b | release ignores the record bytes | 1 |
| S6 | write the witness before the rename | 10 |
| S7 | drop genesis's fsync | 1 |
| S8 | treat a reused pid as dead | 11 |
S5 survived at first: the delayed-release test was caught by the byte
comparison alone. The inode test in `lock.test.mjs` closes that gap.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,819 @@
diff --git a/docs/plans/BRIEF-TEMPLATE.md b/docs/plans/BRIEF-TEMPLATE.md
index c5ece4ba..1b0facef 100644
--- a/docs/plans/BRIEF-TEMPLATE.md
+++ b/docs/plans/BRIEF-TEMPLATE.md
@@ -13,7 +13,8 @@ Rules the queue enforces (queue-as-data plan 8.13):
refuse until the lead re-pins it.
`queued` means the brief exists, not that it is accepted. The row moves to
-`briefed` when its owner accepts it.
+`briefed` when a privileged actor (jason or sage) accepts it; the owner
+can't.
---
diff --git a/packages/queue/README.md b/packages/queue/README.md
index 74956816..1c8774fa 100644
--- a/packages/queue/README.md
+++ b/packages/queue/README.md
@@ -44,7 +44,18 @@ Exit codes: 0 ok; 1 the operation failed; 2 invalid data or refused;
Until piece D, `move ID in-review` needs `--candidate`: an existing file is
read as a manifest (one `<sha256> <path>` line per file), anything else as a
commit reachable from `refs/heads` or `refs/tags`. The candidate is frozen
-for the round. The review's issue is the row's first issue.
+for the round.
+
+The review's issue follows lead decision 23. A row with no issues can't
+request review. A row with one issue uses it. A row with several needs
+`--issue N`, one of its issues. Later rounds keep the previous round's issue
+unless `--issue` names another; if the row no longer lists the kept issue,
+the request refuses until `--issue` names one.
+
+`move ID done` from in-review needs `--evidence
+comment=<id>,round=<n>,candidate=<digest>`. The round must be the current
+one and the digest its candidate's, so a comment from an earlier round
+can't close a later one, even when the candidate is the same.
## Where the files live
diff --git a/packages/queue/src/cli.mjs b/packages/queue/src/cli.mjs
index c5517747..e8df80f7 100644
--- a/packages/queue/src/cli.mjs
+++ b/packages/queue/src/cli.mjs
@@ -4,7 +4,7 @@
// Reads: list | show ID | next [SEAT]
// Changes: add --piece TEXT --gate TEXT --brief PATH#ANCHOR [--issue N]... [--note TEXT]
// [--owner SEAT] [--gate-owner SEAT] [--after ID[:settled]]... [--reviewer SEAT]... [--required]
-// move ID STATE [--reason TEXT] [--candidate COMMIT|MANIFEST] [--evidence TEXT]
+// move ID STATE [--reason TEXT] [--candidate COMMIT|MANIFEST] [--issue N] [--evidence TEXT]
// release ID | assign ID SEAT | note ID TEXT | set ID FIELD VALUE [--reason TEXT]
// genesis --root PATH --branch NAME --map PATH
// accept-history --reason TEXT --yes
@@ -23,7 +23,7 @@ import { list, mutate, next, renderView, show, snapshot, sync, unlock, verify, v
const USAGE = [
"usage: queue list | show ID | next [SEAT]",
" queue add --op ID --piece TEXT --gate TEXT --brief PATH#ANCHOR [--issue N]... [--note TEXT] [--owner SEAT] [--gate-owner SEAT] [--after ID[:settled]]... [--reviewer SEAT]... [--required]",
- " queue move ID STATE --op ID [--reason TEXT] [--candidate COMMIT|MANIFEST] [--evidence TEXT]",
+ " queue move ID STATE --op ID [--reason TEXT] [--candidate COMMIT|MANIFEST] [--issue N] [--evidence TEXT]",
" queue release ID --op ID | assign ID SEAT --op ID | note ID TEXT --op ID",
` queue set ID FIELD VALUE --op ID [--reason TEXT] (fields: ${SET_FIELDS.join(", ")})`,
" queue genesis --op ID --root PATH --branch NAME --map PATH",
@@ -134,8 +134,12 @@ export function run(argv, opts = {}) {
});
}
case "move":
- allow(flags, [...CHANGE, "--reason", "--candidate", "--evidence"]); positional(pos, 2, "move ID STATE");
- return change("move", { id: intArg(pos[0], "ID"), to: pos[1], reason: f("--reason"), candidate: f("--candidate"), evidence: f("--evidence") });
+ allow(flags, [...CHANGE, "--reason", "--candidate", "--issue", "--evidence"]); positional(pos, 2, "move ID STATE");
+ if ((flags.get("--issue") ?? []).length > 1) throw usage("move takes one --issue");
+ return change("move", {
+ id: intArg(pos[0], "ID"), to: pos[1], reason: f("--reason"), candidate: f("--candidate"), evidence: f("--evidence"),
+ issue: flags.has("--issue") ? intArg(flags.get("--issue")[0].replace(/^#/, ""), "--issue") : null,
+ });
case "release":
allow(flags, CHANGE); positional(pos, 1, "release ID");
return change("release", { id: intArg(pos[0], "ID") });
diff --git a/packages/queue/src/lock.mjs b/packages/queue/src/lock.mjs
index 56c43f0f..34e88e3b 100644
--- a/packages/queue/src/lock.mjs
+++ b/packages/queue/src/lock.mjs
@@ -70,6 +70,7 @@ function describe(c) {
function publish(io, target, bytes, hook, waitMs, stepMs) {
const tmp = `${target}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
let fd;
+ let st;
try {
fd = io.openExcl(tmp, 0o600);
} catch (err) {
@@ -82,6 +83,9 @@ function publish(io, target, bytes, hook, waitMs, stepMs) {
fd = null;
const back = io.readFile(tmp);
if (!back.equals(bytes)) throw Object.assign(new Error("read-back differs from the record"), { code: "EREADBACK" });
+ // The link gives the target this inode, so read it before linking:
+ // nothing that can fail runs between a successful link and the return.
+ st = io.stat(tmp);
} catch (err) {
if (fd !== null) { try { io.close(fd); } catch { /* already failing */ } }
unlinkQuiet(io, tmp);
@@ -99,7 +103,6 @@ function publish(io, target, bytes, hook, waitMs, stepMs) {
sleepMs(stepMs);
continue;
}
- const st = io.stat(tmp);
return { linked: true, dev: st.dev, ino: st.ino, bytes };
}
} finally {
@@ -122,8 +125,14 @@ export function acquire({ gitDir, io, proc = realProc, op = null, verb, waitMs =
const handle = { path, dev: got.dev, ino: got.ino, bytes: got.bytes };
hook("lock-linked");
const gate = join(gitDir, GATE_NAME);
- if (lstatOrNull(io, gate) !== null) {
- const c = classify(readOrNull(io, gate), proc);
+ let c = null;
+ try {
+ if (lstatOrNull(io, gate) !== null) c = classify(readOrNull(io, gate), proc);
+ } catch (err) {
+ const left = release(handle, io);
+ throw new QueueError(`cannot check the unlock gate ${gate}: ${errno(err)}; ${left ?? "lock released"}`, 1);
+ }
+ if (c !== null) {
release(handle, io);
throw new QueueError(`unlock gate ${gate} is present (${describe(c)}); check it with \`scripts/mosaic queue unlock --check-gate\``, 2);
}
@@ -154,18 +163,29 @@ export function unlock({ gitDir, io, proc = realProc, hook = () => {} }) {
}
const gate = { path: gatePath, dev: got.dev, ino: got.ino, bytes };
hook("gate-held");
+ let result;
+ let failure = null;
try {
const lockBytes = readOrNull(io, lockPath);
- if (lockBytes === null) return "no queue lock present; nothing removed";
- const c = classify(lockBytes, proc);
- if (c.state !== "dead" && c.state !== "mismatch") {
- throw new QueueError(`queue lock owner is ${describe(c)}; unlock refuses`, 2);
+ if (lockBytes === null) {
+ result = "no queue lock present; nothing removed";
+ } else {
+ const c = classify(lockBytes, proc);
+ if (c.state !== "dead" && c.state !== "mismatch") {
+ throw new QueueError(`queue lock owner is ${describe(c)}; unlock refuses`, 2);
+ }
+ io.unlink(lockPath);
+ result = `removed queue lock (${describe(c)}): ${lockBytes.toString("utf8").trim()}`;
}
- io.unlink(lockPath);
- return `removed queue lock (${describe(c)}): ${lockBytes.toString("utf8").trim()}`;
- } finally {
- release(gate, io);
+ } catch (err) {
+ failure = err;
}
+ let msg;
+ try { msg = release(gate, io); } catch (err) { msg = `cannot release the unlock gate (${errno(err)})`; }
+ // Like the lock, a swapped gate is reported on success and on refusal (8.4).
+ if (msg && failure instanceof Error) failure.message += `\nwarning: ${msg}`;
+ if (failure) throw failure;
+ return msg ? `${result}\nwarning: ${msg}` : result;
}
export function checkGate({ gitDir, io, proc = realProc }) {
diff --git a/packages/queue/src/queue.mjs b/packages/queue/src/queue.mjs
index 9999ba18..06e77c17 100644
--- a/packages/queue/src/queue.mjs
+++ b/packages/queue/src/queue.mjs
@@ -208,7 +208,7 @@ export function validateRow(row) {
checkNames(row.reviewers, `${w} reviewers`);
if (row.review !== null) {
keysExactly(row.review, ["issue", "rounds"], `${w} review`);
- if (row.review.issue !== null) checkId(row.review.issue, `${w} review issue`);
+ checkId(row.review.issue, `${w} review issue`);
if (!Array.isArray(row.review.rounds) || row.review.rounds.length === 0) throw refuse(`${w} review needs at least one round`);
row.review.rounds.forEach((r, i) => checkRound(r, i + 1));
}
@@ -338,6 +338,7 @@ export function canonArgs(verb, a) {
reason: nullable(a.reason, (v) => checkText(v, "reason")),
candidate: nullable(a.candidate, (v) => checkText(v, "candidate", { max: 300 })),
evidence: nullable(a.evidence, (v) => checkText(v, "evidence")),
+ issue: nullable(a.issue, (v) => checkId(v, "issue")),
};
case "release":
return { id: checkId(a.id) };
@@ -393,11 +394,30 @@ function afterSatisfied(rows, row) {
return missing;
}
-// `comment=<id>,candidate=<digest>`: the J5 evidence before Piece D.
+// `comment=<id>,round=<n>,candidate=<digest>`: the J5 evidence before Piece D.
export function parseReviewEvidence(text) {
- const m = /^comment=([1-9][0-9]{0,19}),candidate=([0-9a-f]{40}|[0-9a-f]{64})$/.exec(text ?? "");
- if (!m) throw refuse("in-review to done needs --evidence comment=<id>,candidate=<digest> for the current round");
- return { comment: m[1], candidate: m[2] };
+ const m = /^comment=([1-9][0-9]{0,19}),round=([1-9][0-9]{0,5}),candidate=([0-9a-f]{40}|[0-9a-f]{64})$/.exec(text ?? "");
+ if (!m) throw refuse("in-review to done needs --evidence comment=<id>,round=<n>,candidate=<digest> for the current round");
+ return { comment: m[1], round: Number(m[2]), candidate: m[3] };
+}
+
+// The issue a review round posts to (lead decision 23). The row must list
+// one; with several, --issue names it. A later round keeps the previous
+// round's issue unless --issue names another, and the kept issue must still
+// be one of the row's.
+function reviewIssue(row, issue) {
+ const list = row.issues.map((n) => `#${n}`).join(", ");
+ if (row.issues.length === 0) throw refuse(`row ${row.id} lists no issues; a privileged actor sets one before review`);
+ if (issue !== null) {
+ if (!row.issues.includes(issue)) throw refuse(`--issue #${issue} is not one of row ${row.id}'s issues (${list})`);
+ return issue;
+ }
+ if (row.review) {
+ if (!row.issues.includes(row.review.issue)) throw refuse(`row ${row.id}'s review issue #${row.review.issue} is no longer one of its issues (${list}); name one with --issue`);
+ return row.review.issue;
+ }
+ if (row.issues.length > 1) throw refuse(`row ${row.id} lists several issues (${list}); name the review's issue with --issue`);
+ return row.issues[0];
}
function touch(row, entry) {
@@ -424,7 +444,7 @@ function getRow(rows, id) {
}
function applyMove(rows, row, entry, resolved) {
- const { to, reason, candidate, evidence } = entry.args;
+ const { to, reason, candidate, evidence, issue } = entry.args;
const by = entry.by;
const from = row.state;
const illegal = () => refuse(`row ${row.id}: ${from}→${to} is not a transition`);
@@ -432,9 +452,11 @@ function applyMove(rows, row, entry, resolved) {
if (reason !== null && to !== "blocked") throw refuse("--reason applies only to a move to blocked");
if (candidate !== null && !(from === "in-progress" && to === "in-review")) throw refuse("--candidate applies only to in-progress→in-review");
if (evidence !== null && to !== "done") throw refuse("--evidence applies only to a move to done");
+ if (issue !== null && !(from === "in-progress" && to === "in-review")) throw refuse("--issue applies only to in-progress→in-review");
let next = { ...row, state: to };
let round = null;
let cand = null;
+ let revIssue = null;
if (to === "blocked") {
if (from === "blocked") throw refuse(`row ${row.id} is already blocked; update the reason with note`);
if (!NON_TERMINAL.has(from)) throw illegal();
@@ -457,11 +479,12 @@ function applyMove(rows, row, entry, resolved) {
} else if (from === "in-progress" && to === "in-review") {
if (row.claim === null || by !== row.claim.seat) throw refuse(`only the claimant (${row.claim?.seat ?? "nobody"}) may request review of row ${row.id}`);
if (candidate === null) throw refuse("in-progress→in-review needs --candidate <commit|manifest>");
+ revIssue = reviewIssue(row, issue);
cand = checkCandidate(resolved.candidate);
const rounds = row.review ? row.review.rounds : [];
round = rounds.length + 1;
next.review = {
- issue: row.review ? row.review.issue : (row.issues[0] ?? null),
+ issue: revIssue,
rounds: [...rounds, { n: round, op: entry.op, by, at: entry.at, candidate: cand, request: "none" }],
};
} else if (from === "in-review" && (to === "in-progress" || to === "waiting-on-jason")) {
@@ -479,6 +502,7 @@ function applyMove(rows, row, entry, resolved) {
const ev = parseReviewEvidence(evidence);
const cur = row.review?.rounds.at(-1);
if (!cur) throw refuse(`row ${row.id} has no review round to cite`);
+ if (ev.round !== cur.n) throw refuse(`evidence names round ${ev.round}; row ${row.id} is in round ${cur.n}`);
if (ev.candidate !== cur.candidate.digest) throw refuse(`evidence candidate ${ev.candidate} is not round ${cur.n}'s candidate ${cur.candidate.digest}`);
round = cur.n;
next.claim = null;
@@ -491,7 +515,7 @@ function applyMove(rows, row, entry, resolved) {
throw illegal();
}
next = touch(next, entry);
- return { row: next, result: { row: row.id, from, to, round, candidate: cand } };
+ return { row: next, result: { row: row.id, from, to, round, issue: revIssue, candidate: cand } };
}
function applySet(rows, row, entry, resolved) {
@@ -588,7 +612,7 @@ export function applyEntry(state, entry, resolved) {
const out = applyMove(rows, row, entry, resolved);
rows.set(row.id, out.row);
const r = out.result;
- result = { ...r, receipt: receipt(entry, rev, `row ${row.id} ${r.from}→${r.to}${r.round ? ` round ${r.round}` : ""}`) };
+ result = { ...r, receipt: receipt(entry, rev, `row ${row.id} ${r.from}→${r.to}${r.round ? ` round ${r.round}` : ""}${r.issue ? ` on #${r.issue}` : ""}`) };
break;
}
case "release": {
diff --git a/packages/queue/src/store.mjs b/packages/queue/src/store.mjs
index 97f8784c..c3a66b8c 100644
--- a/packages/queue/src/store.mjs
+++ b/packages/queue/src/store.mjs
@@ -242,7 +242,11 @@ function writeWitness(ctx, loc, doc, bytes) {
unlinkQuiet(ctx.io, tmp);
throw err;
}
- ctx.io.fsyncDir(loc.gitDir);
+ try {
+ ctx.io.fsyncDir(loc.gitDir);
+ } catch (err) {
+ throw Object.assign(new Error(errno(err)), { code: err.code, renamed: true });
+ }
}
function confirmTail(ctx, loc, cur) {
@@ -316,6 +320,7 @@ function writeView(ctx, loc, before, parts, body, tag) {
const now = readOrNull(ctx.io, loc.viewPath);
if (now === null || !now.equals(before)) return stale;
const tmp = `${loc.viewPath}.${tag}.tmp`;
+ let renamed = false;
try {
const mode = Number(ctx.io.stat(loc.viewPath).mode & 0o777n);
unlinkQuiet(ctx.io, tmp);
@@ -326,9 +331,11 @@ function writeView(ctx, loc, before, parts, body, tag) {
return stale;
}
ctx.io.rename(tmp, loc.viewPath);
+ renamed = true;
ctx.io.fsyncDir(loc.docsDir);
return null;
} catch (err) {
+ if (renamed) return `the view is written but not confirmed durable (${errno(err)}); the op stands; after a host crash, check the table with \`${FIX} verify\``;
unlinkQuiet(ctx.io, tmp);
return `the view write failed (${errno(err)}); the op stands and the view is stale; run \`${FIX} render\``;
}
@@ -390,7 +397,8 @@ function commitWrite(ctx, loc, cur, doc, bytes, op, view, body, exclusive) {
try {
writeWitness(ctx, loc, doc, bytes);
} catch (err) {
- throw new QueueError(`uncertain ${op} rev ${rev}: durable, witness not updated (${errno(err)})`, 3);
+ const what = err.renamed ? "witness written, its directory fsync failed" : "witness not updated";
+ throw new QueueError(`uncertain ${op} rev ${rev}: durable, ${what} (${errno(err)})`, 3);
}
ctx.hook("witnessed");
const warn = writeView(ctx, loc, view.bytes, view.parts, body, op);
@@ -402,14 +410,19 @@ function withLock(ctx, loc, { op = null, verb }, fn) {
checkPlatform(ctx.io, [loc.docsDir, loc.gitDir]);
const handle = acquire({ gitDir: loc.gitDir, io: ctx.io, proc: ctx.proc, op, verb, waitMs: ctx.lockWaitMs, stepMs: ctx.lockStepMs, hook: ctx.hook });
const res = { out: [], err: [], code: 0 };
+ let failure = null;
try {
ctx.hook("locked");
fn(res);
- } finally {
- let msg;
- try { msg = release(handle, ctx.io); } catch (err) { msg = `cannot release the queue lock (${errno(err)})`; }
- if (msg) res.err.push(`warning: ${msg}`);
+ } catch (err) {
+ failure = err;
}
+ let msg;
+ try { msg = release(handle, ctx.io); } catch (err) { msg = `cannot release the queue lock (${errno(err)})`; }
+ // A refusal still reports what release found (8.4).
+ if (msg && failure instanceof Error) failure.message += `\nwarning: ${msg}`;
+ else if (msg) res.err.push(`warning: ${msg}`);
+ if (failure) throw failure;
return res;
}
@@ -763,5 +776,6 @@ export function unlock(opts, { checkGateOnly = false } = {}) {
const ctx = makeCtx(opts);
const loc = unlockLoc(ctx);
if (checkGateOnly) return { out: [checkGate({ gitDir: loc.gitDir, io: ctx.io, proc: ctx.proc }).line], err: [], code: 0 };
- return { out: [unlockLock({ gitDir: loc.gitDir, io: ctx.io, proc: ctx.proc, hook: ctx.hook })], err: [], code: 0 };
+ const [line, ...warnings] = unlockLock({ gitDir: loc.gitDir, io: ctx.io, proc: ctx.proc, hook: ctx.hook }).split("\n");
+ return { out: [line], err: warnings, code: 0 };
}
diff --git a/packages/queue/tests/commit.test.mjs b/packages/queue/tests/commit.test.mjs
index 55105163..9a1b6ec1 100644
--- a/packages/queue/tests/commit.test.mjs
+++ b/packages/queue/tests/commit.test.mjs
@@ -152,7 +152,12 @@ fi`);
assert.equal(r.blob("HEAD", "src.txt"), "src\n");
});
-test("F1: a commit whose guard ran before update-ref fails at its own HEAD update", async (t) => {
+// A commit paused in its editor after its guard passed against H. Whether
+// git holds index.lock during the editor depends on the form: git 2.55
+// doesn't for plain `commit -e` and does for `commit -e -- path`. Step 8
+// reconciles when the lock is free and exits 3 when it isn't; either way
+// the paused commit loses at its own HEAD update.
+async function pausedCommit(t, form) {
const r = ready(t);
note(r);
stageFile(r, "src.txt");
@@ -161,27 +166,38 @@ test("F1: a commit whose guard ran before update-ref fails at its own HEAD updat
const go = join(r.ctl, "editor-go");
const editor = join(r.ctl, "editor.sh");
writeFileSync(editor, `#!/bin/sh\n: > ${q(started)}\nwhile [ ! -e ${q(go)} ]; do sleep 0.05; done\necho "ordinary" > "$1"\n`, { mode: 0o755 });
- const child = spawn("git", ["-C", r.root, "commit", "-e", "-q"], { env: { ...r.env, GIT_EDITOR: editor }, stdio: ["ignore", "pipe", "pipe"] });
+ const child = spawn("git", ["-C", r.root, "commit", "-e", "-q", ...form], { env: { ...r.env, GIT_EDITOR: editor }, stdio: ["ignore", "pipe", "pipe"] });
let childErr = "";
child.stderr.on("data", (d) => { childErr += d; });
const exited = new Promise((resolve) => child.on("exit", resolve));
for (let i = 0; i < 200 && !existsSync(started); i++) sleepMs(50);
assert.ok(existsSync(started), "the editor never started");
- // The paused commit ran its guard against H and holds index.lock.
+ const locked = existsSync(join(r.gitDir, "index.lock"));
+ t.diagnostic(`git commit -e${form.map((a) => ` ${a}`).join("")}: index.lock ${locked ? "held" : "free"} during the editor`);
const res = r.qc(["-m", "queue rev 1"]);
writeFileSync(go, "");
const code = await exited;
- // git 2.55 does not hold index.lock while the editor runs, so step 8
- // reconciles; the paused commit then loses at its HEAD update.
- assert.equal(res.code, 0, res.err);
+ assert.equal(res.code, locked ? 3 : 0, `index.lock ${locked ? "held" : "free"} during the editor: ${res.err}`);
+ if (locked) assert.match(res.err, /another git process holds \.git\/index\.lock/);
const c = r.head();
assert.equal(r.g("rev-parse", "HEAD^").trim(), h);
+ assert.equal(r.revAt(c), 1);
assert.notEqual(code, 0);
assert.match(childErr, new RegExp(`cannot lock ref 'HEAD': is at ${c} but expected ${h}`));
assert.equal(r.head(), c, "the old queue landed on top of C");
+ if (locked) r.g("reset", "-q", "--", "docs/plans/queue.json", "docs/plans/QUEUE.md");
assert.equal(r.g("diff", "--cached", "--name-only").trim(), "src.txt");
r.g("commit", "-q", "-m", "ordinary");
assert.equal(r.revAt("HEAD"), 1);
+ return locked;
+}
+
+test("F1: a plain `commit -e` whose guard ran before update-ref fails at its own HEAD update", async (t) => {
+ await pausedCommit(t, []);
+});
+
+test("F1: a `commit -e -- path` whose guard ran before update-ref fails at its own HEAD update", async (t) => {
+ await pausedCommit(t, ["--", "src.txt"]);
});
test("F1: step 8 with index.lock held exits 3, and ordinary commits stay refused until the printed command runs", (t) => {
diff --git a/packages/queue/tests/data.test.mjs b/packages/queue/tests/data.test.mjs
index 2e6c019f..99080d95 100644
--- a/packages/queue/tests/data.test.mjs
+++ b/packages/queue/tests/data.test.mjs
@@ -3,8 +3,8 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
- CALLER_OP_RE, LOG_OP_RE, applyEntry, buildDoc, canonArgs, classifyView, countHeading, genesisReceipt, genesisRows,
- gitBlobId, loadDoc, nextFor, parseManifest, parseMigrationMap, render, rowsArray, serialize, sha256, splitView,
+ CALLER_OP_RE, LOG_OP_RE, STATES, applyEntry, buildDoc, canonArgs, classifyView, countHeading, genesisReceipt, genesisRows,
+ gitBlobId, loadDoc, nextFor, parseManifest, parseMigrationMap, render, rowsArray, serialize, sha256, splitView, validateRow,
} from "../src/queue.mjs";
import { QueueError } from "../src/errors.mjs";
import { MAP_ROWS, mapText } from "./helpers.mjs";
@@ -49,7 +49,7 @@ function refused(fn, re) {
assert.throws(fn, (err) => err instanceof QueueError && err.code === 2 && re.test(err.message));
}
-const mv = (id, to, extra = {}) => ({ id, to, reason: null, candidate: null, evidence: null, ...extra });
+const mv = (id, to, extra = {}) => ({ id, to, reason: null, candidate: null, evidence: null, issue: null, ...extra });
const row = (doc, id) => doc.rows.find((r) => r.id === id);
const MANIFEST = `${"c".repeat(64)} packages/queue/src/queue.mjs\n`;
const CAND = { kind: "manifest", digest: sha256(MANIFEST), text: MANIFEST };
@@ -156,7 +156,7 @@ test("matrix: release, review round, changes requested and waiting-on-jason", ()
const rv = row(d, 9).review;
assert.equal(rv.issue, 1508);
assert.deepEqual(rv.rounds.map((r) => [r.n, r.request, r.candidate.digest]), [[1, "none", CAND.digest]]);
- assert.match(d.log.at(-1).result.receipt, /in-progress→in-review round 1$/);
+ assert.match(d.log.at(-1).result.receipt, /in-progress→in-review round 1 on #1508$/);
refused(() => step(d, "move", mv(9, "in-progress"), "dewey"), /claimed by darkwing/);
d = step(d, "move", mv(9, "in-progress"), "darkwing");
assert.equal(row(d, 9).claim.seat, "darkwing");
@@ -176,9 +176,11 @@ test("matrix: release, review round, changes requested and waiting-on-jason", ()
test("matrix J5: in-review→done by the gate owner with evidence naming the current round", () => {
let d = row9Started();
d = step(d, "move", mv(9, "in-review", { candidate: "x" }), "darkwing", { candidate: CAND });
- const ev = `comment=4242,candidate=${CAND.digest}`;
- refused(() => step(d, "move", mv(9, "done"), "filbert"), /--evidence comment=<id>,candidate=<digest>/);
- refused(() => step(d, "move", mv(9, "done", { evidence: `comment=1,candidate=${"d".repeat(64)}` }), "filbert"), /is not round 1's candidate/);
+ const ev = `comment=4242,round=1,candidate=${CAND.digest}`;
+ refused(() => step(d, "move", mv(9, "done"), "filbert"), /--evidence comment=<id>,round=<n>,candidate=<digest>/);
+ refused(() => step(d, "move", mv(9, "done", { evidence: `comment=4242,candidate=${CAND.digest}` }), "filbert"), /round=<n>/);
+ refused(() => step(d, "move", mv(9, "done", { evidence: `comment=1,round=1,candidate=${"d".repeat(64)}` }), "filbert"), /is not round 1's candidate/);
+ refused(() => step(d, "move", mv(9, "done", { evidence: `comment=4242,round=2,candidate=${CAND.digest}` }), "filbert"), /evidence names round 2; row 9 is in round 1/);
refused(() => step(d, "move", mv(9, "done", { evidence: ev }), "rocko"), /only the gate owner \(filbert\)/);
const done = step(d, "move", mv(9, "done", { evidence: ev }), "filbert");
assert.equal(row(done, 9).state, "done");
@@ -187,6 +189,155 @@ test("matrix J5: in-review→done by the gate owner with evidence naming the cur
let e = step(genesisDoc(), "move", mv(11, "in-progress"), "dewey");
e = step(e, "move", mv(11, "in-review", { candidate: "x" }), "dewey", { candidate: CAND });
refused(() => step(e, "move", mv(11, "done", { evidence: ev }), "jason"), /gate is Jason's/);
+ // Changes requested, then the same candidate again: round 1's comment
+ // does not close round 2 (8.7, R3).
+ d = step(d, "move", mv(9, "in-progress"), "darkwing");
+ d = step(d, "move", mv(9, "in-review", { candidate: "x" }), "darkwing", { candidate: CAND });
+ assert.deepEqual(row(d, 9).review.rounds.map((r) => r.candidate.digest), [CAND.digest, CAND.digest]);
+ refused(() => step(d, "move", mv(9, "done", { evidence: ev }), "filbert"), /evidence names round 1; row 9 is in round 2/);
+ const done2 = step(d, "move", mv(9, "done", { evidence: `comment=4343,round=2,candidate=${CAND.digest}` }), "filbert");
+ assert.equal(done2.log.at(-1).result.round, 2);
+});
+
+// A row 12 owned by darkwing with the given issues, started, so the next
+// move is the review request.
+function row12Started(issues, { gateOwner = "filbert" } = {}) {
+ let d = step(genesisDoc(), "add", { piece: "N", gate: "g", brief: "docs/plans/brief-b.md#Queue", issues, owner: "darkwing", gateOwner }, "sage", { brief: brief() });
+ d = step(d, "move", mv(12, "briefed"), "sage");
+ return step(d, "move", mv(12, "in-progress"), "darkwing");
+}
+
+const review = (d, extra = {}) => step(d, "move", mv(12, "in-review", { candidate: "x", ...extra }), "darkwing", { candidate: CAND });
+const again = (d) => step(d, "move", mv(12, "in-progress"), "darkwing");
+
+test("review issue, lead decision 23: none refuses, one is used, several need --issue, later rounds keep it", () => {
+ // No issues: refused before the round opens.
+ refused(() => review(row12Started([])), /row 12 lists no issues; a privileged actor sets one before review/);
+ // One issue: used without --issue; --issue may name it; any other refuses.
+ const one = review(row12Started([1508]));
+ assert.equal(row(one, 12).review.issue, 1508);
+ assert.match(one.log.at(-1).result.receipt, /round 1 on #1508$/);
+ assert.equal(one.log.at(-1).result.issue, 1508);
+ refused(() => review(row12Started([1508]), { issue: 1495 }), /--issue #1495 is not one of row 12's issues \(#1508\)/);
+ // Several: --issue is required and must be one of the row's; the lowest
+ // number is not a default.
+ const several = row12Started([1495, 1508]);
+ refused(() => review(several), /row 12 lists several issues \(#1495, #1508\); name the review's issue with --issue/);
+ refused(() => review(several, { issue: 1600 }), /--issue #1600 is not one of row 12's issues \(#1495, #1508\)/);
+ let d = review(several, { issue: 1508 });
+ assert.equal(row(d, 12).review.issue, 1508);
+ // Later rounds keep the previous round's issue unless --issue names another.
+ d = review(again(d));
+ assert.deepEqual([row(d, 12).review.issue, row(d, 12).review.rounds.length], [1508, 2]);
+ d = review(again(d), { issue: 1495 });
+ assert.deepEqual([row(d, 12).review.issue, row(d, 12).review.rounds.length], [1495, 3]);
+ // A kept issue the row no longer lists refuses until --issue names one.
+ d = step(again(d), "set", { id: 12, field: "issues", value: [1508, 1600] }, "sage");
+ refused(() => review(d), /review issue #1495 is no longer one of its issues \(#1508, #1600\); name one with --issue/);
+ assert.equal(row(review(d, { issue: 1600 }), 12).review.issue, 1600);
+ // --issue belongs to the review request only.
+ refused(() => step(several, "move", mv(12, "blocked", { reason: "x", issue: 1508 }), "darkwing"), /--issue applies only to in-progress→in-review/);
+});
+
+test("the row schema refuses a review with a null issue", () => {
+ const r = structuredClone(row(review(row12Started([1508])), 12));
+ validateRow(r);
+ r.review.issue = null;
+ refused(() => validateRow(r), /review issue must be a positive integer/);
+});
+
+// R1: every state × target × actor class against 8.7's table, written from
+// the spec rather than from queue.mjs. Row 12 is owned by darkwing; the
+// gate owner is filbert, jason or the owner; rocko is any other seat.
+const ACTORS = ["darkwing", "filbert", "rocko", "sage", "jason"];
+const PRIV = new Set(["sage", "jason"]);
+
+function specAllows({ from, prev, to, by, gateOwner, required }) {
+ const own = by === "darkwing";
+ const priv = PRIV.has(by);
+ if (from === "done") return false;
+ if (to === "blocked") return ["queued", "briefed", "in-progress", "in-review", "waiting-on-jason"].includes(from) && (own || priv);
+ if (from === "blocked") return to === prev && (own || priv);
+ const edge = `${from}→${to}`;
+ switch (edge) {
+ case "queued→briefed": return priv;
+ case "briefed→in-progress": return own;
+ case "in-progress→in-review": return own; // the claimant is the owner
+ case "in-review→in-progress": case "in-review→waiting-on-jason": return own || priv;
+ case "waiting-on-jason→done": return by === "jason" || by === "sage"; // sage with evidence, which the case supplies
+ case "in-review→done": return gateOwner !== "jason" && (by === gateOwner || priv);
+ case "queued→parked": case "briefed→parked": return by === "jason" && !required;
+ case "parked→queued": return by === "jason";
+ default: return false; // in-progress→briefed is `release`, not `move`
+ }
+}
+
+function matrixStates(gateOwner, required) {
+ let q = step(genesisDoc(), "add", {
+ piece: "M", gate: "g", brief: "docs/plans/brief-b.md#Queue", issues: [1508], owner: "darkwing", gateOwner, required,
+ }, "sage", { brief: brief() });
+ const out = [{ from: "queued", doc: q }];
+ if (!required) out.push({ from: "parked", doc: step(q, "move", mv(12, "parked"), "jason") });
+ const b = step(q, "move", mv(12, "briefed"), "sage");
+ const p = step(b, "move", mv(12, "in-progress"), "darkwing");
+ const r = review(p);
+ const w = step(r, "move", mv(12, "waiting-on-jason"), "sage");
+ out.push({ from: "briefed", doc: b }, { from: "in-progress", doc: p }, { from: "in-review", doc: r }, { from: "waiting-on-jason", doc: w });
+ out.push({ from: "done", doc: step(w, "move", mv(12, "done"), "jason") });
+ for (const s of [...out]) {
+ if (!["done", "parked"].includes(s.from)) out.push({ from: "blocked", prev: s.from, doc: step(s.doc, "move", mv(12, "blocked", { reason: "r" }), "sage") });
+ }
+ return out;
+}
+
+function matrixArgs(from, to) {
+ const extra = {};
+ if (to === "blocked") extra.reason = "r";
+ if (from === "in-progress" && to === "in-review") extra.candidate = "x";
+ if (to === "done" && from === "in-review") extra.evidence = `comment=1,round=1,candidate=${CAND.digest}`;
+ if (to === "done" && from === "waiting-on-jason") extra.evidence = "Jason approved in thread X";
+ return mv(12, to, extra);
+}
+
+test("matrix R1: every state × target × actor class matches 8.7, gate owner jason or not, required or not", () => {
+ let allowed = 0;
+ let refusals = 0;
+ for (const gateOwner of ["filbert", "jason", "darkwing"]) {
+ for (const required of [false, true]) {
+ for (const { from, prev = null, doc } of matrixStates(gateOwner, required)) {
+ assert.equal(row(doc, 12).state, from);
+ for (const to of STATES) {
+ for (const by of ACTORS) {
+ const c = { from, prev, to, by, gateOwner, required };
+ const label = JSON.stringify(c);
+ let got;
+ try {
+ got = step(doc, "move", matrixArgs(from, to), by, { candidate: CAND });
+ } catch (err) {
+ assert.ok(err instanceof QueueError && err.code === 2, `${label}: ${err.stack}`);
+ assert.equal(specAllows(c), false, `${label} refused: ${err.message}`);
+ refusals++;
+ continue;
+ }
+ assert.equal(specAllows(c), true, `${label} was allowed`);
+ const r = row(got, 12);
+ assert.equal(r.state, to, label);
+ if (to === "blocked") assert.equal(r.previousState, from, label);
+ if (from === "blocked") assert.deepEqual([r.previousState, r.blockedReason], [null, null], label);
+ allowed++;
+ }
+ }
+ // release: the claimant or a privileged actor, from in-progress only.
+ for (const by of ACTORS) {
+ const ok = from === "in-progress" && (by === "darkwing" || PRIV.has(by));
+ const label = JSON.stringify({ release: from, by, gateOwner, required });
+ if (ok) assert.deepEqual([row(step(doc, "release", { id: 12 }, by), 12).state], ["briefed"], label);
+ else assert.throws(() => step(doc, "release", { id: 12 }, by), (err) => err instanceof QueueError && err.code === 2, label);
+ }
+ }
+ }
+ }
+ assert.ok(allowed > 100 && refusals > 1000, `allowed ${allowed}, refused ${refusals}`);
});
test("matrix: blocked keeps the claim and returns only to previousState", () => {
@@ -301,7 +452,7 @@ test("next: resume, then review, then start, then wait, then nothing; lowest id
let d = genesisDoc();
const add = (owner, extra = {}) => ({ piece: `p-${owner}`, gate: "g", brief: "docs/plans/brief-b.md#Queue", owner, ...extra });
d = step(d, "add", add("rocko"), "sage", { brief: brief() }); // 12
- d = step(d, "add", add("rocko", { reviewers: ["darkwing"] }), "sage", { brief: brief() }); // 13
+ d = step(d, "add", add("rocko", { reviewers: ["darkwing"], issues: [1508] }), "sage", { brief: brief() }); // 13
d = step(d, "add", add("darkwing"), "sage", { brief: brief() }); // 14
for (const id of [12, 13, 14]) d = step(d, "move", mv(id, "briefed"), "sage");
const rows = () => loadDoc(Buffer.from(serialize(d))).state.rows;
diff --git a/packages/queue/tests/lock.test.mjs b/packages/queue/tests/lock.test.mjs
index 9b6b2174..f1b109d9 100644
--- a/packages/queue/tests/lock.test.mjs
+++ b/packages/queue/tests/lock.test.mjs
@@ -89,6 +89,26 @@ test("a link error other than EEXIST refuses", (t) => {
assert.deepEqual(readdirSync(d), []);
});
+test("an error after the link releases the lock: unreadable gate, failing temp stat", (t) => {
+ const d = dir(t);
+ writeFileSync(join(d, GATE_NAME), record({ verb: "unlock", op: null }));
+ const denied = { ...realIo, readFile: (p) => (p.endsWith(GATE_NAME) ? (() => { throw Object.assign(new Error("denied"), { code: "EACCES" }); })() : realIo.readFile(p)) };
+ refused(() => acquire({ gitDir: d, io: denied, verb: "move" }), /cannot check the unlock gate .*EACCES; lock released$/, 1);
+ assert.deepEqual(readdirSync(d), [GATE_NAME]);
+ rmSync(join(d, GATE_NAME));
+ const badStat = { ...realIo, stat: () => { throw Object.assign(new Error("io"), { code: "EIO" }); } };
+ refused(() => acquire({ gitDir: d, io: badStat, verb: "move" }), /cannot write the lock record .*EIO; no lock taken/, 1);
+ assert.deepEqual(readdirSync(d), []);
+ // A stat that fails from its second call on: publish stats once, before the
+ // link, so nothing after the link can fail and strand the lock.
+ let stats = 0;
+ const lateStat = { ...realIo, stat: (p) => { if (++stats > 1) throw Object.assign(new Error("io"), { code: "EIO" }); return realIo.stat(p); } };
+ const h = acquire({ gitDir: d, io: lateStat, verb: "move" });
+ assert.equal(stats, 1);
+ assert.equal(release(h, realIo), null);
+ assert.deepEqual(readdirSync(d), []);
+});
+
test("a paused holder: another writer waits 10 s, then refuses naming it live", async (t) => {
const d = dir(t);
const child = spawn(process.execPath, [join(HERE, "fixtures", "lock-child.mjs"), d, "hold"], { stdio: ["ignore", "pipe", "ignore"] });
@@ -150,6 +170,22 @@ test("a writer publishing during an unlock, gate first: the writer releases and
assert.deepEqual(readdirSync(d), []);
});
+test("a gate swapped while held is left in place and reported, on success and on refusal (N1)", (t) => {
+ const d = dir(t);
+ const gate = join(d, GATE_NAME);
+ // A copy renamed over the gate: same bytes, a new inode.
+ const swap = () => { writeFileSync(`${gate}.copy`, readFileSync(gate)); renameSync(`${gate}.copy`, gate); };
+ const out = unlock({ gitDir: d, io: realIo, hook: (name) => { if (name === "gate-held") swap(); } });
+ assert.match(out, /^no queue lock present; nothing removed\nwarning: lock .*mosaic-queue\.unlock is not the one this process took; left in place$/);
+ assert.equal(existsSync(gate), true);
+ rmSync(gate);
+ writeFileSync(join(d, LOCK_NAME), record({}));
+ refused(() => unlock({ gitDir: d, io: realIo, hook: (name) => { if (name === "gate-held") swap(); } }),
+ /owner is live: .*; unlock refuses\nwarning: lock .*mosaic-queue\.unlock is not the one this process took; left in place$/);
+ assert.equal(existsSync(gate), true);
+ assert.equal(existsSync(join(d, LOCK_NAME)), true);
+});
+
test("a reused pid within one boot is mismatch; unlock removes the lock and never signals the process", async (t) => {
const d = dir(t);
const s = await sleeper(t);
@@ -177,6 +213,12 @@ test("a foreign host is unknown whatever the local pid says; unlock refuses", as
refused(() => acquire({ gitDir: d, io: realIo, verb: "move", waitMs: 0 }), /unknown: .*recorded on host some-other-host; unlock refuses this too/);
refused(() => unlock({ gitDir: d, io: realIo }), /owner is unknown: .*; unlock refuses/);
assert.equal(existsSync(join(d, LOCK_NAME)), true);
+ // A real foreign host has its own boot id. Host is tested before boot, so
+ // this is still unknown, never mismatch, and unlock still refuses.
+ writeFileSync(join(d, LOCK_NAME), record({ pid: await deadPid(), host: "some-other-host", boot: OTHER_BOOT }));
+ assert.equal(classify(readFileSync(join(d, LOCK_NAME)), realProc).state, "unknown");
+ refused(() => unlock({ gitDir: d, io: realIo }), /owner is unknown: .*recorded on host some-other-host/);
+ assert.equal(existsSync(join(d, LOCK_NAME)), true);
});
test("unreadable /proc: classification is unknown and acquire refuses", (t) => {
diff --git a/packages/queue/tests/store.test.mjs b/packages/queue/tests/store.test.mjs
index 7033b076..70425a73 100644
--- a/packages/queue/tests/store.test.mjs
+++ b/packages/queue/tests/store.test.mjs
@@ -166,10 +166,28 @@ test("Rocko's S4 schedule: a lost result, another writer, then the retry opens n
const review = ["move", "9", "in-review", "--candidate", "HEAD", "--op", "review-9-00001"];
cli(repo, review, { by: "darkwing" }); // result lost
ok(cli(repo, ["note", "9", "looking now", "--op", "note-9-000001"], { by: "filbert" }));
- ok(cli(repo, review, { by: "darkwing" }), /round 1 \(already recorded at rev 3\)/);
+ ok(cli(repo, review, { by: "darkwing" }), /round 1 on #1508 \(already recorded at rev 3\)/);
assert.equal(row(repo, 9).review.rounds.length, 1);
});
+test("the review issue and the evidence round through the CLI (lead decision 23, 8.7)", (t) => {
+ const repo = ready(t);
+ ok(cli(repo, ["move", "6", "blocked", "--reason", "paused", "--op", "block-6-00001"], { by: "darkwing" }));
+ ok(cli(repo, ["set", "9", "issues", "1495,1508", "--op", "issues-9-0001"], { by: "sage" }));
+ ok(cli(repo, ["move", "9", "in-progress", "--op", "start-9-00001"], { by: "darkwing" }));
+ const review = (op, ...extra) => ["move", "9", "in-review", "--candidate", "HEAD", "--op", op, ...extra];
+ no(cli(repo, review("review-9-00001"), { by: "darkwing" }), 2, /lists several issues \(#1495, #1508\); name the review's issue with --issue/);
+ no(cli(repo, review("review-9-00001", "--issue", "1495", "--issue", "1508"), { by: "darkwing" }), 4, /one --issue/);
+ no(cli(repo, review("review-9-00001", "--issue", "#1600"), { by: "darkwing" }), 2, /--issue #1600 is not one of row 9's issues/);
+ ok(cli(repo, review("review-9-00001", "--issue", "#1508"), { by: "darkwing" }), /in-progress→in-review round 1 on #1508$/m);
+ const head = repo.g("rev-parse", "HEAD").trim();
+ no(cli(repo, ["move", "9", "done", "--evidence", `comment=7,candidate=${head}`, "--op", "done-9-000001"], { by: "filbert" }), 2, /round=<n>/);
+ ok(cli(repo, ["move", "9", "in-progress", "--op", "changes-9-0001"], { by: "darkwing" }));
+ ok(cli(repo, review("review-9-00002"), { by: "darkwing" }), /round 2 on #1508$/m);
+ no(cli(repo, ["move", "9", "done", "--evidence", `comment=7,round=1,candidate=${head}`, "--op", "done-9-000001"], { by: "filbert" }), 2, /evidence names round 1; row 9 is in round 2/);
+ ok(cli(repo, ["move", "9", "done", "--evidence", `comment=8,round=2,candidate=${head}`, "--op", "done-9-000002"], { by: "filbert" }), /in-review→done round 2$/m);
+});
+
test("claims and add defaults through the CLI; candidates are manifests or reachable commits", (t) => {
const repo = ready(t);
ok(cli(repo, ["add", "--op", "add-by-dewey-1", "--piece", "Mine", "--gate", "tests", "--brief", "docs/plans/brief-b.md#Template"], { by: "dewey" }));
diff --git a/packages/queue/tests/write.test.mjs b/packages/queue/tests/write.test.mjs
index 0d0781c0..35e9829a 100644
--- a/packages/queue/tests/write.test.mjs
+++ b/packages/queue/tests/write.test.mjs
@@ -3,7 +3,7 @@
// racing a writer (8.4, F2).
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
-import { readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
+import { readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { test } from "node:test";
import { cli, genesisCommitted, load, scratchRepo } from "./helpers.mjs";
@@ -41,6 +41,7 @@ function faultIo(m, name, match, code) {
return {
...real,
openExcl: (p, mode) => { const fd = real.openExcl(p, mode); paths.set(fd, p); return fd; },
+ openRead: (p) => { const fd = real.openRead(p); paths.set(fd, p); return fd; },
close: (fd) => { paths.delete(fd); real.close(fd); },
write: (fd, b, off, len) => (hit("write", paths.get(fd)) ? (code === "SHORT" ? 0 : fail()) : real.write(fd, b, off, len)),
fsync: (fd) => (hit("fsync", paths.get(fd)) ? fail() : real.fsync(fd)),
@@ -114,6 +115,65 @@ test("a witness write failure: uncertain, durable, exit 3; the view is untouched
assert.match(m.store.mutate(o(repo), note(9, "x", "note-9-000001")).out[0], /already recorded at rev 1/);
});
+test("the .git fsync after the witness rename fails: uncertain, exit 3, the witness says so", async (t) => {
+ const { repo, m } = await ready(t);
+ const io = faultIo(m, "fsyncDir", (d) => d === repo.gitDir, "EIO");
+ throwsCode(() => m.store.mutate(o(repo, { io }), note(9, "x", "note-9-000001")), 3, /^uncertain note-9-000001 rev 1: durable, witness written, its directory fsync failed \(EIO\)$/);
+ assert.equal(revOf(repo), 1);
+ assert.equal(witness(repo).revision, 1);
+ assert.equal(shownRev(repo), 0);
+ assert.match(m.store.mutate(o(repo), note(9, "x", "note-9-000001")).out[0], /already recorded at rev 1/);
+});
+
+test("confirming a tail fsyncs queue.json and docs/plans before the witness; either failure changes nothing", async (t) => {
+ const { repo, m } = await ready(t);
+ const docs = join(repo.root, "docs/plans");
+ throwsCode(() => m.store.mutate(o(repo, { io: faultIo(m, "fsyncDir", (d) => d === docs, "EIO") }), note(9, "x", "note-9-000001")), 3, /uncertain/);
+ for (const io of [faultIo(m, "fsync", (p) => p === repo.queuePath, "EIO"), faultIo(m, "fsyncDir", (d) => d === docs, "EIO")]) {
+ throwsCode(() => m.store.sync(o(repo, { io })), 1, /^cannot confirm rev 1 durable \(EIO\); nothing changed$/);
+ assert.equal(witness(repo).revision, 0);
+ }
+ assert.match(m.store.sync(o(repo)).out.join("\n"), /durable now, never acknowledged: note-9-000001/);
+ assert.equal(witness(repo).revision, 1);
+});
+
+test("the docs/plans fsync after the view rename fails: the op stands, the view is written, a warning says so", async (t) => {
+ const { repo, m } = await ready(t);
+ const docs = join(repo.root, "docs/plans");
+ let calls = 0;
+ const io = { ...m.io.realIo, fsyncDir: (d) => { if (d === docs && ++calls === 2) throw Object.assign(new Error("EIO"), { code: "EIO" }); m.io.realIo.fsyncDir(d); } };
+ const r = m.store.mutate(o(repo, { io }), note(9, "x", "note-9-000001"));
+ assert.equal(calls, 2);
+ assert.match(r.out[0], /^ok note-9-000001 rev 1/);
+ assert.match(r.err.join("\n"), /the view is written but not confirmed durable \(EIO\); the op stands/);
+ assert.equal(shownRev(repo), 1);
+ assert.deepEqual(tmps(repo), []);
+});
+
+test("a lock swapped while held is left in place and reported, on a receipt and on a refusal", async (t) => {
+ const { repo, m } = await ready(t);
+ const lock = join(repo.gitDir, "mosaic-queue.lock");
+ // Another inode with the same bytes, as a delayed unlock and relock would leave.
+ const swap = (name) => { if (name === "locked") { writeFileSync(`${lock}.copy`, readFileSync(lock)); renameSync(`${lock}.copy`, lock); } };
+ const done = m.store.mutate(o(repo, { hook: swap }), note(9, "x", "note-9-000001"));
+ assert.match(done.out[0], /^ok note-9-000001 rev 1/);
+ assert.match(done.err.join("\n"), /warning: lock .* is not the one this process took; left in place/);
+ unlinkSync(lock);
+ throwsCode(() => m.store.mutate(o(repo, { hook: swap }), note(9, "y", "note-9-000002", "rocko")), 2,
+ /may note row 9[^]*\nwarning: lock .* is not the one this process took; left in place$/);
+ unlinkSync(lock);
+});
+
+test("unlock prints a swapped gate's warning on stderr, the result on stdout", async (t) => {
+ const { repo, m } = await ready(t);
+ const gate = join(repo.gitDir, "mosaic-queue.unlock");
+ const swap = (name) => { if (name === "gate-held") { writeFileSync(`${gate}.copy`, readFileSync(gate)); renameSync(`${gate}.copy`, gate); } };
+ const r = m.store.unlock(o(repo, { hook: swap }));
+ assert.deepEqual(r.out, ["no queue lock present; nothing removed"]);
+ assert.match(r.err.join("\n"), /^warning: lock .*mosaic-queue\.unlock is not the one this process took; left in place$/);
+ unlinkSync(gate);
+});
+
test("a view write that fails keeps the op and reports a stale view", async (t) => {
const { repo, m } = await ready(t);
const io = faultIo(m, "rename", (p) => p === repo.viewPath, "EIO");
+159
View File
@@ -0,0 +1,159 @@
# Queue A1 (#1508), revision r1
Darkwing, 2026-09-26. This answers Filbert's review
(`agents/filbert/work/queue-a1-review-2026-09-26.md`, sha256 6933b885) and
Sage's lead decision 23 (40a02d2b). Nothing is committed, staged or pushed.
## Files
| File | sha256 | What it is |
|---|---|---|
| `delta-r1.patch` | b733b894 | the change on top of `build.patch` |
| `build-manifest-r1.sha256` | 85a8a453 | all 20 files after the delta |
The delta changes 11 of the 20 files, +410 −49 lines. It adds no file and changes no mode.
In a fresh clone at 3a209eea, `build.patch` and then `delta-r1.patch` apply
cleanly, and the result matches the new manifest 20/20.
## Required changes
**R1, the matrix.** `data.test.mjs` has a new test, "matrix R1". Its
oracle, `specAllows`, is written from 8.7's table, not from `queue.mjs`.
The test replays a row into every reachable state and tries every target
in `STATES` as five actors: darkwing (the row's owner), filbert, rocko,
sage and jason. It does this with the gate owner as filbert, jason and the
row's owner, and with the row required and not. Release gets the same
treatment. That is 273 allowed moves and 2487 refusals, each compared with
what `applyOp` does. Your R1 mutation and the agent's two survivors each
fail it (R1a to R1c below).
**R2, the review issue, as lead decision 23 rules.** `move in-review`
refuses a row with no issues. A row with one issue uses it. A row with
several needs `--issue N`, and N must be one of them. A later round keeps
the previous round's issue unless `--issue` names another. `--issue` is
accepted only on in-progress→in-review, and giving it twice is a usage
error (exit 4). The receipt now ends `round N on #ISSUE`.
The ruling didn't cover one case: a later round whose kept issue the row
no longer lists, after a `set issues`. I refuse it until `--issue` names
one of the row's issues. Falling back to the first issue would be the
silent choice decision 23 replaced.
The row schema now refuses `review.issue: null`, so a hand-built file
can't hold one either. `set piece` and `set gate` stay privileged only
(decision 23, point 2); the code already did that, and nothing changed.
Tests: "review issue, lead decision 23" in `data.test.mjs` covers the four
cases and a refused `--issue` that isn't in the row. "the review issue and
the evidence round through the CLI" in `store.test.mjs` runs the same
through the CLI. "the row schema refuses a review with a null issue"
checks `validateRow`. Mutations D1 to D5.
**R3, the evidence round.** The format is now
`comment=<id>,round=<n>,candidate=<digest>`. `move done` compares the
round with the current one and refuses `evidence names round 1; row 12 is
in round 2`. The J5 test refuses evidence with no round and with a wrong
round, then sends a row back and re-requests it with the same candidate:
round-1 evidence is refused and round-2 evidence closes it. Mutation E1.
## Notes I took
- **N1.** `withLock` appends the release warning to a refusal's message.
`unlock` now does the same for the gate: a swapped gate is left in place
and reported, on a refusal and on success. On success the CLI prints the
warning on stderr and the result on stdout. Mutations W1, G1, G2, U1.
- **N2.** In `acquire`, an error checking the gate releases the lock, then
refuses with `cannot check the unlock gate ...; lock released`. In
`publish`, the temp file's stat now runs before the link, so nothing
that can fail runs between a successful link and the return. The test
covers an unreadable gate, a stat that fails before the link, and a stat
that fails from its second call on. The last case came late. L1 (a
second stat after the link) survived my first mutation run, so I added
it; L1 is now caught.
- **N3.** `write.test.mjs` has three fault tests: the `queue.json` and
`docs/plans` fsyncs in `confirmTail` (sync exits 1, nothing changes);
the `.git` fsync after the witness rename (exit 3); the `docs/plans`
fsync after the view rename (the op stands, a warning says the view
isn't confirmed durable). Mutations F1 to F4.
- **N4.** The foreign-host test adds a record with another boot id. It
must still classify `unknown`. Mutation H1 swaps the two checks.
- **N6.** The message now says `durable, witness written, its directory
fsync failed` when the rename happened, and `witness not updated` only
when it didn't.
- **N9.** `BRIEF-TEMPLATE.md`: `briefed` when a privileged actor (jason or
sage) accepts it; the owner can't.
- **N14.** You were right. The paused-editor test is now `pausedCommit(t,
form)`, which checks whether `index.lock` exists at the pause and
asserts on that: exit 3 and `another git process holds .git/index.lock`
when held, exit 0 when free. Either way the paused commit then fails
with `cannot lock ref 'HEAD': is at C but expected H`. Two forms run on
git 2.55.0: plain `commit -e` (the lock was free, exit 0) and `commit -e
-- src.txt` (the lock was held, exit 3). The test no longer pins a git
version, and the stale comment is gone.
## A correction to build.md
build.md says "`unlock` works on a missing or invalid lock file". That's
wrong, as you said. It means a missing or invalid `queue.json`. `unlock`
refuses an invalid lock. build.md stays as sent, since your review pins
it.
## Notes not taken
N5, N7, N8, N10, N11, N12, N15 and N16. They don't block, and none is in
the files this round had to touch for a reason. N8 (the `\` escape in
`cell()`) and N11 (replay looser than the CLI on op ids) are cheapest
before genesis. That's Sage's call; I can take them in A2.
## Verification
At 3a209eea plus the candidate (`/tmp/qa1-verify`): `test-queue.sh` 19
checks, `node --test` 107/107 (data 22, lock 19, store 19, write 25,
commit 22), `verify` skipped because HEAD has no `queue.json`.
At today's HEAD, 40a02d2b, plus the candidate and the N13 change
(`/tmp/n13-verify`): config 24, task 90, foundation 44, conductor 17,
release 14, auth 15, discord 64, extension-package 18, queue 19 with
107/107. Foundation and discord each gained one check from N13. I reran
the queue suite there after the last change; the other suites can't reach
`packages/queue`.
The canonical `.git` is unchanged: `.git/hooks` holds only samples, no
`mosaic-queue*` file, and `git config --show-scope --get-all
core.hooksPath` returns nothing (rc 1). Every run was in a `--shared`
clone under `/tmp`.
### Mutations
Each mutation went into the verify clone, the queue tests ran, and the
file was restored from the candidate. All 20 files matched the candidate
after each run. The number is how many tests failed.
| Id | Mutation | Failing tests |
|---|---|---|
| R1a | drop the owner check on unblock | 1 |
| R1b | let the owner move waiting-on-jason→in-progress | 1 |
| R1c | check the owner on block only when not queued | 1 |
| D1 | allow review with no issues | 1 |
| D2 | require `--issue` with one issue | 9 |
| D3a | take the first of several issues | 2 |
| D3b | accept an `--issue` the row doesn't list | 2 |
| D4a | never keep the previous round's issue | 2 |
| D4b | keep an issue the row no longer lists | 1 |
| D5 | schema allows a null review issue | 1 |
| E1 | ignore the evidence round | 2 |
| L1 | stat the temp again after the link | 1 |
| L2 | don't release the lock when the gate check fails | 1 |
| F1 | drop `confirmTail`'s `queue.json` fsync | 1 |
| F2 | drop `confirmTail`'s `docs/plans` fsync | 1 |
| F3 | drop the `.git` fsync after the witness rename | 1 |
| F4 | drop the `docs/plans` fsync after the view rename | 1 |
| W1 | drop the release warning on a refusal | 1 |
| H1 | check boot before host | 1 |
| G1 | drop the gate warning on a refusal | 1 |
| G2 | drop the gate warning on success | 2 |
| U1 | print the gate warning nowhere in the CLI | 1 |
R1a to E1 ran before the last lock and store changes, which touch neither
`queue.mjs` nor the tests that caught them. L1 to U1 ran on the final
candidate. L1 first survived with 0 failures, as noted under N2.