@feigi/fleet-ctl 3.83.0 → 3.83.2

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.
@@ -226,7 +226,7 @@ For each labeled PR clearing the hold rule, lowest first:
226
226
  --out <scratch>/pr<N>/merge-bot-<n>/ci.json; rc=$?
227
227
  ```
228
228
 
229
- `--pre` is the head you recorded at **The labelled head**; `--post` is the head step 1's rebase produced — omit it when no rebase ran, which is the same as passing `pre` twice. `pr<N>` is this PR and `merge-bot-<n>` is your own name, so the reading lands under a directory only you write, never in the scratch root by itself: `<scratch>` is the absolute path your dispatch prompt names, and that root is shared with every sibling. The gate is read-only — it runs the instrument re-check, `gh pr view <pr> --json labels,reviewDecision,headRefOid,body`, the main-gain check against that head in the main checkout, the rebase-carry proof when that head is neither `pre` nor `post`, and `ci-state.mjs --pr <pr>`, answers their conjunction, and never merges, labels, rebases or waits. Stdout is exactly one JSON line, `{pr, verdict, reason, head, pre, post, rebaseCarry, behind, instruments, mainGain, ci}`; stderr is its children's diagnostics, so never fold `2>&1` into the line you read. Read `rc` straight off the call, as above — never through a pipe.
229
+ `--pre` is the head you recorded at **The labelled head**; `--post` is the head step 1's rebase produced — omit it when no rebase ran, which is the same as passing `pre` twice. `pr<N>` is this PR and `merge-bot-<n>` is your own name, so the reading lands under a directory only you write, never in the scratch root by itself: `<scratch>` is the absolute path your dispatch prompt names, and that root is shared with every sibling. The gate is read-only — it runs the instrument re-check, `gh pr view <pr> --json labels,reviewDecision,headRefOid,body`, the main-gain check against that head in the main checkout, the rebase-carry proof when that head is neither `pre` nor `post`, and `ci-state.mjs --pr <pr>`, answers their conjunction, and never merges, labels, rebases or waits. Stdout is exactly one JSON line, `{pr, verdict, reason, head, bodyDigest, pre, post, rebaseCarry, behind, instruments, mainGain, ci}`; stderr is its children's diagnostics, so never fold `2>&1` into the line you read. Read `rc` straight off the call, as above — never through a pipe.
230
230
 
231
231
  **Run a plain `git fetch origin` in the main checkout before every gate run** — never `git fetch origin <src>:<dst>`, for the reason step 1's fallback gives. The main-gain check and the rebase-carry proof read `origin/main` and the head objects there as they stand and fetch nothing themselves: an unfetched head is `main-gain-unanswerable`, an unheld labelled or carried head is no carry, and a stale `origin/main` is a check against the wrong `main`.
232
232
 
@@ -236,7 +236,7 @@ For each labeled PR clearing the hold rule, lowest first:
236
236
  Each `reason` is a fact the gate now enforces, not a step you run:
237
237
 
238
238
  - **`head-moved-after-label`** — the PR head is neither `pre` nor `post`, nor a head whose net change the gate proved identical to `pre`'s (`rebaseCarry`, which names both). **Any third SHA is a push that landed while you waited on CI, and no CI check can see it**: `ci-state.mjs` selects the run whose `headSha` equals the *current* PR head (`scripts/ci-state.mjs`, the `r.headSha === prHead` filter), so a member's push plus its own green run satisfies every CI check while the audit behind the label belongs to a tree that is gone. A carry is proven only for the head `gh pr view` read; a different head at `ci-state`'s own read is still this row. The remedy is a fresh finisher against the new head, label untouched.
239
- - **`main-gain-removed:<path>`** — merging this head would remove lines `main` gained after the PR's work began: lines a later merge landed, deleted by a branch commit that every rebase since has replayed without a conflict, so no conflict preview and no `origin/main...HEAD` read is sure to show them. `mainGain.hits[]` names each file, the landing key (`#<n>` for a merged PR, else the landing commit's 12-character SHA), the removed lines, and the `ack` line to copy. Skip the PR, leave the label alone, and report `main-gain-removed:<path>-#<pr>` with the hit list and the keys. Never restore the lines yourself — the removal may be deliberate. It clears when the PR stops removing them or its body carries `main-gain-removal: <path> <key> - <why>` for every hit; the gate reads the body each run, so editing it is enough. An acknowledged removal is listed in `mainGain.acknowledged[]` and the gate passes it — name each one, with its reason, in your report.
239
+ - **`main-gain-removed:<path>`** — merging this head would remove lines `main` gained after the PR's work began: lines a later merge landed, deleted by a branch commit that every rebase since has replayed without a conflict, so no conflict preview and no `origin/main...HEAD` read is sure to show them. `mainGain.hits[]` names each file, the landing key (`#<n>` for a merged PR, else the landing commit's 12-character SHA), the removed lines, and the `ack` line to copy. Skip the PR, leave the label alone, and report `main-gain-removed:<path>-#<pr>` with the hit list and the keys. Never restore the lines yourself — the removal may be deliberate. It clears when the PR stops removing them or its body carries `main-gain-removal: <path> <key> - <why>` for every hit; the gate reads the body each run, so editing it is enough. An acknowledged removal is listed in `mainGain.acknowledged[]` and the gate passes it — name each one, with its reason, in your report. Your report's `refused[]` entry for the PR carries the gate's `reason` as it printed it, `main-gain-removed:<path>`, and the gate's `--out` file above stays where the gate wrote it: the controller parks the PR off that entry, at the `head` and `bodyDigest` that file records the gate refused, and no merge bot is dispatched on the PR's account until its head or its body changes — though a bot dispatched for another queued PR may still evaluate it again.
240
240
  - **`main-gain-unanswerable`** — the main-gain check could not answer: a head object the main checkout does not hold after `git fetch origin`, a merge that does not resolve cleanly, a blame that failed. `mainGain.reason` says which. Stop and report `main-gain-unanswerable-#<pr>` with it, label untouched.
241
241
  - **`behind:<n>`** — you are the only place currency is proven, and a sibling merge since your rebase expires it (the staleness below).
242
242
  - **`instrument-set-changed` / `instruments-unanswerable`** — the instrument re-check the controller runs before its own gates (`run-team/SKILL.md`, **You read your instruments out of a tree every member can write to**), taken at this seat because nothing dispatches a merge bot to read that rule. Report `<reason>-#<pr>` with what the gate printed, leave the label alone, and do **not** re-run the gate.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.83.0",
3
+ "version": "3.83.2",
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": {
@@ -72,9 +72,15 @@
72
72
  // A settle that refuses on `ready-to-merge` — missing from the PR or from the read-back — is the case the tick's `DISPATCH finisher PR#<M>` catches next: settle it `ledger.mjs settle finisher-pr-<M>=labelled` as reported, and `ledger.mjs filed <N> "<subject>"` for each `unrecorded` entry.
73
73
  // merge-bot-report <merge-bot-n>
74
74
  // `ledger.mjs token <pr> held-behind:#<behind>` for each `held[]` entry
75
- // of the report the PR's rows do not already carry, then `settle
76
- // --from-report`. The reap and the reclaim run under the tick's
77
- // `--apply`.
75
+ // of the report the PR's rows do not already carry, then `ledger.mjs
76
+ // token <pr> parked=<head>:<body digest>:<reason>` for each `refused[]`
77
+ // entry whose reason is `main-gain-removed:<path>`, bound to the head
78
+ // and body digest the bot's gate reading `<scratch>/pr<pr>/<merge-bot-n>/ci.json`
79
+ // records it refused, unless the rows already carry it (a reading that
80
+ // is missing or is not that refusal is a NEED, nothing written), then
81
+ // `settle --from-report`.
82
+ // Every other refusal reason writes nothing. The reap and the reclaim
83
+ // run under the tick's `--apply`.
78
84
  // The verdict moves no label and leaves nothing in the repo, so the row is the only place the tick can read a `held-behind` from.
79
85
  // The reap and the reclaim run once for every merge bot settled `done`, always, even when the pass merged nothing, and again on the next tick after an `APPLY FAILED` line, and on every tick while this run has no controller record to remember the pass by.
80
86
  // The bot may also have written `conflict-hold:#<pr>` onto a held PR's own row earlier in the same pass, before reporting: a token the bot wrote in that pass, never one `reap.sh` writes.
@@ -188,7 +194,7 @@ import { FleetDirUnresolvable, fleetFile } from "./fleet-dir.mjs";
188
194
  import { gitEnv } from "./git-env.mjs";
189
195
  import { isCLI } from "./is-cli.mjs";
190
196
  import { BRIEFS, REVIEW, REVIEWED, REVIEWER, memberTokens, nextReviewerName, parseDispatchedEntry, parseMember, parseToken, readRetired, rowNums } from "./ledger-grammar.mjs";
191
- import { NO_TRANSCRIPT_AFTER_DEFAULT, controllerState, dispositionsToken, foldLedger } from "./fleet-tick.mjs";
197
+ import { NO_TRANSCRIPT_AFTER_DEFAULT, PARKS, controllerState, dispositionsToken, foldLedger, parkedToken } from "./fleet-tick.mjs";
192
198
  import { phase0Gate, readRunReceipt, tickCaps } from "./phase0-receipt.mjs";
193
199
  import { defaultScratch, readReportIn, reportsDir } from "./report-files.mjs";
194
200
  import { readState, statePath } from "./fleet-state.mjs";
@@ -636,9 +642,46 @@ function mergeBotReport(ev, member) {
636
642
  if (r.status === 0) ev.note(`#${pr} ${tok}`);
637
643
  else ev.refuse(`token #${pr} ${tok}`, r);
638
644
  }
645
+ for (const { pr, reason } of read.state === "ok" ? read.report.refused : []) {
646
+ if (PARKS.test(reason)) park(ev, member, pr, reason);
647
+ }
639
648
  ev.settle(member);
640
649
  }
641
650
 
651
+ // `ledger.mjs token <pr> parked=<head>:<body digest>:<reason>` for a refusal
652
+ // only a human edit clears, bound to the head and body the gate refused: the
653
+ // bot's own gate reading, `<scratch>/pr<pr>/<member>/ci.json` (run-merge-bot.md
654
+ // step 3's `--out`), never a read taken now, so a push or a body edit landing
655
+ // after the refusal is never what gets parked. Skipped when the PR's rows
656
+ // already carry that token. A reading that cannot be read, is not JSON, is not
657
+ // this refusal, or carries no full head and body digest is a NEED naming
658
+ // which, nothing written: a park bound to no refused head would never lift.
659
+ function park(ev, member, pr, reason) {
660
+ const file = join(ev.scratch, `pr${pr}`, member, "ci.json");
661
+ let g;
662
+ try {
663
+ g = JSON.parse(readFileSync(file, "utf8"));
664
+ } catch (e) {
665
+ ev.need(`park PR #${pr} ${reason} — the gate reading ${file} ${e instanceof SyntaxError ? "is not JSON" : `cannot be read (${e.code ?? e.message})`}; nothing parked`);
666
+ return;
667
+ }
668
+ const off = g === null || typeof g !== "object" ? "is not a JSON object"
669
+ : g.pr !== pr || g.verdict !== "blocked" || g.reason !== reason ? `is not this refusal (pr ${g.pr}, verdict ${g.verdict}, reason ${g.reason})`
670
+ : null;
671
+ const tok = off === null ? parkedToken(g.head, g.bodyDigest, reason) : null;
672
+ if (tok === null) {
673
+ ev.need(`park PR #${pr} ${reason} — the gate reading ${file} ${off ?? `carries no full head and body digest (head ${g.head}, bodyDigest ${g.bodyDigest})`}; nothing parked`);
674
+ return;
675
+ }
676
+ if (prWords(ev.ledger, pr).includes(tok)) {
677
+ ev.note(`#${pr} already ${tok}`);
678
+ return;
679
+ }
680
+ const w = node("ledger.mjs", ["token", String(pr), tok]);
681
+ if (w.status === 0) ev.note(`#${pr} ${tok}`);
682
+ else ev.refuse(`token #${pr} ${tok}`, w);
683
+ }
684
+
642
685
  // `p` with its symlinks resolved, or as written when it does not exist yet.
643
686
  function real(p) {
644
687
  try {
@@ -30,7 +30,9 @@
30
30
  // unsettled `fix-pr-`), the merge bot (`## Dispatched`), merge
31
31
  // holds (`held-behind:#M`, and `conflict-hold:#<pr>`, written by
32
32
  // the merge bot or by the controller off a `CONFLICT` line),
33
- // tier mismatches and the drain marker
33
+ // merge parks (`parked=<head>:<body digest>:<reason>`, written by
34
+ // `fleet-event.mjs merge-bot-report`), tier mismatches and the
35
+ // drain marker
34
36
  // all come off it, and so does the ledger half of the In-flight
35
37
  // count: every claimed ticket whose newest `impl-` member is live,
36
38
  // or settled `killed`/`tier-mismatch` and so owed a replacement,
@@ -47,20 +49,22 @@
47
49
  // nothing. Missing or unparsable is depth 0 and a
48
50
  // refresh, never a refusal: the depth only ever bounds PULLs
49
51
  // downward. A file with no `dropped` names no drops.
50
- // gh The open PRs, by label, by whether they close an issue and by
51
- // GitHub's `mergeable` — the merge queue, which PRs are owed a
52
- // review, which conflict, and the PR half of the In-flight count:
52
+ // gh The open PRs, by label, by whether they close an issue, by
53
+ // GitHub's `mergeable`, and by head and body — the merge queue,
54
+ // which PRs are owed a review, which conflict, which queued PR
55
+ // still matches the head and body it was parked at, and the PR
56
+ // half of the In-flight count:
53
57
  // every open PR that closes an issue, whoever opened it and
54
58
  // whatever its state. Also, for each in-flight `review=` token
55
59
  // whose PR the open list does not carry, and for each ticket's
56
60
  // latest settled `impl-*=PR#<M>` PR the open list does not carry,
57
- // that PR's state (`gh pr view`, once per PR): a review cannot
58
- // be running against a finished PR, so its dangling token is not
59
- // a review unit, and a PR CLOSED unmerged prints an ESCALATE line
60
- // for its ticket. And, for each ticket holding the implementer
61
- // row on a tier mismatch, owed a replacement, or whose keyed PR
62
- // read CLOSED or unread, whether its issue is CLOSED (`gh issue
63
- // view`): a closed ticket's mismatch holds nothing, its owed
61
+ // that PR's state (one `gh api graphql` read per hundred PRs): a
62
+ // review cannot be running against a finished PR, so its dangling
63
+ // token is not a review unit, and a PR CLOSED unmerged prints an
64
+ // ESCALATE line for its ticket. And, for each ticket holding the
65
+ // implementer row on a tier mismatch, owed a replacement, or whose
66
+ // keyed PR read CLOSED or unread, whether its issue is CLOSED (`gh
67
+ // issue view`): a closed ticket's mismatch holds nothing, its owed
64
68
  // replacement is not in flight, and its PR prints no line.
65
69
  // main The main checkout against the run-start baseline
66
70
  // `.fleet/main-checkout.sha`, through main-checkout.mjs.
@@ -445,14 +449,23 @@ function reviewers(s) {
445
449
 
446
450
  function mergeBot(s) {
447
451
  // Cap is 1 by invariant, not by configuration.
448
- const row = mkRow("merge-bot", s.mergeBotLive, 1, `merge-queue=${s.mergeQueue} held=${s.mergeHeld}`);
452
+ const parked = s.mergeParked;
453
+ const row = mkRow("merge-bot", s.mergeBotLive, 1,
454
+ `merge-queue=${s.mergeQueue} held=${s.mergeHeld}${parked.length > 0 ? ` parked=${parked.length}` : ""}`);
449
455
  if (s.mergeBotLive >= 1) return row("AT CAP");
450
456
  if (s.mergeQueue === 0) return row("IDLE OK");
451
457
  // `ready-to-merge` is the author's sign-off and nothing more. A bot
452
458
  // dispatched against a queue whose candidates are all held — behind a lower
453
- // open PR, or on a conflict merge-bot already refused to force —
454
- // spends a whole member re-deriving a verdict already recorded.
455
- if (s.mergeQueue - s.mergeHeld <= 0) {
459
+ // open PR, or on a conflict merge-bot already refused to force — or parked
460
+ // on a refusal only a human edit clears spends a whole member re-deriving a
461
+ // verdict already recorded.
462
+ if (s.mergeQueue - s.mergeHeld - parked.length <= 0) {
463
+ if (parked.length > 0) {
464
+ return row(`HOLD (parked ${parked.map((p) => `#${p.pr} ${p.reason}`).join(", ")})`, {
465
+ extra: "a park clears on a PR-body acknowledgement `main-gain-removal: <path> <key> - <why>` or a new head"
466
+ + (s.mergeHeld > 0 ? `; ${s.mergeHeld} more held` : ""),
467
+ });
468
+ }
456
469
  const why = s.mergeConflictHeld ? "behind a lower PR or on a merge conflict no fix-pr has cleared" : "behind a lower PR";
457
470
  return row("HOLD", { extra: `every queued candidate is held ${why}` });
458
471
  }
@@ -565,6 +578,39 @@ export const HELD = /^held-behind[:-]#?(\d+)$/;
565
578
  // spelled like `held-behind` so the two read alike. Distinct from an
566
579
  // Exclusion, which gates a ticket's claim; this gates a PR's merge.
567
580
  export const CONFLICT_HOLD = /^conflict-hold[:-]#?(\d+)$/;
581
+ // The durable record that a merge bot refused a PR on a main-gain hit — a
582
+ // refusal only a human edit clears — written onto the PR's own row by
583
+ // `fleet-event.mjs merge-bot-report`: `parked=<head>:<body digest>:<reason>`,
584
+ // the head and the body digest those the merge gate read when it refused, and
585
+ // the reason the gate's `main-gain-removed:<path>` percent-encoded with
586
+ // encodeURI, and `#` as `%23`, so the token stays one word and carries no
587
+ // `#<n>` a row would read as a PR mention. It parks the PR only while its head
588
+ // and body still match: a new head or an edited body lifts it, with no write.
589
+ export const PARKED = /^parked=([0-9a-f]{40}):([0-9a-f]{12}):(main-gain-removed:\S+)$/;
590
+ // The only gate reason that parks: every other refusal clears without a
591
+ // human edit to the PR. A path is any non-empty text, a newline included.
592
+ export const PARKS = /^main-gain-removed:./s;
593
+ // The digest a park binds a PR body by: gh's `body` string, as gh prints it.
594
+ // merge-gate.mjs computes the same digest of the body it read into its
595
+ // payload's `bodyDigest`, which is where a park's digest comes from.
596
+ export const bodyDigest = (body) => createHash("sha256").update(body).digest("hex").slice(0, 12);
597
+ // The park token for a refusal `reason` at `head` with body digest `digest`,
598
+ // or null when the reason does not park, the head is not a full commit SHA or
599
+ // the digest is not a body digest.
600
+ export function parkedToken(head, digest, reason) {
601
+ if (!PARKS.test(reason)) return null;
602
+ const tok = `parked=${String(head).toLowerCase()}:${digest}:${encodeURI(reason).replaceAll("#", "%23")}`;
603
+ return PARKED.test(tok) ? tok : null;
604
+ }
605
+ // A park token's reason as the gate printed it; a hand-written one that does
606
+ // not decode is shown as written.
607
+ function parkedReason(encoded) {
608
+ try {
609
+ return decodeURIComponent(encoded);
610
+ } catch {
611
+ return encoded;
612
+ }
613
+ }
568
614
  // tier-check.mjs's verdict on an implementer, `tier-ok=<member>:<def>`
569
615
  // or `tier-mismatch=<member>:<def>` — or `tier-unverifiable=<member>:no-transcript`
570
616
  // for a settled member whose transcript was never written.
@@ -803,7 +849,7 @@ export function foldLedger({ rows, dispatched }, open, { lenient = false } = {})
803
849
  inFlight: false, reviewedAny: false, survived: 0, unverified: 0, reviewFixed: false,
804
850
  fixMembers: new Set(), fixLanded: new Set(), conflictLanded: new Set(), held: [],
805
851
  conflictOpen: false, reviewedHead: null, reviewedRun: null, pastPinHalt: false, dispositions: [], workers: new Set(),
806
- finishers: new Set(),
852
+ finishers: new Set(), parked: [],
807
853
  };
808
854
  // The finisher-pr token currently deciding `pastPinHalt`, picked by
809
855
  // retry suffix ("" < "b" < "c" …) — never by row-text position. A
@@ -906,6 +952,10 @@ export function foldLedger({ rows, dispatched }, open, { lenient = false } = {})
906
952
  } else {
907
953
  const h = HELD.exec(tok);
908
954
  if (h) st.held.push(Number(h[1]));
955
+ // Every park the PR's rows carry: the tick matches each against the
956
+ // PR's current head and body, so a stale one is never cleared here.
957
+ const k = PARKED.exec(tok);
958
+ if (k) st.parked.push({ head: k[1], digest: k[2], reason: parkedReason(k[3]) });
909
959
  const c = CONFLICT_HOLD.exec(tok);
910
960
  if (c) {
911
961
  // Bound to the row's own PR: a hold naming another PR is a
@@ -958,7 +1008,7 @@ export function foldLedger({ rows, dispatched }, open, { lenient = false } = {})
958
1008
  export const pastPinDue = (st, head) => st !== undefined && st.pastPinHalt && !st.inFlight && st.reviewedHead !== null
959
1009
  && !String(head).toLowerCase().startsWith(st.reviewedHead);
960
1010
 
961
- // The states `gh pr view --json state` answers: anything else is no answer.
1011
+ // The states a PR-state probe answers: anything else is no answer.
962
1012
  const PR_STATES = ["OPEN", "MERGED", "CLOSED"];
963
1013
 
964
1014
  export function deriveRun({ rows, dispatched, drain, retired = [] }, prs, closed = new Set(), prStates = new Map()) {
@@ -1007,7 +1057,7 @@ export function deriveRun({ rows, dispatched, drain, retired = [] }, prs, closed
1007
1057
  // A mismatch on the keyed member itself is no replacement of M and still
1008
1058
  // prints. A keyed PR the open list carries is normal flow; one it does not
1009
1059
  // is named in `prKeyed` for the caller's state probe, `prStates` its
1010
- // answers (null for a probe that could not answer, none by default).
1060
+ // answers (none for a PR whose probe could not answer, as by default).
1011
1061
  // CLOSED escalates, an unanswered probe escalates as unchecked, OPEN and
1012
1062
  // MERGED say nothing, and a ticket whose issue the caller found CLOSED
1013
1063
  // (`closed`) says nothing either way.
@@ -1052,6 +1102,15 @@ export function deriveRun({ rows, dispatched, drain, retired = [] }, prs, closed
1052
1102
  const state = (n) => byPr.get(n);
1053
1103
  const heldBehind = (st) => (st?.held ?? []).some((n) => open.has(n));
1054
1104
  const conflictHeld = (st) => st !== undefined && st.conflictOpen;
1105
+ const mergeHeldOn = (p) => heldBehind(state(p.number)) || conflictHeld(state(p.number));
1106
+ // The latest park on a queued PR whose head and body still match it, or
1107
+ // undefined: a new head or an edited body lifts every earlier park.
1108
+ const parkOn = (p) => {
1109
+ const parks = state(p.number)?.parked ?? [];
1110
+ if (parks.length === 0) return undefined;
1111
+ const digest = bodyDigest(p.body);
1112
+ return parks.findLast((k) => k.head === p.headRefOid.toLowerCase() && k.digest === digest);
1113
+ };
1055
1114
  const anyLive = (names) => [...names].some((n) => members.get(n).outcome === null);
1056
1115
  const fixRunning = (st) => anyLive(st.fixMembers);
1057
1116
  // An open PR the ledger tracks that GitHub reads CONFLICTING — no other
@@ -1144,9 +1203,15 @@ export function deriveRun({ rows, dispatched, drain, retired = [] }, prs, closed
1144
1203
  // MERGED or CLOSED, the same lift rule as a `behind-pr` Exclusion. A
1145
1204
  // conflict hold lifts only once a fix-applier after it has SETTLED
1146
1205
  // `applied:`/`no-op` — never on dispatch alone.
1147
- mergeHeld: queued.filter((p) => heldBehind(state(p.number)) || conflictHeld(state(p.number))).length,
1206
+ mergeHeld: queued.filter(mergeHeldOn).length,
1148
1207
  // How many of those are conflict holds — the HOLD row's wording, no field.
1149
1208
  mergeConflictHeld: queued.filter((p) => conflictHeld(state(p.number))).length,
1209
+ // Each queued PR no hold counts that a park still matches, `{pr, reason}`
1210
+ // ascending: the merge bot would only refuse it again.
1211
+ mergeParked: queued.flatMap((p) => {
1212
+ const k = mergeHeldOn(p) ? undefined : parkOn(p);
1213
+ return k === undefined ? [] : [{ pr: p.number, reason: k.reason }];
1214
+ }).sort((a, b) => a.pr - b.pr),
1150
1215
  // Finishers settled `labelled` on an open PR without the label.
1151
1216
  unlabelled: unlabelledFinishers({ rows, dispatched },
1152
1217
  new Set(prs.filter((p) => !isQueued(p)).map((p) => p.number))),
@@ -1493,6 +1558,7 @@ import { isCLI } from "./is-cli.mjs";
1493
1558
  import { parseArgs } from "node:util";
1494
1559
  import { makeDie, isDigits } from "./arg.mjs";
1495
1560
  import { gitEnv } from "./git-env.mjs";
1561
+ import { ghAliasedRead } from "./gh-aliased-read.mjs";
1496
1562
  import { fleetFile, FleetDirUnresolvable, scratchRoot } from "./fleet-dir.mjs";
1497
1563
  import { statePath, readState, writeState, assessBeat, isStalled, isReported, stallReport, stallOwner } from "./fleet-state.mjs";
1498
1564
  import { checkMainCheckout, describe } from "./main-checkout.mjs";
@@ -1621,7 +1687,7 @@ function openPrs() {
1621
1687
  let out;
1622
1688
  try {
1623
1689
  out = execFileSync("gh", ["pr", "list", "--state", "open", "--limit", String(PR_LIMIT),
1624
- "--json", "number,labels,closingIssuesReferences,headRefOid,mergeable"], { encoding: "utf8", timeout: GH_TIMEOUT_MS });
1690
+ "--json", "number,labels,closingIssuesReferences,headRefOid,mergeable,body"], { encoding: "utf8", maxBuffer: MAX_BUFFER, timeout: GH_TIMEOUT_MS });
1625
1691
  } catch (e) {
1626
1692
  // Never interpolates e.stderr or e.message: execFileSync already forwarded
1627
1693
  // the child's stderr to ours, and Node builds e.message out of it, so
@@ -1641,12 +1707,12 @@ function openPrs() {
1641
1707
  // would leave that ticket's implementer counted twice. headRefOid likewise
1642
1708
  // — absent, a `halted:past-pin` PR would read as moved forever — and
1643
1709
  // mergeable: absent or outside GitHub's MergeableState enum, no PR would ever
1644
- // read CONFLICTING.
1710
+ // read CONFLICTING. body too: absent, no park could ever be matched or lifted.
1645
1711
  if (!Array.isArray(prs) || prs.some((p) => !p || typeof p.number !== "number"
1646
1712
  || !Array.isArray(p.labels) || !Array.isArray(p.closingIssuesReferences)
1647
1713
  || !p.closingIssuesReferences.every((r) => r && Number.isInteger(r.number))
1648
- || typeof p.headRefOid !== "string" || !MERGEABLE_STATES.includes(p.mergeable))) {
1649
- die("gh pr list did not return {number,labels,closingIssuesReferences[{number}],headRefOid,mergeable} rows, mergeable one of MERGEABLE, CONFLICTING, UNKNOWN");
1714
+ || typeof p.headRefOid !== "string" || !MERGEABLE_STATES.includes(p.mergeable) || typeof p.body !== "string")) {
1715
+ die("gh pr list did not return {number,labels,closingIssuesReferences[{number}],headRefOid,mergeable,body} rows, mergeable one of MERGEABLE, CONFLICTING, UNKNOWN");
1650
1716
  }
1651
1717
  if (prs.length === PR_LIMIT) {
1652
1718
  die(`exactly ${PR_LIMIT} open PRs — the list is capped and may be truncated. Raise PR_LIMIT; a backlog that silently drops PRs is not a reconcile.`);
@@ -1815,34 +1881,36 @@ function closedTickets(mismatched, owed, surfacing) {
1815
1881
  return closed;
1816
1882
  }
1817
1883
 
1818
- // The state of each PR the tick asks gh about, one `gh pr view --json state`
1819
- // per PR however many readings need it: `reviews`, the PRs whose review the
1820
- // ledger reads as in flight yet the open list does not carry, and `keyed`,
1821
- // each ticket's keyed PR the open list does not carry (deriveRun's
1822
- // `prKeyed`). The answer is OPEN, MERGED or CLOSED, or null for a probe that
1823
- // could not answer — a nonzero exit, a reply carrying no state, a state that
1824
- // is none of the three — which is disclosed with what it leaves standing.
1884
+ // The state of each PR the tick asks gh about, one aliased `gh api graphql`
1885
+ // read per hundred PRs however many readings need them: `reviews`, the PRs
1886
+ // whose review the ledger reads as in flight yet the open list does not carry,
1887
+ // and `keyed`, each ticket's keyed PR the open list does not carry (deriveRun's
1888
+ // `prKeyed`). The answer is OPEN, MERGED or CLOSED. A probe that could not
1889
+ // answer — a failed call, an alias GitHub errored on or answered with nothing,
1890
+ // a node carrying no state, a state that is none of the three — leaves its PR
1891
+ // out of the map and is disclosed, per PR, with what it leaves standing; a
1892
+ // call killed at GH_TIMEOUT_MS is disclosed with that bound.
1825
1893
  function prStates(reviews, keyed) {
1826
1894
  const states = new Map();
1827
1895
  const reviewed = new Set(reviews);
1828
1896
  // Two tickets can be keyed on one PR: each is named, on its one probe.
1829
1897
  const ticketsOf = new Map();
1830
1898
  for (const k of keyed) ticketsOf.set(k.pr, [...(ticketsOf.get(k.pr) ?? []), k.n]);
1831
- for (const number of [...new Set([...reviews, ...ticketsOf.keys()])].sort((a, b) => a - b)) {
1899
+ const numbers = [...new Set([...reviews, ...ticketsOf.keys()])].sort((a, b) => a - b);
1900
+ const read = ghAliasedRead(numbers, { field: "pullRequest", selection: "{state}", timeoutMs: GH_TIMEOUT_MS });
1901
+ for (const number of numbers) {
1902
+ const r = read.get(number);
1903
+ const st = r.node?.state;
1904
+ if (PR_STATES.includes(st)) {
1905
+ states.set(number, st);
1906
+ continue;
1907
+ }
1832
1908
  const unconfirmed = [
1833
1909
  ...(reviewed.has(number) ? [`review on PR#${number} unconfirmed finished, its unit stands`] : []),
1834
1910
  ...(ticketsOf.get(number) ?? []).map((n) => `#${n}'s PR#${number} unconfirmed closed, printed as pr-unchecked`),
1835
1911
  ].join("; ");
1836
- states.set(number, null);
1837
- const r = spawnSync("gh", ["pr", "view", String(number), "--json", "state"], { encoding: "utf8", timeout: GH_TIMEOUT_MS, env: gitEnv({ GH_REPO: "" }) });
1838
- if (r.error || r.status !== 0) {
1839
- console.error(`${NAME}: ${failure(r, `gh pr view ${number}`)} — ${unconfirmed}`);
1840
- continue;
1841
- }
1842
- let st = null;
1843
- try { st = JSON.parse(r.stdout).state; } catch { /* no state: disclosed below */ }
1844
- if (PR_STATES.includes(st)) states.set(number, st);
1845
- else console.error(`${NAME}: gh pr view ${number} printed no PR state — ${unconfirmed}`);
1912
+ const why = r.unreachable === "ETIMEDOUT" ? TIMED_OUT : r.unreachable ?? (r.notFound ? "no such pull request" : "no PR state");
1913
+ console.error(`${NAME}: gh api graphql gave no answer for PR#${number} (${why}) — ${unconfirmed}`);
1846
1914
  }
1847
1915
  return states;
1848
1916
  }
@@ -0,0 +1,95 @@
1
+ // One GraphQL read of the current repository for many numbers at once: each
2
+ // number is an aliased `<field>(number:N){…}` of the one query, a hundred to a
3
+ // call, so a caller that would ask `gh` about every number one `gh … view` at a
4
+ // time asks once per hundred instead. The caller names the field
5
+ // (`pullRequest`, `issueOrPullRequest`) and what to select on it; what an
6
+ // answer means is the caller's.
7
+ //
8
+ // `gh api graphql` exits 1 when any one alias errors yet still prints the full
9
+ // body, `data.repository` holding every alias GitHub answered and `errors[]`
10
+ // naming each failed one by `path: ["repository", "<alias>"]`. So a body that
11
+ // carries an object `data.repository` is read alias by alias at any exit
12
+ // status, never as "the whole call failed".
13
+ //
14
+ // `-F owner={owner} -F name={repo}` is filled in from the environment gh sees:
15
+ // an inherited GH_REPO outranks the repository the cwd is bound to, and a
16
+ // GIT_DIR naming another repository moves it too. gitEnv({ GH_REPO: "" })
17
+ // scrubs both, so the read is of the repository the cwd is in.
18
+
19
+ import { spawnSync } from "node:child_process";
20
+ import { gitEnv } from "./git-env.mjs";
21
+
22
+ const BATCH = 100;
23
+ const MAX_BUFFER = 64 * 1024 * 1024;
24
+
25
+ /** A child that did not answer, as one short reason. */
26
+ export function how(r) {
27
+ if (r.error) return r.error.code ?? r.error.message;
28
+ if (r.signal) return `killed by ${r.signal}`;
29
+ const said = String(r.stderr ?? "").trim().split("\n").at(-1);
30
+ return said ? `exit ${r.status}: ${said}` : `exit ${r.status}`;
31
+ }
32
+
33
+ /**
34
+ * What GitHub says of each of `numbers`, as a Map keyed by number:
35
+ *
36
+ * `{ node }` — the alias GitHub answered with an object, as parsed.
37
+ * `{ notFound }` — null with a NOT_FOUND error naming the alias at path
38
+ * length 2: GitHub's own "this number names nothing".
39
+ * `{ unreachable }` — no usable answer, with the reason. Any other error
40
+ * naming an alias, an error naming no alias (it may
41
+ * belong to any of the batch), a null no NOT_FOUND
42
+ * explains, a missing alias, and an unparseable or
43
+ * absent body all land here: a failed lookup is not an
44
+ * answer.
45
+ *
46
+ * `field` and `selection` build each alias, `n<N>:<field>(number:<N>)<selection>`;
47
+ * `timeoutMs` bounds each `gh` call.
48
+ */
49
+ export function ghAliasedRead(numbers, { field, selection, timeoutMs }) {
50
+ const out = new Map();
51
+ for (let i = 0; i < numbers.length; i += BATCH) {
52
+ const batch = numbers.slice(i, i + BATCH);
53
+ const fields = batch.map((n) => `n${n}:${field}(number:${n})${selection}`).join(" ");
54
+ const r = spawnSync("gh", ["api", "graphql", "-F", "owner={owner}", "-F", "name={repo}",
55
+ "-f", `query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${fields}}}`], {
56
+ encoding: "utf8", maxBuffer: MAX_BUFFER, timeout: timeoutMs, env: gitEnv({ GH_REPO: "" }),
57
+ });
58
+ let body;
59
+ let unparseable;
60
+ try {
61
+ body = JSON.parse(r.stdout);
62
+ } catch (e) {
63
+ if (r.status === 0) unparseable = e.message;
64
+ }
65
+ const repo = body?.data?.repository;
66
+ const errors = body?.errors ?? [];
67
+ const answered = repo !== null && typeof repo === "object" && !Array.isArray(repo) && Array.isArray(errors);
68
+ const nothing = new Set();
69
+ const refused = new Map();
70
+ let batchWide;
71
+ if (answered) {
72
+ for (const e of errors) {
73
+ const path = e?.path;
74
+ const alias = Array.isArray(path) && path[0] === "repository" && typeof path[1] === "string" ? path[1] : null;
75
+ const said = `${e?.type ?? "error"}${e?.message ? `: ${e.message}` : ""}`;
76
+ if (alias === null) batchWide ??= said;
77
+ else if (e.type === "NOT_FOUND" && path.length === 2) nothing.add(alias);
78
+ else refused.set(alias, said);
79
+ }
80
+ }
81
+ for (const n of batch) {
82
+ const alias = `n${n}`;
83
+ const gone = (why) => out.set(n, { unreachable: why });
84
+ if (!answered) gone(unparseable === undefined ? how(r) : `${how(r)}: unparseable body: ${unparseable}`);
85
+ else if (refused.has(alias)) gone(refused.get(alias));
86
+ else if (batchWide !== undefined) gone(batchWide);
87
+ else if (!Object.hasOwn(repo, alias)) gone(how(r));
88
+ else if (repo[alias] === null) {
89
+ if (nothing.has(alias)) out.set(n, { notFound: true });
90
+ else gone("null with no NOT_FOUND error");
91
+ } else out.set(n, { node: repo[alias] });
92
+ }
93
+ }
94
+ return out;
95
+ }
@@ -54,7 +54,7 @@ import { spawnSync, execFileSync } from "node:child_process";
54
54
  import { makeDie, isFlagLike, hasEqualsForm, isDigits, writeAll } from "./arg.mjs";
55
55
  import { fleetFile, FleetDirUnresolvable } from "./fleet-dir.mjs";
56
56
  import { parseMember, parseToken, memberTokens, nextMergeBot, nextRetry, liveOnLedger, retiredOnLedger, retiredName, readRetired, agentDefinition, CELL, tierValues, rowPr, rowWriteRefusal, isReviewToken, addedReviewToken, newSplitRow, splitRowClause, added, premisesOf, MEMBER_FAMILIES, HALT_CAUSES, ROWS, DISPATCHED, FILED, RULED, DRAIN, RETIRED, RECEIPT, archiveStamp, receiptArchiveName, CLOSEOUT_RECEIPT, closeoutArchiveName, BRIEFS, briefsArchiveName, memberRole, parseDispatchedEntry, dispatchedEntry, headerRe, escapeText, unescapeText, nextReviewerName } from "./ledger-grammar.mjs";
57
- import { deriveRun, LedgerError, labelOffMember, HELD, CONFLICT_HOLD } from "./fleet-tick.mjs";
57
+ import { deriveRun, LedgerError, labelOffMember, HELD, CONFLICT_HOLD, PARKED } from "./fleet-tick.mjs";
58
58
  import { stateFileIn, readState, writeState, assessBeat, stallsAt } from "./fleet-state.mjs";
59
59
  import { processSource, ancestry, controllerOf, judgeController, ProcUnreadable } from "./proc.mjs";
60
60
  import { attemptLock, releaseLockFile, reapPathOf } from "./pid-lock.mjs";
@@ -2674,9 +2674,9 @@ function badLabelOff(words) {
2674
2674
  // ` · ` separator are no token. A member token is `dispatch`'s or `settle`'s,
2675
2675
  // a review token `review`'s. A word shaped like a member that the grammar
2676
2676
  // cannot parse, a `tier=` that is not a cell, a `tier=` naming a second tier
2677
- // on a row that already has one, a `held-behind` or `conflict-hold` token the
2678
- // tick cannot read, and a lone `→` — an arrow with no PR beside it points at
2679
- // nothing — are refused. Every other word is a freeform token.
2677
+ // on a row that already has one, a `held-behind`, `conflict-hold` or `parked`
2678
+ // token the tick cannot read, and a lone `→` — an arrow with no PR beside it
2679
+ // points at nothing — are refused. Every other word is a freeform token.
2680
2680
  function tokenRefusal(tok, rowText) {
2681
2681
  if (tok === "" || /\s/.test(tok)) {
2682
2682
  return "is not one token — pass each token as its own argument, with no space in it";
@@ -2697,6 +2697,9 @@ function tokenRefusal(tok, rowText) {
2697
2697
  return `is not ${prefix}:#<M> — the tick reads only ${prefix}:#<number> and would ignore this one`;
2698
2698
  }
2699
2699
  }
2700
+ if (tok.startsWith("parked=") && !PARKED.test(tok)) {
2701
+ return "is not parked=<head>:<body digest>:main-gain-removed:<path> — the tick reads only a 40-hex head, a 12-hex body digest and a main-gain-removed reason, and would ignore this one";
2702
+ }
2700
2703
  const [tier] = tierValues(tok);
2701
2704
  if (tier !== undefined) {
2702
2705
  if (!CELL.test(tier)) return "is not tier=<cell> — a cell is <slow|task|smol>-<level>";
@@ -23,14 +23,18 @@
23
23
  // member number only the bot knows, so the bot passes it and this script never
24
24
  // guesses it.
25
25
  //
26
- // Stdout: exactly one JSON line, `{pr, verdict, reason, head, pre, post,
27
- // rebaseCarry, behind, instruments, mainGain, ci}`, `rebaseCarry` naming the
28
- // labelled head and the head accepted as carrying it (`{labelled, accepted}`),
29
- // or null when no carry was proven, `mainGain` echoing main-gain.mjs's own
30
- // payload — hits, acknowledged removals and unchecked files — or null when
31
- // it did not run or printed no JSON object; an unusable payload is still
32
- // echoed. Stderr: the children's own diagnostics, passed straight through
33
- // and never folded into stdout.
26
+ // Stdout: exactly one JSON line, `{pr, verdict, reason, head, bodyDigest, pre,
27
+ // post, rebaseCarry, behind, instruments, mainGain, ci}`, `bodyDigest` the
28
+ // first 12 hex of the sha256 of the body gh read (fleet-tick.mjs's
29
+ // `bodyDigest`, the digest a `parked=` token binds — `fleet-event.mjs
30
+ // merge-bot-report` parks a `main-gain-removed` refusal at this `head` and
31
+ // `bodyDigest`), or null with `head` when gh could not be read, `rebaseCarry`
32
+ // naming the labelled head and the head accepted as carrying it (`{labelled,
33
+ // accepted}`), or null when no carry was proven, `mainGain` echoing
34
+ // main-gain.mjs's own payload — hits, acknowledged removals and unchecked
35
+ // files — or null when it did not run or printed no JSON object; an unusable
36
+ // payload is still echoed. Stderr: the children's own diagnostics, passed
37
+ // straight through and never folded into stdout.
34
38
  //
35
39
  // Exit vocabulary — the same three-way contract as inflight.sh, prove-merge.sh
36
40
  // and staleness.mjs:
@@ -77,6 +81,7 @@
77
81
  // no carry, and the head row blocks exactly as it does for any other moved head.
78
82
 
79
83
  import { spawnSync } from "node:child_process";
84
+ import { createHash } from "node:crypto";
80
85
  import { mkdirSync, writeFileSync } from "node:fs";
81
86
  import { dirname, join } from "node:path";
82
87
  import { fileURLToPath } from "node:url";
@@ -390,6 +395,7 @@ try {
390
395
  verdict,
391
396
  reason,
392
397
  head: prView?.headRefOid ?? null,
398
+ bodyDigest: prView === null ? null : createHash("sha256").update(prView.body).digest("hex").slice(0, 12),
393
399
  pre,
394
400
  post,
395
401
  rebaseCarry,
@@ -109,6 +109,7 @@ import { fileURLToPath } from "node:url";
109
109
  import { defineFlags, isDigits, makeDie, writeAll } from "./arg.mjs";
110
110
  import { fleetFile, FleetDirUnresolvable, SCRATCH_KEY_SHAPE, scratchRoot } from "./fleet-dir.mjs";
111
111
  import { gitEnv } from "./git-env.mjs";
112
+ import { ghAliasedRead, how } from "./gh-aliased-read.mjs";
112
113
  import { isCLI } from "./is-cli.mjs";
113
114
  import { liveOnLedger, parseMember, REVIEWER } from "./ledger-grammar.mjs";
114
115
 
@@ -116,7 +117,6 @@ const NAME = "reclaim-scratch";
116
117
  const die = makeDie(NAME);
117
118
  const DAY_MS = 24 * 60 * 60 * 1000;
118
119
  export const RETENTION_MS = 7 * DAY_MS;
119
- const BATCH = 100;
120
120
  const GH_TIMEOUT_MS = 30_000;
121
121
  const MAX_BUFFER = 64 * 1024 * 1024;
122
122
 
@@ -151,13 +151,6 @@ const REPORT_PR_ENTRY = /^(?:fix-pr|finisher-pr|review-pr)-([1-9][0-9]*)(?:-[a-z
151
151
  const REPORT_IMPL_ENTRY = /^impl-([1-9][0-9]*)(?:-[a-z])?(?:\.check)?\.json$/;
152
152
  const RUN_ROOT = /^run-/;
153
153
 
154
- function how(r) {
155
- if (r.error) return r.error.code ?? r.error.message;
156
- if (r.signal) return `killed by ${r.signal}`;
157
- const said = String(r.stderr ?? "").trim().split("\n").at(-1);
158
- return said ? `exit ${r.status}: ${said}` : `exit ${r.status}`;
159
- }
160
-
161
154
  /** The path of something under `path` (itself included) modified at or after
162
155
  * `cutoff`, or null when everything is older. Symlinks are not followed. A
163
156
  * directory that cannot be listed counts its own mtime only, and so does one
@@ -302,59 +295,18 @@ function claimWorktree(listing, n) {
302
295
 
303
296
  // `{type, state}` per number GitHub answered (type "Issue" or "PullRequest",
304
297
  // or null for a number that names nothing), or `{unreachable}` for a number
305
- // that has no usable answer. GitHub resolves every alias it can and nulls the
306
- // rest, and gh exits 1 with the whole body, so a body carrying an object
307
- // `data.repository` is read alias by alias at any exit status. An alias is
308
- // answered by a node, or by null with a NOT_FOUND error naming it — GitHub's
309
- // own "this number names nothing". Any other error naming an alias, a null
310
- // no NOT_FOUND explains, and an error naming no alias (it may belong to any
311
- // of the batch) leave the number unreachable: a failed lookup is not an
312
- // answer.
298
+ // that has no usable answer. What counts as an answer is gh-aliased-read.mjs's:
299
+ // a number GitHub nulls with NOT_FOUND names nothing, and a failed lookup is
300
+ // not an answer.
313
301
  function githubStates(numbers) {
314
302
  const out = new Map();
315
- for (let i = 0; i < numbers.length; i += BATCH) {
316
- const batch = numbers.slice(i, i + BATCH);
317
- const fields = batch.map((n) => `n${n}:issueOrPullRequest(number:${n}){__typename ... on Issue{state} ... on PullRequest{state}}`).join(" ");
318
- const r = spawnSync("gh", ["api", "graphql", "-F", "owner={owner}", "-F", "name={repo}",
319
- "-f", `query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${fields}}}`], {
320
- encoding: "utf8", maxBuffer: MAX_BUFFER, timeout: GH_TIMEOUT_MS, env: gitEnv({ GH_REPO: "" }),
321
- });
322
- let body;
323
- let unparseable;
324
- try {
325
- body = JSON.parse(r.stdout);
326
- } catch (e) {
327
- body = undefined;
328
- if (r.status === 0) unparseable = e.message;
329
- }
330
- const repo = body?.data?.repository;
331
- const errors = body?.errors ?? [];
332
- const answered = repo !== null && typeof repo === "object" && !Array.isArray(repo) && Array.isArray(errors);
333
- const nothing = new Set();
334
- const refused = new Map();
335
- let batchWide;
336
- if (answered) {
337
- for (const e of errors) {
338
- const path = e?.path;
339
- const alias = Array.isArray(path) && path[0] === "repository" && typeof path[1] === "string" ? path[1] : null;
340
- const said = `${e?.type ?? "error"}${e?.message ? `: ${e.message}` : ""}`;
341
- if (alias === null) batchWide ??= said;
342
- else if (e.type === "NOT_FOUND" && path.length === 2) nothing.add(alias);
343
- else refused.set(alias, said);
344
- }
345
- }
346
- for (const n of batch) {
347
- const alias = `n${n}`;
348
- const gone = (why) => out.set(n, { unreachable: `gh api graphql gave no answer for #${n} (${why})` });
349
- if (!answered) gone(unparseable === undefined ? how(r) : `${how(r)}: unparseable body: ${unparseable}`);
350
- else if (refused.has(alias)) gone(refused.get(alias));
351
- else if (batchWide !== undefined) gone(batchWide);
352
- else if (!Object.hasOwn(repo, alias)) gone(how(r));
353
- else if (repo[alias] === null) {
354
- if (nothing.has(alias)) out.set(n, { type: null });
355
- else gone("null with no NOT_FOUND error");
356
- } else out.set(n, { type: repo[alias].__typename, state: String(repo[alias].state).toUpperCase() });
357
- }
303
+ const read = ghAliasedRead(numbers, {
304
+ field: "issueOrPullRequest", selection: "{__typename ... on Issue{state} ... on PullRequest{state}}", timeoutMs: GH_TIMEOUT_MS,
305
+ });
306
+ for (const [n, r] of read) {
307
+ if (r.unreachable !== undefined) out.set(n, { unreachable: `gh api graphql gave no answer for #${n} (${r.unreachable})` });
308
+ else if (r.notFound) out.set(n, { type: null });
309
+ else out.set(n, { type: r.node.__typename, state: String(r.node.state).toUpperCase() });
358
310
  }
359
311
  return out;
360
312
  }
@@ -831,7 +831,7 @@ and flag the PR for a human. A new review of the PR counts again from none.
831
831
 
832
832
  | Failure | Response |
833
833
  |---|---|
834
- | Merge bot's gate finds the merge would remove lines `main` gained after the PR's work began (`main-gain-removed:<path>`, exit 1) | Relay `main-gain-removed:<path>-#<pr>` to a human with the hit list and the acknowledgement keys the bot printed. PR stays queued, label untouched, nothing written to the ledger, and no member is dispatched to restore the lines — the removal may be deliberate. It clears when the PR stops removing them or its body carries `main-gain-removal: <path> <key> - <why>` for each hit |
834
+ | Merge bot's gate finds the merge would remove lines `main` gained after the PR's work began (`main-gain-removed:<path>`, exit 1) | Relay `main-gain-removed:<path>-#<pr>` to a human with the hit list and the acknowledgement keys the bot printed. PR stays queued, label untouched, and no member is dispatched to restore the lines — the removal may be deliberate. `fleet-event.mjs merge-bot-report` parks it: a `parked=<head>:<body digest>:<reason>` token on its row, at the head and body digest the bot's gate reading `<scratch>/pr<pr>/merge-bot-<n>/ci.json` records it refused, and no bot is dispatched on its account — when nothing else queued is dispatchable the tick prints `HOLD (parked #<pr> <reason>)`, though a bot dispatched for another queued PR may still evaluate it again; a new head or an edited body lifts the park with no write. It clears when the PR stops removing them or its body carries `main-gain-removal: <path> <key> - <why>` for each hit |
835
835
  | Merge bot's gate cannot answer the main-gain check (`main-gain-unanswerable`, exit 2) | Report `main-gain-unanswerable-#<pr>` with what it printed, PR stays queued, label untouched |
836
836
 
837
837
  ## Failure handling
@@ -88,6 +88,8 @@ A member that dies between spawn and write is invisible — and members die in b
88
88
 
89
89
  `ci=<run-id>:<attempt>:<conclusion>` and `held-behind:#<lower>` are row tokens the same way, and so is `conflict-hold:#<pr>` — written by the merge bot itself (`run-merge-bot.md` step 1), or by you off the tick's `CONFLICT` line, naming the row's own PR. It is not an Exclusion: that gates a ticket's claim, this a PR's merge.
90
90
 
91
+ `parked=<head>:<body digest>:main-gain-removed:<path>` is a row token too, written by `fleet-event.mjs merge-bot-report` off a merge bot's `main-gain-removed` refusal, binding the head and a digest of the body the bot's gate refused, read from its gate reading `<scratch>/pr<N>/merge-bot-<n>/ci.json`: while both still match, the tick counts the PR parked beside the held ones, so it alone never prints `DISPATCH merge-bot`, and a new head or an edited body lifts it with no write. No other refusal reason parks.
92
+
91
93
  ## The review pair on a PR's row
92
94
 
93
95
  A PR's review is not a member, so it carries its own pair of row tokens, written with `review` onto the PR's row (**The reviewer**): `review=wf:<runId>` | `review=member:<name>` at launch, `<name>` being `review-pr-<n>` for the PR's first runner and the next letter its rows do not carry yet (`review-pr-<n>-b`, `-c`, …) for every later one, settled dead as `…=failed`, then `reviewed=<head>:<survived>/<refuted>/<unverified>:<run>` when the result lands, `<run>` the review's `run-XXXXXXXX` run root under `<scratch>/pr<n>/`, which holds its `review.json`.
@@ -95,8 +95,8 @@ The full text of each row `fleet-tick.mjs` prints and what it asks of the contro
95
95
  carried on at the right tier — and once that one settles, the line reads it
96
96
  the same way. A tier mismatch on the member that settled `PR#<M>` itself
97
97
  replaces nothing, so the line stands beside that hold.
98
- - `ESCALATE pr-unchecked #<N> PR#<M>` — the same ticket, but `gh pr view
99
- <M>` could not answer, so whether M closed is unknown. The maintainer's on
98
+ - `ESCALATE pr-unchecked #<N> PR#<M>` — the same ticket, but gh could not
99
+ answer for PR M, so whether M closed is unknown. The maintainer's on
100
100
  the same terms as `pr-closed`; the next tick probes again.
101
101
  - `HOLD (…)` — the row is held and says why: draining, a tier mismatch, an
102
102
  unchecked tier, the In-flight bound reached (`HOLD (in-flight <n>/<B>)` —