@feigi/fleet-ctl 3.76.5 → 3.76.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.76.5",
3
+ "version": "3.76.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": {
@@ -104,16 +104,20 @@
104
104
  // up; a member with no schema is never checked against one.
105
105
  // pulls `<scratch>/impl-<N>/pull.json`, pull.mjs's record of a Pull, for
106
106
  // every head on the shortlist, `<scratch>` as for the report
107
- // files. A question standing prints `PULL #<N> (Q<n> pending —
108
- // pull.mjs answer <N> <qid> …)` in place of the fresh row, with
109
- // ` (stale)` once the file is older than `--pull-stale-minutes`; a
110
- // Pull that dispatched with the head still holding no `impl-` row
111
- // prints `(dispatched, row missing — ledger.mjs row …)`. A Pull
112
- // that ended in this run's session (the ticket left, or its claim
113
- // was released) names no row: `pull.mjs next` skips it, `next
114
- // --head` would start the ticket over, so the next head fills the
115
- // slot. A record from another phase-0 session, or one unreadable
116
- // or absent, is the fresh row: `pull.mjs next` starts or resumes it.
107
+ // files: read before the refresh is judged, and again for the
108
+ // heads a refresh replaced them with. A question standing prints
109
+ // `PULL #<N> (Q<n> pending — pull.mjs answer <N> <qid> …)` in
110
+ // place of the fresh row, with ` (stale)` once the file is older
111
+ // than `--pull-stale-minutes`; a Pull that dispatched with the
112
+ // head still holding no `impl-` row prints `(dispatched, row
113
+ // missing — ledger.mjs row …)`. A Pull that ended in this run's
114
+ // session (the ticket left, or its claim was released) names no
115
+ // row: `pull.mjs next` skips it, `next --head` would start the
116
+ // ticket over, so the next head fills the slot. Nor is it counted
117
+ // in `unclaimed=` or toward the short-queue refresh, so a
118
+ // shortlist of ended heads refreshes. A record from another
119
+ // phase-0 session, or one unreadable or absent, is the fresh row:
120
+ // `pull.mjs next` starts or resumes it.
117
121
  // session The listing of the session directory omp writes one
118
122
  // `<member>.jsonl` transcript into per dispatched member: this
119
123
  // run's phase-0 receipt's `session`, else `--session`; with
@@ -278,12 +282,23 @@ function pullAction(n, p) {
278
282
  return `PULL #${n} — pull.mjs next --head ${n}`;
279
283
  }
280
284
 
285
+ // The heads a PULL row could name: a head whose Pull ended in this run takes
286
+ // no slot, and the next head takes it. Order kept, so the slots fill oldest first.
287
+ function pullable(heads, pulls) {
288
+ return heads.filter((n) => pulls?.get(n)?.kind !== "ended");
289
+ }
290
+
281
291
  // The implementer rows: one per head a free slot Pulls, else the one row
282
- // saying why none is pulled.
292
+ // saying why none is pulled. `unclaimed=` counts the heads a PULL row could
293
+ // name. A refresh whose trigger text names a count carries it as `unclaimed`,
294
+ // and the row prints that count, not the refreshed file's: one tick never
295
+ // prints two different counts.
283
296
  function implementers(s) {
284
297
  const dropped = specsDropped(s.dropped);
298
+ const heads = pullable(s.heads, s.pulls);
299
+ const counted = s.refresh?.unclaimed ?? heads.length;
285
300
  const one = mkRow("implementers", s.inFlight, s.bound,
286
- `implementers=${s.inFlightImpl} prs=${s.inFlightPrs} unclaimed=${s.heads.length} supply=${s.supply ?? "?"}`
301
+ `implementers=${s.inFlightImpl} prs=${s.inFlightPrs} unclaimed=${counted} supply=${s.supply ?? "?"}`
287
302
  + (s.shortlistStatus === "ok" ? "" : ` shortlist=${s.shortlistStatus}`)
288
303
  + (dropped ? ` ${dropped}` : ""));
289
304
  const row = (...a) => [one(...a)];
@@ -323,9 +338,7 @@ function implementers(s) {
323
338
  return s.owedCounted.length ? row(hold, { acts: true, extra: replaceStep(s.owedCounted, s.implNames) }) : row(hold);
324
339
  }
325
340
 
326
- // A head whose Pull ended in this run takes no slot: the next head does.
327
- const pullable = s.heads.filter((n) => s.pulls?.get(n)?.kind !== "ended");
328
- if (pullable.length) return pullable.slice(0, s.bound - s.inFlight).map((n) => one(pullAction(n, s.pulls?.get(n)), { acts: true }));
341
+ if (heads.length) return heads.slice(0, s.bound - s.inFlight).map((n) => one(pullAction(n, s.pulls?.get(n)), { acts: true }));
329
342
 
330
343
  // Nothing to Pull even after a refresh: no unclaimed head, or only heads
331
344
  // whose Pull ended in this run, which `pull.mjs next` skips. `SUGGEST /triage` asks a
@@ -1138,7 +1151,9 @@ export function parseShortlist(text) {
1138
1151
  };
1139
1152
  }
1140
1153
 
1141
- // Why this tick refreshes the shortlist, or null. The premise-lifted
1154
+ // Why this tick refreshes the shortlist, or null. `unclaimed` counts the
1155
+ // heads a PULL row could name, so a head whose Pull ended in this run is not
1156
+ // in it. The premise-lifted
1142
1157
  // trigger costs gh calls, so main() asks it only when these answer null.
1143
1158
  // Nothing refreshes while the bound holds (`held`, n ≥ B, whichever HOLD row
1144
1159
  // prints): a refresh then cannot feed a Pull, and the first tick back under
@@ -1147,10 +1162,15 @@ export function refreshWhy({ draining, held, status, entries, unclaimed: k, boun
1147
1162
  if (draining !== null || held) return null;
1148
1163
  if (status !== "ok") return `shortlist ${status}`;
1149
1164
  if (entries === 0) return "shortlist empty";
1150
- if (k < bound) return `unclaimed ${k} < in-flight bound ${bound}`;
1165
+ if (k < bound) return shortQueue(k, bound);
1151
1166
  return null;
1152
1167
  }
1153
1168
 
1169
+ // The short-queue trigger's text, the one trigger that names a count.
1170
+ function shortQueue(k, bound) {
1171
+ return `unclaimed ${k} < in-flight bound ${bound}`;
1172
+ }
1173
+
1154
1174
  // How long, in minutes, a live token of each family may run before the tick
1155
1175
  // asks the controller to look at it. Each default is that family's p99
1156
1176
  // run time: a token past it is rare among members that finished, so a line
@@ -2046,49 +2066,58 @@ function main() {
2046
2066
  if (unchecked.length && applyTierChecks(unchecked, phase0.session)) run = readRun(prs);
2047
2067
  }
2048
2068
 
2069
+ // The scratch root of the report files and the Pull records alike.
2070
+ const scratch = reportsScratch(receipt, scratchFlag);
2071
+ if (scratch === null) console.error(`${NAME}: report files and Pull records not checked — no scratch in the phase-0 receipt and no --scratch`);
2072
+ // Each head's Pull record read, not only the heads a free slot names: a head
2073
+ // whose Pull ended gives its slot to the next, so the heads past the slots
2074
+ // can fill one. The session it is judged against is the receipt's, as
2075
+ // pull.mjs judges it.
2076
+ const runSession = receipt.read.state === "ok" && receipt.read.receipt.session !== "" ? receipt.read.receipt.session : null;
2077
+ const pullsOf = (hs) => scratch === null ? new Map()
2078
+ : readPulls(scratch.root, hs, { session: runSession, now: Date.now(), staleMinutes: pullStaleMinutes });
2079
+
2049
2080
  // Pull from the current file first; refresh when it is short, absent, an
2050
2081
  // exclusion has lifted, or a branch-named or issue one still names a ticket on it.
2051
2082
  // A refresh that succeeds replaces the heads outright: the PULL is read off
2052
2083
  // what shortlist.mjs just probed, never merged with the file's own heads,
2053
2084
  // or a ticket the refresh dropped would be pulled anyway.
2054
2085
  let heads = unclaimed(current.entries, run);
2086
+ let pulls = pullsOf(heads);
2055
2087
  let supply = current.scanned;
2056
2088
  let dropped = current.dropped;
2057
2089
  let fresh = null;
2090
+ // Short means fewer heads than B that a PULL row could name: `pull.mjs next`
2091
+ // skips a head whose Pull ended in this run, so counting it would hold back
2092
+ // the refresh that finds the head to take its slot.
2093
+ const counted = pullable(heads, pulls).length;
2058
2094
  // While the bound holds a refresh cannot feed a Pull, so none of its
2059
2095
  // triggers runs: the first tick back under the bound refreshes and Pulls in
2060
2096
  // the same tick.
2061
2097
  const held = run.inFlight >= caps.bound;
2062
2098
  const trigger = refreshWhy({ draining: run.draining, held, status: current.status, entries: current.entries.length,
2063
- unclaimed: heads.length, bound: caps.bound })
2099
+ unclaimed: counted, bound: caps.bound })
2064
2100
  ?? (run.draining === null && !held ? liftedPremise(run.excluded, current.entries, prs) : null);
2065
2101
  if (trigger) {
2066
2102
  const r = refresh();
2067
2103
  if (r.ok) {
2068
2104
  const excludedSet = new Set(run.excluded.map((x) => x.n));
2069
2105
  heads = unclaimed(r.entries, run);
2106
+ pulls = pullsOf(heads);
2070
2107
  supply = r.scanned;
2071
2108
  dropped = r.dropped;
2072
2109
  fresh = {
2073
2110
  ok: true, trigger, entries: r.entries.length,
2074
2111
  lifted: r.entries.filter((e) => excludedSet.has(e.n)).length,
2075
2112
  changed: r.entries.map((e) => e.n).join(",") !== current.entries.map((e) => e.n).join(","),
2113
+ // The count this trigger's text names, which the implementer row prints in place of the refreshed file's.
2114
+ ...(trigger === shortQueue(counted, caps.bound) ? { unclaimed: counted } : {}),
2076
2115
  };
2077
2116
  } else {
2078
2117
  fresh = { ok: false, trigger, why: r.why };
2079
2118
  }
2080
2119
  }
2081
2120
 
2082
- // The scratch root of the report files and the Pull records alike.
2083
- const scratch = reportsScratch(receipt, scratchFlag);
2084
- if (scratch === null) console.error(`${NAME}: report files and Pull records not checked — no scratch in the phase-0 receipt and no --scratch`);
2085
- // Each head's Pull record read, not only the heads a free slot names: a head
2086
- // whose Pull ended gives its slot to the next, so the heads past the slots
2087
- // can fill one. The session it is judged against is the receipt's, as
2088
- // pull.mjs judges it.
2089
- const runSession = receipt.read.state === "ok" && receipt.read.receipt.session !== "" ? receipt.read.receipt.session : null;
2090
- const pulls = scratch === null ? new Map()
2091
- : readPulls(scratch.root, heads, { session: runSession, now: Date.now(), staleMinutes: pullStaleMinutes });
2092
2121
  const rows = reconcile({
2093
2122
  ...caps, ...run, heads, supply, dropped, shortlistStatus: current.status, refresh: fresh, mainCheckout, phase0, router, pulls,
2094
2123
  });
@@ -147,7 +147,8 @@ phase, or in any later one, asks the maintainer which tickets to take.
147
147
  lifted, and when a branch-named `behind-pr` exclusion whose PR is not yet
148
148
  MERGED or CLOSED, or a `behind-issue` exclusion whose issue is not yet
149
149
  closed, names a ticket the file still carries, and prints
150
- `REFRESHED shortlist: <n> entries; <k> lifted`. Phase 0 runs it by hand
150
+ `REFRESHED shortlist: <n> entries; <k> lifted`. A head whose Pull ended
151
+ this run is not counted. Phase 0 runs it by hand
151
152
  once because a dead run's shortlist is stale by definition.
152
153
  None of those triggers runs while `HOLD (in-flight <n>/<B>)` stands
153
154
  (**Queue depth**).
@@ -249,6 +250,7 @@ unclaimed count is below the In-flight bound, when the file is missing or
249
250
  empty, when an Exclusion's premise has lifted, or when a branch-named
250
251
  `behind-pr` or a `behind-issue` Exclusion still not lifted names a ticket the
251
252
  file carries — never while the bound holds; the open PRs come off `gh`. A
253
+ head whose Pull ended this run is not counted. A
252
254
  ledger token it
253
255
  cannot read, it refuses rather than guessing — a member counted live or gone on
254
256
  a guess is the over- or under-dispatch it exists to prevent — so correct that
@@ -2110,6 +2112,8 @@ Read the depth off the tick and never derive it by hand: act on the row it print
2110
2112
 
2111
2113
  - **shortlist** — `.fleet/shortlist.json`'s entries with no `impl-` row and no
2112
2114
  live `excluded` row: admissible, not yet pulled. The tick's `unclaimed=`.
2115
+ Not after a refresh at unclaimed < B (below). A head whose Pull ended this
2116
+ run is not counted.
2113
2117
  - **supply** — `scanned`, the open `ready-for-agent` tickets `shortlist.mjs`'s
2114
2118
  candidate scan returned, before any filter. Only a Pull's full read judges
2115
2119
  decided?, so a shortlist of undecided tickets is supply that every Pull
@@ -2149,18 +2153,12 @@ Read the depth off the tick and never derive it by hand: act on the row it print
2149
2153
 
2150
2154
  **The review backlog never gates, and the merge-queue depth is still no
2151
2155
  signal.** The only gate on a Pull is the In-flight count. A deep backlog is a
2152
- `DISPATCH review` on the same tick, never a reason to idle an implementer. A
2153
- deep `ready-to-merge` queue adds no rebases per PR — each PR rebases exactly
2154
- once, when it becomes the candidate — and those PRs count as In flight because
2155
- they sit between Claim and merge, not because the queue is deep.
2156
-
2157
- **Reconcile, do not wait for an event.** "A slot is free and the shortlist is
2158
- non-empty" is a *level* condition — re-derive the deficit on **every** tick,
2159
- whatever woke you: a member finishing, a member bailing before implementing, a
2160
- merge landing. An edge-triggered loop that only Pulls on completion stalls
2161
- silently the moment the shortlist empties, because 0 implementers emit no
2162
- completion event. With the shortlist empty the table below governs — refresh,
2163
- and never Pull a ticket the shortlist does not hold.
2156
+ `DISPATCH review` on the same tick, never a reason to idle an implementer.
2157
+ For the merge queue, see references/pull.md.
2158
+
2159
+ **Reconcile, do not wait for an event.** With the shortlist empty the table
2160
+ below governs — refresh, and never Pull a ticket the shortlist does not hold.
2161
+ See references/pull.md.
2164
2162
 
2165
2163
  **Do not re-derive this by hand — `fleet-tick.mjs` computes it.** One invocation
2166
2164
  prints per-role `actual/target` and an explicit ACTION with the backlog gate
@@ -2170,7 +2168,9 @@ the whole point: a table you must remember to consult is one you will not
2170
2168
  consult under a merge-side event storm, and that is how implementers reached
2171
2169
  0/target with one approved ticket waiting and ~57 `ready-for-agent` in supply while nobody
2172
2170
  noticed (#3). What follows stays here as the explanation of what the script decides —
2173
- never as a second, hand-run copy of it.
2171
+ never as a second, hand-run copy of it. The table's shortlist counts no head
2172
+ whose Pull ended this run. A refresh at unclaimed < B leaves `unclaimed=` at
2173
+ that count, so `SUGGEST /triage` can print it above 0.
2174
2174
 
2175
2175
  | shortlist (unclaimed) | implementer row prints |
2176
2176
  |---|---|
@@ -2186,7 +2186,8 @@ awaiting to-tickets, so an empty shortlist carrying one is not "no work".
2186
2186
 
2187
2187
  The refresh runs at unclaimed < B and, with every other refresh trigger, never
2188
2188
  while the bound holds: a refresh then cannot feed a Pull, and the first tick
2189
- back under the bound refreshes and Pulls in the same tick.
2189
+ back under the bound refreshes and Pulls in the same tick. Unclaimed there
2190
+ counts no head whose Pull ended this run.
2190
2191
 
2191
2192
  Four holds outrank every row: `HOLD (draining)` once `ledger.mjs drain` has
2192
2193
  recorded the drain, `HOLD (tier mismatch impl-<N>)` until a replacement is
@@ -435,3 +435,16 @@ raising B past that buys nothing. 8 was rejected: peak review concurrency above
435
435
  the former reviewer count, for a throughput gain the backlog does not show it
436
436
  needs. Re-derive it from the map's guard once 20 or more PRs have merged under
437
437
  it, never from one run's rows.
438
+
439
+ A deep `ready-to-merge` queue adds no rebases per PR — each PR rebases exactly
440
+ once, when it becomes the candidate — and those PRs count as In flight because
441
+ they sit between Claim and merge, not because the queue is deep.
442
+
443
+ ## Reconcile, do not wait for an event
444
+
445
+ "A slot is free and the shortlist is
446
+ non-empty" is a *level* condition — re-derive the deficit on **every** tick,
447
+ whatever woke you: a member finishing, a member bailing before implementing, a
448
+ merge landing. An edge-triggered loop that only Pulls on completion stalls
449
+ silently the moment the shortlist empties, because 0 implementers emit no
450
+ completion event.
@@ -8,9 +8,14 @@ The full text of each row `fleet-tick.mjs` prints and what it asks of the contro
8
8
  - `REFRESHED shortlist: <n> entries; <k> lifted` — the tick refreshed the
9
9
  shortlist itself, and its `PULL` row already reads the result. `REFRESH
10
10
  FAILED` is yours: `shortlist.mjs` could not run, and the implementer row is
11
- blind until it can.
11
+ blind until it can. When the refresh ran because fewer than B heads were
12
+ unclaimed (`unclaimed <u> < in-flight bound <B>`), a head whose Pull ended
13
+ this run not counted, the implementer row's `unclaimed=` prints that same
14
+ `<u>`, not the refreshed file's count.
12
15
  - `SUGGEST /triage, hold idle` — nothing is admissible after the refresh.
13
- Suggest, never run (**Queue depth**).
16
+ Suggest, never run (**Queue depth**). A head whose Pull already ended in
17
+ this run is not counted toward the refresh, so a shortlist of only such
18
+ heads triggers the refresh before this row prints.
14
19
  - `specs-dropped=#<N> … (to-spec: User Stories heading)` at the end of the
15
20
  implementer row's counts, whatever its ACTION — the tickets the to-spec
16
21
  filter kept out of `supply`, read off the shortlist's `dropped`. Never