@feigi/fleet-ctl 3.79.5 → 3.79.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.79.5",
3
+ "version": "3.79.6",
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": {
@@ -34,7 +34,7 @@ import {
34
34
  import { readReceipt } from "./phase0-receipt.mjs";
35
35
  import { RECEIPT } from "./ledger-grammar.mjs";
36
36
  import { NAME, warnOnce, execRead } from "./board-common.mjs";
37
- import { mergedReadPrs } from "./compute-board.mjs";
37
+ import { mergedReadPrs, issuesOfPrs } from "./compute-board.mjs";
38
38
  // The heartbeat's liveness mark and the controller record beside it, read here
39
39
  // and never written: the heartbeat is the mark's only writer and
40
40
  // `ledger.mjs rotate` the record's. stallOwner() reads the process table to
@@ -48,7 +48,7 @@ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
48
48
  // The "is this a plain JSON object" predicate and its kind word, one
49
49
  // copy for the reads in this file that reject a parsed-but-wrong payload
50
50
  // WHOLE — gather's `--prev` payload, gather's per-entry `tickets[i]` check,
51
- // and readMerged()'s `data.repository`. Each carried its own inline
51
+ // and readStates()'s `data.repository`. Each carried its own inline
52
52
  // copy of both halves before, which is three places for one policy to drift in.
53
53
  //
54
54
  // TWO functions and not one, because `typeof` alone cannot name the fault the
@@ -155,48 +155,60 @@ function hitLimit(parsed, limit) {
155
155
  return Array.isArray(parsed) && parsed.length >= limit;
156
156
  }
157
157
 
158
- // Which of `prNums` gh calls MERGED, in ONE `gh api graphql` query —
159
- // one aliased `pullRequest(number:)` field per PR, on the cwd's repository
160
- // (gh's own `{owner}`/`{repo}` placeholders, the repo every other read here
161
- // resolves) — so a PR merged at any age is found, and ten PRs cost what one
162
- // does. It replaced `gh pr list --state merged --limit 100`, the repo's 100
163
- // most recent merges: about six days on 2026-09-25, so a row PR merged
164
- // before that and never seen MERGED rendered REVIEW, turned stale and
165
- // counted as claimed. Measured 2026-09-26: 400 aliases answer in one call.
158
+ // The merged and closed states of `prNums`, and the CLOSED-ness of `issueNums`,
159
+ // in ONE `gh api graphql` query — one aliased `pullRequest(number:)` field per
160
+ // PR and one `issue(number:)` field per issue, on the cwd's repository (gh's own
161
+ // `{owner}`/`{repo}` placeholders, the repo every other read here resolves) — so
162
+ // a PR merged at any age is found, and ten PRs cost what one does. It replaced
163
+ // `gh pr list --state merged --limit 100`, the repo's 100 most recent merges: about
164
+ // six days on 2026-09-25, so a row PR merged before that and never seen MERGED
165
+ // rendered REVIEW, turned stale and counted as claimed. Measured 2026-09-26: 400
166
+ // aliases answer in one call.
166
167
  //
167
- // An empty `prNums` makes no read at all. `state` is checked, so an entry gh
168
- // does not call MERGED never makes a card MERGED on its own account.
168
+ // An empty `prNums` makes no read at all. `state` is checked per alias, so an
169
+ // entry gh does not call MERGED is never merged, and one it does not call CLOSED
170
+ // is never closed; an issue not CLOSED is not CLOSED.
169
171
  //
170
172
  // A body with an object `data.repository` IS an answer even at exit 1:
171
- // GitHub resolves every alias it can and nulls the rest — a number that is
172
- // not a PR comes back null beside a NOT_FOUND error, and gh exits 1 with the
173
- // whole body on stdout (measured 2026-09-26, gh 2.101.0, which also names
174
- // the number on stderr). Refusing that body would let one such row — a
175
- // typo, or a row with no impl token keyed by an issue number — put every other
176
- // row PR back in REVIEW on every tick. A null alias reads as not merged,
177
- // which is what a failed read means for that one PR.
173
+ // GitHub resolves every alias it can and nulls the rest — a number that is not
174
+ // a PR (or, for an issue alias, not an issue) comes back null beside a
175
+ // NOT_FOUND error, and gh exits 1 with the whole body on stdout (measured
176
+ // 2026-09-26, gh 2.101.0, which also names the number on stderr). Refusing that
177
+ // body would let one such row — a typo, or a row with no impl token keyed by an
178
+ // issue number — put every other row PR back in REVIEW on every tick. A null
179
+ // alias reads as not merged and not closed, which is what a failed read means
180
+ // for that one number.
178
181
  //
179
- // Real GitHub always answers EVERY requested `p<N>` alias, null or an
180
- // object — never omits one — so an object naming NONE of `prNums` is not a
181
- // shape the API produces, only a corrupted or truncated one; `isJsonObject`
182
- // alone cannot tell that apart from a genuine "checked, nothing merged"
183
- // answer, so it is checked for too. Anything else is a failed read: `[]`,
184
- // and computeBoard()'s carry-forward rule takes it from there.
185
- async function readMerged(prNums) {
186
- if (!prNums.length) return [];
187
- const fields = prNums.map((n) => `p${n}:pullRequest(number:${n}){state}`).join(" ");
182
+ // Real GitHub always answers EVERY requested PR alias, null or an object — never
183
+ // omits one — so an object naming NONE of the PR numbers is not a shape the API
184
+ // produces, only a corrupted or truncated one; `isJsonObject` alone cannot tell
185
+ // that apart from a genuine "checked, nothing matched" answer, so it is checked
186
+ // for too. Anything else is a failed read: every list empty, and
187
+ // computeBoard()'s carry-forward rule takes it from there.
188
+ async function readStates(prNums, issueNums) {
189
+ const none = { merged: [], closed: [], closedIssues: [] };
190
+ if (!prNums.length) return none;
191
+ const fields = [
192
+ ...prNums.map((n) => `p${n}:pullRequest(number:${n}){state}`),
193
+ ...issueNums.map((n) => `i${n}:issue(number:${n}){state}`),
194
+ ].join(" ");
188
195
  const r = await execRead("gh", ["api", "graphql", "-F", "owner={owner}", "-F", "name={repo}",
189
196
  "-f", `query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${fields}}}`]);
190
197
  let repo;
191
198
  try { repo = JSON.parse(r.stdout)?.data?.repository; } catch {}
192
199
  if (!isJsonObject(repo) || !prNums.some((n) => Object.prototype.hasOwnProperty.call(repo, `p${n}`))) {
193
200
  // `how` as runCiState() spells it; never r.error.message, which repeats
194
- // the whole query — one field per PR — into every failed tick's line.
201
+ // the whole query — one field per number — into every failed tick's line.
195
202
  const how = r.code ?? (r.signal ? `killed by ${r.signal}` : `exit ${r.status}`);
196
- console.error(`${NAME}: gh api graphql merged read (${prNums.length} PR${prNums.length === 1 ? "" : "s"}) failed (${how}) with no usable answer; they read as not merged`);
197
- return [];
203
+ const n = prNums.length;
204
+ console.error(`${NAME}: gh api graphql merged read (${n} PR${n === 1 ? "" : "s"}) failed (${how}) with no usable answer`);
205
+ return none;
198
206
  }
199
- return prNums.filter((n) => repo[`p${n}`]?.state === "MERGED");
207
+ return {
208
+ merged: prNums.filter((n) => repo[`p${n}`]?.state === "MERGED"),
209
+ closed: prNums.filter((n) => repo[`p${n}`]?.state === "CLOSED"),
210
+ closedIssues: issueNums.filter((n) => repo[`i${n}`]?.state === "CLOSED"),
211
+ };
200
212
  }
201
213
 
202
214
  // labels: gh emits an array of {name,...} objects, but a malformed row can
@@ -692,7 +704,7 @@ export async function gather({ ledgerFile, prevFile, stateFile = null, scriptDir
692
704
  // JSON.parse SUCCEEDS on `null`, a bare number, a string and an array,
693
705
  // none of which is a board; `null` in particular is absent, not an
694
706
  // object, and `typeof null` alone would let it through. Same check and
695
- // same wording as readMerged()'s `data.repository` read, which is this
707
+ // same wording as readStates()'s `data.repository` read, which is this
696
708
  // repo's precedent for rejecting a parsed-but-wrong payload at its own
697
709
  // read — literally the same, both calling the shared predicate.
698
710
  const p = JSON.parse(readFileSync(prevFile, "utf8"));
@@ -711,7 +723,7 @@ export async function gather({ ledgerFile, prevFile, stateFile = null, scriptDir
711
723
  // (drop the bad entry, keep the rest) would make this a new, fourth policy for
712
724
  // one payload class in this file — mapCi's null-only guard and tryParse's
713
725
  // unguarded read are the other two — rather than the reject-whole-payload
714
- // policy this guard already shares with readMerged()'s own read.
726
+ // policy this guard already shares with readStates()'s own read.
715
727
  const bad = p.tickets.findIndex((t) => !isJsonObject(t));
716
728
  if (bad !== -1)
717
729
  throw new TypeError(`expected tickets[${bad}] to be a JSON object, got ${jsonKind(p.tickets[bad])}`);
@@ -805,11 +817,13 @@ export async function gather({ ledgerFile, prevFile, stateFile = null, scriptDir
805
817
  // <sha>`, and the open list above drops a PR the moment it merges. It is
806
818
  // asked only about mergedReadPrs()'s set — row PRs absent from the open
807
819
  // list, with no token, not carried forward — in one batched read
808
- // that answers whatever their merge age; see readMerged(). A failed read
809
- // is `[]`, and the carry-forward in computeBoard() covers exactly the
810
- // PRs this set leaves out, so a failure costs only a PR never yet seen
811
- // MERGED, which reads REVIEW.
812
- const merged = await readMerged(mergedReadPrs({ ledger, prs, prev }));
820
+ // that answers whatever their merge age; see readStates(). A failed read
821
+ // leaves every list empty, and the carry-forward in computeBoard() covers
822
+ // exactly the PRs this set leaves out, so a failure costs only a PR never
823
+ // yet seen MERGED, which reads REVIEW — and flags no pr-closed, since the
824
+ // closed lists are empty too.
825
+ const asked = mergedReadPrs({ ledger, prs, prev });
826
+ const { merged, closed, closedIssues } = await readStates(asked, issuesOfPrs({ ledger, prs: asked }));
813
827
 
814
828
  // CI per open PR. On failure, carry the previous board's value for that PR.
815
829
  const prevCi = new Map((prev?.tickets || []).filter((t) => t.pr != null).map((t) => [t.pr, t.ci]));
@@ -869,7 +883,7 @@ export async function gather({ ledgerFile, prevFile, stateFile = null, scriptDir
869
883
  // stalled, so a healthy board reads none. computeBoard() words it.
870
884
  const now = Date.now();
871
885
  const stallOwnerOf = isStalled(assessBeat({ beat, ticked, acted, controller, now })) ? stallOwner(controller, NAME) : null;
872
- return { ledger, issues, prs, merged, ci, prev, repo, repoUrl, workspace, port, spend, beat, ticked, acted, controller, stallOwner: stallOwnerOf, poolOk,
886
+ return { ledger, issues, prs, merged, closed, closedIssues, ci, prev, repo, repoUrl, workspace, port, spend, beat, ticked, acted, controller, stallOwner: stallOwnerOf, poolOk,
873
887
  poolCapped, prsCapped,
874
888
  now, interval };
875
889
  }
@@ -197,7 +197,8 @@ export function parseRow(row) {
197
197
  const alive = live.filter((e) => !settled.has(e.name));
198
198
  const implOutcome = lastImpl ? (implOutcomes.get(lastImpl.name) ?? null) : null;
199
199
  const prM = implOutcome && /^PR#(\d+)$/.exec(implOutcome);
200
- let pr = prM ? Number(prM[1]) : null;
200
+ const implPr = prM ? Number(prM[1]) : null;
201
+ let pr = implPr;
201
202
  // A row with NO impl token — a malformed one still
202
203
  // counts, since that row's key is a ticket — is keyed, once THIS row
203
204
  // carries a PR-bound signal, the way fleet-tick.mjs's rowPr-then-
@@ -217,6 +218,7 @@ export function parseRow(row) {
217
218
  excluded,
218
219
  impl: lastImpl?.name ?? null,
219
220
  implOutcome,
221
+ implPr,
220
222
  agent: alive.length ? alive[alive.length - 1].name : null,
221
223
  pr,
222
224
  merged: !!mergedM,
@@ -287,8 +289,12 @@ export function deriveFlags(parsed, ctx) {
287
289
  if (ctx.finisher?.startsWith("halted:") && ctx.column === "REVIEW") flags.push(ctx.finisher);
288
290
  if (ctx.finisher === "labelled" && ctx.column === "REVIEW") flags.push("unlabelled");
289
291
  if ((ctx.finisher === "failed" || ctx.finisher === "killed") && ctx.column === "REVIEW") flags.push(`finisher:${ctx.finisher}`);
292
+ // A closed-unmerged PR's card reads `pr-closed` in place of `stale`: the
293
+ // closed PR is what a reader has to act on, not how long the card has sat.
294
+ const prClosed = ctx.prClosed === true && ctx.column === "REVIEW";
295
+ if (prClosed) flags.push("pr-closed");
290
296
  const limit = STALE_MS[ctx.column];
291
- if (limit != null && ctx.sinceEnteredStage != null && ctx.now - ctx.sinceEnteredStage > limit) {
297
+ if (!prClosed && limit != null && ctx.sinceEnteredStage != null && ctx.now - ctx.sinceEnteredStage > limit) {
292
298
  flags.push("stale");
293
299
  }
294
300
  return flags;
@@ -315,7 +321,7 @@ function titleFor(issue, pr, issues) {
315
321
  }
316
322
 
317
323
  const FLAG_SEVERITY = {
318
- "red-ci": 5, "ledger-error": 5, killed: 4, "tier-mismatch": 4, blocked: 4, "sha-off-branch": 4, unlabelled: 4,
324
+ "red-ci": 5, "ledger-error": 5, killed: 4, "tier-mismatch": 4, "pr-closed": 4, blocked: 4, "sha-off-branch": 4, unlabelled: 4,
319
325
  "finisher:failed": 4, "finisher:killed": 4,
320
326
  ...Object.fromEntries(HALT_CAUSES.map((c) => [`halted:${c}`, 4])),
321
327
  stale: 1,
@@ -432,6 +438,20 @@ export function mergedReadPrs({ ledger, prs, prev }) {
432
438
  return [...need].sort((a, b) => a - b);
433
439
  }
434
440
 
441
+ // The ticket issues whose row's latest settled impl PR is one of `prs`, sorted
442
+ // and once each. gather() reads their state in the same call as the PR read;
443
+ // computeBoard() takes the CLOSED ones as `closedIssues`. An Exclusion is none.
444
+ // An issue number outside Number.isSafeInteger is left out for the reason
445
+ // mergedReadPrs() leaves such a PR out: its `i1e+21` alias would fail the
446
+ // whole batched query, PR aliases included.
447
+ export function issuesOfPrs({ ledger, prs }) {
448
+ const set = new Set(prs);
449
+ const issues = (ledger.rows || []).map(parseRow).filter(Boolean)
450
+ .filter((p) => !p.excluded && p.implPr != null && set.has(p.implPr) && Number.isSafeInteger(p.issue))
451
+ .map((p) => p.issue);
452
+ return [...new Set(issues)].sort((a, b) => a - b);
453
+ }
454
+
435
455
  // `merged` is gh's list of merged PR numbers, consulted only for a row
436
456
  // PR absent from the open list `prs`; absent means none known, and a PR in
437
457
  // neither list (closed unmerged, or a failed read) keeps REVIEW — unless
@@ -441,10 +461,16 @@ export function mergedReadPrs({ ledger, prs, prev }) {
441
461
  // this carry-forward — only the "absent from both `prs` and (maybe)
442
462
  // `merged`" branch below ever consults it. gather() asks gh only about
443
463
  // mergedReadPrs()'s set, so `merged` never lists more than that.
464
+ // `closed` is the same read's CLOSED PRs, and `closedIssues` the CLOSED issues
465
+ // among issuesOfPrs(). A pr-closed flag needs both: a missing `closed`
466
+ // flags nothing, and a failed issue read reads as not CLOSED, as closedTickets()
467
+ // reads its own failed probe.
444
468
  export function computeBoard(inputs) {
445
469
  const { ledger, issues, prs, ci, prev, now } = inputs;
446
470
  const prByNum = new Map(prs.map((p) => [p.number, p]));
447
471
  const mergedPrs = new Set(inputs.merged || []);
472
+ const closedPrs = new Set(inputs.closed || []);
473
+ const closedIssues = new Set(inputs.closedIssues || []);
448
474
  const prevByIssue = new Map((prev?.tickets || []).map((t) => [t.issue, t]));
449
475
  const ruledByPr = new Map();
450
476
  for (const r of ledger.ruled || []) {
@@ -501,7 +527,8 @@ export function computeBoard(inputs) {
501
527
  rowIssues.add(p.issue);
502
528
  const sinceEnteredStage = stageEntry(prevTicket, column, now);
503
529
  const ciState = p.pr != null ? (ci[p.pr] ?? "unknown") : null;
504
- const flags = deriveFlags(p, { ci: ciState, column, sinceEnteredStage, now, finisher: latestFinishers.get(p.pr) });
530
+ const prClosed = pr === undefined && p.implPr != null && closedPrs.has(p.pr) && !closedIssues.has(p.issue);
531
+ const flags = deriveFlags(p, { ci: ciState, column, sinceEnteredStage, now, finisher: latestFinishers.get(p.pr), prClosed });
505
532
  tickets.push({
506
533
  issue: p.issue,
507
534
  title: titleFor(p.issue, pr, issues),