@feigi/fleet-ctl 3.23.0 → 3.23.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/agents/fleet-recipe-deriver.agent.md +11 -2
- package/package.json +1 -1
- package/scripts/fleet-tick.mjs +27 -9
- package/scripts/fleet-tick.test.mjs +114 -1
- package/scripts/is-cli.test.mjs +1 -1
- package/scripts/recipe-derivation-prose.test.mjs +14 -1
- package/scripts/recipe-prove.mjs +61 -15
- package/scripts/recipe-prove.test.mjs +119 -3
- package/skills/run-team/SKILL.md +5 -3
|
@@ -89,6 +89,13 @@ no cache was written. Exit 2 means no verdict on the proof: it could not be
|
|
|
89
89
|
attempted (no `origin/main`, not a repository) or the cache could not be
|
|
90
90
|
written — report it, do not work around it.
|
|
91
91
|
|
|
92
|
+
The script bounds each command it runs itself — the Install step, both Test
|
|
93
|
+
runs and the mutation — and refuses one still running at that bound as
|
|
94
|
+
`timed out`. **Never wrap the `recipe-prove.mjs` call in a timeout of your
|
|
95
|
+
own**: an outside kill leaves no refusal to report. A deadline on the tool
|
|
96
|
+
call that runs it does the same when it is shorter than the script's bound,
|
|
97
|
+
so give that call `timeout: 0` and wait for the script.
|
|
98
|
+
|
|
92
99
|
You may revise and re-run after a `NOT PROVEN` you can act on — a wrong
|
|
93
100
|
command, a count line you mis-copied, a mutation that changed nothing — **at
|
|
94
101
|
most four `recipe-prove.mjs` runs in all**, the first one included. Never
|
|
@@ -104,6 +111,8 @@ Your last message is exactly one of:
|
|
|
104
111
|
verbatim, the commands you tried, and one line naming the cause:
|
|
105
112
|
**vacuous** (the suite ran no tests, or none a mutation could reach),
|
|
106
113
|
**did not run** (a command not found or not executable — a toolchain this
|
|
107
|
-
machine lacks), **dirty install** (the Install step changes the tree),
|
|
108
|
-
**
|
|
114
|
+
machine lacks), **dirty install** (the Install step changes the tree),
|
|
115
|
+
**timed out** (a command the proof runs — the Install step, the Test
|
|
116
|
+
entrypoint or the mutation — hung until the script's own bound stopped it),
|
|
117
|
+
or **unreadable** (you could not tell from the repository how it is built or
|
|
109
118
|
tested).
|
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
|
+
});
|
package/scripts/is-cli.test.mjs
CHANGED
|
@@ -67,7 +67,7 @@ test("isCLI defaults to the live process.argv[1]", () => {
|
|
|
67
67
|
// symlinked run must match it, not merely be non-zero.
|
|
68
68
|
const CLI_SCRIPTS = [
|
|
69
69
|
"diff-stats", "fleet-heartbeat", "fleet-tick", "frontmatter-check", "main-checkout",
|
|
70
|
-
"member-outcomes", "tier-check", "tier-outcomes", "tier-roles", "board", "dispositions-check",
|
|
70
|
+
"member-outcomes", "tier-check", "tier-outcomes", "tier-roles", "board", "dispositions-check", "recipe-prove",
|
|
71
71
|
];
|
|
72
72
|
|
|
73
73
|
for (const name of CLI_SCRIPTS) {
|
|
@@ -50,6 +50,7 @@ test("an invalid cache is re-derived once; a failed derivation stalls the run in
|
|
|
50
50
|
assert.match(s, phrase("A red suite never triggers it: a failing test is a finding, not a stale Recipe."));
|
|
51
51
|
assert.match(s, phrase("`RECIPE NOT PROVEN` from any derivation, or a second invalid-cache refusal after this run's one re-derivation, halts the run"));
|
|
52
52
|
assert.match(s, phrase("It is never a loop: no third derivation, and never a cache written by hand."));
|
|
53
|
+
assert.match(s, phrase("an Install step, Test entrypoint or mutation that hangs until the proof's own bound refuses it as timed out."));
|
|
53
54
|
});
|
|
54
55
|
|
|
55
56
|
test("the claim paragraph sends an absent or invalid cache to the derivation step, and infers no Install step", () => {
|
|
@@ -92,8 +93,20 @@ test("every flag the deriver's procedure passes is one recipe-prove.mjs accepts"
|
|
|
92
93
|
for (const flag of used) assert.ok(accepted.has(flag), `the deriver passes ${flag}, which recipe-prove.mjs does not accept`);
|
|
93
94
|
});
|
|
94
95
|
|
|
95
|
-
test("the deriver reports one of two lines, naming vacuity
|
|
96
|
+
test("the deriver reports one of two lines, naming vacuity and a timeout as causes", () => {
|
|
96
97
|
assert.match(DERIVER, /`RECIPE PROVEN` followed by the JSON line/);
|
|
97
98
|
assert.match(DERIVER, /`RECIPE NOT PROVEN` followed by the last run's `NOT PROVEN` reason/);
|
|
98
99
|
assert.match(DERIVER, /\*\*vacuous\*\*/);
|
|
100
|
+
assert.match(DERIVER, /\*\*timed out\*\*/);
|
|
101
|
+
assert.match(DERIVER, phrase("the Install step, the Test entrypoint or the mutation — hung until the script's own bound stopped it"));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// recipe-prove.mjs bounds its own commands and refuses one that overruns; an
|
|
105
|
+
// outside kill would end the run with no refusal for the deriver to report.
|
|
106
|
+
test("the deriver is told the script bounds its own commands, and never to wrap the call in a timeout", () => {
|
|
107
|
+
assert.match(DERIVER, phrase("The script bounds each command it runs itself"));
|
|
108
|
+
assert.match(DERIVER, phrase("**Never wrap the `recipe-prove.mjs` call in a timeout of your own**"));
|
|
109
|
+
assert.match(DERIVER, phrase("refuses one still running at that bound as `timed out`"));
|
|
110
|
+
assert.match(DERIVER, phrase("so give that call `timeout: 0` and wait for the script"));
|
|
111
|
+
assert.match(PROVER, /timed out: still running after/, "recipe-prove.mjs no longer names a timeout the way the deriver's cause reads it");
|
|
99
112
|
});
|
package/scripts/recipe-prove.mjs
CHANGED
|
@@ -34,6 +34,12 @@
|
|
|
34
34
|
// With neither, nothing is proven: the refusal still names where the test
|
|
35
35
|
// run's output is, so the caller can read the count line off it.
|
|
36
36
|
//
|
|
37
|
+
// Each of those commands — the Install step, both Test runs, the mutation —
|
|
38
|
+
// runs under a time bound of its own, 20 minutes, that RECIPE_PROVE_TIMEOUT
|
|
39
|
+
// (whole seconds) can shorten and never lengthen. A command still running at
|
|
40
|
+
// the bound is killed, and the proof is NOT PROVEN for it, the refusal naming
|
|
41
|
+
// the bound and the command's log.
|
|
42
|
+
//
|
|
37
43
|
// On proof, the cache is written atomically to <workspace>/.fleet/recipe.json
|
|
38
44
|
// — <workspace> being the directory holding the repository's common git dir,
|
|
39
45
|
// the place derive-testcmd.sh reads it from — and then read back through
|
|
@@ -59,6 +65,7 @@ import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync,
|
|
|
59
65
|
import { tmpdir } from "node:os";
|
|
60
66
|
import { dirname, join } from "node:path";
|
|
61
67
|
import { fileURLToPath } from "node:url";
|
|
68
|
+
import { isCLI } from "./is-cli.mjs";
|
|
62
69
|
import { gitEnv, workspaceDirFromGitCommonDir } from "./git-env.mjs";
|
|
63
70
|
import { isDigits } from "./arg.mjs";
|
|
64
71
|
|
|
@@ -73,6 +80,27 @@ const READER = join(dirname(fileURLToPath(import.meta.url)), "derive-testcmd.sh"
|
|
|
73
80
|
// at that repository's tree, while the cache still landed here.
|
|
74
81
|
const ENV = gitEnv();
|
|
75
82
|
|
|
83
|
+
// The bound on each Recipe command. A command that never returns — a stalled
|
|
84
|
+
// fetch, a watch-mode runner — would otherwise hang the Recipe derivation,
|
|
85
|
+
// which a run waits on before its first claim, with no refusal ever printed.
|
|
86
|
+
// 20 minutes is chosen against the false failure: far above a healthy install
|
|
87
|
+
// or suite, and still a bound.
|
|
88
|
+
//
|
|
89
|
+
// The bound is per command, never for the whole proof: the longest a proof
|
|
90
|
+
// can take is every command it runs taking its full bound.
|
|
91
|
+
//
|
|
92
|
+
// RECIPE_PROVE_TIMEOUT can only SHORTEN it: a knob that could lengthen the
|
|
93
|
+
// bound is one more way for configuration to remove it. A value that is not a
|
|
94
|
+
// positive whole number of seconds below the default is not an error and not
|
|
95
|
+
// a bound either — the default stands, in silence. isDigits() is arg.mjs's
|
|
96
|
+
// own predicate, so `3.5`, `-5` and `5e3` are refused flat, never coerced.
|
|
97
|
+
const COMMAND_DEFAULT_SECONDS = 20 * 60;
|
|
98
|
+
export function commandBudget(override) {
|
|
99
|
+
const seconds = isDigits(String(override)) ? Number(override) : 0;
|
|
100
|
+
return (seconds > 0 && seconds < COMMAND_DEFAULT_SECONDS ? seconds : COMMAND_DEFAULT_SECONDS) * 1000;
|
|
101
|
+
}
|
|
102
|
+
const COMMAND_TIMEOUT_MS = commandBudget(process.env.RECIPE_PROVE_TIMEOUT);
|
|
103
|
+
|
|
76
104
|
class Refusal extends Error {
|
|
77
105
|
constructor(code, message) {
|
|
78
106
|
super(message);
|
|
@@ -133,10 +161,25 @@ function git(args, cwd) {
|
|
|
133
161
|
// killed by a signal (an OOM kill, a timeout wrapper) has no exit status, so
|
|
134
162
|
// it is refused here and never reported as one: a crashed run must not count
|
|
135
163
|
// as a red suite.
|
|
164
|
+
//
|
|
165
|
+
// A command that overruns COMMAND_TIMEOUT_MS is killed with SIGKILL, never the
|
|
166
|
+
// default SIGTERM: a command that traps or ignores SIGTERM would keep spawnSync
|
|
167
|
+
// waiting on it forever. spawnSync reports that kill as an ETIMEDOUT `error`
|
|
168
|
+
// alongside the signal, so the timeout is read first: through the `error`
|
|
169
|
+
// branch it would be a `sh` that could not start, and through the `signal`
|
|
170
|
+
// one a kill with no cause named. Whatever it printed before the kill stays in
|
|
171
|
+
// the log, which is opened for append. Only the `sh` itself is killed:
|
|
172
|
+
// spawnSync cannot kill a process tree, so a child the command started may
|
|
173
|
+
// outlive the refusal.
|
|
136
174
|
function sh(cmd, cwd, log) {
|
|
137
175
|
const fd = openSync(log, "a");
|
|
138
176
|
try {
|
|
139
|
-
const r = spawnSync("sh", ["-c", cmd], {
|
|
177
|
+
const r = spawnSync("sh", ["-c", cmd], {
|
|
178
|
+
cwd, env: ENV, stdio: ["ignore", fd, fd], timeout: COMMAND_TIMEOUT_MS, killSignal: "SIGKILL",
|
|
179
|
+
});
|
|
180
|
+
if (r.error?.code === "ETIMEDOUT") {
|
|
181
|
+
throw notProven(`'${cmd}' timed out: still running after ${COMMAND_TIMEOUT_MS / 1000}s, the bound on each Recipe command, so it was stopped; output: ${log}`);
|
|
182
|
+
}
|
|
140
183
|
if (r.error) throw cannot(`could not start sh: ${r.error.message}`);
|
|
141
184
|
if (r.signal) throw notProven(`'${cmd}' was killed by ${r.signal}, so it has no exit status to read; output: ${log}`);
|
|
142
185
|
return r.status;
|
|
@@ -326,19 +369,22 @@ function main(argv) {
|
|
|
326
369
|
process.stdout.write(`${NAME}: PROVEN — Recipe cache written to ${cache}\n${JSON.stringify(recipe)}\n`);
|
|
327
370
|
}
|
|
328
371
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
372
|
+
// Only run main() as a CLI, never when imported by a test (see is-cli.mjs).
|
|
373
|
+
if (isCLI(import.meta.url)) {
|
|
374
|
+
try {
|
|
375
|
+
main(process.argv.slice(2));
|
|
376
|
+
} catch (e) {
|
|
377
|
+
// A fault outside the proof — the cache or its temp file could not be
|
|
378
|
+
// written — is "no verdict" (2), never the NOT PROVEN (1) a stack trace's
|
|
379
|
+
// exit status would read as.
|
|
380
|
+
if (!(e instanceof Refusal)) {
|
|
381
|
+
process.stderr.write(`${NAME}: ${e.message}\n`);
|
|
382
|
+
process.exit(2);
|
|
383
|
+
}
|
|
384
|
+
// The closing sentence on a line of its own: most reasons end in a log path,
|
|
385
|
+
// and a `.` riding on one would read as part of it.
|
|
386
|
+
const msg = e.code === 1 ? `NOT PROVEN — ${e.message}\nNo Recipe cache written.` : e.message;
|
|
387
|
+
process.stderr.write(`${NAME}: ${msg}\n`);
|
|
388
|
+
process.exit(e.code);
|
|
338
389
|
}
|
|
339
|
-
// The closing sentence on a line of its own: most reasons end in a log path,
|
|
340
|
-
// and a `.` riding on one would read as part of it.
|
|
341
|
-
const msg = e.code === 1 ? `NOT PROVEN — ${e.message}\nNo Recipe cache written.` : e.message;
|
|
342
|
-
process.stderr.write(`${NAME}: ${msg}\n`);
|
|
343
|
-
process.exit(e.code);
|
|
344
390
|
}
|
|
@@ -5,6 +5,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, sy
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { tempDir } from "./temp-dir.mjs";
|
|
7
7
|
import { writeExecStub } from "./exec-stub.mjs";
|
|
8
|
+
import { commandBudget } from "./recipe-prove.mjs";
|
|
8
9
|
|
|
9
10
|
// recipe-prove.mjs is the Recipe derivation step's proof and the ONE writer of
|
|
10
11
|
// the Recipe cache: the deriving agent chooses the Install step and the Test
|
|
@@ -324,7 +325,7 @@ test("the throwaway worktree is removed afterwards, proven or not", () => {
|
|
|
324
325
|
const { dir } = repo(MAVEN_FILES);
|
|
325
326
|
prove(dir, MAVEN_PROOF);
|
|
326
327
|
prove(dir, ["--install", "exit 3", ...MAVEN_PROOF.slice(2)]);
|
|
327
|
-
assert.equal(
|
|
328
|
+
assert.equal(worktrees(dir), 1);
|
|
328
329
|
});
|
|
329
330
|
|
|
330
331
|
test("an ambient GIT_DIR naming another repository does not change the answer", () => {
|
|
@@ -367,10 +368,13 @@ test("a proof that cannot be attempted exits 2, distinct from a Recipe that is n
|
|
|
367
368
|
const missing = prove(half, ["--install", "--test", "--test", "mvn -q test"]);
|
|
368
369
|
assert.equal(missing.status, 2);
|
|
369
370
|
assert.match(missing.err, /^recipe-prove: usage:/);
|
|
370
|
-
assert.equal(
|
|
371
|
+
assert.equal(worktrees(half), 1,
|
|
371
372
|
"a refusal at the argument boundary creates no worktree");
|
|
372
373
|
});
|
|
373
374
|
|
|
375
|
+
// The number of worktrees the repository at `dir` has registered, the main checkout included.
|
|
376
|
+
const worktrees = (dir) => git(dir, "worktree", "list", "--porcelain").split("\n").filter((l) => l.startsWith("worktree ")).length;
|
|
377
|
+
|
|
374
378
|
// Every temp dir the proof made, scanned for the throwaway worktree it names `wt`.
|
|
375
379
|
function assertNoWorktreeLeft(tmp) {
|
|
376
380
|
for (const d of readdirSync(tmp)) {
|
|
@@ -581,7 +585,7 @@ test("read-only install output cannot turn a proof that held into a crash", { sk
|
|
|
581
585
|
assert.equal(r.status, 0, r.err);
|
|
582
586
|
assert.equal(JSON.parse(readFileSync(cachePath(dir), "utf8")).derivedAt, head);
|
|
583
587
|
assert.deepEqual(logDirs(r.tmp), []);
|
|
584
|
-
assert.equal(
|
|
588
|
+
assert.equal(worktrees(dir), 1);
|
|
585
589
|
});
|
|
586
590
|
|
|
587
591
|
test("read-only install output does not replace a refusal's reason with a stack trace", { skip: IGNORES_MODES }, () => {
|
|
@@ -721,3 +725,115 @@ test("the mutation is credited only with what it changed, never with what the un
|
|
|
721
725
|
assert.equal(r.status, 0, r.err);
|
|
722
726
|
assert.match(JSON.parse(readFileSync(cachePath(real), "utf8")).mutation, /changed state\.txt$/);
|
|
723
727
|
});
|
|
728
|
+
|
|
729
|
+
// Every Recipe command runs under a time bound, shortened here through
|
|
730
|
+
// RECIPE_PROVE_TIMEOUT so a hang costs seconds. Each case below hangs one of
|
|
731
|
+
// the four commands the proof runs; a bound that missed one would leave that
|
|
732
|
+
// case to the helper's own 60s spawn timeout, which reads as status null.
|
|
733
|
+
const timedOut = (cmd, seconds) =>
|
|
734
|
+
new RegExp(`NOT PROVEN — '${cmd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}' timed out: still running after ${seconds}s, the bound on each Recipe command`);
|
|
735
|
+
|
|
736
|
+
const HANGS = [
|
|
737
|
+
{
|
|
738
|
+
name: "an Install step",
|
|
739
|
+
files: MAVEN_FILES,
|
|
740
|
+
seconds: 1,
|
|
741
|
+
hung: "echo fetching deps; exec sleep 30",
|
|
742
|
+
args: (hung) => ["--install", hung, ...MAVEN_PROOF.slice(2)],
|
|
743
|
+
log: "install.log",
|
|
744
|
+
output: /fetching deps/,
|
|
745
|
+
},
|
|
746
|
+
{
|
|
747
|
+
name: "a Test entrypoint",
|
|
748
|
+
files: MAVEN_FILES,
|
|
749
|
+
seconds: 1,
|
|
750
|
+
hung: "echo 'Tests run: 1,'; exec sleep 30",
|
|
751
|
+
args: (hung) => ["--install", "true", "--test", hung, "--count-line", "Tests run: 1,", "--test-count", "1"],
|
|
752
|
+
log: "test.log",
|
|
753
|
+
output: /Tests run: 1,/,
|
|
754
|
+
},
|
|
755
|
+
// The two below run a healthy command first, so their bound leaves it room.
|
|
756
|
+
{
|
|
757
|
+
name: "a mutation command",
|
|
758
|
+
files: GO_FILES,
|
|
759
|
+
seconds: 3,
|
|
760
|
+
hung: "echo mutating; exec sleep 30",
|
|
761
|
+
args: (hung) => [...GO_PROOF.slice(0, 4), "--mutate", hung, "--mutation", "never finishes"],
|
|
762
|
+
log: "mutate.log",
|
|
763
|
+
output: /mutating/,
|
|
764
|
+
},
|
|
765
|
+
{
|
|
766
|
+
name: "a mutated Test run",
|
|
767
|
+
files: GO_FILES,
|
|
768
|
+
seconds: 3,
|
|
769
|
+
hung: "if grep -q 'a - b' calc.go; then echo mutated; exec sleep 30; else go test ./...; fi",
|
|
770
|
+
args: (hung) => ["--install", "true", "--test", hung, ...GO_PROOF.slice(4)],
|
|
771
|
+
log: "test-mutated.log",
|
|
772
|
+
output: /mutated/,
|
|
773
|
+
},
|
|
774
|
+
];
|
|
775
|
+
|
|
776
|
+
for (const c of HANGS) {
|
|
777
|
+
test(`${c.name} that never finishes is refused as timed out, NOT PROVEN, naming the bound and its log`, () => {
|
|
778
|
+
const { dir } = repo(c.files);
|
|
779
|
+
// A cache from an earlier proof: a refusal must leave its bytes alone.
|
|
780
|
+
mkdirSync(join(dir, ".fleet"));
|
|
781
|
+
writeFileSync(cachePath(dir), '{"earlier":"cache"}\n');
|
|
782
|
+
const start = Date.now();
|
|
783
|
+
const r = prove(dir, c.args(c.hung), { env: { RECIPE_PROVE_TIMEOUT: String(c.seconds) } });
|
|
784
|
+
const elapsed = Date.now() - start;
|
|
785
|
+
assert.equal(r.status, 1, `status ${r.status} after ${elapsed} ms: ${r.err}`);
|
|
786
|
+
assert.match(r.err, timedOut(c.hung, c.seconds));
|
|
787
|
+
assert.doesNotMatch(r.err, /could not start sh/);
|
|
788
|
+
assert.doesNotMatch(r.err, /was killed by/, "a timeout is named as one, not as a bare signal");
|
|
789
|
+
assert.ok(elapsed < 20_000, `the 30s command was not cut at the ${c.seconds}s bound: ${elapsed} ms`);
|
|
790
|
+
// The log the refusal names is kept, with what the command printed before the kill.
|
|
791
|
+
const log = r.err.match(/output: (\S+)$/m);
|
|
792
|
+
assert.ok(log, r.err);
|
|
793
|
+
assert.ok(log[1].endsWith(`/${c.log}`), `${log[1]} is not the ${c.log}`);
|
|
794
|
+
assert.match(readFileSync(log[1], "utf8"), c.output);
|
|
795
|
+
// The throwaway worktree is removed and unregistered, and the cache untouched.
|
|
796
|
+
assertNoWorktreeLeft(r.tmp);
|
|
797
|
+
assert.equal(worktrees(dir), 1, git(dir, "worktree", "list"));
|
|
798
|
+
assert.equal(readFileSync(cachePath(dir), "utf8"), '{"earlier":"cache"}\n');
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// SIGTERM, the default kill signal, is one a command can trap: spawnSync then
|
|
803
|
+
// waits on it forever. The bound must not depend on the command's consent.
|
|
804
|
+
test("a command that ignores SIGTERM is still refused at the bound, not waited on", () => {
|
|
805
|
+
const { dir } = repo(MAVEN_FILES);
|
|
806
|
+
const hung = "trap '' TERM; while :; do sleep 1; done";
|
|
807
|
+
const start = Date.now();
|
|
808
|
+
const r = prove(dir, ["--install", "true", "--test", hung, "--count-line", "x", "--test-count", "1"], { env: { RECIPE_PROVE_TIMEOUT: "1" } });
|
|
809
|
+
const elapsed = Date.now() - start;
|
|
810
|
+
assert.equal(r.status, 1, `status ${r.status} after ${elapsed} ms: ${r.err}`);
|
|
811
|
+
assert.match(r.err, timedOut(hung, 1));
|
|
812
|
+
assert.ok(elapsed < 20_000, `the bound did not stop a command that traps SIGTERM: ${elapsed} ms`);
|
|
813
|
+
assert.equal(existsSync(cachePath(dir)), false);
|
|
814
|
+
assert.equal(worktrees(dir), 1, git(dir, "worktree", "list"));
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
// The bound is per command, never one budget for the whole proof: two
|
|
818
|
+
// commands each under it, together over it, still prove.
|
|
819
|
+
test("commands that each finish inside the bound still prove, whatever their total", () => {
|
|
820
|
+
const { dir } = repo(MAVEN_FILES);
|
|
821
|
+
const r = prove(dir, ["--install", "sleep 3", "--test", "sleep 3; mvn -q test", ...MAVEN_PROOF.slice(4)], { env: { RECIPE_PROVE_TIMEOUT: "5" } });
|
|
822
|
+
assert.equal(r.status, 0, r.err);
|
|
823
|
+
assert.equal(JSON.parse(readFileSync(cachePath(dir), "utf8")).testCount, 1);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
// RECIPE_PROVE_TIMEOUT may only shorten the 20-minute default: a value that is
|
|
827
|
+
// not a whole number of seconds strictly between 0 and the default leaves the
|
|
828
|
+
// default standing. `3.5` and `-5` are numbers Number() would take; a bound
|
|
829
|
+
// they set would be configuration lengthening or removing nothing it may.
|
|
830
|
+
test("RECIPE_PROVE_TIMEOUT can only shorten the default bound", () => {
|
|
831
|
+
const DEFAULT_MS = 20 * 60 * 1000;
|
|
832
|
+
assert.equal(commandBudget(undefined), DEFAULT_MS);
|
|
833
|
+
for (const ignored of ["", "0", "abc", "3.5", "-5", " 5", "5e3", "1200", "1201", "86400"]) {
|
|
834
|
+
assert.equal(commandBudget(ignored), DEFAULT_MS, `RECIPE_PROVE_TIMEOUT=${JSON.stringify(ignored)} changed the bound`);
|
|
835
|
+
}
|
|
836
|
+
assert.equal(commandBudget("1"), 1000);
|
|
837
|
+
assert.equal(commandBudget("1199"), 1_199_000);
|
|
838
|
+
assert.equal(commandBudget("007"), 7000);
|
|
839
|
+
});
|
package/skills/run-team/SKILL.md
CHANGED
|
@@ -403,9 +403,11 @@ phase, or in any later one, asks the maintainer which tickets to take.
|
|
|
403
403
|
re-derivation, halts the run — no Pull, no claim, no review dispatch — and
|
|
404
404
|
you report `STALL — Recipe not proven: <cause>` to the maintainer with the
|
|
405
405
|
deriver's reason verbatim. A repository the fleet cannot run needs human
|
|
406
|
-
hands, the `ready-for-human` cause: a toolchain this machine lacks,
|
|
407
|
-
suite that runs no tests the proof can see
|
|
408
|
-
|
|
406
|
+
hands, the `ready-for-human` cause: a toolchain this machine lacks, a
|
|
407
|
+
suite that runs no tests the proof can see, or an Install step, Test
|
|
408
|
+
entrypoint or mutation that hangs until the proof's own bound refuses it as
|
|
409
|
+
timed out. It is never a loop: no third derivation, and never a cache
|
|
410
|
+
written by hand.
|
|
409
411
|
|
|
410
412
|
**Fold in every PR a prior run left open, before shortlisting.** A chore PR
|
|
411
413
|
carrying that run's own metrics, or ticket work whose review was deferred —
|