@feigi/fleet-ctl 3.17.7 → 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/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/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`
|