@@ -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");