@feigi/fleet-ctl 3.23.0 → 3.23.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 +1 -1
- package/scripts/fleet-tick.mjs +27 -9
- package/scripts/fleet-tick.test.mjs +114 -1
package/package.json
CHANGED
package/scripts/fleet-tick.mjs
CHANGED
|
@@ -912,6 +912,22 @@ const PR_LIMIT = 200;
|
|
|
912
912
|
const CLAIMED_LIMIT = 200;
|
|
913
913
|
const MAX_BUFFER = 64 * 1024 * 1024;
|
|
914
914
|
const GIT_TIMEOUT_MS = 10_000;
|
|
915
|
+
// Every gh spawn below is killed at this bound, so a gh that never answers is
|
|
916
|
+
// a failed read on that call's own failure path — the open-PR read refuses the
|
|
917
|
+
// tick, a probe leaves standing what it could not confirm, the claimed count
|
|
918
|
+
// reads unknown — never a tick that does not return. The bound is per gh call,
|
|
919
|
+
// not per tick: the probes run one after another, so a tick whose probes all
|
|
920
|
+
// hang waits one bound for each of them. 20 s, shortlist.mjs's own gh bound.
|
|
921
|
+
// FLEET_TICK_GH_TIMEOUT overrides it, in seconds, and can only ever
|
|
922
|
+
// SHORTEN it — ledger.mjs's LEDGER_GIT_TIMEOUT rule: a value that is not a
|
|
923
|
+
// positive whole number below the default leaves the default standing, in
|
|
924
|
+
// silence. Exported for its own test; it touches nothing.
|
|
925
|
+
export function ghBudget(defaultSeconds, override) {
|
|
926
|
+
const seconds = isDigits(String(override)) ? Number(override) : 0;
|
|
927
|
+
return (seconds > 0 && seconds < defaultSeconds ? seconds : defaultSeconds) * 1000;
|
|
928
|
+
}
|
|
929
|
+
const GH_TIMEOUT_MS = ghBudget(20, process.env.FLEET_TICK_GH_TIMEOUT);
|
|
930
|
+
const TIMED_OUT = `timed out after ${GH_TIMEOUT_MS / 1000}s`;
|
|
915
931
|
|
|
916
932
|
// die() shared with the other fleet scripts (writeSync-based, pipe-safe —
|
|
917
933
|
// see arg.mjs for the #176/#328/#363 rationale). This file parses its own
|
|
@@ -979,12 +995,12 @@ function openPrs() {
|
|
|
979
995
|
let out;
|
|
980
996
|
try {
|
|
981
997
|
out = execFileSync("gh", ["pr", "list", "--state", "open", "--limit", String(PR_LIMIT),
|
|
982
|
-
"--json", "number,labels,closingIssuesReferences,headRefOid"], { encoding: "utf8" });
|
|
998
|
+
"--json", "number,labels,closingIssuesReferences,headRefOid"], { encoding: "utf8", timeout: GH_TIMEOUT_MS });
|
|
983
999
|
} catch (e) {
|
|
984
1000
|
// Never interpolates e.stderr or e.message: execFileSync already forwarded
|
|
985
1001
|
// the child's stderr to ours, and Node builds e.message out of it, so
|
|
986
1002
|
// either one emits every byte a second time (#176).
|
|
987
|
-
die(`gh pr list failed: ${e.code ?? (e.signal ? `killed by ${e.signal}` : `exit ${e.status}`)} — a failed read is not an empty queue`);
|
|
1003
|
+
die(`gh pr list failed: ${e.code === "ETIMEDOUT" ? TIMED_OUT : e.code ?? (e.signal ? `killed by ${e.signal}` : `exit ${e.status}`)} — a failed read is not an empty queue`);
|
|
988
1004
|
}
|
|
989
1005
|
let prs;
|
|
990
1006
|
try {
|
|
@@ -1006,8 +1022,10 @@ function openPrs() {
|
|
|
1006
1022
|
return prs;
|
|
1007
1023
|
}
|
|
1008
1024
|
|
|
1009
|
-
// A child that did not exit 0, named with the last line it printed.
|
|
1025
|
+
// A child that did not exit 0, named with the last line it printed. A gh
|
|
1026
|
+
// killed at GH_TIMEOUT_MS ran and overran: "did not run" would misstate it.
|
|
1010
1027
|
function failure(r, what) {
|
|
1028
|
+
if (r.error?.code === "ETIMEDOUT") return `${what} ${TIMED_OUT}`;
|
|
1011
1029
|
if (r.error) return `${what} did not run: ${r.error.code ?? r.error.message}`;
|
|
1012
1030
|
const last = String(r.stderr ?? "").trim().split("\n").at(-1);
|
|
1013
1031
|
return `${what} ${r.signal ? `killed by ${r.signal}` : `exited ${r.status}`}${last ? `: ${last}` : ""}`;
|
|
@@ -1077,7 +1095,7 @@ function liftedPremise(excluded, entries, prs) {
|
|
|
1077
1095
|
if (kind === "issue") {
|
|
1078
1096
|
// Scrubbed as shortlist.mjs's probeState() is: gh's remote resolution
|
|
1079
1097
|
// follows GIT_DIR/GIT_WORK_TREE, and GH_REPO outranks both.
|
|
1080
|
-
const r = spawnSync("gh", ["issue", "view", target, "--json", "state"], { encoding: "utf8", env: gitEnv({ GH_REPO: "" }) });
|
|
1098
|
+
const r = spawnSync("gh", ["issue", "view", target, "--json", "state"], { encoding: "utf8", timeout: GH_TIMEOUT_MS, env: gitEnv({ GH_REPO: "" }) });
|
|
1081
1099
|
if (r.error || r.status !== 0) {
|
|
1082
1100
|
// Disclosed, not dropped: every other gh call in this file either
|
|
1083
1101
|
// dies or logs its failure — a bare empty catch here would be the
|
|
@@ -1104,7 +1122,7 @@ function liftedPremise(excluded, entries, prs) {
|
|
|
1104
1122
|
function closedTickets(mismatched) {
|
|
1105
1123
|
const closed = new Set();
|
|
1106
1124
|
for (const { number } of mismatched.map(parseMember)) {
|
|
1107
|
-
const r = spawnSync("gh", ["issue", "view", String(number), "--json", "state"], { encoding: "utf8", env: gitEnv({ GH_REPO: "" }) });
|
|
1125
|
+
const r = spawnSync("gh", ["issue", "view", String(number), "--json", "state"], { encoding: "utf8", timeout: GH_TIMEOUT_MS, env: gitEnv({ GH_REPO: "" }) });
|
|
1108
1126
|
if (r.error || r.status !== 0) {
|
|
1109
1127
|
console.error(`${NAME}: ${failure(r, `gh issue view ${number}`)} — tier mismatch on #${number} unconfirmed closed, hold stands`);
|
|
1110
1128
|
continue;
|
|
@@ -1131,7 +1149,7 @@ function closedTickets(mismatched) {
|
|
|
1131
1149
|
function finishedReviewPrs(offList) {
|
|
1132
1150
|
const finished = new Set();
|
|
1133
1151
|
for (const number of offList) {
|
|
1134
|
-
const r = spawnSync("gh", ["pr", "view", String(number), "--json", "state"], { encoding: "utf8", env: gitEnv({ GH_REPO: "" }) });
|
|
1152
|
+
const r = spawnSync("gh", ["pr", "view", String(number), "--json", "state"], { encoding: "utf8", timeout: GH_TIMEOUT_MS, env: gitEnv({ GH_REPO: "" }) });
|
|
1135
1153
|
if (r.error || r.status !== 0) {
|
|
1136
1154
|
console.error(`${NAME}: ${failure(r, `gh pr view ${number}`)} — review on PR#${number} unconfirmed finished, its slot stands`);
|
|
1137
1155
|
continue;
|
|
@@ -1166,12 +1184,12 @@ function refresh() {
|
|
|
1166
1184
|
// reporting "0 claimed" says the dead run stranded nothing.
|
|
1167
1185
|
function claimed() {
|
|
1168
1186
|
const r = spawnSync("gh", ["issue", "list", "--label", "in-progress", "--state", "open",
|
|
1169
|
-
"--limit", String(CLAIMED_LIMIT), "--json", "number", "--jq", "length"], { encoding: "utf8" });
|
|
1187
|
+
"--limit", String(CLAIMED_LIMIT), "--json", "number", "--jq", "length"], { encoding: "utf8", timeout: GH_TIMEOUT_MS });
|
|
1170
1188
|
if (r.error || r.status !== 0) {
|
|
1171
1189
|
// Disclosed, not dropped: a real gh failure must be distinguishable from
|
|
1172
1190
|
// the deliberately-undiagnosed case.
|
|
1173
|
-
const why = r.error
|
|
1174
|
-
? `gh did not run: ${r.error.code ?? r.error.message}`
|
|
1191
|
+
const why = r.error?.code === "ETIMEDOUT" ? `gh ${TIMED_OUT}`
|
|
1192
|
+
: r.error ? `gh did not run: ${r.error.code ?? r.error.message}`
|
|
1175
1193
|
: `gh ${r.signal ? `killed by ${r.signal}` : `exited ${r.status}`}`;
|
|
1176
1194
|
const tail = (r.stderr ?? "").trim().split("\n").slice(-5).join("\n");
|
|
1177
1195
|
console.error(`${NAME}: ${why} — claimed ticket count unknown${tail ? `\n${tail}` : ""}`);
|
|
@@ -1307,8 +1307,13 @@ test("CLI: every synchronous spawn in this file goes through spawnBounded (#2320
|
|
|
1307
1307
|
// `pr list` for the open PRs, `issue view` for a behind-issue premise's state,
|
|
1308
1308
|
// `pr view` for the state of a PR an in-flight review names that is no longer
|
|
1309
1309
|
// on the open list, `issue list --label in-progress` for the stall report's
|
|
1310
|
-
// claimed count.
|
|
1310
|
+
// claimed count. `GH_HANG` names one of those (`pr list`, `issue view`, …) to
|
|
1311
|
+
// stall for `GH_HANG_S` seconds before answering as usual, so a tick that waits
|
|
1312
|
+
// a hang out still reads a normal reply. The sleep holds none of the stub's
|
|
1313
|
+
// pipes: killed at the tick's bound, the stub leaves no child keeping the
|
|
1314
|
+
// tick's read open.
|
|
1311
1315
|
const GH_STUB = `#!/bin/sh
|
|
1316
|
+
[ -n "$GH_HANG" ] && [ "$1 $2" = "$GH_HANG" ] && sleep "$GH_HANG_S" </dev/null >/dev/null 2>&1
|
|
1312
1317
|
case "$1 $2" in
|
|
1313
1318
|
"pr list") [ -n "$PR_FAIL" ] && { echo "boom" >&2; exit 1; }; cat "$FIXTURE_PRS" ;;
|
|
1314
1319
|
"issue view")
|
|
@@ -2329,3 +2334,111 @@ test("CLI: a busy, fully-staffed fleet is not reported STALLED just because the
|
|
|
2329
2334
|
assert.doesNotMatch(r.stdout, /STALLED/);
|
|
2330
2335
|
assert.match(r.stdout, /^implementers 2\/2 → AT CAP/m);
|
|
2331
2336
|
});
|
|
2337
|
+
|
|
2338
|
+
// Every gh spawn in the tick carries a bound: a gh that never answers is a
|
|
2339
|
+
// failed read on that call's own failure path, never a tick that hangs. Each
|
|
2340
|
+
// case stalls one gh call for 30s under a 1s override, so a spawn that lost its
|
|
2341
|
+
// bound waits the stall out, reads the stub's normal reply, and fails the
|
|
2342
|
+
// assertions on the disclosed timeout — inside spawnBounded's own backstop.
|
|
2343
|
+
import { ghBudget } from "./fleet-tick.mjs";
|
|
2344
|
+
|
|
2345
|
+
const hang = (call, seconds = "30", budget = "1") => ({ GH_HANG: call, GH_HANG_S: seconds, FLEET_TICK_GH_TIMEOUT: budget });
|
|
2346
|
+
|
|
2347
|
+
test("CLI: a hung gh pr list refuses the tick at the bound — a read that timed out is not an empty queue", () => {
|
|
2348
|
+
const r = runCli([], { shortlist: shortlistText([]), env: hang("pr list") });
|
|
2349
|
+
assert.equal(r.status, 2, r.stderr);
|
|
2350
|
+
assert.equal(r.stdout.trim(), "");
|
|
2351
|
+
assert.match(r.stderr, /gh pr list failed: timed out after 1s — a failed read is not an empty queue/);
|
|
2352
|
+
});
|
|
2353
|
+
|
|
2354
|
+
test("CLI: a hung probe of a mismatched ticket is disclosed at the bound and the hold stands", () => {
|
|
2355
|
+
const r = runCli([], {
|
|
2356
|
+
shortlist: shortlistText([1, 2, 3]), ledger: { rows: ["#7 impl-7=tier-mismatch"], dispatched: ["impl-7=tier-mismatch"] },
|
|
2357
|
+
issueStates: { 7: "CLOSED" }, env: hang("issue view"),
|
|
2358
|
+
});
|
|
2359
|
+
assert.equal(r.status, 0, r.stderr);
|
|
2360
|
+
assert.match(r.stdout, /^implementers 0\/2 → HOLD \(tier mismatch impl-7\)/m);
|
|
2361
|
+
assert.match(r.stderr, /fleet-tick: gh issue view 7 timed out after 1s — tier mismatch on #7 unconfirmed closed, hold stands/);
|
|
2362
|
+
});
|
|
2363
|
+
|
|
2364
|
+
test("CLI: a hung behind-issue probe is disclosed at the bound and the exclusion stands", () => {
|
|
2365
|
+
const r = runCli([], {
|
|
2366
|
+
shortlist: shortlistText([1, 2, 3]), refresh: shortlistText([1, 2, 3, 50]),
|
|
2367
|
+
ledger: { rows: ["#50 excluded · behind-issue:#9"] }, issueStates: { 9: "CLOSED" }, env: hang("issue view"),
|
|
2368
|
+
});
|
|
2369
|
+
assert.equal(r.status, 0, r.stderr);
|
|
2370
|
+
assert.equal(r.refreshed, 0, "a probe that timed out cannot confirm the lift");
|
|
2371
|
+
assert.match(r.stderr, /fleet-tick: gh issue view 9 timed out after 1s — behind-issue:#9 premise unconfirmed, exclusion stands/);
|
|
2372
|
+
});
|
|
2373
|
+
|
|
2374
|
+
test("CLI: a hung probe of a PR off the open list is disclosed at the bound and its review keeps the slot", () => {
|
|
2375
|
+
const r = runCli(["--reviewer-cap", "1", "--max-reviews", "1"], {
|
|
2376
|
+
shortlist: shortlistText([]), prs: [REVIEW_DUE], ledger: { rows: zombieRows([101]) }, prStates: { 101: "MERGED" },
|
|
2377
|
+
env: hang("pr view"),
|
|
2378
|
+
});
|
|
2379
|
+
assert.equal(r.status, 0, r.stderr);
|
|
2380
|
+
assert.match(r.stderr, /fleet-tick: gh pr view 101 timed out after 1s — review on PR#101 unconfirmed finished, its slot stands/);
|
|
2381
|
+
assert.match(r.stdout, /^reviewers +1\/1 → AT CAP/m);
|
|
2382
|
+
});
|
|
2383
|
+
|
|
2384
|
+
test("CLI: a hung claimed-count read makes the stall report's count unknown at the bound", (t) => {
|
|
2385
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), "fleet-tick-stall-hung-")));
|
|
2386
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
2387
|
+
const path = join(dir, "heartbeat.json");
|
|
2388
|
+
writeFileSync(path, beat(90 * 60_000, 1200));
|
|
2389
|
+
const r = runCli(["--state", path], { ...IDLE, claimed: [{ number: 41 }], env: hang("issue list") });
|
|
2390
|
+
assert.equal(r.status, 0, r.stderr);
|
|
2391
|
+
assert.match(r.stdout, /unknown ticket\(s\) claimed and in flight/);
|
|
2392
|
+
assert.match(r.stderr, /fleet-tick: gh timed out after 1s — claimed ticket count unknown/);
|
|
2393
|
+
});
|
|
2394
|
+
|
|
2395
|
+
// The one case that proves the DEFAULT is a bound, and pays it in full: an
|
|
2396
|
+
// override past the default is refused, so the hang is cut at 20s rather than
|
|
2397
|
+
// waited out at 40s.
|
|
2398
|
+
test("CLI: FLEET_TICK_GH_TIMEOUT cannot lengthen the default bound", () => {
|
|
2399
|
+
const r = runCli([], { shortlist: shortlistText([]), env: hang("pr list", "40", "600") });
|
|
2400
|
+
assert.equal(r.status, 2, r.stderr);
|
|
2401
|
+
assert.match(r.stderr, /gh pr list failed: timed out after 20s/);
|
|
2402
|
+
});
|
|
2403
|
+
|
|
2404
|
+
// What the bound must ACCEPT: a gh that answers slowly, inside the bound, is a
|
|
2405
|
+
// normal read, not a failure.
|
|
2406
|
+
test("CLI: a slow gh that answers inside the bound is read as usual", () => {
|
|
2407
|
+
const r = runCli([], {
|
|
2408
|
+
shortlist: shortlistText([1, 2, 3]), ledger: { rows: ["#7 impl-7=tier-mismatch"], dispatched: ["impl-7=tier-mismatch"] },
|
|
2409
|
+
issueStates: { 7: "CLOSED" }, env: hang("issue view", "2", "10"),
|
|
2410
|
+
});
|
|
2411
|
+
assert.equal(r.status, 0, r.stderr);
|
|
2412
|
+
assert.doesNotMatch(r.stderr, /timed out/);
|
|
2413
|
+
assert.deepEqual(r.issueViews, ["7"]);
|
|
2414
|
+
assert.match(r.stdout, /^implementers 0\/2 → PULL #1 #2 /m, "the slow CLOSED answer was read and lifted the hold");
|
|
2415
|
+
});
|
|
2416
|
+
|
|
2417
|
+
// The cases above pin the gh spawns the tick has today; this pins the next
|
|
2418
|
+
// one, on the script's code with its comments blanked. Every call through
|
|
2419
|
+
// spawnSync or execFileSync that does not name process.execPath or git must
|
|
2420
|
+
// pass the shared bound — as `timeout: GH_TIMEOUT_MS` closed by a comma or a
|
|
2421
|
+
// brace, never a longer expression — before its call closes. A gh spawned
|
|
2422
|
+
// through any other function must name child_process again, and the import pin
|
|
2423
|
+
// fails on every line that does.
|
|
2424
|
+
test("CLI: every gh spawn in fleet-tick.mjs passes the shared bound", () => {
|
|
2425
|
+
const code = readFileSync(SCRIPT, "utf8").replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, (c) => c.replace(/[^\n]/g, " "));
|
|
2426
|
+
assert.deepEqual(code.split("\n").map((l) => l.trimEnd()).filter((l) => l.includes("child_process")),
|
|
2427
|
+
['import { execFileSync, spawnSync } from "node:child_process";'],
|
|
2428
|
+
"the script spawns through something new — extend this sweep to it");
|
|
2429
|
+
const lineOf = (i) => code.slice(0, i).split("\n").length;
|
|
2430
|
+
const calls = [...code.matchAll(/\b(?:spawnSync|execFileSync)\(\s*(?=\S)(?!process\.execPath\b|["']git["'])/g)];
|
|
2431
|
+
assert.ok(calls.length > 0, "no gh spawn found; this sweep has nothing to measure");
|
|
2432
|
+
const unbounded = calls
|
|
2433
|
+
.filter((m) => !/\btimeout:\s*GH_TIMEOUT_MS\s*[,}]/.test(code.slice(m.index, code.indexOf(");", m.index))))
|
|
2434
|
+
.map((m) => `line ${lineOf(m.index)}`);
|
|
2435
|
+
assert.deepEqual(unbounded, []);
|
|
2436
|
+
});
|
|
2437
|
+
|
|
2438
|
+
test("ghBudget: the override only ever shortens, and anything but digits leaves the default", () => {
|
|
2439
|
+
const cases = [
|
|
2440
|
+
[undefined, 20_000], ["", 20_000], ["5", 5_000], ["1", 1_000], ["19", 19_000],
|
|
2441
|
+
["20", 20_000], ["600", 20_000], ["0", 20_000], ["-1", 20_000], ["3.5", 20_000], ["1e1", 20_000], [" 5", 20_000], ["abc", 20_000],
|
|
2442
|
+
];
|
|
2443
|
+
for (const [override, ms] of cases) assert.equal(ghBudget(20, override), ms, JSON.stringify(override));
|
|
2444
|
+
});
|