@feigi/fleet-ctl 3.17.6 → 3.18.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/compute-board.mjs +19 -9
- package/scripts/compute-board.test.mjs +91 -0
- package/scripts/finisher-died-prose.test.mjs +31 -0
- package/scripts/fleet-tick-prose.test.mjs +1 -0
- package/scripts/fleet-tick.mjs +20 -9
- package/scripts/fleet-tick.test.mjs +12 -1
- package/scripts/review-core.mjs +1 -1
- package/scripts/snapshot-repo.test.mjs +98 -37
- package/skills/run-team/SKILL.md +14 -0
package/package.json
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
// here, main() being guarded on argv[1].
|
|
26
26
|
import { assessBeat, isStalled, stallReport } from "./fleet-state.mjs";
|
|
27
27
|
import { parseToken, HALT_CAUSES } from "./ledger-grammar.mjs";
|
|
28
|
-
import { PR_MENTION, REVIEWED,
|
|
28
|
+
import { PR_MENTION, REVIEWED, latestFinisherAttempts } from "./fleet-tick.mjs";
|
|
29
29
|
|
|
30
30
|
// A ledger row is freeform, controller-authored text. Two real examples:
|
|
31
31
|
// #332 impl-332=PR#344 → PR#344 → MERGED 73b356de
|
|
@@ -61,9 +61,10 @@ import { PR_MENTION, REVIEWED, unlabelledFinishers } from "./fleet-tick.mjs";
|
|
|
61
61
|
// that outcome as a severity-4 flag while the PR sits in REVIEW. The halt is
|
|
62
62
|
// the finisher working correctly — it refused to label — so the PR has no
|
|
63
63
|
// `ready-to-merge` until the controller resolves the cause; the flag is what
|
|
64
|
-
// puts it in front of a human. "Latest" is
|
|
65
|
-
// finisher
|
|
66
|
-
// clears it
|
|
64
|
+
// puts it in front of a human. "Latest" is the reading `unlabelled` and
|
|
65
|
+
// `finisher:*` below share (latestFinisherAttempts, off the same rows and
|
|
66
|
+
// `## Dispatched` tokens): a live `-b` after the halt clears it, and a later
|
|
67
|
+
// attempt settled anywhere replaces it.
|
|
67
68
|
//
|
|
68
69
|
// #2331: a PR whose finisher settled `labelled` while the open list shows no
|
|
69
70
|
// `ready-to-merge` on it carries a severity-4 `unlabelled` flag in REVIEW —
|
|
@@ -72,6 +73,13 @@ import { PR_MENTION, REVIEWED, unlabelledFinishers } from "./fleet-tick.mjs";
|
|
|
72
73
|
// reads, so the card is flagged whenever the tick is repairing it or escalated
|
|
73
74
|
// it — including after a whole-line `row` rewrite dropped the settled token.
|
|
74
75
|
//
|
|
76
|
+
// A PR in REVIEW whose latest finisher attempt settled `failed` or `killed`
|
|
77
|
+
// while it is open and lacks `ready-to-merge` carries `finisher:failed` or
|
|
78
|
+
// `finisher:killed`, severity 4 — read off the same latest attempt and stretch
|
|
79
|
+
// as `unlabelled`, so the tick and the cockpit never read it two ways. The tick
|
|
80
|
+
// dispatches nothing for these; the flag is what puts the PR in front of a
|
|
81
|
+
// human. The names are not the bare `killed` flag, which is the implementer's.
|
|
82
|
+
//
|
|
75
83
|
// A PR's review is not a member (#1773 §7): `review=wf:<runId>` is a Workflow
|
|
76
84
|
// with nobody to name, while `review=member:<name>` and
|
|
77
85
|
// `review=fallback:<name>` name a runner member, live until the token is
|
|
@@ -288,14 +296,15 @@ export function deriveFlags(parsed, ctx) {
|
|
|
288
296
|
// escalated halt by labelling or merging it, the flag has nothing to ask.
|
|
289
297
|
// `past-pin`'s own automatic resolution (SKILL.md "Resolving a finisher
|
|
290
298
|
// halt") re-reviews the PR through fleet-tick.mjs, but a returned
|
|
291
|
-
// `reviewed=` does not itself clear
|
|
299
|
+
// `reviewed=` does not itself clear the latest finisher outcome here — the halt is
|
|
292
300
|
// answered only once "its result reaches a fresh finisher through the
|
|
293
301
|
// same gate" (ibid.), so the card stays flagged, same as any other halt
|
|
294
302
|
// cause, until that finisher settles. reviewBacklog below reads the same
|
|
295
303
|
// gap the other way: still counted due for a fresh review while this flag
|
|
296
304
|
// is up and the head has not caught up to what was reviewed.
|
|
297
|
-
if (
|
|
298
|
-
if (ctx.
|
|
305
|
+
if (ctx.finisher?.startsWith("halted:") && ctx.column === "REVIEW") flags.push(ctx.finisher);
|
|
306
|
+
if (ctx.finisher === "labelled" && ctx.column === "REVIEW") flags.push("unlabelled");
|
|
307
|
+
if ((ctx.finisher === "failed" || ctx.finisher === "killed") && ctx.column === "REVIEW") flags.push(`finisher:${ctx.finisher}`);
|
|
299
308
|
const limit = STALE_MS[ctx.column];
|
|
300
309
|
if (limit != null && ctx.sinceEnteredStage != null && ctx.now - ctx.sinceEnteredStage > limit) {
|
|
301
310
|
flags.push("stale");
|
|
@@ -325,6 +334,7 @@ function titleFor(issue, pr, issues) {
|
|
|
325
334
|
|
|
326
335
|
const FLAG_SEVERITY = {
|
|
327
336
|
"red-ci": 5, "ledger-error": 5, killed: 4, "tier-mismatch": 4, blocked: 4, "sha-off-branch": 4, unlabelled: 4,
|
|
337
|
+
"finisher:failed": 4, "finisher:killed": 4,
|
|
328
338
|
...Object.fromEntries(HALT_CAUSES.map((c) => [`halted:${c}`, 4])),
|
|
329
339
|
stale: 1,
|
|
330
340
|
};
|
|
@@ -453,7 +463,7 @@ export function computeBoard(inputs) {
|
|
|
453
463
|
|
|
454
464
|
const parsed = (ledger.rows || []).map(parseRow).filter(Boolean);
|
|
455
465
|
const unqueued = new Set(prs.filter((p) => p.state === "OPEN" && !(p.labels || []).includes("ready-to-merge")).map((p) => p.number));
|
|
456
|
-
const
|
|
466
|
+
const latestFinishers = new Map(latestFinisherAttempts({ rows: ledger.rows || [], dispatched: ledger.dispatched || [] }, unqueued).map((u) => [u.pr, u.outcome]));
|
|
457
467
|
const rowIssues = new Set();
|
|
458
468
|
const tickets = [];
|
|
459
469
|
|
|
@@ -468,7 +478,7 @@ export function computeBoard(inputs) {
|
|
|
468
478
|
rowIssues.add(p.issue);
|
|
469
479
|
const sinceEnteredStage = stageEntry(prevTicket, column, now);
|
|
470
480
|
const ciState = p.pr != null ? (ci[p.pr] ?? "unknown") : null;
|
|
471
|
-
const flags = deriveFlags(p, { ci: ciState, column, sinceEnteredStage, now,
|
|
481
|
+
const flags = deriveFlags(p, { ci: ciState, column, sinceEnteredStage, now, finisher: latestFinishers.get(p.pr) });
|
|
472
482
|
tickets.push({
|
|
473
483
|
issue: p.issue,
|
|
474
484
|
title: titleFor(p.issue, pr, issues),
|
|
@@ -683,6 +683,97 @@ test("#2331: the cockpit reads `## Dispatched` as the tick does — a settled fi
|
|
|
683
683
|
}
|
|
684
684
|
});
|
|
685
685
|
|
|
686
|
+
// A finisher that settled `failed` or `killed` leaves its PR unlabelled with
|
|
687
|
+
// nothing dispatched for it, so the cockpit is what surfaces it — one token per
|
|
688
|
+
// outcome, distinct from each other, from `unlabelled`, and from the
|
|
689
|
+
// implementer's bare `killed`.
|
|
690
|
+
test("a PR whose latest finisher settled failed or killed flags finisher:failed / finisher:killed, ranked exactly at severity 4", () => {
|
|
691
|
+
for (const outcome of ["failed", "killed"]) {
|
|
692
|
+
const b = computeBoard(reproInputs({
|
|
693
|
+
rows: ["#904 impl-904=killed", `#941 impl-941=PR#931 · finisher-pr-931=${outcome}`, "#942 impl-942=killed",
|
|
694
|
+
"#943 impl-943=PR#932 · held-behind:#931"],
|
|
695
|
+
prev: { tickets: [] },
|
|
696
|
+
}));
|
|
697
|
+
assert.equal(card(b, 941).column, "REVIEW", outcome);
|
|
698
|
+
assert.deepEqual(card(b, 941).flags, [`finisher:${outcome}`], outcome);
|
|
699
|
+
assert.deepEqual(b.attention.map((t) => t.issue), [904, 941, 942, 943], outcome);
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
test("the finisher's latest attempt decides failed / killed — by suffix order, never token position", () => {
|
|
704
|
+
for (const [row, flags] of [
|
|
705
|
+
["#941 impl-941=PR#931 · finisher-pr-931=labelled · finisher-pr-931-b=failed", ["finisher:failed"]],
|
|
706
|
+
["#941 impl-941=PR#931 · finisher-pr-931-b=killed · finisher-pr-931=labelled", ["finisher:killed"]],
|
|
707
|
+
["#941 impl-941=PR#931 · finisher-pr-931=halted:rebase · finisher-pr-931-b=failed", ["finisher:failed"]],
|
|
708
|
+
// A later attempt settles it: a labelled one on a PR still without the label is `unlabelled`'s,
|
|
709
|
+
// a halt is its own flag, a live repair flags nothing.
|
|
710
|
+
["#941 impl-941=PR#931 · finisher-pr-931=failed · finisher-pr-931-b=labelled", ["unlabelled"]],
|
|
711
|
+
["#941 impl-941=PR#931 · finisher-pr-931-b=labelled · finisher-pr-931=killed", ["unlabelled"]],
|
|
712
|
+
["#941 impl-941=PR#931 · finisher-pr-931=killed · finisher-pr-931-b=halted:other", ["halted:other"]],
|
|
713
|
+
["#941 impl-941=PR#931 · finisher-pr-931=failed · finisher-pr-931-b", []],
|
|
714
|
+
["#941 impl-941=PR#931 · finisher-pr-931-b · finisher-pr-931=failed", []],
|
|
715
|
+
// An unsettled bare copy beside the settled token is what a whole-line rewrite leaves.
|
|
716
|
+
["#941 impl-941=PR#931 · finisher-pr-931 · finisher-pr-931=failed", ["finisher:failed"]],
|
|
717
|
+
]) {
|
|
718
|
+
const { card: c } = cardFor(row, 941);
|
|
719
|
+
assert.equal(c.column, "REVIEW", row);
|
|
720
|
+
assert.deepEqual(c.flags, flags, row);
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
test("a failed or killed finisher flags nothing once the label is on, the PR is shut, or a label-off accounts for it", () => {
|
|
725
|
+
for (const outcome of ["failed", "killed"]) {
|
|
726
|
+
const row = `#941 impl-941=PR#931 · finisher-pr-931=${outcome}`;
|
|
727
|
+
const ready = computeBoard(reproInputs({ rows: [row], prs: [openPr(931, ["ready-to-merge"])], prev: { tickets: [] } }));
|
|
728
|
+
assert.equal(card(ready, 941).column, "READY");
|
|
729
|
+
assert.deepEqual(card(ready, 941).flags, [], outcome);
|
|
730
|
+
const merged = computeBoard(reproInputs({ rows: [row], prs: [], merged: [931], prev: { tickets: [] } }));
|
|
731
|
+
assert.deepEqual(card(merged, 941).flags, [], outcome);
|
|
732
|
+
for (const state of ["CLOSED", "MERGED"]) {
|
|
733
|
+
const shut = computeBoard(reproInputs({ rows: [row], prs: [{ ...openPr(931), state }], prev: { tickets: [] } }));
|
|
734
|
+
assert.deepEqual(card(shut, 941).flags, [], `${outcome} ${state}`);
|
|
735
|
+
}
|
|
736
|
+
// REVIEW only: a card the row already carries past review flags nothing.
|
|
737
|
+
const past = cardFor(`#941 impl-941=PR#931 → MERGED abc1234 · finisher-pr-931=${outcome}`, 941).card;
|
|
738
|
+
assert.equal(past.column, "MERGED");
|
|
739
|
+
assert.deepEqual(past.flags, [], outcome);
|
|
740
|
+
}
|
|
741
|
+
// A label-off naming a later attempt than the failed one cancels it; a failed attempt after the latest label-off flags.
|
|
742
|
+
for (const [row, flags] of [
|
|
743
|
+
["#941 impl-941=PR#931 · finisher-pr-931=failed label-off=finisher-pr-931", []],
|
|
744
|
+
["#941 impl-941=PR#931 · finisher-pr-931=failed · finisher-pr-931-b=failed label-off=finisher-pr-931-b", []],
|
|
745
|
+
["#941 impl-941=PR#931 · finisher-pr-931=labelled label-off=finisher-pr-931 · finisher-pr-931-b=failed", ["finisher:failed"]],
|
|
746
|
+
["#941 impl-941=PR#931 · finisher-pr-931=failed label-off=finisher-pr-931 · finisher-pr-931-b=killed", ["finisher:killed"]],
|
|
747
|
+
["#941 impl-941=PR#931 · finisher-pr-931-b=failed label-off=finisher-pr-931", ["finisher:failed"]],
|
|
748
|
+
]) {
|
|
749
|
+
assert.deepEqual(cardFor(row, 941).card.flags, flags, row);
|
|
750
|
+
}
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
test("a failed or killed finisher token on another PR's row is a stray; one settled only in `## Dispatched` still flags", () => {
|
|
754
|
+
const stray = ["#941 impl-941=PR#931", "#942 impl-942=PR#932 · finisher-pr-931=failed"];
|
|
755
|
+
const b = computeBoard(reproInputs({ rows: stray, prev: { tickets: [] } }));
|
|
756
|
+
assert.deepEqual(card(b, 941).flags, []);
|
|
757
|
+
const masked = ["#941 impl-941=PR#931 · finisher-pr-931=failed", "#942 impl-942=PR#932 · finisher-pr-931-b"];
|
|
758
|
+
const m = computeBoard(reproInputs({ rows: masked, prev: { tickets: [] } }));
|
|
759
|
+
assert.deepEqual(card(m, 941).flags, ["finisher:failed"]);
|
|
760
|
+
const rows = ["#941 impl-941=PR#931 → PR#931 · reviewed=abc1234:0/0/0"];
|
|
761
|
+
const d = computeBoard(reproInputs({ rows, ledger: { rows, dispatched: ["impl-941=PR#931", "finisher-pr-931=killed"], filed: [], ruled: [] }, prev: { tickets: [] } }));
|
|
762
|
+
assert.deepEqual(card(d, 941).flags, ["finisher:killed"]);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
test("the latest finisher attempt decides the halt flag too, whichever of the row and `## Dispatched` holds it", () => {
|
|
766
|
+
const flagsFor = (rowToken, dispatchedToken) => {
|
|
767
|
+
const rows = [`#941 impl-941=PR#931 → PR#931 · reviewed=abc1234:0/0/0 · ${rowToken}`];
|
|
768
|
+
const b = computeBoard(reproInputs({ rows, ledger: { rows, dispatched: ["impl-941=PR#931", dispatchedToken], filed: [], ruled: [] }, prev: { tickets: [] } }));
|
|
769
|
+
return card(b, 941).flags;
|
|
770
|
+
};
|
|
771
|
+
assert.deepEqual(flagsFor("finisher-pr-931=halted:rebase", "finisher-pr-931-b=failed"), ["finisher:failed"]);
|
|
772
|
+
assert.deepEqual(flagsFor("finisher-pr-931=failed", "finisher-pr-931-b=halted:rebase"), ["halted:rebase"]);
|
|
773
|
+
// ACCEPT side: the halt still flags when it is the latest attempt in the row alone.
|
|
774
|
+
assert.deepEqual(flagsFor("finisher-pr-931=halted:rebase", "finisher-pr-931=halted:rebase"), ["halted:rebase"]);
|
|
775
|
+
});
|
|
776
|
+
|
|
686
777
|
test("#1820: a live implementer is IMPLEMENTING, and still earns stale", () => {
|
|
687
778
|
const b = computeBoard(reproInputs());
|
|
688
779
|
assert.equal(card(b, 906).column, "IMPLEMENTING");
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// A finisher settled `failed` or `killed` leaves its PR without
|
|
2
|
+
// `ready-to-merge` and nothing dispatches for it. The ruling: flag only — the
|
|
3
|
+
// cockpit raises one severity-4 token per outcome and the controller resolves
|
|
4
|
+
// it by hand, never an automatic retry and never the label itself. Each pin is
|
|
5
|
+
// sliced to the paragraph or table row carrying its claim.
|
|
6
|
+
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { paragraph } from "./prose-pin.mjs";
|
|
12
|
+
|
|
13
|
+
const RUN_TEAM = readFileSync(join(import.meta.dirname, "..", "skills", "run-team", "SKILL.md"), "utf8");
|
|
14
|
+
const flat = (s) => s.replace(/\s+/g, " ");
|
|
15
|
+
|
|
16
|
+
test("a finisher that died is flagged by the cockpit and resolved by hand — no automatic retry, no label from the controller", () => {
|
|
17
|
+
const para = flat(paragraph(RUN_TEAM, "**Resolving a finisher that died.**", "died-finisher resolution"));
|
|
18
|
+
assert.match(para, /`finisher:failed` or `finisher:killed` at severity 4/);
|
|
19
|
+
assert.match(para, /neither the implementer's bare `killed` flag nor `unlabelled`/);
|
|
20
|
+
assert.match(para, /the tick dispatches no finisher for it/);
|
|
21
|
+
assert.match(para, /You resolve it by hand: investigate why the finisher died, or dispatch the next-suffix finisher after the finisher gate/);
|
|
22
|
+
assert.match(para, /You never add the label yourself, and the tick does not retry\./);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("the finisher-report wake row records failed and killed and points at the by-hand resolution", () => {
|
|
26
|
+
const row = flat(RUN_TEAM.split("\n").find((l) => l.startsWith("| Finisher report (failed / killed) |")) ?? "");
|
|
27
|
+
assert.match(row, /settle finisher-pr-<M>=failed` for a finisher that crashed or gave up, `=killed` for one that was killed/);
|
|
28
|
+
assert.match(row, /the tick prints no dispatch for it/);
|
|
29
|
+
assert.match(row, /`finisher:failed` \/ `finisher:killed` at severity 4/);
|
|
30
|
+
assert.match(row, /\*\*Resolving a finisher that died\*\*/);
|
|
31
|
+
});
|
|
@@ -84,6 +84,7 @@ test("the record-before-tick table names every wake, each with what it records",
|
|
|
84
84
|
["Finisher report", ["settle finisher-pr-<M>=labelled"]],
|
|
85
85
|
// #2083: a halt is its own outcome, escalated on the PR itself, then resolved by cause.
|
|
86
86
|
["Finisher report (halted)", ["settle finisher-pr-<M>=halted:<cause>", "gh pr comment <M>", "**Resolving a finisher halt**"]],
|
|
87
|
+
["Finisher report (failed / killed)", ["settle finisher-pr-<M>=failed", "**Resolving a finisher that died**"]],
|
|
87
88
|
["Label seen (persistent Monitor)", ["nothing to record"]],
|
|
88
89
|
["CI run terminal", ["ci=<run-id>:<attempt>:<conclusion>", "finisher gate"]],
|
|
89
90
|
["Merge-bot pass report", ["held-behind:#<lower>", "settle merge-bot-<n>=done", "reap.sh --apply"]],
|
package/scripts/fleet-tick.mjs
CHANGED
|
@@ -370,23 +370,23 @@ export function rowNums(text) {
|
|
|
370
370
|
return { keyNum, pr: mention ? Number(mention[1]) : keyNum };
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
-
//
|
|
374
|
-
// `ready-to-merge` on them — `unqueued` is the set of open PR numbers without
|
|
373
|
+
// PRs whose finisher attempts are read off the ledger while the open list shows
|
|
374
|
+
// no `ready-to-merge` on them — `unqueued` is the set of open PR numbers without
|
|
375
375
|
// it, so the label shape stays the caller's (gh's `{name}` here, plain names in
|
|
376
376
|
// compute-board.mjs). A PR's attempts are its `finisher-pr-M` tokens in
|
|
377
377
|
// `## Dispatched` and on PR M's own rows; a copy on another PR's row is a
|
|
378
378
|
// stray (#2329) that neither makes nor masks a miss. Settled anywhere among
|
|
379
379
|
// those is settled. The STRETCH is the attempts whose retry suffix sorts after
|
|
380
380
|
// the highest one a `label-off=` names — suffix order ("" < "b" < …), never
|
|
381
|
-
// row position (#2083).
|
|
382
|
-
//
|
|
383
|
-
//
|
|
381
|
+
// row position (#2083). `attempts` is the stretch in suffix order and `outcome`
|
|
382
|
+
// the outcome of its latest attempt (null while that one is live); a PR whose
|
|
383
|
+
// stretch is empty is left out.
|
|
384
384
|
//
|
|
385
385
|
// Lenient on purpose — a malformed token is skipped, never thrown — because
|
|
386
386
|
// the cockpit reads the same ledger and flags rather than refuses; deriveRun
|
|
387
387
|
// has already refused anything malformed before it calls this.
|
|
388
|
-
/** @returns {{pr: number,
|
|
389
|
-
export function
|
|
388
|
+
/** @returns {{pr: number, outcome: string|null, attempts: {name: string, retry: string, outcome: string|null}[]}[]} ascending by PR */
|
|
389
|
+
export function latestFinisherAttempts({ rows, dispatched }, unqueued) {
|
|
390
390
|
const attempts = new Map();
|
|
391
391
|
const offs = new Map();
|
|
392
392
|
const add = (t) => {
|
|
@@ -417,12 +417,23 @@ export function unlabelledFinishers({ rows, dispatched }, unqueued) {
|
|
|
417
417
|
const stretch = [...byName.values()]
|
|
418
418
|
.filter((a) => !offs.has(n) || a.retry > offs.get(n))
|
|
419
419
|
.sort((a, b) => (a.retry < b.retry ? -1 : a.retry > b.retry ? 1 : 0));
|
|
420
|
-
if (stretch.
|
|
421
|
-
out.push({ pr: n,
|
|
420
|
+
if (stretch.length === 0) continue;
|
|
421
|
+
out.push({ pr: n, outcome: stretch.at(-1).outcome, attempts: stretch });
|
|
422
422
|
}
|
|
423
423
|
return out.sort((a, b) => a.pr - b.pr);
|
|
424
424
|
}
|
|
425
425
|
|
|
426
|
+
// #2331: PRs whose latest finisher attempt settled `labelled` while the open
|
|
427
|
+
// list shows no `ready-to-merge` on them. `labelled` lists every attempt in the
|
|
428
|
+
// stretch that settled so, the count the tick splits one repair from an
|
|
429
|
+
// escalation on.
|
|
430
|
+
/** @returns {{pr: number, labelled: string[]}[]} ascending by PR */
|
|
431
|
+
export function unlabelledFinishers(ledger, unqueued) {
|
|
432
|
+
return latestFinisherAttempts(ledger, unqueued)
|
|
433
|
+
.filter((u) => u.outcome === "labelled")
|
|
434
|
+
.map((u) => ({ pr: u.pr, labelled: u.attempts.filter((a) => a.outcome === "labelled").map((a) => a.name) }));
|
|
435
|
+
}
|
|
436
|
+
|
|
426
437
|
export function deriveRun({ rows, dispatched, drain }, prs) {
|
|
427
438
|
// One entry per member name across `## Dispatched` and every row. A member
|
|
428
439
|
// settled ANYWHERE is settled: `settle` is the only writer of an outcome, and
|
|
@@ -561,7 +561,9 @@ test("deriveRun: only a settled-labelled LATEST attempt is a miss — live, halt
|
|
|
561
561
|
`${base} · finisher-pr-40-b · finisher-pr-40=labelled`, // the same, by suffix not position
|
|
562
562
|
`${base} · finisher-pr-40=halted:rebase`, // a halt refused to label, and says so
|
|
563
563
|
`${base} · finisher-pr-40=labelled · finisher-pr-40-b=halted:past-pin`,
|
|
564
|
-
`${base} · finisher-pr-40=failed`, //
|
|
564
|
+
`${base} · finisher-pr-40=failed`, // died, no label: the cockpit's to flag, no repair here
|
|
565
|
+
`${base} · finisher-pr-40=killed`,
|
|
566
|
+
`${base} · finisher-pr-40=labelled · finisher-pr-40-b=failed`,
|
|
565
567
|
`${base} · fix-pr-40=applied:def5678`, // no finisher at all
|
|
566
568
|
]) {
|
|
567
569
|
assert.deepEqual(run({ rows: [row] }, open).unlabelled, [], row);
|
|
@@ -569,6 +571,15 @@ test("deriveRun: only a settled-labelled LATEST attempt is a miss — live, halt
|
|
|
569
571
|
assert.deepEqual(run({ rows: [LABELLED] }, []).unlabelled, [], "a PR off the open list is nobody's work");
|
|
570
572
|
});
|
|
571
573
|
|
|
574
|
+
test("deriveRun: a failed or killed latest finisher prints no DISPATCH or ESCALATE finisher line", () => {
|
|
575
|
+
const open = [pr(40, ["minor"], [10])];
|
|
576
|
+
for (const outcome of ["failed", "killed"]) {
|
|
577
|
+
const r = run({ rows: [`${LABELLED} · finisher-pr-40-b=${outcome}`] }, open);
|
|
578
|
+
assert.deepEqual(r.unlabelled, [], outcome);
|
|
579
|
+
assert.deepEqual(reconcile({ ...state(), ...r }).filter((x) => x.role === "reviewers").map((x) => x.action), ["IDLE OK"], outcome);
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
|
|
572
583
|
test("deriveRun: a label-off'd attempt is the controller's own removal — nothing until a later attempt labels again", () => {
|
|
573
584
|
const open = [pr(40, ["minor"], [10])];
|
|
574
585
|
for (const row of [
|
package/scripts/review-core.mjs
CHANGED
|
@@ -755,7 +755,7 @@ export async function runReview(host, args) {
|
|
|
755
755
|
mkdir -p "${runRootParent}" || { echo SNAPSHOT_RUNROOT_FAILED; exit 1; }
|
|
756
756
|
RUN=$(mktemp -d "${runRootPrefix}XXXXXXXX") || { echo SNAPSHOT_RUNROOT_FAILED; exit 1; }
|
|
757
757
|
echo SNAPSHOT_RUN_ROOT="$RUN"
|
|
758
|
-
find "${runRootParent}" -maxdepth 1 -type d -name 'run-*' -mtime +7 -exec sh -c 'rc=0; for d; do chmod -R u+rwx "$d" 2>/dev/null; if command -v chflags >/dev/null 2>&1; then
|
|
758
|
+
find "${runRootParent}" -maxdepth 1 -type d -name 'run-*' -mtime +7 -exec sh -c 'rc=0; ph="{""}"; for d; do chmod -R u+rwx "$d" 2>/dev/null; m=$?; if command -v chflags >/dev/null 2>&1; then [ $m -eq 0 ] || { chflags nouchg,nouappnd "$d"; chmod u+rwx "$d"; find "$d" -type d -exec chflags nouchg,nouappnd "$ph" ";" -exec chmod u+rwx "$ph" ";"; }; chflags -R nouchg,nouappnd "$d" 2>/dev/null; fi; rm -rf "$d" || rc=1; done; exit $rc' sh {} + || echo SNAPSHOT_PRUNE_FAILED
|
|
759
759
|
SHA=$(git -C ${worktree} rev-parse --short HEAD) || { echo SNAPSHOT_REVPARSE_FAILED; exit 1; }
|
|
760
760
|
SNAP="$RUN/snapshot-$SHA"
|
|
761
761
|
echo SNAPSHOT_DEST="$SNAP"
|
|
@@ -164,31 +164,48 @@ const renderPrune = (path, runRootParent) =>
|
|
|
164
164
|
|
|
165
165
|
/**
|
|
166
166
|
* A `run-` root under `parent` holding a `locked` directory with no permission
|
|
167
|
-
* bits at all, aged `days`.
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
167
|
+
* bits at all, aged `days`. `nested` puts a second such directory, `deeper`,
|
|
168
|
+
* inside `locked`, and the file moves into it. On darwin, optionally, the file
|
|
169
|
+
* carries `fileFlag` (`uchg` or `uappnd`), and every locked directory carries
|
|
170
|
+
* `dirFlag` — set after the mode, so a directory's mode cannot be changed back
|
|
171
|
+
* until the flag is gone. `rootFlag` locks the run root itself the same way,
|
|
172
|
+
* with no permission bits and that flag. `fileMode` is the file's own mode:
|
|
173
|
+
* one that already grants owner rwx leaves `chmod -R u+rwx` nothing to change
|
|
174
|
+
* on the file, so that pass exits 0 over a flagged file. Aged before the root
|
|
175
|
+
* is locked: creating children sets the root's mtime to now, and a flagged
|
|
176
|
+
* root refuses `utimes`.
|
|
171
177
|
*/
|
|
172
|
-
function lockedRunRoot(parent, name, days, {
|
|
178
|
+
function lockedRunRoot(parent, name, days, { fileFlag = null, fileMode = 0o644, dirFlag = null, rootFlag = null, nested = false } = {}) {
|
|
173
179
|
const root = join(parent, name);
|
|
174
180
|
const locked = join(root, "locked");
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
181
|
+
const dirs = nested ? [join(locked, "deeper"), locked] : [locked];
|
|
182
|
+
mkdirSync(dirs[0], { recursive: true });
|
|
183
|
+
const file = join(dirs[0], "inner.txt");
|
|
184
|
+
writeFileSync(file, "");
|
|
185
|
+
chmodSync(file, fileMode);
|
|
186
|
+
if (fileFlag) execFileSync("chflags", [fileFlag, file]);
|
|
187
|
+
// Deepest first: once a directory is 000, nothing below it can be reached.
|
|
188
|
+
for (const dir of dirs) {
|
|
189
|
+
chmodSync(dir, 0);
|
|
190
|
+
if (dirFlag) execFileSync("chflags", [dirFlag, dir]);
|
|
191
|
+
}
|
|
180
192
|
const when = (Date.now() - days * DAY) / 1000;
|
|
181
193
|
utimesSync(root, when, when);
|
|
194
|
+
if (rootFlag) {
|
|
195
|
+
chmodSync(root, 0);
|
|
196
|
+
execFileSync("chflags", [rootFlag, root]);
|
|
197
|
+
}
|
|
182
198
|
return root;
|
|
183
199
|
}
|
|
184
200
|
|
|
185
201
|
/**
|
|
186
202
|
* Undoes `lockedRunRoot` so the scratch teardown can remove what a prune left.
|
|
187
|
-
*
|
|
188
|
-
*
|
|
203
|
+
* A flagged directory's mode is only changeable once its flag is gone, and a
|
|
204
|
+
* directory below it only reachable once that mode is back, so each directory
|
|
205
|
+
* is unlocked before `find` descends into it.
|
|
189
206
|
*/
|
|
190
207
|
function unlock(parent) {
|
|
191
|
-
spawnSync("sh", ["-c", 'chmod -R u+rwx "$1"; if command -v chflags >/dev/null; then
|
|
208
|
+
spawnSync("sh", ["-c", 'chmod -R u+rwx "$1"; if command -v chflags >/dev/null; then find "$1" -type d -exec chflags nouchg,nouappnd {} ";" -exec chmod u+rwx {} ";"; chflags -R nouchg,nouappnd "$1"; fi; :', "sh", parent], { env: ENV });
|
|
192
209
|
}
|
|
193
210
|
|
|
194
211
|
/**
|
|
@@ -596,8 +613,8 @@ for (const [name, path] of SOURCES) {
|
|
|
596
613
|
// #2322. A fixture that drops its own read bit, or a darwin file carrying the
|
|
597
614
|
// user-immutable flag, made the plain `rm -rf` fail on that root — so it
|
|
598
615
|
// stayed forever and every later review of the PR printed
|
|
599
|
-
// SNAPSHOT_PRUNE_FAILED. The prune now restores owner rwx and clears
|
|
600
|
-
// on the roots it SELECTED, then removes them.
|
|
616
|
+
// SNAPSHOT_PRUNE_FAILED. The prune now restores owner rwx and clears darwin's
|
|
617
|
+
// user flags on the roots it SELECTED, then removes them.
|
|
601
618
|
test(`${name}: the prune removes an aged run root a chmod 000 directory used to wedge`, (t) => {
|
|
602
619
|
const parent = join(scratch(t, "snapshot-repo-prune-perm-"), "pr7");
|
|
603
620
|
const stale = lockedRunRoot(parent, "run-staleaa", 9);
|
|
@@ -640,31 +657,75 @@ for (const [name, path] of SOURCES) {
|
|
|
640
657
|
}
|
|
641
658
|
});
|
|
642
659
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
}
|
|
654
|
-
|
|
660
|
+
// #2322, #2370. On darwin a user flag wedges the removal too: `uchg` refuses
|
|
661
|
+
// every change and `uappnd` refuses the unlink, and on a directory either
|
|
662
|
+
// one also refuses the `chmod` that makes a `000` directory readable again —
|
|
663
|
+
// while `chflags -R` cannot read a `000` directory it has just unflagged. So
|
|
664
|
+
// a directory's flag and mode have to come off together, before anything
|
|
665
|
+
// inside it is read, at every depth and on the run root itself. The `uappnd`
|
|
666
|
+
// file whose mode already grants owner rwx is the shape where `chmod -R`
|
|
667
|
+
// exits 0 over a flag.
|
|
668
|
+
for (const [shape, opts] of [
|
|
669
|
+
["a uchg file inside a chmod 000 directory", { fileFlag: "uchg" }],
|
|
670
|
+
["a uappnd file whose own mode leaves chmod nothing to change", { fileFlag: "uappnd", fileMode: 0o700 }],
|
|
671
|
+
["a directory that is both uchg and chmod 000", { dirFlag: "uchg" }],
|
|
672
|
+
["a directory that is both uappnd and chmod 000", { dirFlag: "uappnd" }],
|
|
673
|
+
["a uchg file inside a directory that is both uchg and chmod 000", { fileFlag: "uchg", dirFlag: "uchg" }],
|
|
674
|
+
["a uchg, chmod 000 directory inside another", { dirFlag: "uchg", nested: true }],
|
|
675
|
+
["its own uchg and chmod 000 bits", { rootFlag: "uchg" }],
|
|
676
|
+
["its own uappnd and chmod 000 bits", { rootFlag: "uappnd" }],
|
|
677
|
+
["its own uchg and chmod 000 bits over a uchg, chmod 000 directory", { rootFlag: "uchg", dirFlag: "uchg" }],
|
|
678
|
+
]) {
|
|
679
|
+
test(`${name}: on darwin the prune removes an aged run root with ${shape}`, { skip: process.platform !== "darwin" && "chflags/uchg/uappnd are darwin's" }, (t) => {
|
|
680
|
+
const parent = join(scratch(t, "snapshot-repo-prune-flags-"), "pr7");
|
|
681
|
+
const stale = lockedRunRoot(parent, "run-staleaa", 9, opts);
|
|
682
|
+
try {
|
|
683
|
+
const r = spawnSync("sh", ["-c", renderPrune(path, parent)], { env: ENV, encoding: "utf8" });
|
|
684
|
+
|
|
685
|
+
assert.doesNotMatch(r.stdout ?? "", /SNAPSHOT_PRUNE_FAILED/, `the prune named a failure on a root it can normalise: ${r.stderr}`);
|
|
686
|
+
assert.equal(r.status, 0, `the prune exited non-zero: ${r.stderr}`);
|
|
687
|
+
assert.ok(!existsSync(stale), `an aged run root with ${shape} survived the prune — it now stays forever and every later review prints SNAPSHOT_PRUNE_FAILED`);
|
|
688
|
+
} finally {
|
|
689
|
+
unlock(parent);
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
}
|
|
655
693
|
|
|
656
|
-
//
|
|
657
|
-
//
|
|
658
|
-
//
|
|
659
|
-
//
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
const
|
|
694
|
+
// GNU find (Linux) refuses an `-exec ... {} +` whose argument list holds the
|
|
695
|
+
// `{}` placeholder more than once — and counts a `{}` written inside an
|
|
696
|
+
// `sh -c` script argument as one. BSD find (darwin) does not, so a dev
|
|
697
|
+
// machine passes a prune that fails every Linux CI run. This shim enforces
|
|
698
|
+
// GNU's rule over the real `find`, so the case holds on either host.
|
|
699
|
+
test(`${name}: the prune's find invocation keeps to GNU find's one-placeholder rule for -exec ... +`, (t) => {
|
|
700
|
+
const parent = join(scratch(t, "snapshot-repo-prune-gnu-"), "pr7");
|
|
701
|
+
const stale = lockedRunRoot(parent, "run-staleaa", 9);
|
|
702
|
+
const bin = scratch(t, "snapshot-repo-prune-gnu-bin-");
|
|
703
|
+
const realFind = execFileSync("sh", ["-c", "command -v find"], { env: ENV, encoding: "utf8" }).trim();
|
|
704
|
+
writeFileSync(
|
|
705
|
+
join(bin, "find"),
|
|
706
|
+
`#!/bin/sh
|
|
707
|
+
# GNU find: with a terminating '+', only one argument may contain {}.
|
|
708
|
+
n=0; plus=0; prev=
|
|
709
|
+
for a in "$@"; do
|
|
710
|
+
case "$a" in *'{}'*) n=$((n + 1));; esac
|
|
711
|
+
[ "$a" = "+" ] && [ "$prev" = '{}' ] && plus=1
|
|
712
|
+
prev=$a
|
|
713
|
+
done
|
|
714
|
+
if [ "$plus" = 1 ] && [ "$n" -gt 1 ]; then
|
|
715
|
+
echo "find: Only one instance of {} is supported with -exec ... +" >&2
|
|
716
|
+
exit 1
|
|
717
|
+
fi
|
|
718
|
+
exec "${realFind}" "$@"
|
|
719
|
+
`,
|
|
720
|
+
{ mode: 0o755 },
|
|
721
|
+
);
|
|
722
|
+
const env = { ...ENV, PATH: `${bin}:${ENV.PATH}` };
|
|
663
723
|
try {
|
|
664
|
-
const r = spawnSync("sh", ["-c", renderPrune(path, parent)], { env
|
|
724
|
+
const r = spawnSync("sh", ["-c", renderPrune(path, parent)], { env, encoding: "utf8" });
|
|
665
725
|
|
|
666
|
-
assert.doesNotMatch(r.stdout ?? "", /SNAPSHOT_PRUNE_FAILED/, `
|
|
667
|
-
assert.
|
|
726
|
+
assert.doesNotMatch(r.stdout ?? "", /SNAPSHOT_PRUNE_FAILED/, `a GNU-rule find rejected the prune: ${r.stderr}`);
|
|
727
|
+
assert.doesNotMatch(r.stderr ?? "", /Only one instance of \{\}/, "the prune's find tripped GNU's one-placeholder rule");
|
|
728
|
+
assert.ok(!existsSync(stale), "the aged run root survived a find that enforces GNU's placeholder rule");
|
|
668
729
|
} finally {
|
|
669
730
|
unlock(parent);
|
|
670
731
|
}
|
package/skills/run-team/SKILL.md
CHANGED
|
@@ -1396,6 +1396,7 @@ for a token on a ticket's line — never a hand edit.
|
|
|
1396
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
|
+
| Finisher report (failed / killed) | `ledger.mjs settle finisher-pr-<M>=failed` for a finisher that crashed or gave up, `=killed` for one that was killed; no label, and the tick prints no dispatch for it. The cockpit flags the PR `finisher:failed` / `finisher:killed` at severity 4 and you resolve it by hand (**Resolving a finisher that died**, below) |
|
|
1399
1400
|
| Label seen (persistent Monitor) | nothing to record |
|
|
1400
1401
|
| CI run terminal | `ci=<run-id>:<attempt>:<conclusion>` on the row; then the finisher gate (below) |
|
|
1401
1402
|
| Merge-bot pass report | `held-behind:#<lower>` rows; `ledger.mjs settle merge-bot-<n>=done`; `reap.sh --apply`. (The bot may also have written `conflict-hold:#<pr>` onto a held PR's own row earlier in this same pass, before reporting — that token is the bot's, never `reap.sh`'s.) |
|
|
@@ -2801,6 +2802,19 @@ review that died before returning. A head that moved past `reviewed=` with no
|
|
|
2801
2802
|
such halt — a fix-applier's push — stays not due: duty 2 verifies what it
|
|
2802
2803
|
applied.
|
|
2803
2804
|
|
|
2805
|
+
**Resolving a finisher that died.** A finisher settled `failed` or `killed` left
|
|
2806
|
+
its PR without `ready-to-merge`, and nothing else notices: the PR is out of the
|
|
2807
|
+
merge queue, and the tick dispatches no finisher for it. The cockpit flags a
|
|
2808
|
+
REVIEW-column open PR without the label, whose latest finisher attempt settled
|
|
2809
|
+
so, `finisher:failed` or `finisher:killed` at severity 4 — one token per
|
|
2810
|
+
outcome, neither the implementer's bare `killed` flag nor `unlabelled`. You
|
|
2811
|
+
resolve it by hand: investigate why the finisher died, or dispatch the
|
|
2812
|
+
next-suffix finisher after the finisher gate, `ledger.mjs dispatch <M>
|
|
2813
|
+
finisher-pr-<M>-<x>`. The flag clears when a later attempt settles — `labelled`
|
|
2814
|
+
(then `unlabelled` takes over while the label is still off), `halted:<cause>`,
|
|
2815
|
+
or a live `-b` — or when the PR carries `ready-to-merge`, merges or closes.
|
|
2816
|
+
You never add the label yourself, and the tick does not retry.
|
|
2817
|
+
|
|
2804
2818
|
Gate on the `check` job, **not** on `ci-state --quiet` exit 0: a behind PR never
|
|
2805
2819
|
reaches full green, so an exit-0 gate strands it unlabelled. The finisher reads
|
|
2806
2820
|
per-job state (`ci-state.mjs` without `--quiet`, or its `jobs`), since `--quiet`
|