@feigi/fleet-ctl 3.84.0 → 3.86.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.
@@ -15,7 +15,9 @@ Your scratch directory is `<scratch>/`; you write only under
15
15
  `<scratch>/pr<N>/merge-bot-<n>/`.
16
16
 
17
17
  Your report is the one file you write outside it: `<scratch>/reports/merge-bot-<n>.json`,
18
- the path your brief names, holding every PR you merged in `merged`. Your final
18
+ the path your brief names, giving every PR queued when you were dispatched
19
+ exactly one verdict — `merged`, `held`, `conflictHold` or `refused` — as
20
+ run-merge-bot.md's **One verdict per queued PR** says. Your final
19
21
  message is the one line your brief gives, `merge-bot-<n>: done <path>`.
20
22
 
21
23
  The shell traps below are copied from the controller's skill, `run-team`. In
@@ -283,6 +283,7 @@ Measured over one three-merge pass: the next queue member went 0 → 2 → 7 →
283
283
  **A PR whose heavy jobs have only ever `skipped` is getting its first real verification from your rebase.** Reviewers may legitimately have labelled on the checks that did run plus local evidence, saying so explicitly. When your post-rebase run finally executes those suites, treat a red there as a **genuine first result**, not a regression you caused — read the failing job before concluding, and do not hand it back as "the rebase broke it".
284
284
 
285
285
  Report merged / skipped-unlabeled / held-behind-#X / worktree-diverged-#X / head-moved-after-label-#X / rebase-carry-#X (merged or not, with the labelled and accepted SHAs) / main-gain-removed:<path>-#X / label-drop-failed-#X / label-read-failed-#X / branch-delete-failed-#X / branch-kept-#X (X the open PR that keeps the merged branch — a keep, not a failure) / rebase-fallback-#X / instrument-set-changed-#X / ci-wait-timeout-#X (with the run id and its `status`) / `<reason>-#X` for any other gate refusal / blocked after the pass — a dispatched bot once, at exit, after its grace (**Grace, then one report**, below). Every acknowledged main-gain removal the gate passed is named in the report, with its reason.
286
+ In the report file, every PR queued when you were dispatched is listed under exactly one of `merged`, `held`, `conflictHold` or `refused` — those four lists are its verdict, the tokens above are not — and a settle check refuses a report that leaves one out (**One verdict per queued PR**, under **Grace, then one report**).
286
287
 
287
288
  ## No-undo audit (before every rebase)
288
289
 
@@ -324,6 +325,8 @@ The wait:
324
325
 
325
326
  **One report, at exit, and none before it** — every PR this pass touched, in the vocabulary above, a pass that merged nothing included. The controller reaps on that report and records each `held-behind-#<lower>` on the held ticket's row, so a report sent mid-pass is one it acts on too early.
326
327
 
328
+ **One verdict per queued PR, in the report file.** A dispatched bot's report file (`<scratch>/reports/merge-bot-<n>.json`) gives every PR that carried `ready-to-merge` when you were dispatched exactly one verdict — the last it reached in the pass: `merged`, `held` (a `held-behind-#<lower>`), `conflictHold` (a `conflict-held-#<pr>`, or a `rebase-fallback-#<pr>` you recorded as a conflict hold), or `refused` with the token above as its `reason` (`main-gain-removed:<path>`, `head-moved-after-label`, `ci-wait-timeout`, any other gate refusal). A PR you never reached is still `refused`, with the reason you stopped short of it; a PR refused `behind:<n>` and merged later in the same pass is only `merged`. `branchKept` is not a verdict. Two checks enforce this, not the rule alone: `report-schema.mjs validate merge-bot` answers `invalid` for a PR named twice, in one list or in two, and for a `refused` entry whose `reason` is empty or only whitespace; and `ledger.mjs settle merge-bot-<n> --from-report`, which `fleet-event.mjs merge-bot-report` runs, judges the report against the queue `ledger.mjs dispatch merge-bot` recorded on your `## Dispatched` entry — never a list your report supplies — and refuses it, naming each PR, when a PR from that queue that is still open and carries `ready-to-merge` has no verdict. A PR labelled after your dispatch, or one that left the queue before the settle, never fails it. Each `refused` entry lands in the ledger's `## Ruled` as `merge-bot-<n> refused #<pr>: <reason>`, the reason trimmed, a record only: it moves no label and holds nothing.
329
+
327
330
  Your `task` result is the report, delivered when you exit.
328
331
 
329
332
  ## Then stay armed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.84.0",
3
+ "version": "3.86.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": {
@@ -45,8 +45,8 @@
45
45
  // THE PROOF IT IS ARMED. At `session_start`, for the main session only, and
46
46
  // only AFTER the timer is set, it writes `~/.fleet/alarm/<pid>.json` holding
47
47
  // `{ pid, lstart, fleet }`: this process's pid, its start time as `proc.mjs`
48
- // reads it, and the `.fleet/` path it resolved. The file is for run-team's
49
- // phase 0 to check, and nothing reads it yet: this file only writes it. A
48
+ // reads it, and the `.fleet/` path it resolved. Phase 0's `alarm` step reads
49
+ // the file and refuses unless it names this omp and this `.fleet/`. A
50
50
  // session that set no timer writes no file, so the file proves the timer is
51
51
  // armed against that repository's `.fleet/` and not only that the extension
52
52
  // loaded. A start time that cannot be read writes no file, the safe direction:
@@ -59,13 +59,15 @@
59
59
 
60
60
  import { mkdirSync, writeFileSync } from "node:fs";
61
61
  import { homedir } from "node:os";
62
- import { join } from "node:path";
62
+ import { dirname, join } from "node:path";
63
63
  import { fleetFile, FleetDirUnresolvable } from "./fleet-dir.mjs";
64
64
  import { readState, stateFileIn } from "./fleet-state.mjs";
65
65
  import { processSource } from "./proc.mjs";
66
66
 
67
67
  export const POLL_MS = 30_000;
68
68
  export const ALARM_LINE = "[fleet-alarm] run ~/.fleet/bin/fleet-run fleet-event.mjs heartbeat";
69
+ // Where the extension running as `pid` records that its timer is armed; phase 0 reads it there.
70
+ export const alarmFile = (pid) => join(homedir(), ".fleet", "alarm", `${pid}.json`);
69
71
 
70
72
  /**
71
73
  * Whether the alarm fires now. `beat` is `{ at, interval, stopped }` (`at` in
@@ -132,7 +134,7 @@ function arm(pi, ctx) {
132
134
  // An unreadable process table is an unreadable start time.
133
135
  }
134
136
  if (typeof lstart !== "string" || lstart === "") return;
135
- const dir = join(homedir(), ".fleet", "alarm");
136
- mkdirSync(dir, { recursive: true });
137
- writeFileSync(join(dir, `${process.pid}.json`), `${JSON.stringify({ pid: process.pid, lstart, fleet: fleetDir })}\n`);
137
+ const file = alarmFile(process.pid);
138
+ mkdirSync(dirname(file), { recursive: true });
139
+ writeFileSync(file, `${JSON.stringify({ pid: process.pid, lstart, fleet: fleetDir })}\n`);
138
140
  }
@@ -117,27 +117,37 @@ export function memberRole(family) {
117
117
 
118
118
  // A `## Dispatched` entry is a member token, followed by `brief=<hash>` when
119
119
  // `ledger.mjs dispatch` recorded the brief the member was dispatched with:
120
- // the first 8 hex of the sha256 the brief's sidecar carries. `settle`
121
- // rewrites the token and keeps the word beside it.
122
- const BRIEF_WORD = /^brief=([0-9a-f]{8})$/;
120
+ // the first 8 hex of the sha256 the brief's sidecar carries. A merge bot's
121
+ // entry then carries `queued=<pr>,<pr>…` — `queued=none` for an empty queue —
122
+ // the open `ready-to-merge` PRs `dispatch` read when it dispatched the bot,
123
+ // which its report is judged against at `settle --from-report`. `settle`
124
+ // rewrites the token and keeps the words beside it.
125
+ const DISPATCHED_ENTRY = /^([^ ]*) brief=([0-9a-f]{8})(?: queued=(none|[1-9][0-9]*(?:,[1-9][0-9]*)*))?$/;
123
126
 
124
127
  /**
125
- * `{token, brief}` for one `## Dispatched` entry: the member token and the
126
- * brief's hash, or null for an entry without one. An entry that is not a
127
- * member token plus one well-formed `brief=` word is returned whole as
128
- * `token`, so a reader parsing it with parseToken() refuses it exactly as it
129
- * refuses any other entry this grammar cannot parse.
128
+ * `{token, brief, queued}` for one `## Dispatched` entry: the member token,
129
+ * the brief's hash or null, and the PR numbers of its `queued=` word or null
130
+ * for an entry without one. An entry that is not a member token plus a
131
+ * well-formed `brief=` word, optionally followed by a well-formed `queued=`
132
+ * word, is returned whole as `token`, so a reader parsing it with
133
+ * parseToken() refuses it exactly as it refuses any other entry this grammar
134
+ * cannot parse.
130
135
  */
131
136
  export function parseDispatchedEntry(entry) {
132
- const words = entry.split(" ");
133
- const m = words.length === 2 ? BRIEF_WORD.exec(words[1]) : null;
134
- if (m === null || parseToken(words[0]) === null) return { token: entry, brief: null };
135
- return { token: words[0], brief: m[1] };
137
+ const m = DISPATCHED_ENTRY.exec(entry);
138
+ if (m === null || parseToken(m[1]) === null) return { token: entry, brief: null, queued: null };
139
+ const queued = m[3] === undefined ? null : m[3] === "none" ? [] : m[3].split(",").map(Number);
140
+ return { token: m[1], brief: m[2], queued };
136
141
  }
137
142
 
138
- /** The `## Dispatched` entry for `token`, with `brief=<brief>` unless brief is null. */
139
- export function dispatchedEntry(token, brief) {
140
- return brief === null ? token : `${token} brief=${brief}`;
143
+ /**
144
+ * The `## Dispatched` entry for `token`, with `brief=<brief>` unless brief is
145
+ * null and, after it, `queued=…` unless queued is null.
146
+ */
147
+ export function dispatchedEntry(token, brief, queued = null) {
148
+ if (brief === null) return token;
149
+ const entry = `${token} brief=${brief}`;
150
+ return queued === null ? entry : `${entry} queued=${queued.join(",") || "none"}`;
141
151
  }
142
152
 
143
153
  // `PR#<n>` as a word of a row's text, `PR #<n>` too.
@@ -13,7 +13,9 @@
13
13
  // every liveness count from this file alone, rather than from row text a
14
14
  // controller typed by hand: `dispatch` writes a member's live token onto its
15
15
  // row and appends it to `## Dispatched` with `brief=<hash>`, the brief it
16
- // refuses to dispatch without; when that brief's sidecar names an absolute
16
+ // refuses to dispatch without — and, for a merge bot, `queued=<pr>,…`, the
17
+ // open `ready-to-merge` PRs gh lists at dispatch, which its report must give
18
+ // a verdict each; when that brief's sidecar names an absolute
17
19
  // scratch, it also clears the member's report file there, so a prior run's
18
20
  // report is not read as this run's; `settle` rewrites that token to
19
21
  // `<member>=<outcome>` in both places (an implementer's settle to `PR#M` also
@@ -59,6 +61,7 @@ import { stateFileIn, readState, writeState, assessBeat, stallsAt } from "./flee
59
61
  import { processSource, ancestry, controllerOf, judgeController, ProcUnreadable } from "./proc.mjs";
60
62
  import { attemptLock, releaseLockFile, reapPathOf } from "./pid-lock.mjs";
61
63
  import { readCheckRecordIn, readReportIn, reportsDir, reportFamily, defaultScratch, reportPath } from "./report-files.mjs";
64
+ import { mergeBotVerdicts } from "./report-schema.mjs";
62
65
 
63
66
  const NAME = "ledger";
64
67
  // The plugin's own agent definitions — the directory tier-check.mjs reads
@@ -494,7 +497,7 @@ function sectionOf(text, name) {
494
497
  }
495
498
 
496
499
  function load() {
497
- if (!existsSync(file)) return { rows: [], filed: [], ruled: [], dispatched: [], briefs: {}, drain: null, retired: [] };
500
+ if (!existsSync(file)) return { rows: [], filed: [], ruled: [], dispatched: [], briefs: {}, queued: {}, drain: null, retired: [] };
498
501
  let text;
499
502
  try {
500
503
  text = readFileSync(file, "utf8");
@@ -526,15 +529,14 @@ function load() {
526
529
  }
527
530
 
528
531
  // `## Dispatched` held as its tokens, which is what every reader of `read`
529
- // parses, and `briefs`, each member's `brief=` hash by name: one token per
530
- // member name for the session, so a settle that rewrites the token keeps the
531
- // name its brief is filed under. save() joins them back.
532
+ // parses, `briefs`, each member's `brief=` hash by name, and `queued`, each
533
+ // merge bot's `queued=` PR numbers by name: one token per member name for
534
+ // the session, so a settle that rewrites the token keeps the name its brief
535
+ // and its queue are filed under. save() joins them back.
532
536
  function dispatchedOf(entries) {
533
537
  const parsed = entries.map(parseDispatchedEntry);
534
- return {
535
- dispatched: parsed.map((p) => p.token),
536
- briefs: Object.fromEntries(parsed.filter((p) => p.brief !== null).map((p) => [parseToken(p.token).name, p.brief])),
537
- };
538
+ const byName = (field) => Object.fromEntries(parsed.filter((p) => p[field] !== null).map((p) => [parseToken(p.token).name, p[field]]));
539
+ return { dispatched: parsed.map((p) => p.token), briefs: byName("brief"), queued: byName("queued") };
538
540
  }
539
541
 
540
542
  // `## Drain` is written only once there is a marker to hold: an empty section
@@ -547,7 +549,10 @@ function save(d) {
547
549
  const list = (entries) => entries.map((r) => `- ${escapeText(r)}`).join("\n");
548
550
  const out =
549
551
  `# Fleet run ledger\n\n${ROWS}\n\n` + list(d.rows) +
550
- `\n\n${DISPATCHED}\n\n` + list(d.dispatched.map((t) => dispatchedEntry(t, d.briefs[parseToken(t)?.name] ?? null))) +
552
+ `\n\n${DISPATCHED}\n\n` + list(d.dispatched.map((t) => {
553
+ const name = parseToken(t)?.name;
554
+ return dispatchedEntry(t, d.briefs[name] ?? null, d.queued[name] ?? null);
555
+ })) +
551
556
  `\n\n${FILED}\n\n` + list(d.filed) +
552
557
  `\n\n${RULED}\n\n` + list(d.ruled) +
553
558
  (d.drain === null ? "" : `\n\n${DRAIN}\n\n` + list([d.drain])) +
@@ -694,6 +699,9 @@ const WRITE_COMMANDS = new Set(["row", "token", "filed", "ruled", "dispatch", "s
694
699
  // `settle --from-report` takes the lock itself, once its checks have run —
695
700
  // see runSettleFromReport().
696
701
  const FROM_REPORT = cmd === "settle" && rest.includes("--from-report");
702
+ // A merge bot's dispatch reads the queue it records before the lock, for the
703
+ // same reason: a gh call held under the lock stalls every other writer.
704
+ const QUEUED = cmd === "dispatch" && rest.length === 1 && /^merge-bot(-[0-9]+)?$/.test(rest[0]) ? queueSnapshot() : null;
697
705
  if (WRITE_COMMANDS.has(cmd) && !FROM_REPORT) await acquireLock();
698
706
  // `rotate` never load()s: a ledger load() would refuse is still the previous
699
707
  // run's record, and it is archived as-is — and read line by line when it
@@ -1630,7 +1638,7 @@ function runRotate() {
1630
1638
  }
1631
1639
  // Read before anything moves: a ledger whose live tokens cannot be read is
1632
1640
  // refused with nothing moved, never archived with those tokens dropped.
1633
- let carry = { rows: [], dispatched: [], briefs: {}, retired: [], skipped: [] };
1641
+ let carry = { rows: [], dispatched: [], briefs: {}, queued: {}, retired: [], skipped: [] };
1634
1642
  if (prior === "ancestor" && exists) {
1635
1643
  let text;
1636
1644
  try {
@@ -1691,7 +1699,7 @@ function runRotate() {
1691
1699
  // Nothing to carry writes nothing, so the next write starts the skeleton
1692
1700
  // exactly as after any other verdict.
1693
1701
  if (carrying) {
1694
- save({ rows: carry.rows, dispatched: carry.dispatched, briefs: carry.briefs, filed: [], ruled: [], drain: null, retired: carry.retired });
1702
+ save({ ...carry, filed: [], ruled: [], drain: null });
1695
1703
  }
1696
1704
  if (archive !== null) console.error(` rotated ${file} -> ${archive}`);
1697
1705
  for (const { line, why } of carry.skipped) {
@@ -1751,10 +1759,11 @@ function carryOf(text) {
1751
1759
  return false;
1752
1760
  }
1753
1761
  });
1754
- // An entry's `brief=` travels with it: save() writes a brief only beside
1755
- // the token it writes, so a carried member's record still names the brief
1756
- // it was dispatched with and a dropped member's does not.
1757
- const { dispatched: allDispatched, briefs: allBriefs } = dispatchedOf(sectionOf(text, DISPATCHED));
1762
+ // An entry's `brief=` and `queued=` travel with it: save() writes them only
1763
+ // beside the token it writes, so a carried member's record still names the
1764
+ // brief it was dispatched with — and a carried merge bot the queue it is
1765
+ // judged against — and a dropped member's does not.
1766
+ const { dispatched: allDispatched, briefs: allBriefs, queued: allQueued } = dispatchedOf(sectionOf(text, DISPATCHED));
1758
1767
  const dispatched = allDispatched.filter((e) => {
1759
1768
  const t = parseToken(e);
1760
1769
  if (t !== null && t.error === null) return true;
@@ -1777,7 +1786,7 @@ function carryOf(text) {
1777
1786
  const live = liveOnLedger(rows, dispatched);
1778
1787
  const carried = { rows: live.rows.map((i) => rows[i]), dispatched: live.dispatched };
1779
1788
  const retired = retiredOnLedger([...rows, ...skippedRows], allDispatched, archivedRetired, carried);
1780
- return { ...carried, briefs: allBriefs, retired, skipped };
1789
+ return { ...carried, briefs: allBriefs, queued: allQueued, retired, skipped };
1781
1790
  }
1782
1791
 
1783
1792
  // ── dispatch / settle ────────────────────────────────────────────────────────
@@ -1884,6 +1893,37 @@ function dispositionsRefusal(pr, review) {
1884
1893
  return null;
1885
1894
  }
1886
1895
 
1896
+ // The open `ready-to-merge` PRs, ascending, as gh answers from the ledger's
1897
+ // own directory with runCheck()'s environment: the queue a merge bot is
1898
+ // dispatched against, recorded on its `## Dispatched` entry. Anything but a
1899
+ // JSON array of PR numbers, or an answer filling the limit — gh stops there
1900
+ // at exit 0 — could not read the queue: refused, nothing written.
1901
+ function queueSnapshot() {
1902
+ const limit = 1000;
1903
+ const what = `gh pr list --state open --label ready-to-merge --limit ${limit} --json number`;
1904
+ const env = { ...process.env, GH_REPO: "" };
1905
+ delete env.GIT_DIR;
1906
+ delete env.GIT_WORK_TREE;
1907
+ let dir = dirname(resolve(file));
1908
+ while (!existsSync(dir) && dirname(dir) !== dir) dir = dirname(dir);
1909
+ const r = spawnSync("gh", what.split(" ").slice(1), { encoding: "utf8", cwd: dir, env, timeout: 60_000, stdio: ["ignore", "pipe", "pipe"] });
1910
+ const refuse = (why) => die(`dispatch merge-bot: could not read the queue to record — ${what}${why}; nothing written`);
1911
+ if (r.status !== 0) {
1912
+ const why = cause(r.error && r.error.message, r.stderr);
1913
+ refuse(`${r.error ? "" : r.signal ? ` killed by ${r.signal}` : ` exited ${r.status}`}${why ? `: ${why}` : ""}`);
1914
+ }
1915
+ let list = null;
1916
+ try {
1917
+ list = JSON.parse(r.stdout);
1918
+ } catch {
1919
+ // Refused below.
1920
+ }
1921
+ const prs = Array.isArray(list) ? list.map((p) => p?.number) : null;
1922
+ if (prs === null || !prs.every((n) => Number.isSafeInteger(n) && n > 0)) refuse(` printed no list of PR numbers: ${cause(r.stdout)}`);
1923
+ if (prs.length >= limit) refuse(` answered ${prs.length} PRs, the limit, so the queue may be longer`);
1924
+ return [...new Set(prs)].sort((a, b) => a - b);
1925
+ }
1926
+
1887
1927
  function runDispatch() {
1888
1928
  const usage = "usage: ledger.mjs dispatch <ticket|pr> <member>, or dispatch merge-bot";
1889
1929
  if (rest.length === 0 || rest.length > 2) die(usage);
@@ -2055,10 +2095,14 @@ function runDispatch() {
2055
2095
  }
2056
2096
  data.dispatched.push(member.name);
2057
2097
  data.briefs[member.name] = brief.sha256.slice(0, 8);
2098
+ // A merge bot's queue is recorded here, beside its brief, where nothing in
2099
+ // its report can reach: `settle --from-report` judges the report against it.
2100
+ if (QUEUED !== null) data.queued[member.name] = QUEUED;
2058
2101
  save(data);
2059
2102
  console.error(` dispatched ${member.name}`);
2060
2103
  console.log(JSON.stringify({
2061
2104
  member: member.name, agent, ticket: line === null ? null : rowKey(line), line, created, total: data.dispatched.length, brief,
2105
+ queued: QUEUED,
2062
2106
  }));
2063
2107
  }
2064
2108
 
@@ -2292,7 +2336,11 @@ function recordFiled(id, subject) {
2292
2336
  // the report's labelsReadBack
2293
2337
  // finisher-pr-M=halted:<c> cause is in the halted:<cause> vocabulary and
2294
2338
  // evidence is not empty
2295
- // merge-bot-n=done every merged[].pr reads MERGED
2339
+ // merge-bot-n=done every merged[].pr reads MERGED; every PR the
2340
+ // bot's `## Dispatched` entry records as queued=
2341
+ // at dispatch has a verdict in merged, held,
2342
+ // conflictHold or refused, or no longer reads
2343
+ // OPEN with ready-to-merge
2296
2344
  //
2297
2345
  // THE CHECK-STEP RECORD, for `impl-N=PR#M` and `fix-pr-M=applied:<head>`:
2298
2346
  // recipe-check.mjs writes `<reports>/<member>.check.json` on the member's
@@ -2312,15 +2360,21 @@ function recordFiled(id, subject) {
2312
2360
  // stderr names each failed check; nothing written. Exit 2: the report is
2313
2361
  // absent, unreadable or invalid, a check could not look (a `gh`, `git` or
2314
2362
  // verify-sha.sh call that failed, printed what it was not asked for, or never
2315
- // finished), or a refusal the bare form makes; nothing written either.
2363
+ // finished), a merge bot's `## Dispatched` entry records no queued=, or a
2364
+ // refusal the bare form makes; nothing written either.
2316
2365
  //
2317
2366
  // After the settle, in the same write: every `unrecorded` entry goes to `##
2318
- // Filed` the way `filed` records one, and for a fix-applier every `deferred`
2367
+ // Filed` the way `filed` records one, for a fix-applier every `deferred`
2319
2368
  // entry in the `refuted` bucket goes to `## Ruled` as `#M reversed refutation
2320
- // <index>: <reason>`. A line already there is not written again, so a second
2321
- // run of the same settle writes nothing. The receipt on stdout is `{member,
2322
- // outcome, checks: [{name, ok}], filed, ruled}`, `filed` and `ruled` the
2323
- // lines this call added.
2369
+ // <index>: <reason>`, and for a merge bot every `refused` entry goes to `##
2370
+ // Ruled` as `merge-bot-n refused #<pr>: <reason>`, the reason trimmed — a
2371
+ // record, which moves no label and writes no row token. It is led by the bot,
2372
+ // never by `#<pr>`: compute-board.mjs reads a `## Ruled` line led by `#<n>` as
2373
+ // the controller's ruling on PR n, the last such line winning, so a refusal
2374
+ // led by its PR would replace that ruling on the board. A line already there
2375
+ // is not written again, so a second run of the same settle writes nothing.
2376
+ // The receipt on stdout is `{member, outcome, checks: [{name, ok}], filed,
2377
+ // ruled}`, `filed` and `ruled` the lines this call added.
2324
2378
  //
2325
2379
  // The checks run before the write lock is taken, against the ledger as read
2326
2380
  // without it, because they are network calls and every other writer waits
@@ -2558,6 +2612,22 @@ async function runSettleFromReport() {
2558
2612
  } else {
2559
2613
  outcome = "done";
2560
2614
  for (const { pr } of report.merged) check(`PR #${pr} reads MERGED`, prView(pr, "state").state === "MERGED");
2615
+ // Every PR queued when the bot was dispatched has a verdict, unless it has
2616
+ // left the queue since — merged by someone else, closed or unlabelled. The
2617
+ // queue is the one `dispatch` recorded, never one the report names, so a
2618
+ // PR the report leaves out cannot shrink it; a PR labelled after dispatch
2619
+ // is not in it.
2620
+ const queued = data.queued[member];
2621
+ if (queued === undefined) {
2622
+ die(`${where}: ## Dispatched records no queued= for ${member}, so whether its report gives every PR queued at dispatch a verdict cannot be judged — settle it by hand; nothing written`);
2623
+ }
2624
+ const given = new Set(mergeBotVerdicts(report).map((v) => v.pr));
2625
+ for (const pr of queued.filter((n) => !given.has(n))) {
2626
+ const v = prView(pr, "state,labels");
2627
+ const left = v.state !== "OPEN" || !v.labels.some((l) => l?.name === ready);
2628
+ check(`PR #${pr}, queued at dispatch, has a verdict in merged, held, conflictHold or refused, or has left the queue`, left);
2629
+ }
2630
+ for (const { pr, reason } of report.refused) ruledLines.push(`${member} refused #${pr}: ${reason.trim()}`);
2561
2631
  }
2562
2632
 
2563
2633
  const failed = checks.filter((c) => !c.ok);
@@ -18,7 +18,7 @@ const isRecord = (x) => x !== null && typeof x === "object" && !Array.isArray(x)
18
18
  // a step not yet run being `pending`. A receipt missing one was not written
19
19
  // by a finished phase 0.
20
20
  export const STEP_NAMES = Object.freeze([
21
- "branch", "fast-forward", "pin", "baseline", "preflight", "ruleset", "tier-roles", "metrics",
21
+ "alarm", "branch", "fast-forward", "pin", "baseline", "preflight", "ruleset", "tier-roles", "metrics",
22
22
  "cockpit", "rotate", "reclaim", "recipe", "fold-in", "stranded", "shortlist",
23
23
  ]);
24
24
 
@@ -20,6 +20,9 @@
20
20
  // reclaim straight after the rotation, the metrics refresh before the first
21
21
  // Pull is routed:
22
22
  //
23
+ // alarm `~/.fleet/alarm/<pid>.json` names this omp's pid and start time
24
+ // and this `.fleet/`. Missing, malformed or mismatched, or no omp
25
+ // above the script, refuses: restart omp.
23
26
  // branch `git symbolic-ref -q HEAD` is refs/heads/main. A checkout left
24
27
  // on a branch whose PR has merged would fast-forward that branch
25
28
  // and the baseline would record it as the run's ref.
@@ -82,29 +85,31 @@
82
85
  // is the In-flight bound this run used — `--in-flight`, or its default.
83
86
  //
84
87
  // RESUMABLE. A re-run reads the receipt. It continues it — skipping every `ok`
85
- // step and re-running the rest — only when its `run` equals the `at` the
86
- // controller record holds now AND its `session` equals this run's session. That
87
- // comparison alone does not separate two runs before the new run's rotation:
88
- // the record still carries the previous run's `at` until `ledger.mjs rotate`
89
- // writes the next, so the previous run's receipt still matches it, and a
90
- // re-run that trusted `run` alone would skip `rotate`. The session rule closes
91
- // a new session. A receipt of any other run (a `run` that is set and does not
92
- // match, or a different session) is held as `phase0.previous.json`, where
93
- // `closeout.mjs finish` also leaves a finished run's receipt, and the
94
- // script starts at `branch`; this run's own receipt is written at
95
- // `phase0.json` after every step. A receipt whose rotation has not run is
96
- // simply overwritten: every step before `rotate` is idempotent and is re-run
97
- // on every invocation until the rotation has run.
88
+ // step but `alarm` and re-running the rest — only when its `run` equals the
89
+ // `at` the controller record holds now AND its `session` equals this run's
90
+ // session. That comparison alone does not separate two runs before the new
91
+ // run's rotation: the record still carries the previous run's `at` until
92
+ // `ledger.mjs rotate` writes the next, so the previous run's receipt still
93
+ // matches it, and a re-run that trusted `run` alone would skip `rotate`. The
94
+ // session rule closes a new session. `alarm` is re-run on every invocation,
95
+ // `ok` or not: it proves something about the omp running this invocation, and
96
+ // a resumed receipt may have been written under another. A receipt of any
97
+ // other run (a `run` that is set and does not match, or a different session)
98
+ // is held as `phase0.previous.json`, where `closeout.mjs finish` also leaves a
99
+ // finished run's receipt, and the script starts at `alarm`; this run's own
100
+ // receipt is written at `phase0.json` after every step. A receipt whose
101
+ // rotation has not run is simply overwritten: every step before `rotate` is
102
+ // idempotent and is re-run on every invocation until the rotation has run.
98
103
  //
99
104
  // THE ROTATION FILES THE PREVIOUS RUN'S RECEIPT. The `rotate` step takes this
100
105
  // run's partial receipt off disk, puts the held one back at `phase0.json` and
101
106
  // runs `ledger.mjs rotate`, which archives it as `phase0.<UTC stamp>.json`
102
107
  // beside `ledger.<UTC stamp>.md`, one stamp for both. A rotation that refused
103
108
  // leaves it held for the next attempt; one that archived the ledger and not
104
- // the receipt files it under a stamp of this script's own. With no omp process
105
- // above the run the rotation writes no controller record and `run` stays
106
- // null; a receipt whose `rotate` step is `ok` is then resumed on the session
107
- // alone.
109
+ // the receipt files it under a stamp of this script's own. A rotation that
110
+ // writes no controller record (the ledger finds no omp above itself) leaves
111
+ // `run` null; a receipt whose `rotate` step is `ok` is then resumed on the
112
+ // session alone.
108
113
  //
109
114
  // A step that fails stops the script; so does a pending one — nothing after
110
115
  // the recipe may run before it is proven, and the shortlist waits for the
@@ -133,16 +138,19 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync,
133
138
  import { dirname, join, resolve } from "node:path";
134
139
  import { fileURLToPath } from "node:url";
135
140
  import { makeDie, defineFlags, isDigits } from "./arg.mjs";
141
+ import { alarmFile } from "./controller-alarm.mjs";
136
142
  import { FleetDirUnresolvable, fleetFile } from "./fleet-dir.mjs";
137
143
  import { readState, stateFileIn } from "./fleet-state.mjs";
138
144
  import { gitEnv } from "./git-env.mjs";
139
145
  import { isCLI } from "./is-cli.mjs";
140
146
  import { RECEIPT, RECEIPT_HELD, archiveStamp, receiptArchiveName, rowNums } from "./ledger-grammar.mjs";
147
+ import { ancestry, controllerOf, processSource, ProcUnreadable } from "./proc.mjs";
141
148
  import { IN_FLIGHT_DEFAULT } from "./phase0-receipt.mjs";
142
149
 
143
150
  const NAME = "phase0";
144
151
  const SCRIPTS = dirname(fileURLToPath(import.meta.url));
145
152
  const MAX_BUFFER = 64 * 1024 * 1024;
153
+ const isRecord = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
146
154
  const NETWORK_TIMEOUT_MS = 120_000;
147
155
  const PR_LIMIT = 1000;
148
156
  // The tail of a child's output is the part that says why it stopped.
@@ -419,8 +427,46 @@ function shortlist({ cwd }) {
419
427
  return ok("shortlist built");
420
428
  }
421
429
 
430
+ // The controller alarm: this omp's own record that its timer is armed against
431
+ // this checkout's `.fleet/` (controller-alarm.mjs writes it at session_start).
432
+ // The record is read, not trusted: it must name this omp's pid and start time
433
+ // and this `.fleet/`.
434
+ function alarm({ fleetDir }) {
435
+ const tail = " — restart omp";
436
+ let controller;
437
+ try {
438
+ const source = processSource();
439
+ controller = controllerOf(source, ancestry(source));
440
+ } catch (e) {
441
+ if (!(e instanceof ProcUnreadable)) throw e;
442
+ return failed(`${e.message}${tail}`);
443
+ }
444
+ if (controller === null) return failed(`no omp process above phase 0: the controller alarm cannot be checked${tail}`);
445
+ const path = alarmFile(controller.pid);
446
+ let text;
447
+ try {
448
+ text = readFileSync(path, "utf8");
449
+ } catch (e) {
450
+ if (e.code === "ENOENT") return failed(`controller alarm is not armed: ${path} does not exist${tail}`);
451
+ return failed(`controller alarm file ${path} cannot be read (${e.code ?? e.message})${tail}`);
452
+ }
453
+ let rec;
454
+ try {
455
+ rec = JSON.parse(text);
456
+ } catch (e) {
457
+ return failed(`controller alarm file ${path} is not JSON (${e.message})${tail}`);
458
+ }
459
+ if (!isRecord(rec)) return failed(`controller alarm file ${path} is not an object${tail}`);
460
+ const missing = ["pid", "lstart", "fleet"].filter((k) => !Object.hasOwn(rec, k));
461
+ if (missing.length > 0) return failed(`controller alarm file ${path} lacks ${missing.join(", ")}${tail}`);
462
+ if (rec.pid !== controller.pid) return failed(`controller alarm file ${path} names pid ${rec.pid}, but this omp is pid ${controller.pid}${tail}`);
463
+ if (rec.lstart !== controller.lstart) return failed(`controller alarm file ${path} was written by a process started at "${rec.lstart}", but this omp started at "${controller.lstart}"${tail}`);
464
+ if (rec.fleet !== fleetDir) return failed(`controller alarm file ${path} names fleet "${rec.fleet}", not ${fleetDir}${tail}`);
465
+ return ok(`controller alarm armed: pid ${controller.pid}, ${fleetDir}`);
466
+ }
467
+
422
468
  const STEPS = {
423
- "branch": branch, "fast-forward": fastForward, "pin": pin, "baseline": baseline, "preflight": preflight,
469
+ "alarm": alarm, "branch": branch, "fast-forward": fastForward, "pin": pin, "baseline": baseline, "preflight": preflight,
424
470
  "ruleset": ruleset, "tier-roles": tierRoles, "metrics": metrics, "cockpit": cockpit, "rotate": rotate,
425
471
  "reclaim": reclaim, "recipe": recipe, "fold-in": foldIn, "stranded": stranded, "shortlist": shortlist,
426
472
  };
@@ -445,8 +491,7 @@ function readReceipt(path) {
445
491
  } catch (e) {
446
492
  throw new Error(`the receipt ${path} is not JSON (${e.message}) — move it aside and re-run`);
447
493
  }
448
- const record = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
449
- if (!(record(r) && record(r.steps) && Object.hasOwn(r, "run") && typeof r.session === "string")) {
494
+ if (!(isRecord(r) && isRecord(r.steps) && Object.hasOwn(r, "run") && typeof r.session === "string")) {
450
495
  throw new Error(`the receipt ${path} has no steps, run and session — move it aside and re-run`);
451
496
  }
452
497
  return r;
@@ -548,9 +593,9 @@ function main() {
548
593
  return;
549
594
  }
550
595
  // A receipt belongs to a rotated run when the rotation wrote a controller
551
- // record it can be keyed on, or, with no omp process above the run to write
552
- // one, when its rotation is recorded `ok`. Either way it is this run's only
553
- // if the record now holds the `at` it names and the session is the same.
596
+ // record it can be keyed on, or, when it wrote none, when its rotation is
597
+ // recorded `ok`. Either way it is this run's only if the record now holds
598
+ // the `at` it names and the session is the same.
554
599
  const rotated = previous !== null && (previous.run !== null || previous.steps.rotate?.status === "ok");
555
600
  const resumed = rotated && previous.run === (record?.at ?? null) && previous.session === session;
556
601
  if (rotated && !resumed) {
@@ -564,7 +609,8 @@ function main() {
564
609
 
565
610
  const receipt = { run: resumed ? previous.run : null, session, scratch, inFlight, steps: {} };
566
611
  for (const name of STEP_NAMES) {
567
- const was = resumed ? previous.steps[name] : undefined;
612
+ // `alarm` checks the omp running this invocation, so its `ok` is never carried.
613
+ const was = resumed && name !== "alarm" ? previous.steps[name] : undefined;
568
614
  receipt.steps[name] = was?.status === "ok" ? was : { status: "pending", at: now, output: "not run" };
569
615
  }
570
616
 
@@ -6,8 +6,9 @@
6
6
  // (flat: no per-run directory), `<member>` being its ledger name (`impl-412`,
7
7
  // `impl-412-b`, `fix-pr-88`, `finisher-pr-88`, `merge-bot-3`). The file is
8
8
  // checked against the schema of the member's family, report-schema.mjs's
9
- // SCHEMAS. The review runner and every member outside those four families
10
- // have no schema, so no file to look up.
9
+ // SCHEMAS, and the rule reportViolation() adds to it. The review runner and
10
+ // every member outside those four families have no schema, so no file to
11
+ // look up.
11
12
  //
12
13
  // The Check-step record beside a report, `<reports>/<member>.check.json`, is
13
14
  // looked up here the same way: recipe-check.mjs writes it, and this module
@@ -18,7 +19,7 @@
18
19
 
19
20
  import { readdirSync, readFileSync } from "node:fs";
20
21
  import { join } from "node:path";
21
- import { SCHEMAS, parseReport, validate } from "./report-schema.mjs";
22
+ import { parseReport, reportViolation } from "./report-schema.mjs";
22
23
  import { parseMember } from "./ledger-grammar.mjs";
23
24
  import { scratchRoot } from "./fleet-dir.mjs";
24
25
 
@@ -113,7 +114,7 @@ export function readReportIn(dir, member) {
113
114
  const read = readAt(path);
114
115
  if (read.text === undefined) return read;
115
116
  const parsed = parseReport(read.text);
116
- const violation = parsed.violation ?? validate(SCHEMAS[family], parsed.value);
117
+ const violation = parsed.violation ?? reportViolation(family, parsed.value);
117
118
  return violation === null ? { state: "ok", path, report: parsed.value } : { state: "invalid", path, violation };
118
119
  }
119
120
 
@@ -18,7 +18,9 @@
18
18
  // 0 print: the schema is on stdout. validate: `valid` on stdout
19
19
  // 1 validate: `invalid: <violation>` on stdout, naming the FIRST violation;
20
20
  // a file that is not JSON is invalid too, as `$: not JSON (...)`, and so
21
- // is one that repeats a key, as `$.merged: duplicate key "merged"`
21
+ // is one that repeats a key, as `$.merged: duplicate key "merged"`, and
22
+ // a merge-bot report giving one PR two verdicts, as
23
+ // `$.refused[0].pr: PR #<pr> already has a verdict at $.merged[0].pr`
22
24
  // 2 usage: no or unknown subcommand, a wrong argument count, an unknown
23
25
  // family, any flag; or <file> could not be read; or print could not
24
26
  // write the schema to stdout. Refused on stderr, nothing on stdout
@@ -153,11 +155,41 @@ export const SCHEMAS = {
153
155
  merged: listOf({ pr: id, sha }),
154
156
  held: listOf({ pr: id, behind: id }),
155
157
  conflictHold: { type: "array", items: id },
156
- refused: listOf({ pr: id, reason: str }),
158
+ // A refusal is a verdict only with a reason: the reason is the record
159
+ // `## Ruled` gets, so one with no text but whitespace is invalid.
160
+ refused: listOf({ pr: id, reason: { type: "string", pattern: "\\S" } }),
157
161
  branchKept: listOf({ pr: id, by: id }),
158
162
  }),
159
163
  };
160
164
 
165
+ // Every verdict a merge-bot report gives, as `{pr, path}`, `path` the entry's
166
+ // PR field: `merged`, `held`, `conflictHold`, then `refused`, each in report
167
+ // order. `branchKept` gives none: it names a merged PR's branch and the open
168
+ // PR keeping it.
169
+ export function mergeBotVerdicts(report) {
170
+ return [
171
+ ...["merged", "held"].flatMap((k) => report[k].map((e, i) => ({ pr: e.pr, path: `$.${k}[${i}].pr` }))),
172
+ ...report.conflictHold.map((pr, i) => ({ pr, path: `$.conflictHold[${i}]` })),
173
+ ...report.refused.map((e, i) => ({ pr: e.pr, path: `$.refused[${i}].pr` })),
174
+ ];
175
+ }
176
+
177
+ // The first violation of `family`'s schema by `value`, or null; for a
178
+ // merge-bot report then the rule no schema keyword states: each PR gets one
179
+ // verdict — merged, held, conflict-held or refused — the last it reached in
180
+ // the pass, so a PR named twice, in one list or in two, is a violation at
181
+ // its second entry.
182
+ export function reportViolation(family, value) {
183
+ const found = validate(SCHEMAS[family], value);
184
+ if (found !== null || family !== "merge-bot") return found;
185
+ const first = new Map();
186
+ for (const { pr, path } of mergeBotVerdicts(value)) {
187
+ if (first.has(pr)) return `${path}: PR #${pr} already has a verdict at ${first.get(pr)}`;
188
+ first.set(pr, path);
189
+ }
190
+ return null;
191
+ }
192
+
161
193
  const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
162
194
 
163
195
  function typeOf(v) {
@@ -279,7 +311,7 @@ function main() {
279
311
  }
280
312
  if (cmd !== "validate" || path === undefined) die(USAGE);
281
313
 
282
- const schema = familyOf(family);
314
+ familyOf(family);
283
315
  let text;
284
316
  try {
285
317
  text = readFileSync(path, "utf8");
@@ -287,7 +319,7 @@ function main() {
287
319
  die(`cannot read ${path}: ${e.code ?? e.message}`);
288
320
  }
289
321
  const parsed = parseReport(text);
290
- const violation = parsed.violation ?? validate(schema, parsed.value);
322
+ const violation = parsed.violation ?? reportViolation(family, parsed.value);
291
323
  if (violation !== null) {
292
324
  writeAll(1, `invalid: ${violation}\n`);
293
325
  process.exit(1);
@@ -50,8 +50,8 @@ phase, or in any later one, asks the maintainer which tickets to take.
50
50
  when the repository has no workflow named CI. Its steps and receipt: See
51
51
  references/phase-0.md. Dispatch nothing before it exits 0. Exit 4 is a
52
52
  step that needs you: act on each `NEED` line below, then run it again. Once
53
- `rotate` has run, a re-run skips every `ok` step and resumes where the last
54
- one stopped; before it, a re-run runs every step again.
53
+ `rotate` has run, a re-run skips every `ok` step but `alarm` and resumes
54
+ where the last one stopped; before it, a re-run runs every step again.
55
55
 
56
56
  The `tier-roles` step runs `~/.fleet/bin/fleet-run tier-roles.mjs --check
57
57
  --in-flight <B>`, `<B>` being the `--in-flight` given, else the default.
@@ -76,11 +76,13 @@ One line per ticket, rewritten in place (`ledger.mjs row <ticket> <text>`; `ledg
76
76
 
77
77
  A member that dies between spawn and write is invisible — and members die in batches.
78
78
 
79
+ `dispatch merge-bot` also records the queue the bot is dispatched against: its `## Dispatched` entry carries `queued=<pr>,<pr>…` (`queued=none` for an empty queue), the open `ready-to-merge` PRs gh lists at that moment. A gh that cannot answer refuses the dispatch, nothing written. `settle merge-bot-<n> --from-report` judges the bot's report against that queue, not against anything the report names: a PR from it that is still open and carries the label, with no verdict in `merged`, `held`, `conflictHold` or `refused`, fails the settle at exit 3, naming the PR, and an entry without `queued=` cannot be judged (exit 2) — settle that bot by hand.
80
+
79
81
  ## filed and ruled
80
82
 
81
83
  `filed` is checked with `ledger.mjs check <subject>` before every `gh issue create`, so a finding already recorded this run is not filed twice. That is not a guarantee against duplicates at large; the tracker query, described under `check`'s exits and its `verdict` above, is what covers those.
82
84
 
83
- `ruled` records PR + decision + one-line reason, so a replacement controller does not re-litigate a settled call.
85
+ `ruled` records PR + decision + one-line reason, so a replacement controller does not re-litigate a settled call. A merge bot's settle adds one line per `refused[]` entry of its report, `merge-bot-<n> refused #<pr>: <reason>`: a record of the refusal and nothing more — no label moves and no row token is written. It is led by the bot, not by `#<pr>`, because the board reads a `#<pr>`-led line as your ruling on that PR, the last one winning, and a refusal must not replace it.
84
86
 
85
87
  ## Label-off and conflict holds
86
88
 
@@ -13,13 +13,17 @@ Exit 3 is a step that failed: its line carries the refusal, so fix the cause
13
13
  and run it again.
14
14
 
15
15
  `phase0.mjs` runs the start sequence in
16
- its fixed order — branch, fast-forward, pin, baseline, preflight, ruleset,
16
+ its fixed order — alarm, branch, fast-forward, pin, baseline, preflight, ruleset,
17
17
  tier-roles, metrics, cockpit, rotate, reclaim, recipe, fold-in, stranded,
18
18
  shortlist — prints one `phase0: <step>: <status>` line per step with the
19
19
  `NEED` lines last, and records each step in `.fleet/phase0.json`, which the
20
20
  tick reads: it prints `PHASE0-INCOMPLETE` and holds every dispatching row
21
21
  until the receipt is whole.
22
22
 
23
+ ## Check the controller alarm
24
+
25
+ **Check the controller alarm first. Phase 0's first step refuses when this omp's timer is not armed.** The `alarm` step reads `~/.fleet/alarm/<pid>.json`, where `<pid>` is the omp process above the script, and checks that the file names that pid, that process's start time, and this checkout's `.fleet/`. A file that is missing, not JSON, or names something else, and no omp process above the script, each fail the step with exit 3 and a message ending `restart omp`. Restart omp, then run phase 0 again. The step runs on every run of phase 0, one its receipt records `ok` included, because it checks the omp running that run; nothing after it runs until it passes.
26
+
23
27
  ## Fast-forward the checkout
24
28
 
25
29
  **Fast-forward the checkout before you trust these rules — you are reading