@feigi/fleet-ctl 3.17.1 → 3.17.3
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/commands/review-and-fix.md +1 -1
- package/package.json +1 -1
- package/scripts/tier-roles.mjs +37 -11
- package/scripts/tier-roles.test.mjs +55 -1
|
@@ -79,7 +79,7 @@ It cannot ask the user anything and cannot wait on CI — rebasing, watching che
|
|
|
79
79
|
- **Do not trust the obvious contamination check.** `diff -rq` and `md5` against the mutating tree both returned *clean* — because the probe had been reverted between the two reads. A clean diff against a live tree is not evidence in either direction; only `snap-ro` and the object store settle it.
|
|
80
80
|
- **`git archive` carries tracked files only.** No `node_modules`, no gitignored runner or config. Provision in the same step — symlink `node_modules`, copy in what the runner needs — or specialists silently have no runnable suite and reason from source instead of measuring. That failure is invisible: you get confident prose where you asked for a measurement.
|
|
81
81
|
- **Initialize the copy before anyone measures in it — a bare `git archive` tree cannot validly run every suite.** It is not a git repository, so anything that asks git about the checkout breaks: `derive_workspace_id` shells out to `git rev-parse`, falls back to the directory basename and the hook tests fail on the *name* — naming the dir after the repo makes those pass **by coincidence, not correctness** — while every sweep that asks git what ships declines instead of running, which is the silent half (measured on this repo at `ffa9026`: 2401 tests, 2382 pass, 19 skipped in an extraction against 2401/2401/0 in a worktree, the totals identical). `git init && git add -A -f && git commit` in the extracted copy is what makes a count mean what it looks like; the review's snapshot agent does exactly that and verifies it by comparing the new commit's tree hash against the reviewed commit's (#1056), and a hand-cut tree needs the same before you read a count off it. Suites that need the real worktree for some *other* reason — the stack below — still do.
|
|
82
|
-
- **Give specialists a stack-free test command, not the worktree's runner, and their own scratch dir** — `<scratch>/pr<N>/fleet-review-<key>/`, resolved to an absolute path that you write into that specialist's prompt; a specialist never derives its own. Specialists inherit no environment, and one falling back to the default config runs a `globalSetup` that brings the shared compose stack up and tears it down, recreating the DB mid-run for every sibling. **The snapshot does not cover this** — the compose project name comes from the environment, not the working directory, so three agents on three copies still collide. But `./agent-test` does not fix it either: it exports **one** `TEST_COMPOSE_PROJECT`/port triple per *worktree* (`ab-<issue>`), so N concurrent specialists sharing it tear down each other's postgres mid-run. Observed: a probe returned `No test files found / No such container` and would have read as a test failure. **That war story is from another repo, and the config-swap escape it calls for does not exist here** — `feigi/claude-config` has no compose file, no `globalSetup`, and no vitest, so there is no `vitest.ci.config.ts` to point anyone at. (It does now have CI — `.github/workflows/ci.yml` — but that runs `node --test`, not a stack.) Naming one sends every specialist into `Cannot find module`, which reads as a broken tree rather than a bad instruction. In *this* repo the collision is inert for a different reason: the `export TEST_COMPOSE_PROJECT=…` line `claim-ticket.sh` writes into the runner sets `TEST_COMPOSE_PROJECT`/`TEST_POSTGRES_PORT`/`TEST_OLLAMA_PORT` and **nothing reads them** — the suite is `node --test` throughout. So tell specialists
|
|
82
|
+
- **Give specialists a stack-free test command, not the worktree's runner, and their own scratch dir** — `<scratch>/pr<N>/fleet-review-<key>/`, resolved to an absolute path that you write into that specialist's prompt; a specialist never derives its own. Specialists inherit no environment, and one falling back to the default config runs a `globalSetup` that brings the shared compose stack up and tears it down, recreating the DB mid-run for every sibling. **The snapshot does not cover this** — the compose project name comes from the environment, not the working directory, so three agents on three copies still collide. But `./agent-test` does not fix it either: it exports **one** `TEST_COMPOSE_PROJECT`/port triple per *worktree* (`ab-<issue>`), so N concurrent specialists sharing it tear down each other's postgres mid-run. Observed: a probe returned `No test files found / No such container` and would have read as a test failure. **That war story is from another repo, and the config-swap escape it calls for does not exist here** — `feigi/claude-config` has no compose file, no `globalSetup`, and no vitest, so there is no `vitest.ci.config.ts` to point anyone at. (It does now have CI — `.github/workflows/ci.yml` — but that runs `node --test`, not a stack.) Naming one sends every specialist into `Cannot find module`, which reads as a broken tree rather than a bad instruction. In *this* repo the collision is inert for a different reason: the `export TEST_COMPOSE_PROJECT=…` line `claim-ticket.sh` writes into the runner sets `TEST_COMPOSE_PROJECT`/`TEST_POSTGRES_PORT`/`TEST_OLLAMA_PORT` and **nothing reads them** — the suite is `node --test` throughout. So tell specialists this repo's test command directly — read off the Recipe cache the way step 3 reads it (`derive-testcmd.sh <worktree or main checkout> test` prints it; the snapshot carries no `.fleet/` cache, so pointing it at the snapshot refuses), here in glob form `node --test plugin/scripts/*.test.mjs` as this repo's example — and keep `./agent-test` for your own verification. **Hand that command with its reading rule: `tests 0` is a failed run, not a pass.** A glob is not self-checking. Where it matches nothing, bash passes the pattern through literally, node globs it itself and finds no files, and you get `ℹ tests 0` / exit 0 — a green having run nothing. Read the count under either reporter: `node --test` prints `ℹ tests 0` to a terminal and `# tests 0` to a pipe on older node, so a rule that greps only for `ℹ` finds nothing in a CI log and cannot tell a zero-test run from a full one. Do not count on the shell to catch it: zsh errors (`no matches found`, exit 1) and bash does not, so the guard has to be the reading rule. Node v26.5.0 has no flag that fails a zero-test run. **And zero is not the only no-work count.** **0 passes with no failures** is everything skipped, and a count **materially below the full suite** is a partial copy of the tree — which is the *documented* behavior, since the prompt tells specialists to run their probe work inside their own copy of the snapshot and nothing anchors an expected count for them. `review-core.mjs` classifies the first itself, in `unrunReason`; the second it structurally cannot, because that function is kept pure and so never learns how many tests the tree has. Handing the rule over with the command is the only guard the partial-copy case has. Carry the rule, not the command: in a repo that *does* have a stack, N concurrent specialists sharing one runner still collide, and the escape is whatever that repo's stack-free config is.
|
|
83
83
|
- **Completion is not delivery — but you can reach a specialist, so never wait idle.** A specialist you dispatched reports when it settles; if a report is missing, `hub send` it by name and ask for the report. Judge delivery by whether a report came out. **Do not settle a dimension you hold no report for**: ask the specialist, then the controller by name; only when neither answers, rule and name that dimension **unrun** — a killed specialist never reports, so waiting on one is unbounded. Never ship "dispatched, never delivered" as settled; a ruling citing a report you do not hold gets verified from source, never applied on trust.
|
|
84
84
|
- **Apply only once the fan-out is settled — every report either in hand or accounted for.** Collection is not guaranteed; a report that never arrived is an open dimension, not a smaller set. And editing while they read is the same defect as probing — one specialist reviewed uncommitted code that was never in the PR diff.
|
|
85
85
|
|
package/package.json
CHANGED
package/scripts/tier-roles.mjs
CHANGED
|
@@ -31,7 +31,10 @@
|
|
|
31
31
|
// flagged as a notice — legal, but every `slow-*` cell then measures
|
|
32
32
|
// the same model as its `task-*` twin;
|
|
33
33
|
// (f) the agents directory holds at least one `*.agent.md` definition —
|
|
34
|
-
// with none, (a), (b) and (d) have nothing to check
|
|
34
|
+
// with none, (a), (b) and (d) have nothing to check; an agents
|
|
35
|
+
// directory that cannot be read (missing, not a directory) or a
|
|
36
|
+
// definition that cannot be read (a directory named `*.agent.md`)
|
|
37
|
+
// fails too.
|
|
35
38
|
|
|
36
39
|
import { readFileSync, readdirSync } from "node:fs";
|
|
37
40
|
import { execFileSync } from "node:child_process";
|
|
@@ -202,19 +205,35 @@ function kindOf(v) {
|
|
|
202
205
|
// definitions need. `violations` stop a run (ADR 0014); `notices` never do —
|
|
203
206
|
// they flag a hazard (two roles' cells measuring one model) that is legal
|
|
204
207
|
// configuration, just probably not what the operator meant.
|
|
205
|
-
export function checkRoutes({ agentsDir, modelRoles, overrides, catalog }) {
|
|
208
|
+
export function checkRoutes({ agentsDir, agentsSource = `--agents ${agentsDir}`, modelRoles, overrides, catalog }) {
|
|
206
209
|
const violations = [];
|
|
207
210
|
const notices = [];
|
|
208
211
|
const usedBy = {}; // role -> [{ file, level }, ...]
|
|
209
212
|
|
|
210
|
-
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
213
|
+
// A path `readdirSync`/`readFileSync` cannot read (missing, a regular file,
|
|
214
|
+
// a directory named `*.agent.md`, no permission) is a wrong agents
|
|
215
|
+
// directory, not a crash: name the path and where it came from
|
|
216
|
+
// (`agentsSource` — `--agents <dir>`, or the built-in default).
|
|
217
|
+
const unreadable = (what, e) => `${agentsSource}: cannot read ${what} (${e.code ?? e.message})`;
|
|
218
|
+
let files = [];
|
|
219
|
+
try {
|
|
220
|
+
files = readdirSync(agentsDir).filter((f) => f.endsWith(".agent.md")).sort();
|
|
221
|
+
// A directory with no definitions has nothing to route, so every
|
|
222
|
+
// per-definition check below (the model route, the role's resolution, the
|
|
223
|
+
// level) would pass over it — a wrong `--agents` path that exists must not
|
|
224
|
+
// read as a healthy install.
|
|
225
|
+
if (files.length === 0) violations.push(`no *.agent.md definition found in ${agentsDir}`);
|
|
226
|
+
} catch (e) {
|
|
227
|
+
violations.push(unreadable("the agents directory", e));
|
|
228
|
+
}
|
|
216
229
|
for (const file of files) {
|
|
217
|
-
|
|
230
|
+
let text;
|
|
231
|
+
try {
|
|
232
|
+
text = readFileSync(join(agentsDir, file), "utf8");
|
|
233
|
+
} catch (e) {
|
|
234
|
+
violations.push(unreadable(file, e));
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
218
237
|
const fm = parseFrontmatter(text);
|
|
219
238
|
if (!fm.role || !fm.level) {
|
|
220
239
|
violations.push(`${file}: model ${JSON.stringify(fm.model)} is not @<role>:<level>`);
|
|
@@ -317,13 +336,20 @@ function main() {
|
|
|
317
336
|
const check = has("check");
|
|
318
337
|
if (!check) die("--check is required (usage: tier-roles.mjs --check [--agents <dir>] [--model-roles <path>] [--overrides <path>] [--catalog <path>])");
|
|
319
338
|
|
|
320
|
-
const
|
|
339
|
+
const agentsFlag = arg("agents");
|
|
340
|
+
const agentsDir = agentsFlag ?? join(SCRIPT_DIR, "..", "agents");
|
|
321
341
|
const config = (key) => [() => readOmpConfigValue(key), `omp config get ${key} --json`];
|
|
322
342
|
const overrides = loadJsonObject(arg("overrides"), ...config("task.agentModelOverrides"), "overrides");
|
|
323
343
|
const modelRoles = loadJsonObject(arg("model-roles"), ...config("modelRoles"), "model-roles");
|
|
324
344
|
const catalog = loadJsonObject(arg("catalog"), readOmpModelCatalog, "omp models --json", "catalog");
|
|
325
345
|
|
|
326
|
-
const { violations, notices } = checkRoutes({
|
|
346
|
+
const { violations, notices } = checkRoutes({
|
|
347
|
+
agentsDir,
|
|
348
|
+
agentsSource: agentsFlag === null ? `the default agents directory ${agentsDir}` : undefined,
|
|
349
|
+
modelRoles,
|
|
350
|
+
overrides,
|
|
351
|
+
catalog,
|
|
352
|
+
});
|
|
327
353
|
|
|
328
354
|
if (violations.length === 0) {
|
|
329
355
|
console.log("tier-roles: every definition routes to a resolvable model at a level it runs, no shadowing override");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
|
-
import { chmodSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { chmodSync, copyFileSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { tempDir } from "./temp-dir.mjs";
|
|
6
6
|
import { delimiter, join } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from "./tier-roles.mjs";
|
|
13
13
|
|
|
14
14
|
const SCRIPT = fileURLToPath(new URL("./tier-roles.mjs", import.meta.url));
|
|
15
|
+
const SCRIPTS_DIR = fileURLToPath(new URL(".", import.meta.url));
|
|
15
16
|
const REPO_AGENTS = fileURLToPath(new URL("../agents", import.meta.url));
|
|
16
17
|
|
|
17
18
|
function dir() {
|
|
@@ -211,6 +212,32 @@ test("checkRoutes: an empty agents directory still reports the other violations
|
|
|
211
212
|
assert.match(violations.join("\n"), /task\.agentModelOverrides\.fleet-a shadows/);
|
|
212
213
|
});
|
|
213
214
|
|
|
215
|
+
test("checkRoutes: an --agents path that is missing, or a regular file, is a violation naming the flag and path, not a crash", () => {
|
|
216
|
+
const d = dir();
|
|
217
|
+
const missing = join(d, "nope");
|
|
218
|
+
const file = join(d, "plain.txt");
|
|
219
|
+
writeFileSync(file, "x\n");
|
|
220
|
+
for (const [path, code] of [[missing, "ENOENT"], [file, "ENOTDIR"]]) {
|
|
221
|
+
const { violations } = checkRoutes({ agentsDir: path, modelRoles: MODEL_ROLES, overrides: {}, catalog: CATALOG });
|
|
222
|
+
assert.deepEqual(violations, [`--agents ${path}: cannot read the agents directory (${code})`]);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("checkRoutes: a directory named *.agent.md is a violation naming the entry, and the readable definitions after it are still checked", () => {
|
|
227
|
+
const agents = agentsDir({ "fleet-a": "@slow:xhigh", "fleet-b": "@smol:max" });
|
|
228
|
+
mkdirSync(join(agents, "a.agent.md"));
|
|
229
|
+
const { violations } = checkRoutes({ agentsDir: agents, modelRoles: MODEL_ROLES, overrides: {}, catalog: CATALOG });
|
|
230
|
+
assert.equal(violations.length, 2);
|
|
231
|
+
assert.equal(violations[0], `--agents ${agents}: cannot read a.agent.md (EISDIR)`);
|
|
232
|
+
assert.match(violations[1], /^fleet-b\.agent\.md: level max is not one modelRoles\.smol's target/);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("checkRoutes: an unreadable agents directory names the source the caller gives, not --agents", () => {
|
|
236
|
+
const missing = join(dir(), "nope");
|
|
237
|
+
const { violations } = checkRoutes({ agentsDir: missing, agentsSource: "the default agents directory X", modelRoles: MODEL_ROLES, overrides: {}, catalog: CATALOG });
|
|
238
|
+
assert.deepEqual(violations, ["the default agents directory X: cannot read the agents directory (ENOENT)"]);
|
|
239
|
+
});
|
|
240
|
+
|
|
214
241
|
test("checkRoutes (a): a definition whose model: is not a route is a violation naming the file", () => {
|
|
215
242
|
const { violations } = routes({ "fleet-a": "opus" });
|
|
216
243
|
assert.equal(violations.length, 1);
|
|
@@ -358,6 +385,33 @@ test("CLI: --check on an agents directory with no definitions exits 1 naming the
|
|
|
358
385
|
assert.doesNotMatch(r.stdout, /every definition routes/);
|
|
359
386
|
});
|
|
360
387
|
|
|
388
|
+
test("CLI: --check on an --agents path that is missing, a regular file, or holds a directory named *.agent.md exits 1 with a refusal naming the cause, no stack trace", () => {
|
|
389
|
+
const d = dir();
|
|
390
|
+
const file = join(d, "plain.txt");
|
|
391
|
+
writeFileSync(file, "x\n");
|
|
392
|
+
const trap = agentsDir({ "fleet-a": "@slow:xhigh" });
|
|
393
|
+
mkdirSync(join(trap, "x.agent.md"));
|
|
394
|
+
for (const [path, code] of [[join(d, "nope"), "ENOENT"], [file, "ENOTDIR"], [trap, "EISDIR"]]) {
|
|
395
|
+
const r = runCli(["--check", "--agents", path, ...installFlags(d)], d);
|
|
396
|
+
assert.equal(r.status, 1, path);
|
|
397
|
+
assert.match(r.stderr, new RegExp(`^tier-roles: --agents ${path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}: cannot read .*\\(${code}\\)`), path);
|
|
398
|
+
assert.doesNotMatch(r.stderr, /\n\s+at /, path);
|
|
399
|
+
assert.doesNotMatch(r.stdout, /every definition routes/);
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("CLI: --check with no --agents and an unreadable built-in agents directory refuses without naming a flag it was not given", () => {
|
|
404
|
+
const d = dir();
|
|
405
|
+
const scripts = join(d, "scripts");
|
|
406
|
+
mkdirSync(scripts);
|
|
407
|
+
for (const f of ["tier-roles.mjs", "arg.mjs", "is-cli.mjs"]) copyFileSync(join(SCRIPTS_DIR, f), join(scripts, f));
|
|
408
|
+
const r = spawnSync(process.execPath, [join(scripts, "tier-roles.mjs"), "--check", ...installFlags(d)], { cwd: d, encoding: "utf8" });
|
|
409
|
+
assert.equal(r.status, 1, r.stderr);
|
|
410
|
+
assert.match(r.stderr, /^tier-roles: the default agents directory .*agents: cannot read the agents directory \(ENOENT\)/);
|
|
411
|
+
assert.doesNotMatch(r.stderr, /--agents/);
|
|
412
|
+
assert.doesNotMatch(r.stderr, /\n\s+at /);
|
|
413
|
+
});
|
|
414
|
+
|
|
361
415
|
test("CLI: --check with a non-route model: exits 1 naming the file", () => {
|
|
362
416
|
const d = dir();
|
|
363
417
|
const agents = agentsDir({ "fleet-a": "opus" });
|