@feigi/fleet-ctl 3.16.8 → 3.17.0
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 +1 -1
- package/scripts/dispositions-check.mjs +34 -9
- package/scripts/dispositions-check.test.mjs +81 -1
- package/scripts/dispositions-gate-prose.test.mjs +17 -2
- package/scripts/fleet-tick.mjs +37 -17
- package/scripts/fleet-tick.test.mjs +64 -1
- package/scripts/ledger-dispatch.test.mjs +25 -0
- package/scripts/ledger.mjs +3 -2
- package/skills/run-team/SKILL.md +29 -11
package/package.json
CHANGED
|
@@ -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
|
|
12
|
-
// or important finding
|
|
13
|
-
// `remedy-outside-diff
|
|
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
|
-
|
|
485
|
-
|
|
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:
|
|
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
|
|
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",
|
package/scripts/fleet-tick.mjs
CHANGED
|
@@ -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
|
|
312
|
-
// the record answers. `ledger.mjs
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
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
|
|
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
|
|
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)
|
|
562
|
-
|
|
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
|
-
//
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
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.
|
package/scripts/ledger.mjs
CHANGED
|
@@ -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
|
-
+
|
|
1674
|
-
+
|
|
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
|
}
|
package/skills/run-team/SKILL.md
CHANGED
|
@@ -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
|
|
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:
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
deferred `remedy-outside-diff`,
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
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
|