@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.23.0",
3
+ "version": "3.23.1",
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": {
@@ -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
+ });