@feigi/fleet-ctl 3.23.1 → 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.
|
@@ -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/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 —
|