@feigi/fleet-ctl 3.100.0 → 3.100.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.100.0",
3
+ "version": "3.100.2",
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,11 +118,10 @@ function arm(pi, ctx) {
118
118
  const now = Date.now();
119
119
  const due = alarmDue({ kind: ctx.agent.kind, pid: process.pid, controller, beat, idle: ctx.isIdle(), now, firedFor });
120
120
  if (!due) return;
121
- // A send that rejects later must not surface as an unhandled rejection in a session that is not ours.
122
- Promise.resolve(pi.sendUserMessage(ALARM_LINE)).catch(() => {});
121
+ pi.sendUserMessage(ALARM_LINE);
123
122
  firedFor = { at: beat.at, firedAt: now };
124
123
  } catch {
125
- // Cannot read or judge: not due.
124
+ // Cannot read, judge or send: not fired, so the next poll tries again.
126
125
  }
127
126
  };
128
127
  ctx.setInterval(poll, POLL_MS);
@@ -67,8 +67,12 @@
67
67
  // review PR#<pr> (closes no issue)`.
68
68
  // stranded `stranded.mjs`. A `resume` entry whose ticket has no ledger row
69
69
  // yet is pending: `NEED resume #<n> <worktree> <branch>`. A
70
- // `report` entry is printed. Resuming a claim writes its row, so
71
- // a re-run after the dispatch finds the need met.
70
+ // `report` entry is printed as `report #<n> — <why>`, and the step
71
+ // stays ok: it covers the claims a dead run left under
72
+ // `in-progress`, and every implementer claim on the disk whose
73
+ // open issue holds no `in-progress` label and no open PR, whatever
74
+ // `prior` says and whatever the ledger holds. Resuming a claim
75
+ // writes its row, so a re-run after the dispatch finds the need met.
72
76
  // shortlist `shortlist.mjs`. A non-zero exit fails the step.
73
77
  //
74
78
  // THE RECEIPT is `.fleet/phase0.json` (resolved through fleet-dir.mjs, so a
@@ -25,11 +25,28 @@
25
25
  // maintainer's to rule on — and a list that does not answer, or a
26
26
  // detached worktree whose branch cannot be asked about, is
27
27
  // `report` too, never `resume`.
28
- // ancestor this session's earlier run: no claim listed — its members may
29
- // still be live, and rotate carried their tokens forward;
30
- // `carried` lists them instead, for the controller to reconcile
31
- // against the session's job list
32
- // none nothing proves the previous run dead: every claim `report`
28
+ // ancestor this session's earlier run: no in-progress claim listed — its
29
+ // members may still be live, and rotate carried their tokens
30
+ // forward; `carried` lists them instead, for the controller to
31
+ // reconcile against the session's job list
32
+ // none nothing proves the previous run dead: every in-progress claim
33
+ // `report`
34
+ //
35
+ // A claim with no `in-progress` label is listed whatever `prior` says, for no
36
+ // member of it is live without one. Each implementer claim on the disk — a
37
+ // worktree `.worktrees/<N>-impl-<N>[-x]` or a local branch
38
+ // `implementer/<N>-impl-<N>[-x]` — whose issue is open, has no `in-progress`
39
+ // label and no open PR is `report`, never `resume`: a bail that left commits
40
+ // behind cannot release its claim, and nothing else names it once the run that
41
+ // made it is gone. Its `why` is the blockers of a read-only `release-ticket.sh
42
+ // <N> impl-<N>[-x] implementer` dry run, then the two ways out: push the
43
+ // branch and open a PR, or release the claim by hand. A dry run or an issue
44
+ // read that does not answer still lists the claim, naming why. Under
45
+ // `ancestor` an issue read that does not answer names neither way out: no
46
+ // in-progress list was read, so the claim may be the earlier run's live
47
+ // member — its `why` says to check it against `carried`. The disk and
48
+ // the issue are read at every run, so the claim is listed until it is pushed
49
+ // or released, whatever any ledger holds. A closed issue's claim is not listed.
33
50
  //
34
51
  // "An open PR about the issue" is the in-flight check's reading, its branch rule
35
52
  // narrowed: a PR linked to the issue that is still open, a linked PR in another
@@ -70,6 +87,7 @@ const LIMIT = 1000;
70
87
  const GH_TIMEOUT_MS = 20_000;
71
88
  const GIT_TIMEOUT_MS = 20_000;
72
89
  const MAX_BUFFER = 64 * 1024 * 1024;
90
+ const RELEASE_TIMEOUT_MS = 120_000;
73
91
 
74
92
  const log = (line) => writeAll(2, `${line}\n`);
75
93
  const segment = (n) => new RegExp(`(^|[/-])${n}([-/]|$)`);
@@ -174,6 +192,92 @@ const real = (p) => {
174
192
  }
175
193
  };
176
194
 
195
+ // A claim's names, off claim-ticket.sh's shapes for an implementer:
196
+ // `.worktrees/<N>-impl-<N>[-x]` and `implementer/<N>-impl-<N>[-x]`, `-x` a
197
+ // replacement's single retry letter. Each captures the number and the member
198
+ // name `impl-<N>[-x]`, the slug release-ticket.sh takes.
199
+ const CLAIM_WORKTREE = /^([1-9][0-9]*)-(impl-\1(?:-[a-z])?)$/;
200
+ const CLAIM_BRANCH = /^implementer\/([1-9][0-9]*)-(impl-\1(?:-[a-z])?)$/;
201
+ const WAYS_OUT = "push the branch and open a PR, or release the claim by hand";
202
+
203
+ // Every local branch under `implementer/`.
204
+ function implementerBranches(cwd) {
205
+ const r = spawnSync("git", ["for-each-ref", "--format=%(refname:short)", "refs/heads/implementer/"], {
206
+ cwd, encoding: "utf8", maxBuffer: MAX_BUFFER, timeout: GIT_TIMEOUT_MS, env: gitEnv(),
207
+ });
208
+ if (r.error || r.status !== 0) die(`git for-each-ref failed (${how(r)}) — which claims have a branch is unknown`);
209
+ return r.stdout.split("\n").filter(Boolean);
210
+ }
211
+
212
+ // The implementer claims the disk holds, one per member name: its worktree
213
+ // under this checkout's `.worktrees/` and its local branch, each null when
214
+ // the disk holds none.
215
+ function diskClaims(all, home, branches) {
216
+ const claims = new Map();
217
+ const slot = (m) => {
218
+ if (!claims.has(m[2])) claims.set(m[2], { n: Number(m[1]), slug: m[2], worktree: null, branch: null });
219
+ return claims.get(m[2]);
220
+ };
221
+ for (const w of all) {
222
+ const m = w.prunable ? null : CLAIM_WORKTREE.exec(basename(w.path));
223
+ if (!m || !present(w.path) || real(dirname(w.path)) !== home) continue;
224
+ const c = slot(m);
225
+ c.worktree = w.path;
226
+ c.branch = w.branch;
227
+ }
228
+ for (const b of branches) {
229
+ const m = CLAIM_BRANCH.exec(b);
230
+ if (!m) continue;
231
+ const c = slot(m);
232
+ c.branch ??= b;
233
+ }
234
+ return [...claims.values()].sort((a, b) => a.n - b.n || a.slug.localeCompare(b.slug));
235
+ }
236
+
237
+ // One issue, as `gh issue view` shows it: `{issue}`, or `{failed}` — why it
238
+ // did not answer. Per claim, like closedHeadPr: an unanswered view leaves this
239
+ // claim reported with the reason, and the others classify.
240
+ function viewIssue(n) {
241
+ const r = spawnSync("gh", ["issue", "view", String(n), "--json", "number,title,url,state,labels,closedByPullRequestsReferences"], {
242
+ encoding: "utf8", maxBuffer: MAX_BUFFER, timeout: GH_TIMEOUT_MS, env: gitEnv({ GH_REPO: "" }),
243
+ });
244
+ if (r.error || r.status !== 0) return { failed: how(r) };
245
+ let v;
246
+ try {
247
+ v = JSON.parse(r.stdout);
248
+ } catch (e) {
249
+ return { failed: `unparsable: ${e.message}` };
250
+ }
251
+ const named = Array.isArray(v?.labels) && v.labels.every((l) => typeof l?.name === "string");
252
+ if (v?.number !== n || typeof v.state !== "string" || typeof v.url !== "string" || !named) {
253
+ return { failed: "not an issue with {number, state, url, labels}" };
254
+ }
255
+ return { issue: v };
256
+ }
257
+
258
+ // release-ticket.sh's dry run for a claim — it reads and changes nothing
259
+ // without `--apply`: `{blockers}`, what stands in the way of releasing it
260
+ // joined, empty when nothing does; or `{failed}`, why the dry run did not
261
+ // answer.
262
+ function releaseDryRun(cwd, n, slug) {
263
+ const script = join(dirname(fileURLToPath(import.meta.url)), "release-ticket.sh");
264
+ const r = spawnSync("sh", [script, String(n), slug, "implementer"], {
265
+ cwd, encoding: "utf8", maxBuffer: MAX_BUFFER, timeout: RELEASE_TIMEOUT_MS, env: gitEnv({ GH_REPO: "" }),
266
+ });
267
+ let receipt = null;
268
+ try {
269
+ receipt = JSON.parse(r.stdout);
270
+ } catch {
271
+ // Not a receipt: a refusal before it could write one, in prose.
272
+ }
273
+ const blockers = receipt?.blockers;
274
+ if (Array.isArray(blockers) && blockers.length > 0 && blockers.every((b) => typeof b === "string")) {
275
+ return { blockers: blockers.join("; ") };
276
+ }
277
+ if (r.status === 0 && receipt?.released === true && Array.isArray(blockers)) return { blockers: "" };
278
+ return { failed: how(r) };
279
+ }
280
+
177
281
  function main() {
178
282
  if (process.argv.length > 2) die(`takes no arguments, got ${process.argv.slice(2).join(" ")}`);
179
283
  let fleetDir;
@@ -189,23 +293,30 @@ function main() {
189
293
  // record, which is `none`: unknown, so nothing is resumed.
190
294
  const { controller } = readState(stateFileIn(fleetDir), NAME);
191
295
  const prior = controller?.prior ?? "none";
192
- if (prior === "ancestor") {
193
- log(`${NAME}: the previous run is this session's own earlier run — its claims are left alone, and the live tokens rotate carried are listed`);
194
- console.log(JSON.stringify({ prior, claims: [], carried: carriedTokens(join(fleetDir, "ledger.md")) }));
195
- return;
196
- }
296
+ const ancestor = prior === "ancestor";
297
+ if (ancestor) log(`${NAME}: the previous run is this session's own earlier run — its in-progress claims are left alone, and the live tokens rotate carried are listed`);
197
298
 
198
- const issues = ghList(`list of open ${LABEL} issues`, [
299
+ // An ancestor's in-progress claims are not listed, so neither list is asked
300
+ // for them: the open PR list waits for the first claim that needs it.
301
+ const issues = ancestor ? [] : ghList(`list of open ${LABEL} issues`, [
199
302
  "issue", "list", "--label", LABEL, "--state", "open", "--json", "number,title,url,closedByPullRequestsReferences",
200
303
  ]);
201
- const prs = ghList("list of open PRs", ["pr", "list", "--state", "open", "--json", "number,headRefName,url"]);
202
- const openByUrl = new Map(prs.map((p) => [p.url, p]));
304
+ let openPrs = null;
305
+ const open = () => {
306
+ if (openPrs === null) {
307
+ const prs = ghList("list of open PRs", ["pr", "list", "--state", "open", "--json", "number,headRefName,url"]);
308
+ openPrs = { prs, openByUrl: new Map(prs.map((p) => [p.url, p])) };
309
+ }
310
+ return openPrs;
311
+ };
312
+ if (!ancestor) open();
203
313
 
204
314
  // The PRs that make an issue someone else's business, or none — and, apart,
205
315
  // the open PRs whose branch carries the number somewhere other than as the
206
316
  // ticket's own: `<type>/<n>-<slug>` is claim-ticket.sh's shape, so
207
317
  // `implementer/1799-bump-node-20` names issue 1799 and merely ends in 20.
208
318
  const prsFor = (issue, seg) => {
319
+ const { prs, openByUrl } = open();
209
320
  const here = repoOf(issue.url);
210
321
  const linked = (issue.closedByPullRequestsReferences ?? []).flatMap((ref) => {
211
322
  if (openByUrl.has(ref.url)) return [`open PR #${openByUrl.get(ref.url).number}`];
@@ -220,6 +331,10 @@ function main() {
220
331
  mentioned: named.filter((p) => !own.test(p.headRefName)).map(describe),
221
332
  };
222
333
  };
334
+ const heldLog = (n, held) => log(` #${n} has ${held.join(", ")} — the fold-in owns it`);
335
+ const mentionedNote = (mentioned) => mentioned.length > 0
336
+ ? `; ${mentioned.join(", ")} carries the number outside the ticket position, so check whether it is this claim's`
337
+ : "";
223
338
 
224
339
  const home = real(join(checkout, ".worktrees"));
225
340
  const all = worktrees(checkout);
@@ -228,7 +343,7 @@ function main() {
228
343
  const seg = segment(issue.number);
229
344
  const { held, mentioned } = prsFor(issue, seg);
230
345
  if (held.length > 0) {
231
- log(` #${issue.number} has ${held.join(", ")} — the fold-in owns it`);
346
+ heldLog(issue.number, held);
232
347
  continue;
233
348
  }
234
349
  const mine = all.filter((w) => !w.prunable && seg.test(basename(w.path)) && present(w.path));
@@ -251,11 +366,49 @@ function main() {
251
366
  else why = "no worktree for it in this checkout";
252
367
  const action = prior === "dead" && one?.branch && mentioned.length === 0 && !head?.failed && !head?.closed ? "resume" : "report";
253
368
  // Not taken for its PR — but never resumed past one that might be.
254
- if (mentioned.length > 0) why += `; ${mentioned.join(", ")} carries the number outside the ticket position, so check whether it is this claim's`;
369
+ why += mentionedNote(mentioned);
255
370
  claims.push({ n: issue.number, t: issue.title, action, worktree: one?.path ?? null, branch: one?.branch ?? null, why });
256
371
  log(` #${issue.number} ${action} — ${why}`);
257
372
  }
258
- console.log(JSON.stringify({ prior, claims, carried: [] }));
373
+
374
+ // A claim no `in-progress` label holds live: its member is not running, so
375
+ // `prior` has nothing to say of it. A bail that left commits behind strands
376
+ // its claim this way, for release-ticket.sh refuses a branch ahead of
377
+ // origin/main. Reported, never resumed — and read off the disk and the
378
+ // issue at every run, so it is named until it is pushed or released.
379
+ const views = new Map();
380
+ const live = new Set(issues.map((i) => i.number));
381
+ for (const c of diskClaims(all, home, implementerBranches(checkout))) {
382
+ if (live.has(c.n)) continue;
383
+ if (!views.has(c.n)) views.set(c.n, viewIssue(c.n));
384
+ const { issue, failed } = views.get(c.n);
385
+ let why;
386
+ let mentioned = [];
387
+ if (failed) {
388
+ why = `gh issue view ${c.n} did not answer (${failed}) — whether ${c.slug}'s ticket is open, unlabelled and without a PR is unknown`;
389
+ // No in-progress list is read under ancestor, so nothing has ruled out
390
+ // that this is the earlier run's live member: never point at a release.
391
+ if (ancestor) why += `, and so is whether ${c.slug} is the earlier run's live member — check it against carried`;
392
+ } else {
393
+ if (issue.state !== "OPEN") continue;
394
+ if (issue.labels.some((l) => l.name === LABEL)) continue;
395
+ const found = prsFor(issue, segment(c.n));
396
+ if (found.held.length > 0) {
397
+ heldLog(c.n, found.held);
398
+ continue;
399
+ }
400
+ mentioned = found.mentioned;
401
+ const probe = releaseDryRun(checkout, c.n, c.slug);
402
+ if (probe.failed) why = `release-ticket.sh ${c.n} ${c.slug} implementer did not answer (${probe.failed}) — what stands in the way of releasing the claim is unknown`;
403
+ else if (probe.blockers) why = probe.blockers;
404
+ else why = "release-ticket.sh finds nothing in the way of releasing the claim";
405
+ }
406
+ if (!(failed && ancestor)) why += ` — ${WAYS_OUT}`;
407
+ why += mentionedNote(mentioned);
408
+ claims.push({ n: c.n, t: issue?.title ?? null, action: "report", worktree: c.worktree, branch: c.branch, why });
409
+ log(` #${c.n} report — ${why}`);
410
+ }
411
+ console.log(JSON.stringify({ prior, claims, carried: ancestor ? carriedTokens(join(fleetDir, "ledger.md")) : [] }));
259
412
  }
260
413
 
261
414
  // Every live token on the ledger, read through `ledger.mjs read` so the
@@ -424,7 +424,7 @@ token, row}]}`:
424
424
  `report` with `why` `PR #<M> closed unmerged`, and so does a list that
425
425
  does not answer, or a detached worktree whose branch cannot be asked
426
426
  about, never `resume`.
427
- - `prior: ancestor` — this session's own earlier run: no claim is listed.
427
+ - `prior: ancestor` — this session's own earlier run: no `in-progress` claim is listed.
428
428
  `carried` lists every live token rotate carried — its member or runner
429
429
  `name` (a `review=wf:` launch's runId), the `token` as the ledger holds
430
430
  it, and the `row` carrying it, null for a member only `## Dispatched`
@@ -446,8 +446,25 @@ token, row}]}`:
446
446
  (`wf:` launches alike), after which the tick owes the PR
447
447
  a review again under the next free reviewer name.
448
448
  - `prior: none` — no record proves the previous run dead (no record yet, or
449
- no `omp` process found above the rotation): every claim is `report`, and
450
- nothing is resumed.
449
+ no `omp` process found above the rotation): every `in-progress` claim is
450
+ `report`, and nothing is resumed.
451
+ - Whatever `prior` says — every implementer claim on the disk, a worktree
452
+ `.worktrees/<N>-impl-<N>[-x]` or a local branch
453
+ `implementer/<N>-impl-<N>[-x]`, whose issue is open and carries no
454
+ `in-progress` label and no open PR: `report`, never `resume`. Without
455
+ the label no member of the claim is live. A bail that left commits behind
456
+ cannot release its claim — `release-ticket.sh` refuses a branch ahead of
457
+ origin/main — so nothing else names it. Its `why` is the blocker list of
458
+ a read-only `release-ticket.sh <N> impl-<N>[-x] implementer` dry run (for
459
+ example `1 commit(s) ahead of origin/main`), then the two ways out: push
460
+ the branch and open a PR, or release the claim by hand. A dry run or an
461
+ issue read that does not answer still lists the claim, naming why. Under
462
+ `prior: ancestor` such an issue read names neither way out: no
463
+ `in-progress` list was read, so the claim may be the earlier run's live
464
+ member, and its `why` says to check it against `carried`.
465
+ Phase 0 reads the disk and the issue again at every run start, so the
466
+ claim is named until it is pushed or released, whatever any ledger holds.
467
+ A closed issue's claim is not listed.
451
468
 
452
469
  A `resume` follows the Member-killed row across the run boundary: a new
453
470
  member under a new name — `impl-<N>-b`, or one letter past the highest