@feigi/fleet-ctl 3.82.12 → 3.82.13

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.82.12",
3
+ "version": "3.82.13",
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": {
@@ -118,7 +118,10 @@
118
118
  // row: `pull.mjs next` skips it, `next --head` would start the
119
119
  // ticket over, so the next head fills the slot. Nor is it counted
120
120
  // in `unclaimed=` or toward the short-queue refresh, so a
121
- // shortlist of ended heads refreshes. A record from another
121
+ // shortlist of ended heads refreshes. A Pull that left on an
122
+ // exclusion (its reason starts `excluded ·`) is no such end: its
123
+ // head is counted and Pulled again once the exclusion lifts. A
124
+ // record from another
122
125
  // phase-0 session, or one unreadable or absent, is the fresh row:
123
126
  // `pull.mjs next` starts or resumes it.
124
127
  // session The listing of the session directory omp writes one
@@ -139,7 +142,7 @@
139
142
  // the ledger; main() does the I/O. Split so the guard table and the reading are
140
143
  // both unit-testable without a network.
141
144
 
142
- import { parseMember, parseToken, premisesOf, rowNums, nextRetry, readRetired, REVIEW, REVIEWED } from "./ledger-grammar.mjs";
145
+ import { parseMember, parseToken, premisesOf, rowNums, nextRetry, readRetired, pullEnded, REVIEW, REVIEWED } from "./ledger-grammar.mjs";
143
146
 
144
147
  // The implementer and reviewer rows' TARGET is the In-flight bound; the merge
145
148
  // bot's is 1. Availability of work belongs in the ACTION, not the target: a
@@ -273,8 +276,10 @@ const QUESTION_NUMBERS = { staleness: "Q1", close: "Q1b", decided: "Q2", collisi
273
276
  * PULL row: `{kind: "pending", qid, stale}`, `{kind: "dispatched", text}` with
274
277
  * the text the Pull's own `ledger.mjs row` wrote (null when it recorded none),
275
278
  * `{kind: "ended"}` for a Pull that left or was released, or null for the
276
- * fresh row. `since` is the file's mtime and `session` this run's phase-0
277
- * session, null when the receipt names none.
279
+ * fresh row. A Pull that left on an Exclusion (its reason starts `excluded ·`)
280
+ * is the fresh row too: it is no end, since the head claims a PULL row again
281
+ * once the tick's own lift logic admits it. `since` is the file's mtime and
282
+ * `session` this run's phase-0 session, null when the receipt names none.
278
283
  */
279
284
  export function pullOf(record, { session, since, now, staleMinutes }) {
280
285
  if (record === null || typeof record !== "object" || Array.isArray(record)) return null;
@@ -283,7 +288,14 @@ export function pullOf(record, { session, since, now, staleMinutes }) {
283
288
  if (session !== null && record.session !== session) return null;
284
289
  // pull.mjs's `next` skips a Pull that ended in this run, and `next --head`
285
290
  // would start the ticket over: the head is no one for a PULL row to name.
286
- if (record.status === "left" || record.status === "released") return { kind: "ended" };
291
+ // `pullEnded` is the one test both read. A Pull that left on an Exclusion is
292
+ // no such end, so it falls through to the fresh row. While a `behind-pr:#M`
293
+ // premise stands, the ledger claims the head and it never reaches here; a
294
+ // `behind-issue` or branch-named premise only the refreshed shortlist keeps
295
+ // out, by dropping its ticket (unclaimed), so after a failed refresh the
296
+ // head can get a PULL row that `pull.mjs` leaves again. Once the premise
297
+ // lifts, `next` takes it again.
298
+ if (pullEnded(record)) return { kind: "ended" };
287
299
  if (record.status === "running" && typeof record.pending?.qid === "string") {
288
300
  return { kind: "pending", qid: record.pending.qid, stale: now - since > staleMinutes * 60_000 };
289
301
  }
@@ -308,7 +320,8 @@ function pullAction(n, p) {
308
320
  }
309
321
 
310
322
  // The heads a PULL row could name: a head whose Pull ended in this run takes
311
- // no slot, and the next head takes it. Order kept, so the slots fill oldest first.
323
+ // no slot, and the next head takes it. A Pull that left on an Exclusion is no
324
+ // such end (pullOf). Order kept, so the slots fill oldest first.
312
325
  function pullable(heads, pulls) {
313
326
  return heads.filter((n) => pulls?.get(n)?.kind !== "ended");
314
327
  }
@@ -366,7 +379,8 @@ function implementers(s) {
366
379
  if (heads.length) return heads.slice(0, s.bound - s.inFlight).map((n) => one(pullAction(n, s.pulls?.get(n)), { acts: true }));
367
380
 
368
381
  // Nothing to Pull even after a refresh: no unclaimed head, or only heads
369
- // whose Pull ended in this run, which `pull.mjs next` skips. `SUGGEST /triage` asks a
382
+ // whose Pull ended in this run, which `pull.mjs next` skips — a Pull that
383
+ // left on an Exclusion is no such end (pullOf). `SUGGEST /triage` asks a
370
384
  // MAINTAINER to tick tickets, and on the unattended night the heartbeat
371
385
  // exists for there is nobody to ask — so it prints (the fold shows it once)
372
386
  // but is not actionable, or it would pin the interval at the base all night.
@@ -1195,10 +1209,12 @@ export function deriveRun({ rows, dispatched, drain, retired = [] }, prs, closed
1195
1209
  // `behind-issue` or branch-named premise) is different: its row is rewritten
1196
1210
  // only when a Pull dispatches the ticket (shortlist.mjs's own header, §3),
1197
1211
  // never on its own, so once shortlist.mjs's live probe has re-admitted the
1198
- // ticket to these entries, that stale row no longer blocks it.
1212
+ // ticket to these entries, that stale row no longer blocks it. A row naming no
1213
+ // premise has nothing to lift: it claims its ticket for good, as `pull.mjs
1214
+ // next` and shortlist.mjs read it.
1199
1215
  export function unclaimed(entries, run) {
1200
1216
  const unverifiable = new Set(run.excluded
1201
- .filter((e) => e.premises.every(({ kind, target }) => kind !== "pr" || !isDigits(target)))
1217
+ .filter((e) => e.premises.length > 0 && e.premises.every(({ kind, target }) => kind !== "pr" || !isDigits(target)))
1202
1218
  .map((e) => e.n));
1203
1219
  return entries.map((e) => e.n).filter((n) => !run.claimed.has(n) || unverifiable.has(n));
1204
1220
  }
@@ -1226,8 +1242,9 @@ export function parseShortlist(text) {
1226
1242
 
1227
1243
  // Why this tick refreshes the shortlist, or null. `unclaimed` counts the
1228
1244
  // heads a PULL row could name, so a head whose Pull ended in this run is not
1229
- // in it. The premise-lifted
1230
- // trigger costs gh calls, so main() asks it only when these answer null.
1245
+ // in it — a Pull that left on an Exclusion is no such end (pullOf). The
1246
+ // premise-lifted trigger costs gh calls, so main() asks it only when these
1247
+ // answer null.
1231
1248
  // Nothing refreshes while the bound holds (`held`, n ≥ B, whichever HOLD row
1232
1249
  // prints): a refresh then cannot feed a Pull, and the first tick back under
1233
1250
  // the bound refreshes and Pulls in the same tick, so nothing is lost.
@@ -2168,8 +2185,8 @@ function main() {
2168
2185
  if (scratch === null) console.error(`${NAME}: report files and Pull records not checked — no scratch in the phase-0 receipt and no --scratch`);
2169
2186
  // Each head's Pull record read, not only the heads a free slot names: a head
2170
2187
  // whose Pull ended gives its slot to the next, so the heads past the slots
2171
- // can fill one. The session it is judged against is the receipt's, as
2172
- // pull.mjs judges it.
2188
+ // can fill one. A Pull that left on an Exclusion is no such end (pullOf). The
2189
+ // session it is judged against is the receipt's, as pull.mjs judges it.
2173
2190
  const runSession = receipt.read.state === "ok" && receipt.read.receipt.session !== "" ? receipt.read.receipt.session : null;
2174
2191
  const pullsOf = (hs) => scratch === null ? new Map()
2175
2192
  : readPulls(scratch.root, hs, { session: runSession, now: Date.now(), staleMinutes: pullStaleMinutes });
@@ -2186,7 +2203,8 @@ function main() {
2186
2203
  let fresh = null;
2187
2204
  // Short means fewer heads than B that a PULL row could name: `pull.mjs next`
2188
2205
  // skips a head whose Pull ended in this run, so counting it would hold back
2189
- // the refresh that finds the head to take its slot.
2206
+ // the refresh that finds the head to take its slot. A Pull that left on an
2207
+ // Exclusion is no such end (pullOf).
2190
2208
  const counted = pullable(heads, pulls).length;
2191
2209
  // While the bound holds a refresh cannot feed a Pull, so none of its
2192
2210
  // triggers runs: the first tick back under the bound refreshes and Pulls in
@@ -609,6 +609,20 @@ export function premisesOf(row) {
609
609
  return [...m[1].matchAll(PREMISE)].map(([, kind, target]) => ({ kind, target }));
610
610
  }
611
611
 
612
+ /** The prefix of the `outcome.reason` of a Pull that ended on an Exclusion. pull.mjs writes it on every path that
613
+ * ends a Pull on an Exclusion naming a premise; a row naming no premise leaves the row's own text as the reason,
614
+ * which carries the prefix only when the row does — that Exclusion never lifts, so neither reader takes its head
615
+ * again whatever the reason says. The fleet tick and `pull.mjs next` read it to tell such a Pull from one that ended
616
+ * for good: taken, relabelled, closed or released. */
617
+ export const EXCLUDED_REASON = "excluded ·";
618
+
619
+ /** Whether a Pull's `outcome.reason` says it ended on an Exclusion. */
620
+ const exclusionEnded = (reason) => typeof reason === "string" && reason.startsWith(EXCLUDED_REASON);
621
+
622
+ /** Whether a Pull record ended for good: released, or left for any reason but an Exclusion. The fleet tick's
623
+ * pullOf and `pull.mjs next` both read a Pull's end through it, so the two cannot disagree. */
624
+ export const pullEnded = (record) => record.status === "released" || (record.status === "left" && !exclusionEnded(record.outcome?.reason));
625
+
612
626
  export const ROWS = "## Rows";
613
627
  export const DISPATCHED = "## Dispatched";
614
628
  export const FILED = "## Filed";
package/scripts/pull.mjs CHANGED
@@ -8,7 +8,9 @@
8
8
  // Reached through the Resolver and run from the MAIN checkout (a linked
9
9
  // worktree refuses). `next` starts a Pull on the shortlist head — `--head`, else
10
10
  // the first `.fleet/shortlist.json` entry with no `impl-` row, no live
11
- // `excluded` row and no Pull of its own already ended in this phase-0 session — or resumes the
11
+ // `excluded` row and no Pull of its own already ended in this phase-0 session
12
+ // (a Pull that ended on an exclusion whose premise has since lifted is not
13
+ // ended in this sense, and its head is taken again) — or resumes the
12
14
  // one in progress for `--head`; `answer` records one answer and resumes. Both
13
15
  // run until the next question or the end of the Pull, and both are safe to
14
16
  // run again: the state is on disk after every step.
@@ -132,7 +134,7 @@ import { isDigits, makeDie } from "./arg.mjs";
132
134
  import { FleetDirUnresolvable, fleetFile } from "./fleet-dir.mjs";
133
135
  import { gitEnv } from "./git-env.mjs";
134
136
  import { isCLI } from "./is-cli.mjs";
135
- import { memberTokens, premisesOf, tierValues } from "./ledger-grammar.mjs";
137
+ import { EXCLUDED_REASON, memberTokens, premisesOf, pullEnded, tierValues } from "./ledger-grammar.mjs";
136
138
  import { sessionFromReceipt } from "./phase0-receipt.mjs";
137
139
  import { briefPaths, briefText, outOfScope, strandedRuling } from "./ticket-router.mjs";
138
140
 
@@ -1133,7 +1135,7 @@ class Pull {
1133
1135
  }
1134
1136
  const r = this.script("exclude", "ledger.mjs", ["row", String(st.ticket), `excluded · ${token}`]);
1135
1137
  if (r.status !== 0) return this.stop(withText(`ledger.mjs row failed (${r.how})`, r));
1136
- return this.leave(`excluded · ${token}`);
1138
+ return this.leave(`${EXCLUDED_REASON} ${token}`);
1137
1139
  }
1138
1140
  }
1139
1141
 
@@ -1196,7 +1198,7 @@ function heldOut(p, n) {
1196
1198
  throw new Refusal(`#${n}: the premise of its exclusion could not be read, so the exclusion stands: ${standing.unread} — run again once gh answers`);
1197
1199
  }
1198
1200
  if (standing.token === null) return row.slice(rowKey(row).length).trim();
1199
- return `excluded · ${standing.token}${standing.state === null ? "" : ` ${standing.state}`}`;
1201
+ return `${EXCLUDED_REASON} ${standing.token}${standing.state === null ? "" : ` ${standing.state}`}`;
1200
1202
  }
1201
1203
 
1202
1204
  // The phase-0 session names the run a Pull belongs to: the persistent scratch
@@ -1235,7 +1237,8 @@ function shortlistHead(p, run) {
1235
1237
  continue;
1236
1238
  }
1237
1239
  const ended = readPull(p.statePath(n));
1238
- if (ended !== null && (ended.status === "left" || ended.status === "released") && (run === null || ended.session === run)) continue;
1240
+ // A Pull that left on an exclusion whose premise has lifted — checked above — is not an end: the head is taken again.
1241
+ if (ended !== null && pullEnded(ended) && (run === null || ended.session === run)) continue;
1239
1242
  return n;
1240
1243
  }
1241
1244
  if (unreadable.length > 0) {
@@ -149,7 +149,7 @@ phase, or in any later one, asks the maintainer which tickets to take.
149
149
  MERGED or CLOSED, or a `behind-issue` exclusion whose issue is not yet
150
150
  closed, names a ticket the file still carries, and prints
151
151
  `REFRESHED shortlist: <n> entries; <k> lifted`. A head whose Pull ended
152
- this run is not counted. Phase 0 runs it by hand
152
+ this run is not counted, unless on a lifted exclusion. Phase 0 runs it by hand
153
153
  once because a dead run's shortlist is stale by definition.
154
154
  None of those triggers runs while `HOLD (in-flight <n>/<B>)` stands
155
155
  (**Queue depth**).
@@ -452,7 +452,7 @@ unclaimed count is below the In-flight bound, when the file is missing or
452
452
  empty, when an Exclusion's premise has lifted, or when a branch-named
453
453
  `behind-pr` or a `behind-issue` Exclusion still not lifted names a ticket the
454
454
  file carries — never while the bound holds; the open PRs come off `gh`. A
455
- head whose Pull ended this run is not counted. A
455
+ head whose Pull ended this run is not counted, unless on a lifted exclusion. A
456
456
  ledger token it
457
457
  cannot read, it refuses rather than guessing — a member counted live or gone on
458
458
  a guess is the over- or under-dispatch it exists to prevent — so correct that
@@ -583,7 +583,7 @@ Pull, and each head a free slot Pulls gets its own row, read off that head's
583
583
  A head whose Pull already ended in this run — the ticket left, or the router
584
584
  refused it and its claim was released — gets no row: `next` without `--head`
585
585
  skips it, `next --head <N>` would start the ticket over, and the next head
586
- fills the slot.
586
+ fills the slot. A lifted exclusion's head (reason `excluded ·`) is the exception: it is counted and Pulled again.
587
587
 
588
588
  **A Pull never re-admits a ticket already relabelled**: the issue step reads the
589
589
  labels fresh and exits 1 `relabelled elsewhere` once `ready-for-agent` is gone.
@@ -1718,7 +1718,7 @@ Read the depth off the tick and never derive it by hand: act on the row it print
1718
1718
  - **shortlist** — `.fleet/shortlist.json`'s entries with no `impl-` row and no
1719
1719
  live `excluded` row: admissible, not yet pulled. The tick's `unclaimed=`.
1720
1720
  Not after a refresh at unclaimed < B (below). A head whose Pull ended this
1721
- run is not counted.
1721
+ run is not counted, unless on a lifted exclusion.
1722
1722
  - **supply** — `scanned`, the open `ready-for-agent` tickets `shortlist.mjs`'s
1723
1723
  candidate scan returned, before any filter. Only a Pull's full read judges
1724
1724
  decided?, so a shortlist of undecided tickets is supply that every Pull
@@ -1774,7 +1774,7 @@ consult under a merge-side event storm, and that is how implementers reached
1774
1774
  0/target with ~57 `ready-for-agent` in supply while nobody
1775
1775
  noticed (#3). What follows stays here as the explanation of what the script decides —
1776
1776
  never as a second, hand-run copy of it. The table's shortlist counts no head
1777
- whose Pull ended this run. A refresh at unclaimed < B leaves `unclaimed=` at
1777
+ whose Pull ended this run, unless on a lifted exclusion. A refresh at unclaimed < B leaves `unclaimed=` at
1778
1778
  that count, so `SUGGEST /triage` can print it above 0.
1779
1779
 
1780
1780
  | shortlist (unclaimed) | implementer row prints |
@@ -1792,7 +1792,7 @@ awaiting to-tickets, so an empty shortlist carrying one is not "no work".
1792
1792
  The refresh runs at unclaimed < B and, with every other refresh trigger, never
1793
1793
  while the bound holds: a refresh then cannot feed a Pull, and the first tick
1794
1794
  back under the bound refreshes and Pulls in the same tick. Unclaimed there
1795
- counts no head whose Pull ended this run.
1795
+ counts no head whose Pull ended this run, unless on a lifted exclusion.
1796
1796
 
1797
1797
  Four holds outrank every row: `HOLD (draining)` once `ledger.mjs drain` has
1798
1798
  recorded the drain, `HOLD (tier mismatch impl-<N>)` until a replacement is
@@ -10,12 +10,15 @@ The full text of each row `fleet-tick.mjs` prints and what it asks of the contro
10
10
  FAILED` is yours: `shortlist.mjs` could not run, and the implementer row is
11
11
  blind until it can. When the refresh ran because fewer than B heads were
12
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
13
+ this run not counted (unless it ended on an exclusion whose premise has
14
+ lifted), the implementer row's `unclaimed=` prints that same
14
15
  `<u>`, not the refreshed file's count.
15
16
  - `SUGGEST /triage, hold idle` — nothing is admissible after the refresh.
16
17
  Suggest, never run (**Queue depth**). A head whose Pull already ended in
17
18
  this run is not counted toward the refresh, so a shortlist of only such
18
- heads triggers the refresh before this row prints.
19
+ heads triggers the refresh before this row prints. It is not printed while the
20
+ refreshed shortlist holds a head whose Pull ended only on an exclusion whose
21
+ premise has lifted and a slot is free: that head gets its `PULL` row.
19
22
  - `specs-dropped=#<N> … (to-spec: User Stories heading)` at the end of the
20
23
  implementer row's counts, whatever its ACTION — the tickets the to-spec
21
24
  filter kept out of `supply`, read off the shortlist's `dropped`. Never