@feigi/fleet-ctl 3.22.2 → 3.22.4

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.22.2",
3
+ "version": "3.22.4",
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": {
@@ -60,9 +60,12 @@ export function classifyRole(signals) {
60
60
  // #1505: a dispatch that recorded `memory-housekeeper` books memory whatever
61
61
  // it called the member, so the eleven rows that shared one definition across
62
62
  // three buckets collapse to one. The NAME stays a signal beside it because a
63
- // missing definition is a real category here rather than a hole — an
64
- // untyped `task` dispatch records none at all — and the memory members named
65
- // `memory-proxy-session-review-2-3` have nothing else to be classified on;
63
+ // definition that says nothing about the role is a real category here rather
64
+ // than a hole — an untyped dispatch records the generic `task` (the
65
+ // transcript's `session_init` writes it for the default agent; the field is
66
+ // blank only where that line itself is absent), which names no memory agent
67
+ // and matches none of the definition branches below — and the memory members
68
+ // named `memory-proxy-session-review-2-3` have nothing else to be classified on;
66
69
  // drop it and they fall to "other", which is the same defect wearing the
67
70
  // other shoe. `desc` is excluded on purpose: every memory-adjacent fleet
68
71
  // member's own prompt says "memory-proxy" somewhere, so blending the prose
@@ -177,13 +177,26 @@ test("the memory exclusion reads the recorded DEFINITION, so any member name boo
177
177
  assert.equal(classifyRole({ agentDefinition: "memory-proxy", memberName: "review-eval", spawnDepth: 2 }), "memory");
178
178
  });
179
179
 
180
- test("an untyped dispatch that recorded no definition still classifies by its NAME", () => {
181
- // #1505's other half. A dispatch before typed agents existed records no
182
- // definition at all — a closed category, not a hole — so the name is the only
183
- // memory signal those members have. Measured over the sidecars on disk: three
184
- // live members rest on this, and dropping it would put `memory-housekeeper`
185
- // (named for its own definition, at depth 1) in the SPECIALIST bucket, which
186
- // is the very defect #1505 reports.
180
+ test("an untyped dispatch, recorded as the generic `task` or as no definition, still classifies by its NAME", () => {
181
+ // #1505's other half. The default agent records the literal `task` in
182
+ // `session_init`, and a transcript with no `session_init` line records no
183
+ // definition at all; neither names a memory agent or any fleet definition, so
184
+ // the name is the only memory signal those members have. Re-measured
185
+ // 2026-10-04: over the Claude Code `meta.json` sidecars under
186
+ // ~/.claude/projects (the reader #1505 fixed, since dropped from
187
+ // member-record.mjs) three members booked memory on their name alone, each
188
+ // with no definition recorded and none with `task`; over the omp members on
189
+ // disk none did, under `task` or without a definition. Dropping the name
190
+ // would put `memory-housekeeper` (named for its own definition, at depth 1)
191
+ // in the SPECIALIST bucket, which is the very defect #1505 reports.
192
+ assert.equal(foldOmpTranscript(JSON.stringify({ type: "session_init", agent: "task" }), "/fake/omp.jsonl").agent, "task");
193
+ assert.equal(foldOmpTranscript(JSON.stringify({ type: "session" }), "/fake/omp.jsonl").agent, null);
194
+ assert.equal(classifyRole({ agentDefinition: "task", memberName: "memory-proxy-session-review-2-3" }), "memory");
195
+ assert.equal(classifyRole({ agentDefinition: "task", memberName: "memory-housekeeper", spawnDepth: 1 }), "memory");
196
+ // `task` matches no definition branch, so with a name it is the name that
197
+ // decides and without one nothing does.
198
+ assert.equal(classifyRole({ agentDefinition: "task", memberName: "fix-pr-1380" }), "reviewer");
199
+ assert.equal(classifyRole({ agentDefinition: "task" }), "other");
187
200
  assert.equal(classifyRole({ agentDefinition: "", memberName: "memory-proxy-session-review-2-3" }), "memory");
188
201
  assert.equal(classifyRole({ agentDefinition: "", memberName: "memory-housekeeper", spawnDepth: 1 }), "memory");
189
202
  // ...and it is still not a guess: a name carrying no memory agent falls
@@ -19,8 +19,9 @@
19
19
  // alternate-tier pair from two members whose models already happened to
20
20
  // differ, and both readers keep it because it is derived rather than
21
21
  // authored and survives a regeneration. `""` is NOT the hole use; it means
22
- // the dispatch named no agent definition, the ordinary shape of an untyped
23
- // `task` call.
22
+ // the transcript carried no `session_init` agent to read (no such line, or
23
+ // one without a string `agent`). An untyped `task` dispatch is not that
24
+ // shape: the omp reader records the generic `task` for it.
24
25
  import { readdirSync, readFileSync, realpathSync } from "node:fs";
25
26
  import { join, basename, relative, isAbsolute, sep } from "node:path";
26
27
 
@@ -42,14 +42,17 @@
42
42
  // the reader stays the one authority on what a usable cache is. A failed
43
43
  // proof never touches an existing cache.
44
44
  //
45
- // Exit 0: proven, cache written (its path and contents on stdout).
46
- // Exit 1: NOT PROVEN — no cache written; the reason on stderr.
45
+ // Exit 0: proven, cache written (its path and contents on stdout); the log
46
+ // directory under $TMPDIR is removed.
47
+ // Exit 1: NOT PROVEN — no cache written; the reason on stderr. The log
48
+ // directory under $TMPDIR is kept when the reason names a log in it,
49
+ // and removed when it names none (the Recipe cache reader's refusal).
47
50
  // Exit 2: no verdict on the proof — it could not be attempted (usage, not a
48
51
  // repository, origin/main missing, the worktree could not be made), or
49
52
  // a git or filesystem fault stopped it before the cache was settled
50
53
  // (a git command could not be started, the reader's own git
51
54
  // included, or the reader's `sh`; the cache or its temp file could
52
- // not be written).
55
+ // not be written). The log directory under $TMPDIR is removed.
53
56
 
54
57
  import { spawnSync } from "node:child_process";
55
58
  import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
@@ -148,9 +151,9 @@ const didNotRun = (rc) => rc === 126 || rc === 127;
148
151
  // install creates dirties the tree as surely as a rewrite, and the untracked
149
152
  // mode is config — under status.showUntrackedFiles=no it is invisible unless
150
153
  // pinned.
151
- function treeChanges(wt) {
154
+ function treeChanges(wt, log) {
152
155
  const s = git(["status", "--porcelain", "-uall"], wt);
153
- if (!s.ok) throw notProven(`could not read the tree state in the throwaway worktree: ${s.err}`);
156
+ if (!s.ok) throw notProven(`could not read the tree state in the throwaway worktree: ${s.err}; install output: ${log}`);
154
157
  return s.out;
155
158
  }
156
159
 
@@ -161,8 +164,10 @@ function prove(o, wt, logs) {
161
164
  throw notProven(`the Install step '${o.install}' did not run (exit ${irc}: not executable or not found); install output: ${installLog}`);
162
165
  }
163
166
  if (irc !== 0) throw notProven(`the Install step '${o.install}' failed (exit ${irc}); install output: ${installLog}`);
164
- const dirty = treeChanges(wt);
165
- if (dirty) throw notProven(`the Install step changed the tree (first: ${dirty.split("\n")[0]}); it must leave every file as checked out`);
167
+ const dirty = treeChanges(wt, installLog);
168
+ if (dirty) {
169
+ throw notProven(`the Install step changed the tree (first: ${dirty.split("\n")[0]}); it must leave every file as checked out; install output: ${installLog}`);
170
+ }
166
171
 
167
172
  const testLog = join(logs, "test.log");
168
173
  const trc = sh(o.test, wt, testLog);
@@ -179,7 +184,7 @@ function prove(o, wt, logs) {
179
184
  const n = Number(o.testCount);
180
185
  if (n === 0) throw notProven(`vacuous: a test count of 0 is a run that executed no tests; ${where}`);
181
186
  if (!new RegExp(`(^|[^0-9])0*${n}([^0-9]|$)`).test(o.countLine)) {
182
- throw notProven(`the count line '${o.countLine}' does not carry the test count ${n}`);
187
+ throw notProven(`the count line '${o.countLine}' does not carry the test count ${n}; ${where}`);
183
188
  }
184
189
  if (!readFileSync(testLog, "utf8").includes(o.countLine)) {
185
190
  throw notProven(`vacuous: the Test entrypoint's output does not contain the count line '${o.countLine}' — no evidence any test ran; ${where}`);
@@ -193,15 +198,15 @@ function prove(o, wt, logs) {
193
198
  // What the unmutated Test run rewrote is not the mutation's work: restore
194
199
  // every tracked file first, so the change read below is the mutation's alone.
195
200
  const reset = git(["reset", "--hard", "-q", "HEAD"], wt);
196
- if (!reset.ok) throw notProven(`could not restore the tree before the mutation: ${reset.err}`);
201
+ if (!reset.ok) throw notProven(`could not restore the tree before the mutation: ${reset.err}; ${where}`);
197
202
  const mutateLog = join(logs, "mutate.log");
198
203
  const mrc = sh(o.mutate, wt, mutateLog);
199
204
  if (mrc !== 0) throw notProven(`the mutation command '${o.mutate}' failed (exit ${mrc}); its output: ${mutateLog}`);
200
205
  // Against HEAD, not the index: a mutation that stages its edit (`git mv`,
201
206
  // `git add`) changed a tracked file just as surely.
202
207
  const changed = git(["diff", "HEAD", "--name-only"], wt);
203
- if (!changed.ok) throw notProven(`could not read what the mutation changed: ${changed.err}`);
204
- if (!changed.out) throw notProven(`the mutation (${o.mutation}) changed no tracked file — mutate a test, or the code a test covers`);
208
+ if (!changed.ok) throw notProven(`could not read what the mutation changed: ${changed.err}; its output: ${mutateLog}`);
209
+ if (!changed.out) throw notProven(`the mutation (${o.mutation}) changed no tracked file — mutate a test, or the code a test covers; its output: ${mutateLog}`);
205
210
  const mutatedLog = join(logs, "test-mutated.log");
206
211
  const red = sh(o.test, wt, mutatedLog);
207
212
  if (didNotRun(red)) throw notProven(`the Test entrypoint did not run on the mutated tree (exit ${red}); test output: ${mutatedLog}`);
@@ -275,6 +280,19 @@ function removeWorktree(wt, repo) {
275
280
  tryGit(["worktree", "prune"]);
276
281
  }
277
282
 
283
+ // The log directory outlives the run only for a refusal that names it, so the
284
+ // caller can read the log it points at. A proof that held has its evidence
285
+ // summarised in the cache, a run with no verdict was stopped by a fault no log
286
+ // records, and a refusal that names no log (the Recipe cache reader's) has
287
+ // nothing in the directory to point at. Best-effort, as removeWorktree is.
288
+ function removeLogs(logs) {
289
+ try {
290
+ rmSync(logs, { recursive: true, force: true });
291
+ } catch (e) {
292
+ process.stderr.write(`${NAME}: could not remove the log directory ${logs}: ${e.message}\n`);
293
+ }
294
+ }
295
+
278
296
  function main(argv) {
279
297
  const o = parseArgs(argv);
280
298
  if (!git(["rev-parse", "--git-dir"], o.repo).ok) throw cannot(`${o.repo} is not a git repository`);
@@ -285,19 +303,26 @@ function main(argv) {
285
303
  if (!sha.ok) throw cannot("origin/main does not resolve to a commit — fetch it; the proof runs against it");
286
304
 
287
305
  const logs = mkdtempSync(join(tmpdir(), `${NAME}-`));
288
- const wt = join(logs, "wt");
289
- const add = git(["worktree", "add", "--detach", wt, sha.out], o.repo);
290
- if (!add.ok) throw cannot(`could not create the throwaway worktree: ${add.err}`);
291
- let proof;
306
+ const cache = join(workspace, ".fleet", "recipe.json");
307
+ let recipe;
292
308
  try {
293
- proof = prove(o, wt, logs);
294
- } finally {
295
- removeWorktree(wt, o.repo);
296
- }
309
+ const wt = join(logs, "wt");
310
+ const add = git(["worktree", "add", "--detach", wt, sha.out], o.repo);
311
+ if (!add.ok) throw cannot(`could not create the throwaway worktree: ${add.err}`);
312
+ let proof;
313
+ try {
314
+ proof = prove(o, wt, logs);
315
+ } finally {
316
+ removeWorktree(wt, o.repo);
317
+ }
297
318
 
298
- const recipe = { install: o.install, test: o.test, derivedAt: sha.out, installClean: true, ...proof };
299
- const cache = join(workspace, ".fleet", "recipe.json");
300
- writeCache(cache, recipe, o.repo);
319
+ recipe = { install: o.install, test: o.test, derivedAt: sha.out, installClean: true, ...proof };
320
+ writeCache(cache, recipe, o.repo);
321
+ } catch (e) {
322
+ if (!(e instanceof Refusal && e.message.includes(logs))) removeLogs(logs);
323
+ throw e;
324
+ }
325
+ removeLogs(logs);
301
326
  process.stdout.write(`${NAME}: PROVEN — Recipe cache written to ${cache}\n${JSON.stringify(recipe)}\n`);
302
327
  }
303
328
 
@@ -378,13 +378,90 @@ function assertNoWorktreeLeft(tmp) {
378
378
  }
379
379
  }
380
380
 
381
- test("the proof leaves no worktree directory behind in its temp dir", () => {
381
+ // The log directories the proof made in its TMPDIR.
382
+ const logDirs = (tmp) => readdirSync(tmp).filter((n) => n.startsWith("recipe-prove-"));
383
+
384
+ test("a proof that held leaves neither a worktree nor a log directory behind in its TMPDIR", () => {
382
385
  const { dir } = repo(MAVEN_FILES);
383
386
  const r = prove(dir, MAVEN_PROOF);
384
387
  assert.equal(r.status, 0, r.err);
388
+ assert.deepEqual(logDirs(r.tmp), []);
389
+ });
390
+
391
+ test("a proof that did not hold keeps its log directory, with the log its refusal names", () => {
392
+ const { dir } = repo(MAVEN_FILES);
393
+ const r = prove(dir, ["--install", "echo fetching deps; exit 3", ...MAVEN_PROOF.slice(2)]);
394
+ assert.equal(r.status, 1, r.err);
395
+ const log = r.err.match(/install output: (\S+)/);
396
+ assert.ok(log, r.err);
397
+ const kept = logDirs(r.tmp);
398
+ assert.equal(kept.length, 1, `log directories in ${r.tmp}: ${kept}`);
399
+ assert.equal(log[1], join(r.tmp, kept[0], "install.log"));
400
+ assert.match(readFileSync(log[1], "utf8"), /fetching deps/);
385
401
  assertNoWorktreeLeft(r.tmp);
386
402
  });
387
403
 
404
+ // Every refusal the proof stage throws names a log in the directory it keeps,
405
+ // so a kept directory is never one the caller has no path to.
406
+ const KEPT_REFUSALS = [
407
+ {
408
+ name: "an Install step that changed the tree",
409
+ files: MAVEN_FILES,
410
+ args: ["--install", ": > deps.lock", ...MAVEN_PROOF.slice(2)],
411
+ reason: /the Install step changed the tree/,
412
+ log: "install.log",
413
+ },
414
+ {
415
+ name: "a count line that lacks the claimed count",
416
+ files: MAVEN_FILES,
417
+ args: ["--install", "true", "--test", "mvn -q test", "--count-line", "Tests run: 1,", "--test-count", "7"],
418
+ reason: /does not carry the test count 7/,
419
+ log: "test.log",
420
+ },
421
+ {
422
+ name: "a mutation that changed no tracked file",
423
+ files: GO_FILES,
424
+ args: ["--install", "true", "--test", "go test ./...", "--mutate", ": > new_test.go", "--mutation", "adds a file"],
425
+ reason: /changed no tracked file/,
426
+ log: "mutate.log",
427
+ },
428
+ ];
429
+
430
+ for (const c of KEPT_REFUSALS) {
431
+ test(`${c.name} keeps its log directory, and the refusal names the log in it`, () => {
432
+ const { dir } = repo(c.files);
433
+ const r = prove(dir, c.args);
434
+ assert.equal(r.status, 1, r.err);
435
+ assert.match(r.err, c.reason);
436
+ const kept = logDirs(r.tmp);
437
+ assert.equal(kept.length, 1, `log directories in ${r.tmp}: ${kept}`);
438
+ const log = join(r.tmp, kept[0], c.log);
439
+ assert.ok(r.err.includes(log), `${log} not named in: ${r.err}`);
440
+ assert.ok(existsSync(log), `${log} does not exist`);
441
+ });
442
+ }
443
+
444
+ test("a refusal that names no log leaves no log directory behind", () => {
445
+ // The Recipe cache reader refuses a Test entrypoint ending in `;` after the
446
+ // proof held, so no log in the directory is what the refusal is about.
447
+ const { dir } = repo(MAVEN_FILES);
448
+ const r = prove(dir, ["--install", "true", "--test", "mvn -q test;", "--count-line", "Tests run: 1,", "--test-count", "1"]);
449
+ assert.equal(r.status, 1, r.err);
450
+ assert.match(r.err, /the Recipe cache reader refuses what was proven/);
451
+ assert.deepEqual(logDirs(r.tmp), []);
452
+ });
453
+
454
+ test("a throwaway worktree that cannot be made is exit 2 and leaves no log directory behind", () => {
455
+ const realGit = execFileSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).trim();
456
+ const { dir } = repo(MAVEN_FILES);
457
+ const bin = tempDir("recipe-prove-path-");
458
+ writeExecStub(join(bin, "git"), `#!/bin/sh\nif [ "$1" = worktree ] && [ "$2" = add ]; then echo 'fatal: no worktree here' >&2; exit 128; fi\nexec '${realGit}' "$@"\n`);
459
+ const r = prove(dir, MAVEN_PROOF, { env: { PATH: `${bin}:${BIN}:${process.env.PATH}` } });
460
+ assert.equal(r.status, 2, r.err);
461
+ assert.match(r.err, /could not create the throwaway worktree: fatal: no worktree here/);
462
+ assert.deepEqual(logDirs(r.tmp), []);
463
+ });
464
+
388
465
  test("a git that cannot be started is named as such, not reported as a missing repository", () => {
389
466
  const { dir } = repo(MAVEN_FILES);
390
467
  const r = prove(dir, MAVEN_PROOF, { env: { PATH: "/nonexistent" } });
@@ -408,7 +485,7 @@ test("a git that stops starting during the Install step is no verdict, never NOT
408
485
  assert.match(r.err, /could not start git: .*ENOENT/);
409
486
  assert.match(r.err, /skipped git worktree remove/);
410
487
  assert.doesNotMatch(r.err, /NOT PROVEN/);
411
- assertNoWorktreeLeft(r.tmp);
488
+ assert.deepEqual(logDirs(r.tmp), [], "no verdict keeps no logs");
412
489
  });
413
490
 
414
491
  // A git that started and ran is never "could not start": here its status
@@ -503,7 +580,7 @@ test("read-only install output cannot turn a proof that held into a crash", { sk
503
580
  const r = prove(dir, ["--install", READ_ONLY_INSTALL, ...MAVEN_PROOF.slice(2)]);
504
581
  assert.equal(r.status, 0, r.err);
505
582
  assert.equal(JSON.parse(readFileSync(cachePath(dir), "utf8")).derivedAt, head);
506
- assertNoWorktreeLeft(r.tmp);
583
+ assert.deepEqual(logDirs(r.tmp), []);
507
584
  assert.equal(git(dir, "worktree", "list", "--porcelain").split("\n").filter((l) => l.startsWith("worktree ")).length, 1);
508
585
  });
509
586
 
@@ -527,6 +604,7 @@ test("a cache that cannot be written is exit 2 with its reason, never a stack tr
527
604
  assert.doesNotMatch(r.err, /\n\s+at /);
528
605
  assert.doesNotMatch(r.err, /NOT PROVEN/);
529
606
  assert.equal(r.out, "");
607
+ assert.deepEqual(logDirs(r.tmp), [], "no verdict keeps no logs");
530
608
  } finally {
531
609
  chmodSync(join(dir, ".fleet"), 0o755);
532
610
  }