@feigi/fleet-ctl 3.83.1 → 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.1",
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,9 +49,11 @@
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
@@ -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
@@ -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))),
@@ -1622,7 +1687,7 @@ function openPrs() {
1622
1687
  let out;
1623
1688
  try {
1624
1689
  out = execFileSync("gh", ["pr", "list", "--state", "open", "--limit", String(PR_LIMIT),
1625
- "--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 });
1626
1691
  } catch (e) {
1627
1692
  // Never interpolates e.stderr or e.message: execFileSync already forwarded
1628
1693
  // the child's stderr to ours, and Node builds e.message out of it, so
@@ -1642,12 +1707,12 @@ function openPrs() {
1642
1707
  // would leave that ticket's implementer counted twice. headRefOid likewise
1643
1708
  // — absent, a `halted:past-pin` PR would read as moved forever — and
1644
1709
  // mergeable: absent or outside GitHub's MergeableState enum, no PR would ever
1645
- // read CONFLICTING.
1710
+ // read CONFLICTING. body too: absent, no park could ever be matched or lifted.
1646
1711
  if (!Array.isArray(prs) || prs.some((p) => !p || typeof p.number !== "number"
1647
1712
  || !Array.isArray(p.labels) || !Array.isArray(p.closingIssuesReferences)
1648
1713
  || !p.closingIssuesReferences.every((r) => r && Number.isInteger(r.number))
1649
- || typeof p.headRefOid !== "string" || !MERGEABLE_STATES.includes(p.mergeable))) {
1650
- 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");
1651
1716
  }
1652
1717
  if (prs.length === PR_LIMIT) {
1653
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.`);
@@ -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,
@@ -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`.