@feigi/fleet-ctl 3.23.18 → 3.23.20

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.18",
3
+ "version": "3.23.20",
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": {
@@ -37,6 +37,11 @@
37
37
  # is asked for must name something that resolves from <repo> (a builtin, a
38
38
  # PATH entry, an executable path). A red suite is a finding, not a stale
39
39
  # Recipe, so nothing here ever runs the command.
40
+ #
41
+ # Exit status: 0 the command is printed; 1 a refusal about the cache, the
42
+ # repository or the arguments; 3 a tool this script needs to read the cache
43
+ # (node, mktemp, cat) could not be started or node died mid-read, so nothing
44
+ # was read and the cache's usability is unknown.
40
45
  set -eu
41
46
 
42
47
  # Byte semantics for every construct below that reads a string by bytes:
@@ -83,6 +88,15 @@ unset GIT_DIR GIT_WORK_TREE
83
88
  NAME=derive-testcmd
84
89
  die() { printf '%s: %s\n' "$NAME" "$1" >&2; exit 1; }
85
90
 
91
+ # A refusal about the environment, not the cache: a tool this script needs to
92
+ # read the cache (the interpreter, mktemp, cat) could not be started, or the
93
+ # interpreter died mid-read, so nothing was read and the cache's usability is
94
+ # unknown. Exit 3, where every refusal about the cache, the repository or the
95
+ # arguments is exit 1. A git that cannot be started is not told apart: it reads
96
+ # as no repository, exit 1. A consumer that only tests for non-zero sees no
97
+ # difference.
98
+ unrunnable() { printf '%s: %s\n' "$NAME" "$1" >&2; exit 3; }
99
+
86
100
  # Named once: every refusal that sends the caller to re-derive names the same
87
101
  # step, so a controller or reviewer reading any of them knows what to run.
88
102
  derive="run the Recipe derivation step (run-team phase 0, before the first claim — ADR 0015) to derive, prove and write it"
@@ -113,7 +127,7 @@ cache="${common%/*}/.fleet/recipe.json"
113
127
  # An INVOCATION, not a name lookup (#1141): a version-manager shim satisfies
114
128
  # `command -v node` and then fails, and its stderr would arrive below under the
115
129
  # cache's name — a corrupt-cache refusal for a cache nobody read.
116
- nodeerr=$(node -e 0 </dev/null 2>&1) || die "node is unusable, refusing to read the Recipe cache without the interpreter — $nodeerr"
130
+ nodeerr=$(node -e 0 </dev/null 2>&1) || unrunnable "node is unusable, refusing to read the Recipe cache without the interpreter — $nodeerr"
117
131
 
118
132
  # Validation and extraction in one pass, so a field is never printed out of a
119
133
  # cache the checks did not pass. rc 0 prints the value; anything else carries
@@ -143,11 +157,15 @@ nodeerr=$(node -e 0 </dev/null 2>&1) || die "node is unusable, refusing to read
143
157
  # newline now survives inside the frame, and the trailing-operator check
144
158
  # below treats it exactly like a trailing `;` or `&`: refused, not
145
159
  # silently accepted.
146
- errf=$(mktemp) || die "cannot create a temporary file to read the Recipe cache"
147
- lenf=$(mktemp) || die "cannot create a temporary file to read the Recipe cache"
148
- trap 'rm -f "$errf" "$lenf"' EXIT
160
+ errf=$(mktemp) || unrunnable "cannot create a temporary file to read the Recipe cache"
161
+ lenf=$(mktemp) || unrunnable "cannot create a temporary file to read the Recipe cache"
162
+ # Cleanup decides nothing: an `rm` that cannot be started would otherwise
163
+ # replace the script's own exit status with 127 (and print a line on the
164
+ # success path), so the status the script was leaving with is kept.
165
+ trap 'rc=$?; rm -f "$errf" "$lenf" 2>/dev/null || :; exit $rc' EXIT
149
166
 
150
167
  open='recipe<' close='>recipe'
168
+ rc=0
151
169
  framed=$(node -e '
152
170
  const fs = require("fs");
153
171
  const [file, field, open, close, lenFile] = process.argv.slice(1);
@@ -166,13 +184,30 @@ if (!counted && !mutated) bad("it carries no proof of real tests — neither a p
166
184
  const value = r[field];
167
185
  fs.writeFileSync(lenFile, String(Buffer.byteLength(open + value + close)));
168
186
  process.stdout.write(open + value + close);
169
- ' "$cache" "$field" "$open" "$close" "$lenf" 2>"$errf") || die "the Recipe cache at $cache is unusable: $(cat "$errf") — $derive"
187
+ ' "$cache" "$field" "$open" "$close" "$lenf" 2>"$errf") || rc=$?
188
+ # node's own refusal of the cache is exit 2 (an uncaught fault is exit 1), the
189
+ # reason on stderr either way; a status of 126 or above is the shell failing to
190
+ # run node or the system killing it mid-read, which says nothing about the cache.
191
+ if [ "$rc" -ge 126 ]; then
192
+ unrunnable "node did not finish reading the Recipe cache (exit $rc), so its usability is unknown"
193
+ fi
194
+ [ "$rc" -eq 0 ] || die "the Recipe cache at $cache is unusable: $(cat "$errf") — $derive"
170
195
  carriedmsg="node's stdout carried more than the framed Recipe value — expected exactly the value between '$open' and '$close', got '$framed'. The cache at $cache passed validation; the extra output comes from how node is launched here (a version-manager or proxy shim, a preload), and is refused rather than cut out of the command"
171
196
  case $framed in
172
197
  "$open"*"$close") ;;
173
198
  *) die "$carriedmsg" ;;
174
199
  esac
175
- framedlen=$(cat "$lenf")
200
+ # A `cat` that cannot be started (126/127) is the environment's fault, as for
201
+ # `node` above; a `cat` that ran and failed refuses with its own reason, and a
202
+ # count file that was read but holds no digits is the corrupt-count refusal
203
+ # below.
204
+ rc=0
205
+ framedlen=$(cat "$lenf" 2>"$errf") || rc=$?
206
+ case $rc in
207
+ 0) ;;
208
+ 126|127) unrunnable "cat could not be started (exit $rc), so the Recipe cache cannot be read" ;;
209
+ *) die "the Recipe cache at $cache is unusable: cat failed reading node's byte-count file (exit $rc): $(cat "$errf" 2>/dev/null) — $derive" ;;
210
+ esac
176
211
  case $framedlen in
177
212
  ''|*[!0-9]*) die "the Recipe cache at $cache is unusable: node's byte-count file is missing or corrupt — $derive" ;;
178
213
  esac
@@ -304,9 +304,10 @@ test("the success path writes nothing to stderr, even when node is chatty (#1175
304
304
  // cache. PATH is REPLACED with links to exactly what the script runs, so the
305
305
  // interpreter is really unreachable rather than shadowed.
306
306
  const SHIMMED = ["sh", "git", "mktemp", "cat", "rm"];
307
- function shimPath({ node }) {
307
+ function shimPath({ node, omit }) {
308
308
  const bin = tempDir("derive-path-");
309
309
  for (const name of SHIMMED) {
310
+ if (name === omit) continue;
310
311
  const real = execFileSync("sh", ["-c", `command -v ${name}`], { encoding: "utf8" }).trim();
311
312
  symlinkSync(real, join(bin, name));
312
313
  }
@@ -314,11 +315,14 @@ function shimPath({ node }) {
314
315
  return bin;
315
316
  }
316
317
 
318
+ // Exit 3, where every refusal about the cache, the repository or the arguments
319
+ // is exit 1: the reader read nothing, so a caller can tell its own
320
+ // environment's fault from a verdict on the cache.
317
321
  test("an unusable interpreter refuses in this script's own voice, never the cache's", () => {
318
322
  const { dir, head } = repo();
319
323
  cache(dir, recipe(head, { test: "true" }));
320
324
  const absent = derive(dir, "test", { ...process.env, PATH: shimPath({ node: false }) });
321
- assert.equal(absent.status, 1);
325
+ assert.equal(absent.status, 3);
322
326
  assert.equal(absent.out, "");
323
327
  assert.match(absent.err, /^derive-testcmd: node is unusable/);
324
328
  assert.doesNotMatch(absent.err, /is unusable: it does not parse/);
@@ -331,6 +335,119 @@ test("an unusable interpreter refuses in this script's own voice, never the cach
331
335
  assert.equal(present.out, "true");
332
336
  });
333
337
 
338
+ // The reader's other tools, taken away the same way. The control above is the
339
+ // same stripped PATH with every tool present.
340
+ for (const [tool, reason] of [
341
+ ["mktemp", /^derive-testcmd: cannot create a temporary file to read the Recipe cache$/m],
342
+ ["cat", /^derive-testcmd: cat could not be started \(exit 127\), so the Recipe cache cannot be read$/m],
343
+ ]) {
344
+ test(`a ${tool} that cannot be started is the environment's refusal, exit 3, never the cache's`, () => {
345
+ const { dir, head } = repo();
346
+ cache(dir, recipe(head, { test: "true" }));
347
+ const r = derive(dir, "test", { ...process.env, PATH: shimPath({ node: true, omit: tool }) });
348
+ assert.equal(r.status, 3, r.err);
349
+ assert.equal(r.out, "");
350
+ assert.match(r.err, reason);
351
+ assert.doesNotMatch(r.err, /is unusable|byte-count/);
352
+ });
353
+ }
354
+
355
+ // The reader asks mktemp twice, and a mktemp that starts and then fails — a
356
+ // full or unwritable temp directory — is the same environment fault as one
357
+ // that is missing, on either call.
358
+ test("a mktemp that runs and fails on its second call is exit 3, never the cache's", () => {
359
+ const { dir, head } = repo();
360
+ cache(dir, recipe(head, { test: "true" }));
361
+ const bin = shimPath({ node: true, omit: "mktemp" });
362
+ const real = execFileSync("sh", ["-c", "command -v mktemp"], { encoding: "utf8" }).trim();
363
+ const mark = join(bin, "called");
364
+ writeExecStub(join(bin, "mktemp"), `#!/bin/sh\nif [ -e '${mark}' ]; then echo 'mktemp: no space left' >&2; exit 1; fi\n: > '${mark}'\nexec '${real}' "$@"\n`);
365
+ const r = derive(dir, "test", { ...process.env, PATH: bin });
366
+ assert.equal(r.status, 3, r.err);
367
+ assert.match(r.err, /^derive-testcmd: cannot create a temporary file to read the Recipe cache$/m);
368
+ });
369
+
370
+ // The reader's `node -e 0` probe passes, and then the interpreter dies during
371
+ // the real read — killed, or no longer runnable. Nothing was read, so that is
372
+ // exit 3 as well. Node's own refusal of the cache (its exit 2) and an uncaught
373
+ // fault of its own (exit 1) are still the cache's exit 1, with node's reason.
374
+ for (const [what, body, status, reason] of [
375
+ ["is killed", "kill -9 $$", 3, /^derive-testcmd: node did not finish reading the Recipe cache \(exit 137\)/m],
376
+ ["can no longer be run", "exit 127", 3, /^derive-testcmd: node did not finish reading the Recipe cache \(exit 127\)/m],
377
+ ["refuses the cache itself", "echo 'it does not parse: boom' >&2; exit 2", 1, /is unusable: it does not parse: boom/],
378
+ ["faults on its own", "echo 'TypeError: boom' >&2; exit 1", 1, /is unusable: TypeError: boom/],
379
+ ]) {
380
+ test(`a node that passes the probe and then ${what} is exit ${status}`, () => {
381
+ const { dir, head } = repo();
382
+ cache(dir, recipe(head, { test: "true" }));
383
+ const bin = shimPath({ node: false });
384
+ writeExecStub(join(bin, "node"), `#!/bin/sh\nif [ "$1" = -e ] && [ "$2" = 0 ]; then exit 0; fi\n${body}\n`);
385
+ const r = derive(dir, "test", { ...process.env, PATH: bin });
386
+ assert.equal(r.status, status, r.err);
387
+ assert.equal(r.out, "");
388
+ assert.match(r.err, reason);
389
+ });
390
+ }
391
+
392
+ // A cat that exits 126 — the status a shell gives a command it found but could
393
+ // not execute — is the environment's fault like one that is missing (127). The
394
+ // stub exits 126 itself: whether a shell reports an unexecutable file as 126
395
+ // depends on the shell and its options, and this pins the reader's reading of
396
+ // the status, not the shell's.
397
+ test("a cat that exits 126 is exit 3, never the cache's", () => {
398
+ const { dir, head } = repo();
399
+ cache(dir, recipe(head, { test: "true" }));
400
+ const bin = shimPath({ node: true, omit: "cat" });
401
+ writeExecStub(join(bin, "cat"), "#!/bin/sh\nexit 126\n");
402
+ const r = derive(dir, "test", { ...process.env, PATH: bin });
403
+ assert.equal(r.status, 3, r.err);
404
+ assert.equal(r.out, "");
405
+ assert.match(r.err, /^derive-testcmd: cat could not be started \(exit 126\), so the Recipe cache cannot be read$/m);
406
+ });
407
+
408
+ // A cat that starts and fails while reading node's byte-count file is the
409
+ // cache refusal, exit 1, and carries cat's own reason.
410
+ test("a cat that runs and fails on the byte-count file is exit 1, carrying its reason", () => {
411
+ const { dir, head } = repo();
412
+ cache(dir, recipe(head, { test: "true" }));
413
+ const bin = shimPath({ node: true, omit: "cat" });
414
+ const real = execFileSync("sh", ["-c", "command -v cat"], { encoding: "utf8" }).trim();
415
+ const mark = join(bin, "called");
416
+ writeExecStub(join(bin, "cat"), `#!/bin/sh\nif [ ! -e '${mark}' ]; then : > '${mark}'; echo 'cat: I/O error' >&2; exit 1; fi\nexec '${real}' "$@"\n`);
417
+ const r = derive(dir, "test", { ...process.env, PATH: bin });
418
+ assert.equal(r.status, 1, r.err);
419
+ assert.equal(r.out, "");
420
+ assert.match(r.err, /cat failed reading node's byte-count file \(exit 1\): cat: I\/O error/);
421
+ assert.doesNotMatch(r.err, /could not be started/);
422
+ });
423
+
424
+ // `rm` is the reader's cleanup only, so it must not decide the outcome: with
425
+ // it gone a good cache still reads cleanly — status 0 and nothing on stderr,
426
+ // the success-path invariant claim-ticket.sh relies on — and a refused cache
427
+ // keeps its own status.
428
+ test("an rm that cannot be started changes neither the value read nor the refusal's status", () => {
429
+ const { dir, head } = repo();
430
+ cache(dir, recipe(head, { test: "true" }));
431
+ const ok = derive(dir, "test", { ...process.env, PATH: shimPath({ node: true, omit: "rm" }) });
432
+ assert.equal(ok.status, 0, ok.err);
433
+ assert.equal(ok.out, "true");
434
+ assert.equal(ok.err, "");
435
+
436
+ cache(dir, "not json");
437
+ const refused = derive(dir, "test", { ...process.env, PATH: shimPath({ node: true, omit: "rm" }) });
438
+ assert.equal(refused.status, 1, refused.err);
439
+ });
440
+
441
+ // The false-positive class: a cache the reader really refuses stays exit 1
442
+ // under the same stripped PATH, so exit 3 is not "any refusal".
443
+ test("a cache that is refused stays exit 1 under the stripped PATH", () => {
444
+ const { dir } = repo();
445
+ cache(dir, "not json");
446
+ const r = derive(dir, "test", { ...process.env, PATH: shimPath({ node: true }) });
447
+ assert.equal(r.status, 1, r.err);
448
+ assert.match(r.err, /is unusable/);
449
+ });
450
+
334
451
  // --- #2217: node's STDOUT is the capture itself, so the stream split above
335
452
  // does nothing for it. A version-manager or proxy shim prints before exec'ing
336
453
  // the real node, or — not exec'ing — after it exits, and unframed that chatter
@@ -57,8 +57,10 @@
57
57
  // repository, origin/main missing, the worktree could not be made), or
58
58
  // a git or filesystem fault stopped it before the cache was settled
59
59
  // (a git command could not be started, the reader's own git
60
- // included, or the reader's `sh`; the cache or its temp file could
61
- // not be written). The log directory under $TMPDIR is removed.
60
+ // included; the reader's `sh`, `node`, `mktemp` or `cat` could not be
61
+ // started, or its `node` died mid-read — a `cat` that ran and failed
62
+ // stays the reader's refusal, exit 1; the cache or its temp file
63
+ // could not be written). The log directory under $TMPDIR is removed.
62
64
 
63
65
  import { spawnSync } from "node:child_process";
64
66
  import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
@@ -110,6 +112,11 @@ class Refusal extends Error {
110
112
  const cannot = (message) => new Refusal(2, message);
111
113
  const notProven = (message) => new Refusal(1, message);
112
114
 
115
+ // The exit status derive-testcmd.sh uses for a refusal about its own
116
+ // environment — a tool it needs would not start, or node died mid-read — where
117
+ // every refusal about the cache is 1.
118
+ const READER_COULD_NOT_RUN = 3;
119
+
113
120
  function parseArgs(argv) {
114
121
  const [repo, ...rest] = argv;
115
122
  if (!repo || repo.startsWith("--")) throw cannot(USAGE);
@@ -278,9 +285,11 @@ function prove(o, wt, logs) {
278
285
  // Write, then read back through the one reader. A cache it refuses is undone:
279
286
  // the prior bytes restored, or the file removed when none stood before. The
280
287
  // cache is undone just the same when the read-back is no verdict: an `sh` that
281
- // could not be started read nothing, and the reader runs git itself and reads
282
- // a git that will not start as no repository at all, so its refusal is a
283
- // verdict on what was proven only while git still starts.
288
+ // could not be started read nothing; a reader that exits READER_COULD_NOT_RUN
289
+ // could not start its `node`, `mktemp` or `cat`, or lost its `node` mid-read;
290
+ // and the reader runs git itself and reads a git that will not start as no
291
+ // repository at all, so its refusal is a verdict on what was proven only while
292
+ // git still starts.
284
293
  function writeCache(cache, recipe, repo) {
285
294
  mkdirSync(dirname(cache), { recursive: true });
286
295
  const prior = existsSync(cache) ? readFileSync(cache) : null;
@@ -298,6 +307,7 @@ function writeCache(cache, recipe, repo) {
298
307
  if (prior === null) unlinkSync(cache);
299
308
  else writeFileSync(cache, prior);
300
309
  if (r.error && !r.pid) throw cannot(`could not start sh to read the Recipe cache back: ${r.error.message}`);
310
+ if (r.status === READER_COULD_NOT_RUN) throw cannot(`${r.stderr.trim()} — the Recipe cache reader could not run a tool it needs, so its refusal is no verdict on what was proven`);
301
311
  try {
302
312
  git(["--version"]);
303
313
  } catch (e) {
@@ -677,6 +677,48 @@ test("an sh that stops starting before the cache is read back is no verdict, nev
677
677
  assert.equal(existsSync(cachePath(dir)), false, "a cache the reader never read is rolled back");
678
678
  });
679
679
 
680
+ // The reader's other external tools, taken away the same way after the proof's
681
+ // last step. The control is the same PATH with the tool left in place, which
682
+ // proves — so the no verdict below is about the missing tool, not a PATH too
683
+ // thin for the reader to run at all. `rm` is the reader's cleanup only: it
684
+ // decides nothing about the cache, so the verdict stands without it.
685
+ const READER_TOOLS = [
686
+ { tool: "node", reason: /derive-testcmd: node is unusable, refusing to read the Recipe cache without the interpreter/ },
687
+ { tool: "mktemp", reason: /derive-testcmd: cannot create a temporary file to read the Recipe cache/ },
688
+ { tool: "cat", reason: /derive-testcmd: cat could not be started/ },
689
+ ];
690
+ for (const c of READER_TOOLS) {
691
+ test(`a ${c.tool} that stops starting before the cache is read back is no verdict, never NOT PROVEN — and the prior cache is restored`, () => {
692
+ const args = (cmd) => ["--install", "true", "--test", cmd, "--count-line", "tests 1", "--test-count", "1"];
693
+ const kept = repo(MAVEN_FILES);
694
+ const ok = prove(kept.dir, args(`echo 'tests 1'`), { env: { PATH: readerPath() } });
695
+ assert.equal(ok.status, 0, ok.err);
696
+ assert.match(ok.out, /^recipe-prove: PROVEN/);
697
+
698
+ const { dir } = repo(MAVEN_FILES);
699
+ mkdirSync(join(dir, ".fleet"));
700
+ writeFileSync(cachePath(dir), "prior bytes");
701
+ const bin = readerPath();
702
+ const r = prove(dir, args(`rm '${join(bin, c.tool)}'; echo 'tests 1'`), { env: { PATH: bin } });
703
+ assert.equal(r.status, 2, r.err);
704
+ assert.match(r.err, c.reason);
705
+ assert.match(r.err, /the Recipe cache reader could not run a tool it needs, so its refusal is no verdict/);
706
+ assert.doesNotMatch(r.err, /NOT PROVEN/);
707
+ assert.equal(readFileSync(cachePath(dir), "utf8"), "prior bytes", "a cache the reader never settled is rolled back");
708
+ assert.deepEqual(logDirs(r.tmp), [], "no verdict keeps no logs");
709
+ });
710
+ }
711
+
712
+ test("an rm that stops starting before the cache is read back leaves the proof standing — the reader's verdict never depended on it", () => {
713
+ const { dir } = repo(MAVEN_FILES);
714
+ const bin = readerPath();
715
+ // `echo` leads the command: the reader refuses a Test entrypoint whose first word does not resolve, and `rm` would not once the PATH link is gone.
716
+ const r = prove(dir, ["--install", "true", "--test", `echo 'tests 1'; rm '${join(bin, "rm")}'`, "--count-line", "tests 1", "--test-count", "1"], { env: { PATH: bin } });
717
+ assert.equal(r.status, 0, r.err);
718
+ assert.match(r.out, /^recipe-prove: PROVEN/);
719
+ assert.equal(existsSync(cachePath(dir)), true);
720
+ });
721
+
680
722
  // Whether the process ignores file modes: root writes into a 0555 directory.
681
723
  const IGNORES_MODES = process.getuid?.() === 0 && "root ignores file modes";
682
724
 
@@ -76,7 +76,7 @@ gh label create in-progress --color FBCA04 --force # first time only
76
76
  gh issue edit <N> --add-label in-progress
77
77
  ```
78
78
 
79
- Infer branch/worktree convention from `git worktree list` / `git branch -r` — commonly `feat|fix|refactor/<N>-slug` and `.worktrees/<N>-slug`. Branch off fresh `origin/main`, run the Install step, run the Test entrypoint as the baseline — both the repo's **Recipe**, read from its Recipe cache: `~/.fleet/bin/fleet-run derive-testcmd.sh . install` and `… . test` print them. **No usable cache (either refuses) → run the Recipe derivation step first:** dispatch a `task` member, agent `fleet-recipe-deriver`, its prompt naming the main checkout's absolute path as `<repo>` and a scratch directory of its own; it reads the repo, proves both commands in a throwaway worktree and writes the cache only on proof. `RECIPE NOT PROVEN` → stop and give the maintainer its reason; never pick a command yourself.
79
+ Infer branch/worktree convention from `git worktree list` / `git branch -r` — commonly `feat|fix|refactor/<N>-slug` and `.worktrees/<N>-slug`. Branch off fresh `origin/main`, run the Install step, run the Test entrypoint as the baseline — both the repo's **Recipe**, read from its Recipe cache: `~/.fleet/bin/fleet-run derive-testcmd.sh . install` and `… . test` print them. **No usable cache (either exits 1) → run the Recipe derivation step first:** dispatch a `task` member, agent `fleet-recipe-deriver`, its prompt naming the main checkout's absolute path as `<repo>` and a scratch directory of its own; it reads the repo, proves both commands in a throwaway worktree and writes the cache only on proof. `RECIPE NOT PROVEN` → stop and give the maintainer its reason; never pick a command yourself. Exit 3 is no verdict on the cache — the reader could not run a tool it needs — so fix that environment fault and re-run instead of deriving.
80
80
 
81
81
  Abandoned before PR opens → remove `in-progress`.
82
82
 
@@ -369,7 +369,10 @@ phase, or in any later one, asks the maintainer which tickets to take.
369
369
  command. Check it first: `~/.fleet/bin/fleet-run derive-testcmd.sh .
370
370
  install` and `~/.fleet/bin/fleet-run derive-testcmd.sh . test`. **Both exit
371
371
  0 → the cache is present and valid: use it as it stands and derive
372
- nothing.** Either refuses → derive it. This step is yours: one derivation
372
+ nothing.** Either exits 1 (a refusal about the cache) → derive it. Either
373
+ exits 3 → the reader could not run a tool it needs, so the cache's state is
374
+ unknown: fix that environment fault and check again; deriving would not
375
+ help. This step is yours: one derivation
373
376
  per run start at most, never one per member, and a member never derives.
374
377
  Dispatch a `task` member named `derive-recipe`, agent
375
378
  `fleet-recipe-deriver`, and wait for its report — nothing in this run can