@feigi/fleet-ctl 3.16.8 → 3.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.16.8",
3
+ "version": "3.17.1",
4
4
  "description": "Agent fleet: run-team controller, merge bot, PR reviewer, and the ticket pipeline they share",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -8,9 +8,12 @@
8
8
  //
9
9
  // dispositions-ok=fix-pr-<M>[-x]:<head> every rule below held
10
10
  // dispositions-mismatch=fix-pr-<M>[-x]:<head> at least one entry broke one
11
- // dispositions-escalate=fix-pr-<M>[-x]:<head> every rule held, but a critical
12
- // or important finding was deferred
13
- // `remedy-outside-diff`: a human rules
11
+ // dispositions-escalate=fix-pr-<M>[-x]:<head> a human rules: either every rule held
12
+ // but a critical or important finding
13
+ // was deferred `remedy-outside-diff`,
14
+ // or a rule broke and an earlier
15
+ // fix-applier's record on the same
16
+ // review already had
14
17
  //
15
18
  // `<head>` is the review file's `head` — the review the record answers — so a
16
19
  // verdict on one review never answers a later one.
@@ -358,6 +361,19 @@ export function withVerdict(rowText, token) {
358
361
  return folded === "" ? token : `${folded} · ${token}`;
359
362
  }
360
363
 
364
+ // Whether a fix-applier on PR `pr` with a lower retry suffix than `member`
365
+ // already has a mismatch or escalate on the ledger for `head` — on any row
366
+ // that resolves to the PR, since a row can be split. A verdict on another
367
+ // review head counts for nothing, so a new review starts the count again.
368
+ export function failedBefore(rows, pr, member, head) {
369
+ const mine = member.retry ?? "";
370
+ return rows.some((r) => rowNums(r).pr === pr && r.split(/\s+/).some((w) => {
371
+ const d = dispositionsToken(w);
372
+ return d !== null && d.verdict !== "ok" && d.member.number === pr
373
+ && (d.member.retry ?? "") < mine && sameHead(d.head, head);
374
+ }));
375
+ }
376
+
361
377
  // ---------------------------------------------------------------------------
362
378
  // CLI
363
379
  // ---------------------------------------------------------------------------
@@ -475,14 +491,20 @@ function main() {
475
491
  const { violations, escalations } = checkDispositions({
476
492
  review, record, recordProblem, touched: touchedLines(diff), diffFiles, roots: [review.snapshot, top],
477
493
  });
478
- const verdict = violations.length > 0 ? "mismatch" : escalations.length > 0 ? "escalate" : "ok";
479
- const token = `dispositions-${verdict}=${member.name}:${head}`;
480
-
481
494
  // The verdict lands on the row carrying the member on its own PR's row —
482
495
  // where `ledger.mjs dispatch` wrote it, and where the gate reads PR M's
483
- // tokens from. No ledger, no row: the exit status below is the verdict.
484
- if (ledgerFile !== null) {
485
- const data = JSON.parse(runLedger(ledgerFile, ["read"], "read the ledger"));
496
+ // tokens from. No ledger, no row: the exit status below is the verdict, and
497
+ // with no earlier fix-applier's verdict to count, a broken rule is always a
498
+ // mismatch.
499
+ const data = ledgerFile === null ? null : JSON.parse(runLedger(ledgerFile, ["read"], "read the ledger"));
500
+ // A mismatch an earlier fix-applier already drew on this review makes this
501
+ // one the second: it is written as an escalate, which no fix-applier answers.
502
+ const secondMismatch = violations.length > 0 && data !== null && failedBefore(data.rows, pr, member, head);
503
+ const verdict = violations.length > 0 ? (secondMismatch ? "escalate" : "mismatch")
504
+ : escalations.length > 0 ? "escalate" : "ok";
505
+ const token = `dispositions-${verdict}=${member.name}:${head}`;
506
+
507
+ if (data !== null) {
486
508
  const row = data.rows.find((r) => rowNums(r).pr === pr && memberTokens(r).some((t) => t.name === member.name));
487
509
  if (row === undefined) die(`${member.name} is on no row of PR #${pr} — \`ledger.mjs dispatch\` records a fix-applier before its record can be checked; with no controller, pass --no-ledger`);
488
510
  const key = row.split(/\s/)[0];
@@ -495,6 +517,9 @@ function main() {
495
517
 
496
518
  for (const v of violations) console.error(`${member.name}: ${formatViolation(v)}`);
497
519
  for (const x of escalations) console.error(`${member.name}: ${formatEscalation(x)}`);
520
+ if (secondMismatch) {
521
+ console.error(`${member.name}: escalate — an earlier fix-applier's record on review ${head} already failed this check; no further fix-applier answers PR #${pr}, a human does`);
522
+ }
498
523
  console.log(JSON.stringify({ member: member.name, pr, head, verdict, token: ledgerFile === null ? null : token, violations, escalations }));
499
524
  // exitCode, not exit(): stdout to a pipe is written asynchronously, and an
500
525
  // exit() here could cut the payload off.
@@ -14,7 +14,7 @@ import { tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { deriveRun } from "./fleet-tick.mjs";
17
- import { touchedLines, checkDispositions, withVerdict, repoPath, formatViolation } from "./dispositions-check.mjs";
17
+ import { touchedLines, checkDispositions, withVerdict, repoPath, formatViolation, failedBefore } from "./dispositions-check.mjs";
18
18
 
19
19
  const SCRIPT = fileURLToPath(new URL("./dispositions-check.mjs", import.meta.url));
20
20
  const LEDGER = fileURLToPath(new URL("./ledger.mjs", import.meta.url));
@@ -255,6 +255,86 @@ test("a re-check replaces the member's own verdict for the same head and never d
255
255
  assert.equal(f.okLedger("dispatch", "40", "finisher-pr-40").agent, "fleet-finisher");
256
256
  });
257
257
 
258
+ // A first mismatch is retried by `fix-pr-<M>-b`; a second on the same review
259
+ // is written as an escalate, which keeps the finisher refused and is the end
260
+ // of the automatic path.
261
+ const badRecord = (f) => {
262
+ const entries = f.baseEntries();
263
+ entries[0] = f.entry("survived", 0, { disposition: "defer" });
264
+ return entries;
265
+ };
266
+ const retry = (f, name = "fix-pr-40-b") => {
267
+ f.okLedger("dispatch", "40", name);
268
+ f.okLedger("settle", name, `applied:${f.head.slice(0, 7)}`);
269
+ };
270
+
271
+ test("a second mismatch on the same review writes dispositions-escalate, and the finisher stays refused", (t) => {
272
+ const f = fixture(t);
273
+ f.writeRecord(badRecord(f));
274
+ mismatch(f.check());
275
+ retry(f);
276
+ const r = f.check("fix-pr-40-b");
277
+ assert.equal(r.status, 1, r.stderr);
278
+ assert.equal(r.json.verdict, "escalate");
279
+ assert.equal(r.json.token, `dispositions-escalate=fix-pr-40-b:${f.head}`);
280
+ assert.match(r.stderr, /^fix-pr-40-b: survived\[0\]: an in-scope survived finding deferred with no reason/m, "the violations are still printed");
281
+ const row = f.row();
282
+ assert.ok(row.includes(`dispositions-mismatch=fix-pr-40:${f.head}`) && row.includes(`dispositions-escalate=fix-pr-40-b:${f.head}`), row);
283
+ const d = f.ledgerCli("dispatch", "40", "finisher-pr-40");
284
+ assert.equal(d.status, 2);
285
+ assert.match(d.stderr, /finisher-pr-40: dispositions escalate — fix-pr-40-b deferred .*second mismatch/);
286
+ // Re-running the escalated member's check is idempotent.
287
+ const again = f.check("fix-pr-40-b");
288
+ assert.equal(again.json.verdict, "escalate");
289
+ assert.equal(f.row().match(/dispositions-escalate=/g).length, 1, f.row());
290
+ });
291
+
292
+ test("the retry that passes the check ends the refusal, and the first mismatch stays on the row unread", (t) => {
293
+ const f = fixture(t);
294
+ f.writeRecord(badRecord(f));
295
+ mismatch(f.check());
296
+ retry(f);
297
+ f.writeRecord(f.baseEntries());
298
+ okVerdict(f.check("fix-pr-40-b"));
299
+ assert.ok(f.row().includes(`dispositions-mismatch=fix-pr-40:${f.head}`), "the first fix-applier's verdict is kept");
300
+ assert.equal(f.okLedger("dispatch", "40", "finisher-pr-40").agent, "fleet-finisher");
301
+ });
302
+
303
+ test("the first mismatch is a plain mismatch, and a mismatch on an earlier review does not count toward a later one", (t) => {
304
+ const f = fixture(t);
305
+ f.writeRecord(badRecord(f));
306
+ const first = f.check();
307
+ assert.equal(first.json.verdict, "mismatch");
308
+ assert.equal(first.json.token, `dispositions-mismatch=fix-pr-40:${f.head}`);
309
+ // The earlier fix-applier's mismatch answered another review's head.
310
+ retry(f);
311
+ f.okLedger("row", "10", f.row().slice(4).replace(`dispositions-mismatch=fix-pr-40:${f.head}`, "dispositions-mismatch=fix-pr-40:deadbee1"));
312
+ const second = f.check("fix-pr-40-b");
313
+ assert.equal(second.json.verdict, "mismatch", second.stderr);
314
+ });
315
+
316
+ test("a mismatch from a later suffix, an ok, or another PR's fix-applier is not an earlier failure", () => {
317
+ const H = "abc1234";
318
+ const row = (...tokens) => `#10 impl-10=PR#40 → PR#40 · reviewed=${H}:1/0/0 · ${tokens.join(" · ")}`;
319
+ const b = { retry: "b" };
320
+ assert.equal(failedBefore([row(`dispositions-mismatch=fix-pr-40:${H}`)], 40, b, H), true);
321
+ assert.equal(failedBefore([row(`dispositions-escalate=fix-pr-40:${H}`)], 40, b, H), true);
322
+ assert.equal(failedBefore([row(`dispositions-ok=fix-pr-40:${H}`)], 40, b, H), false, "an ok is no failure");
323
+ assert.equal(failedBefore([row(`dispositions-mismatch=fix-pr-40-c:${H}`)], 40, b, H), false, "a later suffix is not earlier");
324
+ assert.equal(failedBefore([row(`dispositions-mismatch=fix-pr-40-b:${H}`)], 40, b, H), false, "the member itself is not earlier");
325
+ assert.equal(failedBefore([row(`dispositions-mismatch=fix-pr-40:${H}`)], 40, { retry: null }, H), false, "nor is the first fix-applier's own re-run");
326
+ assert.equal(failedBefore([row(`dispositions-mismatch=fix-pr-40:fedcba9`)], 40, b, H), false, "another review's head");
327
+ assert.equal(failedBefore([row(`dispositions-mismatch=fix-pr-41:${H}`)], 40, b, H), false, "another PR's fix-applier");
328
+ // A split row: the earlier verdict sits on another row that resolves to PR 40.
329
+ assert.equal(failedBefore(["#11 impl-11=PR#40 → PR#40 · dispositions-mismatch=fix-pr-40:abc1234", "#12 impl-12=PR#41 → PR#41"], 40, b, H), true);
330
+ // A copy of PR 40's verdict on PR 41's row is a stray: that row does not resolve to PR 40.
331
+ assert.equal(failedBefore(["#12 impl-12=PR#41 → PR#41 · dispositions-mismatch=fix-pr-40:abc1234"], 40, b, H), false);
332
+ // The same review named by a short head on one side and a full SHA on the other.
333
+ const full = `${H}${"0".repeat(33)}`;
334
+ assert.equal(failedBefore([row(`dispositions-mismatch=fix-pr-40:${H}`)], 40, b, full), true, "an earlier token's short head matches a full check head");
335
+ assert.equal(failedBefore([row(`dispositions-mismatch=fix-pr-40:${full}`)], 40, b, H), true, "an earlier token's full head matches a short check head");
336
+ });
337
+
258
338
  test("nothing is judged or written when the review file or the git history cannot be read", (t) => {
259
339
  const f = fixture(t);
260
340
  f.writeRecord(f.baseEntries());
@@ -72,16 +72,31 @@ test("the gate says what each refusal asks of the controller", () => {
72
72
  "`dispositions unchecked` — no verdict answers that head: run `dispositions-check.mjs` for the fix-applier that answered the review",
73
73
  ));
74
74
  assert.match(s, phrase(
75
- "`dispositions mismatch` — no finisher: post the check's output, which names each violating entry, with `gh pr comment <M>`, and flag the PR for a human",
75
+ "`dispositions mismatch` — no finisher: the tick prints `DISPATCH fix-pr PR#<M>` for it, and the next-suffix fix-applier answers it",
76
76
  ));
77
77
  assert.match(s, phrase(
78
- "`dispositions escalate` — no finisher, and no retry: a `critical` or `important` finding was deferred `remedy-outside-diff`, which only a human can rule on. Post the check's output, which names each escalated finding, with `gh pr comment <M>`, and flag the PR for a human; dispatch no fix-applier and no finisher for that review's head",
78
+ "`dispositions escalate` — no finisher, no retry and no further fix-applier: either a `critical` or `important` finding was deferred `remedy-outside-diff`, or a second mismatch was drawn on one review, and only a human can rule on either.",
79
+ ));
80
+ assert.match(s, phrase(
81
+ "Post the check's output, which names each escalated finding or violating entry, with `gh pr comment <M>`, and flag the PR for a human; re-running the check reprints it. Dispatch no fix-applier and no finisher for that review's head",
79
82
  ));
80
83
  assert.match(s, phrase(
81
84
  "it refuses too while a fix-applier on the PR is still live, verdict or not** — `fix-pr-<M>[-x] still live`: settle it, run the check for it, then dispatch again.",
82
85
  ));
83
86
  });
84
87
 
88
+ test("a mismatch is retried once by the next suffix, whose prompt carries the violation list, and a second failure escalates", () => {
89
+ const s = paragraph(SKILL, "`DISPATCH fix-pr PR#<M>` on a dispositions mismatch", "run-team/SKILL.md", { emphasisTolerant: true });
90
+ assert.match(s, phrase("is one automatic retry, a review fix-applier named the next suffix (`fix-pr-<M>-b`)"));
91
+ assert.match(s, phrase(
92
+ "the review file `<scratch>/review-<M>.json`, the disposition record `<scratch>/dispositions-<M>.json` it rewrites, and the check's violation list **verbatim**",
93
+ ));
94
+ assert.match(s, phrase(
95
+ "A second failure on the same review is written as `dispositions-escalate=fix-pr-<M>-b:<head>` in place of a mismatch: the tick prints no `DISPATCH fix-pr` for it, `dispatch` refuses the finisher naming `dispositions escalate`",
96
+ ));
97
+ assert.match(s, phrase("A new review of the PR counts again from none."));
98
+ });
99
+
85
100
  test("a reversed refutation reaches the ledger from the disposition record", () => {
86
101
  assert.match(paragraph(SKILL, "`refuted` comes back deliberately as well", "run-team/SKILL.md"), phrase(
87
102
  "The fix-applier may reverse one; it records each reversal as a `refuted` entry in its disposition record, the evidence in `reason`, and you copy those entries to the ledger's `ruled` line",
@@ -308,11 +308,12 @@ const TIER_VERDICT = /^tier-(ok|mismatch|unverifiable)=([^:\s]+):\S+$/;
308
308
  // dispositions-check.mjs's verdict on a review fix-applier's disposition
309
309
  // record: `dispositions-ok=fix-pr-<M>[-x]:<head>`,
310
310
  // `dispositions-mismatch=…` or `dispositions-escalate=…` (a critical or
311
- // important deferral that a human rules on), `<head>` the head of the review
312
- // the record answers. `ledger.mjs dispatch` refuses a finisher on any verdict
313
- // but ok; the tick holds nothing on one, having no finisher role. A token
314
- // outside this shape is no verdict at all, which the gate reads as
315
- // unchecked: fail closed.
311
+ // important deferral, or a second mismatch on one review, that a human rules
312
+ // on), `<head>` the head of the review the record answers. `ledger.mjs
313
+ // dispatch` refuses a finisher on a mismatch or an escalate. A mismatch
314
+ // returns the PR to fixDue for one retry; an escalate is a hold only a human
315
+ // answers, so the tick never re-offers the PR for it. A token outside this
316
+ // shape is no verdict at all, which the gate reads as unchecked: fail closed.
316
317
  export function dispositionsToken(tok) {
317
318
  const m = /^dispositions-(ok|mismatch|escalate)=([^:\s]+):([0-9a-f]{7,40})$/i.exec(tok);
318
319
  if (!m) return null;
@@ -325,21 +326,24 @@ export function dispositionsToken(tok) {
325
326
  // `reviewed=` head (7-40 hex) is matched against gh's full headRefOid.
326
327
  export const sameHead = (a, b) => a.startsWith(b) || b.startsWith(a);
327
328
 
329
+ // Which verdict outranks which when one fix-applier carries more than one for
330
+ // a head: the stricter reading wins.
331
+ const VERDICT_RANK = { ok: 0, mismatch: 1, escalate: 2 };
332
+
328
333
  // A PR's current dispositions verdict against its latest review head: among
329
334
  // the tokens answering that head, the one from the fix-applier with the
330
335
  // highest retry suffix ("" < "b" < "c" …) — never row-text position, which a
331
336
  // `row` rewrite can reorder. One fix-applier carrying two verdicts for one
332
- // head reads as the stricter: ok < mismatch < escalate. null when no token
333
- // answers the head.
337
+ // head reads as the stricter one. null when no token answers the head.
334
338
  /** @returns {{verdict: "ok"|"mismatch"|"escalate", member: string}|null} */
335
339
  export function currentDispositions(tokens, head) {
336
- const strictness = { ok: 0, mismatch: 1, escalate: 2 };
337
340
  let best = null;
338
341
  for (const t of tokens) {
339
342
  if (!sameHead(t.head, head)) continue;
340
343
  const retry = t.member.retry ?? "";
341
344
  const bestRetry = best?.member.retry ?? "";
342
- if (best === null || retry > bestRetry || (retry === bestRetry && strictness[t.verdict] > strictness[best.verdict])) best = t;
345
+ if (best === null || retry > bestRetry
346
+ || (retry === bestRetry && VERDICT_RANK[t.verdict] > VERDICT_RANK[best.verdict])) best = t;
343
347
  }
344
348
  return best === null ? null : { verdict: best.verdict, member: best.member.name };
345
349
  }
@@ -506,7 +510,7 @@ export function deriveRun({ rows, dispatched, drain }, prs) {
506
510
  // by head and retry suffix, never by where a token sits.
507
511
  const st = (pr !== null && byPr.get(pr)) || {
508
512
  inFlight: false, reviewedAny: false, survived: 0, unverified: 0, reviewFixed: false,
509
- fixMembers: new Set(), fixLanded: new Set(), held: [],
513
+ fixMembers: new Set(), fixLanded: new Set(), conflictLanded: new Set(), held: [],
510
514
  conflictOpen: false, reviewedHead: null, pastPinHalt: false, dispositions: [],
511
515
  };
512
516
  // The finisher-pr token currently deciding `pastPinHalt`, picked by
@@ -558,8 +562,10 @@ export function deriveRun({ rows, dispatched, drain }, prs) {
558
562
  if ((o === "no-op" || /^applied:/.test(o)) && !st.fixLanded.has(t.name)
559
563
  && (t.outcome !== null || !settledInRow.has(t.name))) {
560
564
  st.fixLanded.add(t.name);
561
- if (st.conflictOpen) st.conflictOpen = false;
562
- else st.reviewFixed = true;
565
+ if (st.conflictOpen) {
566
+ st.conflictOpen = false;
567
+ st.conflictLanded.add(t.name);
568
+ } else st.reviewFixed = true;
563
569
  }
564
570
  }
565
571
  if (t.family === "finisher-pr" && t.number === pr
@@ -650,6 +656,19 @@ export function deriveRun({ rows, dispatched, drain }, prs) {
650
656
  const heldBehind = (st) => (st?.held ?? []).some((n) => open.has(n));
651
657
  const conflictHeld = (st) => st !== undefined && st.conflictOpen;
652
658
  const fixRunning = (st) => [...st.fixMembers].some((n) => members.get(n).outcome === null);
659
+ // The latest review's dispositions verdict is a mismatch no later fix-applier
660
+ // has answered: the verdict is the highest-suffixed one, and no fix-applier
661
+ // with a higher suffix has landed. A landed one is awaiting its own check, so
662
+ // the earlier mismatch is not its verdict; a failed or killed one answered
663
+ // nothing, and so did a conflict fix-applier, which is never checked. An
664
+ // escalate is a different verdict and is never due.
665
+ const mismatchDue = (st) => {
666
+ if (st.reviewedHead === null) return false;
667
+ const cur = currentDispositions(st.dispositions, st.reviewedHead);
668
+ if (cur === null || cur.verdict !== "mismatch") return false;
669
+ const retry = parseMember(cur.member).retry ?? "";
670
+ return ![...st.fixLanded].some((n) => !st.conflictLanded.has(n) && (parseMember(n).retry ?? "") > retry);
671
+ };
653
672
  // The halt holds only while the head is still past what was reviewed: a
654
673
  // `reviewed=` head (7-40 hex) prefix-matching gh's full headRefOid is the
655
674
  // head that review read, and there is nothing more to review.
@@ -661,13 +680,14 @@ export function deriveRun({ rows, dispatched, drain }, prs) {
661
680
  mergeBotLive: live("merge-bot"),
662
681
  reviewsLive: [...byPr.values()].filter((st) => st.inFlight).length,
663
682
  // A returned review whose survivors no review fix-applier has answered,
664
- // or a conflict hold no fix-applier has cleared — on a PR still open,
665
- // with no fix-applier working it and no newer review running. The two
666
- // are answered apart (#2328): a landed conflict fix-applier lifts the
667
- // hold and leaves survivors it never read due for a review one.
683
+ // a conflict hold no fix-applier has cleared, or a dispositions mismatch
684
+ // no retry has answered — on a PR still open, with no fix-applier working
685
+ // it and no newer review running. The first two are answered apart
686
+ // (#2328): a landed conflict fix-applier lifts the hold and leaves
687
+ // survivors it never read due for a review one.
668
688
  fixDue: [...byPr.entries()]
669
689
  .filter(([n, st]) => open.has(n)
670
- && ((st.survived > 0 && !st.reviewFixed) || conflictHeld(st))
690
+ && ((st.survived > 0 && !st.reviewFixed) || conflictHeld(st) || mismatchDue(st))
671
691
  && !fixRunning(st) && !st.inFlight)
672
692
  .map(([n]) => n).sort(asc),
673
693
  // Open, not signed off, closing an issue (GitHub's own linked set — a
@@ -8,7 +8,7 @@
8
8
  // settled member live, has to go red here.
9
9
  import { test } from "node:test";
10
10
  import assert from "node:assert/strict";
11
- import { reconcile, formatLines, actionable, deriveRun, parseShortlist, unclaimed, refreshWhy } from "./fleet-tick.mjs";
11
+ import { reconcile, formatLines, actionable, deriveRun, parseShortlist, unclaimed, refreshWhy, dispositionsToken, currentDispositions } from "./fleet-tick.mjs";
12
12
 
13
13
  // Every field named, so a test that cares about one number still states the
14
14
  // rest — a defaulted field is a guard nobody is pinning.
@@ -720,6 +720,69 @@ test("deriveRun: survivors a review fix-applier already answered stay answered t
720
720
  assert.deepEqual([live.fixDue, live.conflictHeld], [[], [21]]);
721
721
  });
722
722
 
723
+ // A dispositions mismatch is answered by one automatic retry: the PR returns
724
+ // to fix-due on the row that already dispatches fix-appliers, once, until the
725
+ // retry has been checked. A second failure is escalate, and escalate is a hold
726
+ // no fix-applier answers.
727
+ const D = (fix, ...tokens) => `#20 impl-20=PR#21 → PR#21 · reviewed=abc1234:2/0/0 · ${[...fix, ...tokens].join(" · ")}`;
728
+ const MISMATCH = "dispositions-mismatch=fix-pr-21:abc1234";
729
+ const dueAt = (tail, prs = [pr(21)]) => run({ rows: [D(["fix-pr-21=applied:def5678"], ...tail)] }, prs).fixDue;
730
+
731
+ test("deriveRun: a first dispositions mismatch puts the PR back in fixDue, and the reviewers row prints it", () => {
732
+ const r = run({ rows: [D(["fix-pr-21=applied:def5678"], MISMATCH)] }, [pr(21)]);
733
+ assert.deepEqual(r.fixDue, [21]);
734
+ assert.equal(row(state({ fixDue: r.fixDue }), "reviewers").action, "DISPATCH fix-pr PR#21");
735
+ // The survivors alone were answered: with an ok verdict the PR is not due.
736
+ assert.deepEqual(dueAt(["dispositions-ok=fix-pr-21:abc1234"]), []);
737
+ // No other role's row moves on the mismatch.
738
+ const others = (fixDue) => reconcile(state({ fixDue })).filter((x) => x.role !== "reviewers");
739
+ assert.deepEqual(others(r.fixDue), others([]));
740
+ });
741
+
742
+ test("deriveRun: a mismatch is due whether the review counted survivors or only unverified findings", () => {
743
+ const unverifiedOnly = run({ rows: [`#20 impl-20=PR#21 → PR#21 · reviewed=abc1234:0/0/2 · fix-pr-21=applied:def5678 · ${MISMATCH}`] }, [pr(21)]);
744
+ assert.deepEqual(unverifiedOnly.fixDue, [21]);
745
+ });
746
+
747
+ test("deriveRun: the retry is not re-offered while live, nor before its own check has run, nor after any verdict of its own", () => {
748
+ assert.deepEqual(dueAt([MISMATCH, "fix-pr-21-b"]), [], "live");
749
+ assert.deepEqual(dueAt([MISMATCH, "fix-pr-21-b=applied:0123abc"]), [], "settled, its check not yet run: the earlier mismatch is not the retry's answer");
750
+ assert.deepEqual(dueAt([MISMATCH, "fix-pr-21-b=applied:0123abc", "dispositions-ok=fix-pr-21-b:abc1234"]), [], "ok ends it");
751
+ assert.deepEqual(dueAt([MISMATCH, "fix-pr-21-b=applied:0123abc", "dispositions-escalate=fix-pr-21-b:abc1234"]), [], "escalate is never due");
752
+ assert.deepEqual(dueAt([MISMATCH, "fix-pr-21-b=failed"]), [21], "a retry that died leaves the mismatch for a replacement");
753
+ });
754
+
755
+ test("deriveRun: a landed conflict fix-applier does not answer a mismatch — the retry is still due", () => {
756
+ assert.deepEqual(dueAt([MISMATCH, "conflict-hold:#21", "fix-pr-21-b"]), [], "the conflict one is live: nothing is re-offered");
757
+ assert.deepEqual(dueAt([MISMATCH, "conflict-hold:#21", "fix-pr-21-b=applied:0123abc"]), [21], "hold cleared by -b, the mismatch still stands");
758
+ assert.deepEqual(dueAt([MISMATCH, "fix-pr-21-b=applied:0123abc"]), [], "control: a review -b that landed is awaiting its own check");
759
+ });
760
+
761
+ test("deriveRun: a dispositions verdict on a row that never recorded a review head answers nothing", () => {
762
+ const r = run({ rows: [`#20 impl-20=PR#21 → PR#21 · review=wf:x · fix-pr-21=applied:def5678 · ${MISMATCH}`] }, [pr(21)]);
763
+ assert.deepEqual(r.fixDue, []);
764
+ });
765
+
766
+ test("deriveRun: the verdict is read by review head and highest retry, never by position in the row", () => {
767
+ const escalated = ["dispositions-escalate=fix-pr-21-b:abc1234", MISMATCH];
768
+ assert.deepEqual(dueAt(["fix-pr-21-b=applied:0123abc", ...escalated]), [], "the -b escalate answers, wherever it sits");
769
+ // A mismatch on an older review does not answer a newer one.
770
+ const newer = run({ rows: [`#20 impl-20=PR#21 → PR#21 · reviewed=abc1234:2/0/0 · fix-pr-21=applied:def5678 · ${MISMATCH} · review=wf:x reviewed=fedcba9:0/1/0`] }, [pr(21)]);
771
+ assert.deepEqual(newer.fixDue, [], "the new review has no verdict and no survivors");
772
+ const rev = run({ rows: [`#20 impl-20=PR#21 → PR#21 · reviewed=abc1234:2/0/0 · fix-pr-21=applied:def5678 · ${MISMATCH} · review=wf:x`] }, [pr(21)]);
773
+ assert.deepEqual(rev.fixDue, [], "a review running on the PR holds it off");
774
+ assert.deepEqual(dueAt([MISMATCH], []), [], "a PR no longer open is nobody's work");
775
+ });
776
+
777
+ test("currentDispositions: escalate parses, and outranks mismatch which outranks ok at one retry suffix", () => {
778
+ assert.deepEqual(dispositionsToken("dispositions-escalate=fix-pr-21-b:abc1234")?.verdict, "escalate");
779
+ assert.equal(dispositionsToken("dispositions-escalate=finisher-pr-21:abc1234"), null);
780
+ const heads = (...s) => currentDispositions(s.map(dispositionsToken), "abc1234");
781
+ assert.deepEqual(heads("dispositions-ok=fix-pr-21:abc1234", "dispositions-escalate=fix-pr-21:abc1234"), { verdict: "escalate", member: "fix-pr-21" });
782
+ assert.deepEqual(heads("dispositions-escalate=fix-pr-21:abc1234", "dispositions-mismatch=fix-pr-21:abc1234"), { verdict: "escalate", member: "fix-pr-21" });
783
+ assert.deepEqual(heads("dispositions-escalate=fix-pr-21:abc1234", "dispositions-ok=fix-pr-21-b:abc1234"), { verdict: "ok", member: "fix-pr-21-b" });
784
+ });
785
+
723
786
  test("deriveRun: a newer review's survivors are not offered beside a live fix-applier, and are due once it settles", () => {
724
787
  // `dispatch` would refuse a second live one. Settled, it was dispatched
725
788
  // before that review, so it never answered the newer survivors.
@@ -783,6 +783,31 @@ test("the current verdict is the highest-suffixed fix-applier's, in either text
783
783
  assert.deepEqual(read().dispatched, ["finisher-pr-40=failed", "finisher-pr-40-b=failed"]);
784
784
  });
785
785
 
786
+ test("a mismatch is retried by the next fix-applier suffix: ok from it ends the refusal, escalate from it never does", (t) => {
787
+ const { ok, read, refused } = fixture(t);
788
+ ok("row", "10", gateRow("1/0/0", `dispositions-mismatch=fix-pr-40:${GATE_HEAD}`));
789
+ refused(["dispatch", "40", "finisher-pr-40"], /dispositions mismatch/);
790
+ // The retry is dispatched under the next suffix; the first name is spent.
791
+ refused(["dispatch", "40", "fix-pr-40"], /fix-pr-40/);
792
+ ok("dispatch", "40", "fix-pr-40-b");
793
+ ok("settle", "fix-pr-40-b", `applied:${GATE_HEAD.slice(0, 7)}`);
794
+ // Settled with no verdict of its own: the earlier mismatch still stands.
795
+ refused(["dispatch", "40", "finisher-pr-40"], /dispositions mismatch — fix-pr-40's/);
796
+ ok("row", "10", gateRow("1/0/0", `dispositions-mismatch=fix-pr-40:${GATE_HEAD}`, `dispositions-ok=fix-pr-40-b:${GATE_HEAD}`, "fix-pr-40-b=applied:0123abc"));
797
+ assert.equal(ok("dispatch", "40", "finisher-pr-40").agent, "fleet-finisher");
798
+ assert.ok(read().dispatched.includes("finisher-pr-40"));
799
+ // Escalated instead: a finisher is refused naming it, whichever way the tokens sit.
800
+ ok("settle", "finisher-pr-40", "failed");
801
+ const escalated = [`dispositions-mismatch=fix-pr-40:${GATE_HEAD}`, `dispositions-escalate=fix-pr-40-b:${GATE_HEAD}`];
802
+ for (const tokens of [escalated, [...escalated].reverse()]) {
803
+ ok("row", "10", gateRow("1/0/0", ...tokens, "fix-pr-40-b=applied:0123abc"));
804
+ refused(["dispatch", "40", "finisher-pr-40-b"], /finisher-pr-40-b: dispositions escalate — fix-pr-40-b deferred/);
805
+ }
806
+ // A new review (a new head) starts the gate over: the old escalate answers nothing.
807
+ ok("row", "10", `impl-10=PR#40 → PR#40 · reviewed=${GATE_HEAD}:1/0/0 · ${escalated[1]} · review=wf:x reviewed=${OTHER_HEAD}:1/0/0`);
808
+ refused(["dispatch", "40", "finisher-pr-40-b"], /dispositions unchecked/);
809
+ });
810
+
786
811
  test("a later fix-applier that writes no verdict neither satisfies nor resets the gate", (t) => {
787
812
  const { ok, refused } = fixture(t);
788
813
  // A conflict-hold fix-applier after the review one: no review file, no token.
@@ -1670,8 +1670,9 @@ function dispositionsRefusal(pr, review) {
1670
1670
  }
1671
1671
  if (v.verdict === "escalate") {
1672
1672
  return `dispositions escalate — ${v.member} deferred a finding above suggestion severity because its remedy lies outside the PR's diff, `
1673
- + `which needs a human's ruling: review ${review.head} gets no finisher and no retry; dispositions-check.mjs --member ${v.member} `
1674
- + "--scratch <scratch> names each escalated finding by bucket and index, for the PR comment that hands it to a human";
1673
+ + "or its record drew a second mismatch on this review; either needs a human's ruling: "
1674
+ + `review ${review.head} gets no finisher and no retry; dispositions-check.mjs --member ${v.member} `
1675
+ + "--scratch <scratch> names each escalated finding or violating entry by bucket and index, for the PR comment that hands it to a human";
1675
1676
  }
1676
1677
  return null;
1677
1678
  }
@@ -1393,7 +1393,7 @@ for a token on a ticket's line — never a hand edit.
1393
1393
  |---|---|
1394
1394
  | Implementer report | `verify-sha.sh`; `ledger.mjs settle impl-<N>=PR#<M>`, or `=bailed` and relabel by cause (**Implementer bails before implementing**, below) |
1395
1395
  | Review workflow notification / `review-pr-<n>` report | write `<scratch>/review-<pr>.json`; `reviewed=<head>:<survived>/<refuted>/<unverified>` on the PR's row (**Reviewers**) |
1396
- | Fix-applier report | `ledger.mjs settle fix-pr-<M>=…`; for a fix-applier that answered a review — not one that cleared a conflict hold, which has no review file — `~/.fleet/bin/fleet-run dispositions-check.mjs --member <that member> --scratch <scratch>`, from the checkout root: it judges `<scratch>/dispositions-<M>.json` against `<scratch>/review-<M>.json`, writes `dispositions-ok=`, `dispositions-mismatch=` or `dispositions-escalate=<member>:<head>` onto the PR's row itself, and exits 1 on a mismatch or an escalation, naming each violating or escalated entry's bucket and index, a violation its rule too (an escalation is then the gate paragraph's, below); copy the refutations it reversed — the record's `refuted` entries — to `ruled`; `ledger.mjs filed <N> "<subject>"` for each `unrecorded:` line |
1396
+ | Fix-applier report | `ledger.mjs settle fix-pr-<M>=…`; for a fix-applier that answered a review — not one that cleared a conflict hold, which has no review file — `~/.fleet/bin/fleet-run dispositions-check.mjs --member <that member> --scratch <scratch>`, from the checkout root: it judges `<scratch>/dispositions-<M>.json` against `<scratch>/review-<M>.json`, writes `dispositions-ok=`, `dispositions-mismatch=` or `dispositions-escalate=<member>:<head>` onto the PR's row itself, and exits 1 on a mismatch or an escalation, naming each violating or escalated entry's bucket and index, a violation its rule too — **Then dispatch a fix-applier** says what a mismatch asks of you, the gate paragraph below what an escalation does; copy the refutations it reversed — the record's `refuted` entries — to `ruled`; `ledger.mjs filed <N> "<subject>"` for each `unrecorded:` line |
1397
1397
  | Finisher report | `ledger.mjs settle finisher-pr-<M>=labelled` as reported, even when its read-back lacks `ready-to-merge` — the tick's `DISPATCH finisher PR#<M>` catches that next. A **repair** finisher's (one sent on that line) read-back lacking it also gets `gh pr comment <M>` with both finishers' read-backs: the one-shot escalation, where halts comment. `ledger.mjs filed <N> "<subject>"` for each `unrecorded:` line |
1398
1398
  | Finisher report (halted) | `ledger.mjs settle finisher-pr-<M>=halted:<cause>`; `gh pr comment <M>` with the finisher's halt report, cause and evidence; `ledger.mjs filed <N> "<subject>"` for each `unrecorded:` line; then the per-cause rule (**Resolving a finisher halt**, below) |
1399
1399
  | Label seen (persistent Monitor) | nothing to record |
@@ -1562,8 +1562,8 @@ depth** guard table applied in code. Act on each line as it reads:
1562
1562
  blind until it can.
1563
1563
  - `SUGGEST /triage, hold idle` — nothing is admissible after the refresh.
1564
1564
  Suggest, never run (**Queue depth**).
1565
- - `DISPATCH fix-pr PR#<M> …` — a fix-applier per PR, for a review's survivors
1566
- or a merge bot's `conflict-hold:#<M>` (**Reviewers**). Printed
1565
+ - `DISPATCH fix-pr PR#<M> …` — a fix-applier per PR, for a review's survivors,
1566
+ a dispositions mismatch no retry has answered, or a merge bot's `conflict-hold:#<M>` (**Reviewers**). Printed
1567
1567
  ahead of reviews on purpose: finishing what is started beats starting more.
1568
1568
  - `DISPATCH review PR#<M> …` — a review per PR, oldest first, off your turn
1569
1569
  (**Reviewers**).
@@ -2080,6 +2080,22 @@ is a review fix-applier, dispatched as above (#2328). The push moves the
2080
2080
  head after `ready-to-merge`, so the next merge bot refuses it
2081
2081
  `head-moved-after-label-#<M>`: a fresh finisher, per **Failure handling**.
2082
2082
 
2083
+ **A `DISPATCH fix-pr PR#<M>` on a dispositions mismatch** — the PR's row carries
2084
+ `dispositions-mismatch=<member>:<head>` for its latest review, and no later
2085
+ fix-applier has landed — is one automatic retry, a review fix-applier named the
2086
+ next suffix (`fix-pr-<M>-b`) and dispatched like the first. Its prompt is the
2087
+ first one's, with two paths and a list copied in: the review file
2088
+ `<scratch>/review-<M>.json`, the disposition record `<scratch>/dispositions-<M>.json`
2089
+ it rewrites, and the check's violation list **verbatim** — the lines
2090
+ `dispositions-check.mjs` printed, each naming a bucket, an index and a rule;
2091
+ re-run the check for the mismatched member to reprint them. It rewrites the
2092
+ record, and when it reports you run the check for it exactly as for the first
2093
+ one. `ok` ends it. A second failure on the same review is written as
2094
+ `dispositions-escalate=fix-pr-<M>-b:<head>` in place of a mismatch: the tick
2095
+ prints no `DISPATCH fix-pr` for it, `dispatch` refuses the finisher naming
2096
+ `dispositions escalate`, and you post the check's output with `gh pr comment <M>`
2097
+ and flag the PR for a human. A new review of the PR counts again from none.
2098
+
2083
2099
  **A refutation resting on an injection nothing proved landed is not a refutation
2084
2100
  — it is a cell that never ran.** A mutation test and a finding reproduction are
2085
2101
  both failure injections: break something, read what the child does. When the
@@ -2428,14 +2444,16 @@ from the highest-suffixed fix-applier. A PR whose latest review counts
2428
2444
  `0/<n>/0` is not gated. The refusal names its cause. `dispositions
2429
2445
  unchecked` — no verdict answers that head: run `dispositions-check.mjs` for
2430
2446
  the fix-applier that answered the review, as the Fix-applier report edge
2431
- states, then dispatch again. `dispositions mismatch` — no finisher: post the
2432
- check's output, which names each violating entry, with `gh pr comment <M>`, and
2433
- flag the PR for a human; re-running the check reprints it. `dispositions
2434
- escalate` — no finisher, and no retry: a `critical` or `important` finding was
2435
- deferred `remedy-outside-diff`, which only a human can rule on. Post the check's
2436
- output, which names each escalated finding, with `gh pr comment <M>`, and flag
2437
- the PR for a human; dispatch no fix-applier and no finisher for that review's
2438
- head, and expect no `fix-due` row for it. **On any PR with a
2447
+ states, then dispatch again. `dispositions mismatch` — no finisher: the tick
2448
+ prints `DISPATCH fix-pr PR#<M>` for it, and the next-suffix fix-applier answers
2449
+ it (**Then dispatch a fix-applier**). `dispositions escalate` — no finisher, no
2450
+ retry and no further fix-applier: either a `critical` or `important` finding was
2451
+ deferred `remedy-outside-diff`, or a second mismatch was drawn on one review, and
2452
+ only a human can rule on either. Post the check's output, which names each
2453
+ escalated finding or violating entry, with `gh pr comment <M>`, and flag the PR
2454
+ for a human; re-running the check reprints it. Dispatch no fix-applier and no
2455
+ finisher for that review's head, and expect no `fix-due` row for it; a new
2456
+ review of the PR starts the check again. **On any PR with a
2439
2457
  returned review, `0/<n>/0` included, it refuses too while a fix-applier on the
2440
2458
  PR is still live, verdict or not** —
2441
2459
  `fix-pr-<M>[-x] still live`: settle it, run the check for it, then dispatch