@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.17.7",
3
+ "version": "3.18.0",
4
4
  "description": "Agent fleet: run-team controller, merge bot, PR reviewer, and the ticket pipeline they share",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -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, unlabelledFinishers } from "./fleet-tick.mjs";
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 laterAttempt's reading, among the
65
- // finisher tokens bound to the row's own PR: a live `-b` after the halt
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 `finisherOutcome` here — the halt is
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 (parsed.finisherOutcome?.startsWith("halted:") && ctx.column === "REVIEW") flags.push(parsed.finisherOutcome);
298
- if (ctx.unlabelled && ctx.column === "REVIEW") flags.push("unlabelled");
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 unlabelled = new Set(unlabelledFinishers({ rows: ledger.rows || [], dispatched: ledger.dispatched || [] }, unqueued).map((u) => u.pr));
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, unlabelled: unlabelled.has(p.pr) });
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"]],
@@ -370,23 +370,23 @@ export function rowNums(text) {
370
370
  return { keyNum, pr: mention ? Number(mention[1]) : keyNum };
371
371
  }
372
372
 
373
- // #2331: PRs whose finisher settled `labelled` while the open list shows no
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). A miss is the stretch's latest attempt settled
382
- // `labelled`; `labelled` lists every attempt in the stretch that settled so,
383
- // the count the tick splits one repair from an escalation on.
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, labelled: string[]}[]} ascending by PR */
389
- export function unlabelledFinishers({ rows, dispatched }, unqueued) {
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.at(-1)?.outcome !== "labelled") continue;
421
- out.push({ pr: n, labelled: stretch.filter((a) => a.outcome === "labelled").map((a) => a.name) });
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`, // #2359's, not this check's
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 [
@@ -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`