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]>
820 lines
47 KiB
Diff
820 lines
47 KiB
Diff
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");
|