@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.
- 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 +109 -41
- package/scripts/gh-aliased-read.mjs +95 -0
- package/scripts/ledger.mjs +7 -4
- package/scripts/merge-gate.mjs +14 -8
- package/scripts/reclaim-scratch.mjs +11 -59
- package/skills/run-team/SKILL.md +1 -1
- package/skills/run-team/references/ledger.md +2 -0
- package/skills/run-team/references/tick-rows.md +2 -2
|
@@ -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,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
|
|
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
|
|
56
60
|
// latest settled `impl-*=PR#<M>` PR the open list does not carry,
|
|
57
|
-
// that PR's state (`gh
|
|
58
|
-
// be running against a finished PR, so its dangling
|
|
59
|
-
// a review unit, and a PR CLOSED unmerged prints an
|
|
60
|
-
// for its ticket. And, for each ticket holding the
|
|
61
|
-
// row on a tier mismatch, owed a replacement, or whose
|
|
62
|
-
// read CLOSED or unread, whether its issue is CLOSED (`gh
|
|
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
|
|
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
|
|
@@ -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
|
|
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 (
|
|
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(
|
|
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
|
|
1819
|
-
// per
|
|
1820
|
-
// ledger reads as in flight yet the open list does not carry,
|
|
1821
|
-
// each ticket's keyed PR the open list does not carry (deriveRun's
|
|
1822
|
-
// `prKeyed`). The answer is OPEN, MERGED or CLOSED
|
|
1823
|
-
//
|
|
1824
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1837
|
-
|
|
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
|
+
}
|
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,
|
|
@@ -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.
|
|
306
|
-
//
|
|
307
|
-
//
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
}
|
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`.
|
|
@@ -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
|
|
99
|
-
|
|
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>)` —
|