@feigi/fleet-ctl 3.22.0 → 3.22.1

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.22.0",
3
+ "version": "3.22.1",
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": {
@@ -201,8 +201,8 @@ const COVERED_MJS = {
201
201
  // children (`node candidates.mjs`, `node ledger.mjs`, `sh inflight.sh`)
202
202
  // name no git.
203
203
  "shortlist.mjs": 2,
204
- // THREE. The first two are the pair shortlist.mjs carries (#1803), the
205
- // third is below (#2485). `shortlistPath()`'s
204
+ // FOUR. The first two are the pair shortlist.mjs carries (#1803), the
205
+ // third is below (#2485), the fourth after it. `shortlistPath()`'s
206
206
  // `--git-common-dir` probe names the `.fleet/shortlist.json` the tick PULLs
207
207
  // from; an ambient GIT_DIR would read another repository's shortlist and
208
208
  // name its tickets. Measured in fleet-tick.test.mjs, "an ambient GIT_DIR
@@ -216,7 +216,11 @@ const COVERED_MJS = {
216
216
  // implementer's ticket is already closed, and resolves the repository the
217
217
  // same way; measured in fleet-tick.test.mjs, "an inherited GIT_DIR cannot
218
218
  // retarget the tier-mismatch closed-ticket probe".
219
- "fleet-tick.mjs": 3,
219
+ // `finishedReviewPrs()`'s `gh pr view` asks whether a PR with a dangling
220
+ // in-flight review is already merged or closed, and resolves the repository
221
+ // the same way; measured in fleet-tick.test.mjs, "an inherited GIT_DIR
222
+ // cannot retarget the merged-PR review probe".
223
+ "fleet-tick.mjs": 4,
220
224
  // ONE spawn primitive, `git(args, cwd)`, behind both of the file's git calls
221
225
  // — `resolveMainRoot()`'s `rev-parse --git-common-dir` and `checkIgnored()`'s
222
226
  // `check-ignore` (#1411). An ambient GIT_DIR would answer the first for
@@ -39,7 +39,10 @@
39
39
  // merge queue, and which PRs are owed a review. Also, for each ticket
40
40
  // holding the implementer row on a tier mismatch, whether its issue
41
41
  // is CLOSED (`gh issue view`, #2485): a closed ticket's mismatch
42
- // holds nothing.
42
+ // holds nothing. And, for each in-flight `review=` token whose PR
43
+ // the open list does not carry, whether that PR is MERGED or
44
+ // CLOSED (`gh pr view`): a review cannot be running against a
45
+ // finished PR, so its dangling token holds no reviewer slot.
43
46
  // main The main checkout against the run-start baseline
44
47
  // `.fleet/main-checkout.sha`, through main-checkout.mjs (#2210).
45
48
  // Anything but `clean` — dirty, unknown, no baseline — prints one
@@ -496,7 +499,7 @@ export function unlabelledFinishers(ledger, unqueued) {
496
499
  .map((u) => ({ pr: u.pr, labelled: u.attempts.filter((a) => a.outcome === "labelled").map((a) => a.name) }));
497
500
  }
498
501
 
499
- export function deriveRun({ rows, dispatched, drain }, prs, closed = new Set()) {
502
+ export function deriveRun({ rows, dispatched, drain }, prs, closed = new Set(), finished = new Set()) {
500
503
  // One entry per member name across `## Dispatched` and every row. A member
501
504
  // settled ANYWHERE is settled: `settle` is the only writer of an outcome, and
502
505
  // a bare copy beside it is what a whole-line `row` rewrite leaves behind.
@@ -752,11 +755,23 @@ export function deriveRun({ rows, dispatched, drain }, prs, closed = new Set())
752
755
  // head that review read, and there is nothing more to review.
753
756
  const pastPinDue = (st, head) => st !== undefined && st.pastPinHalt && !st.inFlight && st.reviewedHead !== null
754
757
  && !String(head).toLowerCase().startsWith(st.reviewedHead);
758
+ // A `review=` token with no `reviewed=` after it reads as in flight, and the
759
+ // ledger alone never says otherwise: a review whose own `reviewed=` write
760
+ // never landed stays in flight after its PR merges, holding a reviewer slot
761
+ // for good. `finished` is the PR numbers the caller has found MERGED or
762
+ // CLOSED, and a finished PR has no review running against it. A PR on the
763
+ // open list is open whatever `finished` says, and none is the default, so a
764
+ // PR nobody has asked about keeps its review live.
765
+ const reviewing = [...byPr.entries()].filter(([, st]) => st.inFlight);
766
+ const liveReviews = reviewing.filter(([n]) => open.has(n) || !finished.has(n));
755
767
  return {
756
768
  implLive: live("impl"),
757
769
  fixLive: live("fix-pr"),
758
770
  mergeBotLive: live("merge-bot"),
759
- reviewsLive: [...byPr.values()].filter((st) => st.inFlight).length,
771
+ reviewsLive: liveReviews.length,
772
+ // The in-flight reviews of PRs the open list does not carry: the ones
773
+ // `finished` could retire, so the caller asks gh about these and no others.
774
+ reviewsOffList: reviewing.filter(([n]) => !open.has(n)).map(([n]) => n).sort(asc),
760
775
  // A returned review whose survivors no review fix-applier has answered,
761
776
  // a conflict hold no fix-applier has cleared, or a dispositions mismatch
762
777
  // no retry has answered — on a PR still open, with no fix-applier working
@@ -824,7 +839,7 @@ export function deriveRun({ rows, dispatched, drain }, prs, closed = new Set())
824
839
  // run carries no member token of its own.
825
840
  live: [
826
841
  ...all.filter((m) => m.outcome === null).map((m) => m.name),
827
- ...[...byPr.entries()].filter(([, st]) => st.inFlight).map(([n]) => `review:PR#${n}`),
842
+ ...liveReviews.map(([n]) => `review:PR#${n}`),
828
843
  ],
829
844
  };
830
845
  }
@@ -1106,6 +1121,31 @@ function closedTickets(mismatched) {
1106
1121
  return closed;
1107
1122
  }
1108
1123
 
1124
+ // The PRs, of those whose review the ledger reads as in flight yet the open
1125
+ // list does not carry, that gh says are MERGED or CLOSED. A review cannot be
1126
+ // running against a finished PR, so the ledger's dangling `review=` token must
1127
+ // not hold a reviewer slot for good. A probe that cannot answer — a nonzero
1128
+ // exit, a reply carrying no state, a state that is none of the three — is
1129
+ // disclosed and the review keeps its slot: an unreadable state is never read
1130
+ // as finished. A PR gh calls OPEN keeps it quietly.
1131
+ function finishedReviewPrs(offList) {
1132
+ const finished = new Set();
1133
+ for (const number of offList) {
1134
+ const r = spawnSync("gh", ["pr", "view", String(number), "--json", "state"], { encoding: "utf8", env: gitEnv({ GH_REPO: "" }) });
1135
+ if (r.error || r.status !== 0) {
1136
+ console.error(`${NAME}: ${failure(r, `gh pr view ${number}`)} — review on PR#${number} unconfirmed finished, its slot stands`);
1137
+ continue;
1138
+ }
1139
+ let st = null;
1140
+ try { st = JSON.parse(r.stdout).state; } catch { /* no state: disclosed below */ }
1141
+ if (st === "MERGED" || st === "CLOSED") finished.add(number);
1142
+ else if (st !== "OPEN") {
1143
+ console.error(`${NAME}: gh pr view ${number} printed no PR state — review on PR#${number} unconfirmed finished, its slot stands`);
1144
+ }
1145
+ }
1146
+ return finished;
1147
+ }
1148
+
1109
1149
  // shortlist.mjs, run by the tick itself (§ 6 §3). Its per-ticket stderr is
1110
1150
  // captured and dropped rather than billed to the controller's context; only a
1111
1151
  // failure's last line rides along.
@@ -1166,7 +1206,8 @@ function main() {
1166
1206
  const ledger = readLedger();
1167
1207
  run = deriveRun(ledger, prs);
1168
1208
  const closed = closedTickets(run.tierMismatch);
1169
- if (closed.size) run = deriveRun(ledger, prs, closed);
1209
+ const finished = finishedReviewPrs(run.reviewsOffList);
1210
+ if (closed.size || finished.size) run = deriveRun(ledger, prs, closed, finished);
1170
1211
  } catch (e) {
1171
1212
  if (e instanceof LedgerError) die(e.message);
1172
1213
  throw e;
@@ -452,7 +452,7 @@ const pr = (number, labels = [], closes = [number + 1000], headRefOid = HEAD_B)
452
452
  closingIssuesReferences: closes.map((n) => ({ number: n })),
453
453
  headRefOid,
454
454
  });
455
- const run = (ledger, prs = [], closed) => deriveRun({ rows: [], dispatched: [], drain: null, ...ledger }, prs, closed);
455
+ const run = (ledger, prs = [], closed, finished) => deriveRun({ rows: [], dispatched: [], drain: null, ...ledger }, prs, closed, finished);
456
456
 
457
457
  test("deriveRun: live implementers are the unsettled impl- tokens — the #1692 shape, read rather than recited", () => {
458
458
  // #1692 was two sources disagreeing about the same fleet. There is one
@@ -506,6 +506,44 @@ test("deriveRun: live names every unsettled member and each in-flight review by
506
506
  assert.deepEqual(r.live, ["fix-pr-21", "impl-15", "review:PR#20"], "run2 returned and run3 is dead, so neither is live");
507
507
  });
508
508
 
509
+ // A review whose own `reviewed=` write never landed stays in flight in the
510
+ // ledger forever. The ledger cannot say the PR has since merged; the caller's
511
+ // probe can, and hands the merged or closed PR numbers to the fold.
512
+ const ZOMBIE_ROWS = [20, 21, 22].map((n) => `#${n - 10} impl-${n - 10}=PR#${n} → PR#${n} · review=member:review-pr-${n}`);
513
+
514
+ test("deriveRun: an in-flight review whose PR has left the open list is named for a probe, and still counts until the probe answers", () => {
515
+ const r = run({ rows: ZOMBIE_ROWS }, [pr(21)]);
516
+ assert.deepEqual(r.reviewsOffList, [20, 22], "only the in-flight reviews of PRs not on the open list");
517
+ assert.equal(r.reviewsLive, 3, "no probe answer: fail closed, every one still reads as live");
518
+ assert.deepEqual(r.live, ["review:PR#20", "review:PR#21", "review:PR#22"]);
519
+ });
520
+
521
+ test("deriveRun: reviewsOffList is ascending whatever order the ledger rows arrive in", () => {
522
+ const r = run({ rows: [...ZOMBIE_ROWS].reverse() }, [pr(21)]);
523
+ assert.deepEqual(r.reviewsOffList, [20, 22], "rows arrive 22, 21, 20: the sort, not ledger order, makes it ascending");
524
+ });
525
+
526
+ test("deriveRun: a review in flight on a PR the caller found merged or closed is not live", () => {
527
+ const r = run({ rows: ZOMBIE_ROWS }, [pr(21)], undefined, new Set([20, 22]));
528
+ assert.equal(r.reviewsLive, 1, "only PR 21's review, on the open list, is live");
529
+ assert.deepEqual(r.live, ["review:PR#21"]);
530
+ });
531
+
532
+ test("deriveRun: a review in flight on an OPEN PR is live whatever the finished set says", () => {
533
+ // A probe answer that contradicts the open list is not trusted to retire a
534
+ // review: the open list is the fold's own read of which PRs are open.
535
+ const r = run({ rows: ZOMBIE_ROWS }, [pr(20), pr(21), pr(22)], undefined, new Set([20, 21, 22]));
536
+ assert.equal(r.reviewsLive, 3);
537
+ assert.deepEqual(r.reviewsOffList, []);
538
+ });
539
+
540
+ test("deriveRun: a finished PR's returned review and settled members read as before", () => {
541
+ const rows = [...ZOMBIE_ROWS, "#40 impl-40=PR#50 → PR#50 · review=member:review-pr-50 reviewed=abc1234:0/0/0"];
542
+ const r = run({ rows }, [], undefined, new Set([20, 21, 22, 50]));
543
+ assert.deepEqual([r.reviewsLive, r.reviewsOffList, r.live], [0, [20, 21, 22], []]);
544
+ assert.deepEqual(r.reviewed.map((x) => x.pr), [50], "the returned review is still on record");
545
+ });
546
+
509
547
  test("deriveRun: fix-pr is due on a returned review with survivors and no fix-applier since", () => {
510
548
  const r = run({
511
549
  rows: [
@@ -1267,7 +1305,9 @@ test("CLI: every synchronous spawn in this file goes through spawnBounded (#2320
1267
1305
  });
1268
1306
 
1269
1307
  // `pr list` for the open PRs, `issue view` for a behind-issue premise's state,
1270
- // `issue list --label in-progress` for the stall report's claimed count.
1308
+ // `pr view` for the state of a PR an in-flight review names that is no longer
1309
+ // on the open list, `issue list --label in-progress` for the stall report's
1310
+ // claimed count.
1271
1311
  const GH_STUB = `#!/bin/sh
1272
1312
  case "$1 $2" in
1273
1313
  "pr list") [ -n "$PR_FAIL" ] && { echo "boom" >&2; exit 1; }; cat "$FIXTURE_PRS" ;;
@@ -1280,6 +1320,15 @@ case "$1 $2" in
1280
1320
  # scrub them lifts an exclusion the case's own repository still holds.
1281
1321
  [ -n "$GIT_DIR$GIT_WORK_TREE$GH_REPO" ] && { echo '{"state":"CLOSED"}'; exit 0; }
1282
1322
  exec jq -c --arg n "$3" '{state: (.[$n] // error("no such issue"))}' "$FIXTURE_ISSUE_STATES" ;;
1323
+ "pr view")
1324
+ echo "$3" >> "$PR_VIEW_LOG"
1325
+ [ -n "$PR_VIEW_FAIL" ] && { echo "gh: pr view failed" >&2; exit 1; }
1326
+ [ -n "$PR_VIEW_BODY" ] && { printf '%s\n' "$PR_VIEW_BODY"; exit 0; }
1327
+ # Real gh answers for the repository an inherited GIT_DIR or GH_REPO names;
1328
+ # this one answers MERGED for every PR there, so a probe that forgot to
1329
+ # scrub them retires a review the case's own repository still holds.
1330
+ [ -n "$GIT_DIR$GIT_WORK_TREE$GH_REPO" ] && { echo '{"state":"MERGED"}'; exit 0; }
1331
+ exec jq -c --arg n "$3" '{state: (.[$n] // error("no such pull request"))}' "$FIXTURE_PR_STATES" ;;
1283
1332
  "issue list")
1284
1333
  [ -n "$CLAIMED_FAIL" ] && { echo "boom" >&2; exit 1; }
1285
1334
  expr=""
@@ -1320,7 +1369,7 @@ const shortlistText = (ns, scanned = ns.length) => JSON.stringify({ scanned, sho
1320
1369
  // through info/exclude so no tracked file says so.
1321
1370
  function runCli(args = [], {
1322
1371
  prs = [], ledger, shortlist, refresh = shortlistText([]), refreshFail = false,
1323
- issueStates = {}, claimed = [], env: extraEnv = {}, defaultState = false, keep = false, beforeRun = () => {},
1372
+ issueStates = {}, prStates = {}, claimed = [], env: extraEnv = {}, defaultState = false, keep = false, beforeRun = () => {},
1324
1373
  baseline = true, afterBaseline = () => {},
1325
1374
  } = {}) {
1326
1375
  // On macOS tmpdir() is /var -> /private/var; resolving it up front makes
@@ -1355,6 +1404,7 @@ function runCli(args = [], {
1355
1404
  afterBaseline(repo);
1356
1405
  const refreshLog = fx("refresh.log", "");
1357
1406
  const issueViewLog = fx("issue-view.log", "");
1407
+ const prViewLog = fx("pr-view.log", "");
1358
1408
  // Every case gets its own state file unless it names one: the default path
1359
1409
  // resolves against the git common dir, and a shared streak and digest would
1360
1410
  // make the fold cases order-dependent. `defaultState` opts out for the case
@@ -1367,14 +1417,16 @@ function runCli(args = [], {
1367
1417
  FIXTURE_PRS: fx("prs.json", JSON.stringify(prs)),
1368
1418
  FIXTURE_CLAIMED: fx("claimed.json", JSON.stringify(claimed)),
1369
1419
  FIXTURE_ISSUE_STATES: fx("issue-states.json", JSON.stringify(issueStates)),
1420
+ FIXTURE_PR_STATES: fx("pr-states.json", JSON.stringify(prStates)),
1370
1421
  FIXTURE_REFRESH: fx("refresh.json", refresh),
1371
- REFRESH_LOG: refreshLog, ISSUE_VIEW_LOG: issueViewLog,
1422
+ REFRESH_LOG: refreshLog, ISSUE_VIEW_LOG: issueViewLog, PR_VIEW_LOG: prViewLog,
1372
1423
  ...(refreshFail ? { REFRESH_FAIL: "1" } : {}),
1373
1424
  ...extraEnv,
1374
1425
  },
1375
1426
  });
1376
1427
  r.refreshed = readFileSync(refreshLog, "utf8").split("\n").filter(Boolean).length;
1377
1428
  r.issueViews = readFileSync(issueViewLog, "utf8").split("\n").filter(Boolean);
1429
+ r.prViews = readFileSync(prViewLog, "utf8").split("\n").filter(Boolean);
1378
1430
  r.repo = repo;
1379
1431
  r.dir = dir;
1380
1432
  returned = true;
@@ -1843,6 +1895,82 @@ test("CLI: an inherited GIT_DIR cannot retarget the behind-issue premise probe",
1843
1895
  assert.equal(r.refreshed, 0, "#9 is OPEN in the case's own repository — the exclusion stands");
1844
1896
  });
1845
1897
 
1898
+ // A review whose `reviewed=` write never landed stays in flight in the ledger
1899
+ // after its PR merges. Each such row holds one reviewer slot unless the tick
1900
+ // asks gh about the PR and finds it finished.
1901
+ const zombieRows = (numbers) => numbers.map((n) => `#${n - 90} impl-${n - 90}=PR#${n} → PR#${n} · review=member:review-pr-${n}`);
1902
+ const ZOMBIES = Array.from({ length: 10 }, (_, i) => 101 + i);
1903
+ const REVIEW_DUE = pr(500, [], [1500], HEAD_B);
1904
+ const CAPS = ["--reviewer-cap", "10", "--max-reviews", "10"];
1905
+
1906
+ test("CLI: dangling in-flight reviews of merged PRs do not starve review dispatch", () => {
1907
+ const r = runCli(CAPS, {
1908
+ shortlist: shortlistText([]), prs: [REVIEW_DUE], ledger: { rows: zombieRows(ZOMBIES) },
1909
+ prStates: Object.fromEntries(ZOMBIES.map((n) => [n, "MERGED"])),
1910
+ });
1911
+ assert.equal(r.status, 0, r.stderr);
1912
+ assert.deepEqual(r.prViews.map(Number).sort((a, b) => a - b), ZOMBIES, "each off-list PR is asked about");
1913
+ assert.match(r.stdout, /^reviewers +0\/10 → DISPATCH review PR#500/m);
1914
+ });
1915
+
1916
+ test("CLI: a CLOSED PR's dangling review is released too", () => {
1917
+ const r = runCli(["--reviewer-cap", "1", "--max-reviews", "1"], {
1918
+ shortlist: shortlistText([]), prs: [REVIEW_DUE], ledger: { rows: zombieRows([101]) }, prStates: { 101: "CLOSED" },
1919
+ });
1920
+ assert.equal(r.status, 0, r.stderr);
1921
+ assert.match(r.stdout, /^reviewers +0\/1 → DISPATCH review PR#500/m);
1922
+ });
1923
+
1924
+ test("CLI: an in-flight review on an open PR keeps its slot and is never probed", () => {
1925
+ const r = runCli(["--reviewer-cap", "1", "--max-reviews", "1"], {
1926
+ shortlist: shortlistText([]), prs: [pr(101, [], [1101]), REVIEW_DUE], ledger: { rows: zombieRows([101]) },
1927
+ });
1928
+ assert.equal(r.status, 0, r.stderr);
1929
+ assert.deepEqual(r.prViews, [], "an open PR needs no probe");
1930
+ assert.match(r.stdout, /^reviewers +1\/1 → AT CAP/m);
1931
+ assert.doesNotMatch(r.stdout, /DISPATCH review/);
1932
+ });
1933
+
1934
+ test("CLI: a PR off the open list that gh still calls OPEN keeps its review's slot", () => {
1935
+ const r = runCli(["--reviewer-cap", "1", "--max-reviews", "1"], {
1936
+ shortlist: shortlistText([]), prs: [REVIEW_DUE], ledger: { rows: zombieRows([101]) }, prStates: { 101: "OPEN" },
1937
+ });
1938
+ assert.equal(r.status, 0, r.stderr);
1939
+ assert.deepEqual(r.prViews, ["101"]);
1940
+ assert.match(r.stdout, /^reviewers +1\/1 → AT CAP/m);
1941
+ assert.doesNotMatch(r.stdout, /DISPATCH review/);
1942
+ });
1943
+
1944
+ test("CLI: a PR gh cannot answer for is not read as merged - the slot is held and the failure disclosed", () => {
1945
+ const cases = [
1946
+ ["a nonzero exit", { PR_VIEW_FAIL: "1" }, /gh pr view 101 exited 1: gh: pr view failed/],
1947
+ ["a reply with no state", { PR_VIEW_BODY: "<html>proxy error</html>" }, /gh pr view 101 printed no PR state/],
1948
+ ["a state that is not MERGED or CLOSED", { PR_VIEW_BODY: JSON.stringify({ state: "merged" }) }, /gh pr view 101 printed no PR state/],
1949
+ ];
1950
+ for (const [what, env, shown] of cases) {
1951
+ const r = runCli(["--reviewer-cap", "1", "--max-reviews", "1"], {
1952
+ shortlist: shortlistText([]), prs: [REVIEW_DUE], ledger: { rows: zombieRows([101]) }, prStates: { 101: "MERGED" }, env,
1953
+ });
1954
+ assert.equal(r.status, 0, `${what}: ${r.stderr}`);
1955
+ assert.match(r.stderr, shown, what);
1956
+ assert.match(r.stdout, /^reviewers +1\/1 → AT CAP/m, what);
1957
+ assert.doesNotMatch(r.stdout, /DISPATCH review/, what);
1958
+ }
1959
+ });
1960
+
1961
+ test("CLI: an inherited GIT_DIR cannot retarget the merged-PR review probe", (t) => {
1962
+ const decoy = realpathSync(mkdtempSync(join(tmpdir(), "fleet-tick-decoy-pr-")));
1963
+ t.after(() => rmSync(decoy, { recursive: true, force: true }));
1964
+ spawnBounded("git", ["init", "-q", decoy]);
1965
+ const r = runCli(["--reviewer-cap", "1", "--max-reviews", "1"], {
1966
+ shortlist: shortlistText([]), prs: [REVIEW_DUE], ledger: { rows: zombieRows([101]) }, prStates: { 101: "OPEN" },
1967
+ env: { GIT_DIR: join(decoy, ".git"), GH_REPO: "someone/else" },
1968
+ });
1969
+ assert.equal(r.status, 0, r.stderr);
1970
+ assert.deepEqual(r.prViews, ["101"], "the probe must still run");
1971
+ assert.match(r.stdout, /^reviewers +1\/1 → AT CAP/m, "101 is OPEN in the case's own repository - its review keeps the slot");
1972
+ });
1973
+
1846
1974
  // #2210, end to end: a real repo, a real baseline, a real stray write.
1847
1975
  const LIVE = {
1848
1976
  ledger: { rows: ["#412 impl-412 · tier-ok=impl-412:fleet-implementer"], dispatched: ["impl-412"] },
@@ -2032,9 +2032,12 @@ is never something to assume.
2032
2032
  `fix-pr-<pr#>` is one; a finisher and a CI wait are zero; and a PR holds at most
2033
2033
  one slot at a time. In-flight reviews are derived, never remembered: a row with
2034
2034
  `review=` and no `reviewed=` is one, which is why the token goes on before
2035
- anything else. The defaults are 2 implementers and 6 reviewers, and neither is
2036
- a hard cap. The cap bounds units, not the agents a review fans out to — 1
2037
- snapshot + up to 6 specialists + 2 refuters per critical/important finding — and
2035
+ anything else. A review whose PR has left the open list is probed: when
2036
+ `gh pr view` reports that PR MERGED or CLOSED, the tick drops the review,
2037
+ since none runs against a finished PR, and a probe that
2038
+ cannot answer keeps the slot. The defaults are 2 implementers and 6 reviewers,
2039
+ and neither is a hard cap. The cap bounds units, not the agents a review fans
2040
+ out to — 1 snapshot + up to 6 specialists + 2 refuters per critical/important finding — and
2038
2041
  how many reviews may be in flight at once, inside it, is `--max-reviews`. The
2039
2042
  slots a review does not hold still serve fix-appliers.
2040
2043
 
@@ -3704,7 +3707,8 @@ written with `row` (**Reviewers**): `review=wf:<runId>` |
3704
3707
  `review=member:review-pr-<n>` | `review=fallback:review-pr-<n>[-b]` at launch,
3705
3708
  settled dead as `…=failed`, then `reviewed=<head>:<survived>/<refuted>/<unverified>`
3706
3709
  when the result lands. A row with `review=` and no `reviewed=` is a review in
3707
- flight, and the tick counts it against the reviewer cap. `ci=<run-id>:<attempt>:<conclusion>`
3710
+ flight, and the tick counts it against the reviewer cap unless gh reports its PR
3711
+ MERGED or CLOSED. `ci=<run-id>:<attempt>:<conclusion>`
3708
3712
  and `held-behind:#<lower>` are row tokens the same way (Phase 3), and so is
3709
3713
  `conflict-hold:#<pr>` — the one row token the merge bot writes itself, naming
3710
3714
  the row's own PR (`run-merge-bot.md` step 1). It is not an Exclusion: that