@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.
- package/commands/run-merge-bot.md +2 -2
- package/package.json +1 -1
- package/scripts/fleet-event.mjs +47 -4
- package/scripts/fleet-tick.mjs +79 -14
- package/scripts/ledger.mjs +7 -4
- package/scripts/merge-gate.mjs +14 -8
- package/skills/run-team/SKILL.md +1 -1
- package/skills/run-team/references/ledger.md +2 -0
|
@@ -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
package/scripts/fleet-event.mjs
CHANGED
|
@@ -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 `
|
|
76
|
-
//
|
|
77
|
-
//
|
|
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 {
|
package/scripts/fleet-tick.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
51
|
-
// GitHub's `mergeable
|
|
52
|
-
// review, which conflict,
|
|
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
|
|
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
|
|
455
|
-
|
|
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(
|
|
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.`);
|
package/scripts/ledger.mjs
CHANGED
|
@@ -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
|
|
2678
|
-
// tick cannot read, and a lone `→` — an arrow with no PR beside it
|
|
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>";
|
package/scripts/merge-gate.mjs
CHANGED
|
@@ -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,
|
|
27
|
-
// rebaseCarry, behind, instruments, mainGain, ci}`, `
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
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,
|
package/skills/run-team/SKILL.md
CHANGED
|
@@ -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,
|
|
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`.
|