@feigi/fleet-ctl 3.100.1 → 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 +1 -1
- package/scripts/phase0.mjs +6 -2
- package/scripts/stranded.mjs +169 -16
- package/skills/run-team/references/phase-0.md +20 -3
package/package.json
CHANGED
package/scripts/phase0.mjs
CHANGED
|
@@ -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
|
|
71
|
-
//
|
|
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
|
package/scripts/stranded.mjs
CHANGED
|
@@ -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
|
|
29
|
-
// still be live, and rotate carried their tokens
|
|
30
|
-
// `carried` lists them instead, for the controller to
|
|
31
|
-
// against the session's job list
|
|
32
|
-
// none nothing proves the previous run dead: every claim
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|