faberun 0.18.0 → 0.19.1

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.
Files changed (50) hide show
  1. package/integrations/claude-code/statusline.sh +12 -2
  2. package/package.json +2 -2
  3. package/skills/init-agentkit/scripts/install-agentkit.sh +10 -2
  4. package/src/campaign/chain.mjs +5 -4
  5. package/src/cli/brand.mjs +4 -5
  6. package/src/cli/launch.mjs +11 -2
  7. package/src/cli.mjs +10 -4
  8. package/src/contract/index.mjs +16 -3
  9. package/src/contract/task-packet.mjs +13 -1
  10. package/src/engine/bulk-read.mjs +7 -1
  11. package/src/engine/cancel.mjs +3 -3
  12. package/src/engine/failover.mjs +11 -4
  13. package/src/engine/gate.mjs +17 -3
  14. package/src/engine/judge-gate.mjs +9 -13
  15. package/src/engine/live-preflight.mjs +43 -10
  16. package/src/engine/live-silence.mjs +49 -0
  17. package/src/engine/process-identity.mjs +12 -1
  18. package/src/engine/process.mjs +22 -3
  19. package/src/engine/resume.mjs +2 -1
  20. package/src/engine/run-command.mjs +8 -5
  21. package/src/engine/run-identity.mjs +213 -14
  22. package/src/engine/runtime-discovery.mjs +37 -5
  23. package/src/engine/scheduler.mjs +14 -23
  24. package/src/engine/scope.mjs +13 -6
  25. package/src/engine/supervise.mjs +2 -1
  26. package/src/harnesses/catalogue.mjs +8 -1
  27. package/src/harnesses/dsh/runner.mjs +6 -1
  28. package/src/harnesses/index.mjs +35 -7
  29. package/src/host/home.mjs +38 -0
  30. package/src/host/platform.mjs +204 -1
  31. package/src/host/preflight.mjs +183 -19
  32. package/src/notify/index.mjs +7 -1
  33. package/src/notify/session.mjs +6 -1
  34. package/src/plan/pipeline.mjs +14 -4
  35. package/src/plan/preflight.mjs +77 -0
  36. package/src/plan/template.mjs +46 -14
  37. package/src/repo/declared-paths.mjs +87 -6
  38. package/src/repo/signal.mjs +56 -21
  39. package/src/repo/workspace.mjs +3 -2
  40. package/src/repo/worktree.mjs +2 -1
  41. package/src/report/message.mjs +7 -7
  42. package/src/report/next.mjs +15 -1
  43. package/src/run/availability.mjs +138 -0
  44. package/src/run/disk-gc.mjs +16 -2
  45. package/src/run/lock.mjs +8 -0
  46. package/src/run/node-store.mjs +33 -0
  47. package/src/run/paths.mjs +13 -0
  48. package/src/seat/allowance.mjs +4 -1
  49. package/src/seat/tmux.mjs +9 -1
  50. package/src/util.mjs +46 -0
@@ -31,6 +31,15 @@ ALLOWANCE_WARN_PCT=85
31
31
 
32
32
  session=$(cat)
33
33
  repo=$(printf '%s' "$session" | sed -n 's/.*"cwd":"\([^"]*\)".*/\1/p;s/.*"current_dir":"\([^"]*\)".*/\1/p' | head -n 1)
34
+ # A Windows session names its cwd with backslashes, and JSON doubles them.
35
+ # Three forms of one path, because three readers want different ones:
36
+ # repo_json as it appears in the file -- what a fixed-string grep matches
37
+ # repo the path itself -- what jq compares a parsed key to
38
+ # repo_path with forward slashes -- what a POSIX shell can stat
39
+ # On a host with no backslashes in its paths all three are the same string.
40
+ repo_json=$repo
41
+ repo=$(printf '%s' "$repo_json" | sed 's|\\\\|\\|g')
42
+ repo_path=$(printf '%s' "$repo" | tr '\\' '/')
34
43
  # The pointer follows the state (R2): the project registry under the faberun
35
44
  # home maps the repository's resolved path to an opaque id, and the pointer
36
45
  # sits in that project's runs directory. A repository whose runs never moved
@@ -40,6 +49,7 @@ repo=$(printf '%s' "$session" | sed -n 's/.*"cwd":"\([^"]*\)".*/\1/p;s/.*"curren
40
49
  # no glob, no newest-by-mtime. That is the half of R6 already true and to
41
50
  # keep; the one machine-wide pointer is a later phase, not this lookup.
42
51
  home=${FABERUN_HOME:-$HOME/.faberun}
52
+ home=$(printf '%s' "$home" | tr '\\' '/')
43
53
  index="$home/projects/index.json"
44
54
  id=
45
55
  if [ -n "$repo" ] && [ -f "$index" ]; then
@@ -50,10 +60,10 @@ if [ -n "$repo" ] && [ -f "$index" ]; then
50
60
  # fixed-string grep for the quoted key picks exactly that one line and
51
61
  # the id follows its colon. Fixed-string on purpose: a repo path is
52
62
  # data, not a regex.
53
- id=$(grep -F "\"$repo\"" "$index" 2>/dev/null | sed -n 's/^.*:[[:space:]]*"\([^"]*\)".*/\1/p')
63
+ id=$(grep -F "\"$repo_json\"" "$index" 2>/dev/null | sed -n 's/^.*:[[:space:]]*"\([^"]*\)".*/\1/p')
54
64
  fi
55
65
  fi
56
- pointer="$repo/.runs/status.json"
66
+ pointer="$repo_path/.runs/status.json"
57
67
  if [ -n "$id" ] && [ -d "$home/projects/$id/runs" ]; then
58
68
  pointer="$home/projects/$id/runs/status.json"
59
69
  fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faberun",
3
- "version": "0.18.0",
3
+ "version": "0.19.1",
4
4
  "description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,7 +26,7 @@
26
26
  "scripts": {
27
27
  "check": "node -e \"const{readdirSync}=require('node:fs');const{spawnSync}=require('node:child_process');const roots=['bin','.claude/hooks','src','evals','test'];const files=roots.flatMap(r=>readdirSync(r,{recursive:true}).map(String).filter(p=>p.endsWith('.mjs')).map(p=>r+'/'+p));for(const f of files)if(spawnSync(process.execPath,['--check',f],{stdio:'inherit'}).status!==0)process.exit(1);console.log(files.length+' files checked')\"",
28
28
  "typecheck": "tsc",
29
- "test": "node --test --import ./test/scoped-home.mjs --import ./test/setup.mjs test/*.test.mjs test/*/*.test.mjs",
29
+ "test": "node --test --import ./test/scoped-home.mjs --import ./test/git-env.mjs --import ./test/setup.mjs test/*.test.mjs test/*/*.test.mjs",
30
30
  "docs": "node src/cli/manual.mjs --write",
31
31
  "docs:check": "node src/cli/manual.mjs --check",
32
32
  "prepare": "husky"
@@ -95,9 +95,17 @@ for f in CLAUDE.md GEMINI.md CURSOR.md AGENT.md; do
95
95
  fi
96
96
  if [ "$DRY_RUN" = 0 ]; then
97
97
  mkdir -p "$(dirname "$link")"
98
- ( cd "$(dirname "$link")" && rm -f "$(basename "$f")" && ln -s "$dest" "$(basename "$f")" )
98
+ # MSYS's `ln -s` copies the file instead of linking it unless told
99
+ # otherwise, which is exactly how a pointer becomes the real file this
100
+ # script exists to prevent. `winsymlinks:nativestrict` asks for a real
101
+ # symlink and fails rather than copying when the host cannot make one
102
+ # (no Developer Mode, no privilege); the plain `ln -s` after it is that
103
+ # host's honest fallback, and a no-op variable everywhere else.
104
+ ( cd "$(dirname "$link")" && rm -f "$(basename "$f")" \
105
+ && { MSYS=winsymlinks:nativestrict ln -s "$dest" "$(basename "$f")" 2>/dev/null \
106
+ || ln -s "$dest" "$(basename "$f")"; } )
99
107
  fi
100
- say " + $f → $dest" "$G"
108
+ if [ -L "$link" ]; then say " + $f → $dest" "$G"; else say " + $f (copy of $dest; this host makes no symlink)" "$Y"; fi
101
109
  done
102
110
 
103
111
  # --- git hook via core.hooksPath --------------------------------------------
@@ -42,6 +42,7 @@ import { pidAlive, processStartToken } from "../run/lock.mjs";
42
42
  import { delay, errorCode, errorMessage } from "../util.mjs";
43
43
  import { writeJsonAtomic } from "../run/store.mjs";
44
44
  import { runDirectory } from "../run/paths.mjs";
45
+ import { gitArguments, killTarget } from "../host/platform.mjs";
45
46
 
46
47
  /** @typedef {import("../contract/index.mjs").ControllerIdentity} ControllerIdentity */
47
48
  /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
@@ -140,7 +141,7 @@ function groupKill(pid, signal) {
140
141
  if (errorCode(error) !== "ESRCH") throw error;
141
142
  }
142
143
  }
143
- process.kill(pid, signal);
144
+ killTarget(pid, signal);
144
145
  } catch (error) {
145
146
  if (errorCode(error) !== "ESRCH") throw error;
146
147
  }
@@ -235,7 +236,7 @@ function landBranchRef(repo, landBranch) {
235
236
  */
236
237
  function gitHead(repo, ref) {
237
238
  try {
238
- return execFileSync("git", ["-C", repo, "rev-parse", ref], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
239
+ return execFileSync("git", gitArguments(["-C", repo, "rev-parse", ref]), { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
239
240
  } catch {
240
241
  return null;
241
242
  }
@@ -269,7 +270,7 @@ export function validateContractAgainstRef(raw, contractPath, context = {}) {
269
270
  const originalCwd = resolve(dirname(contractPath), typeof raw.cwd === "string" ? raw.cwd : ".");
270
271
  const worktree = mkdtempSync(join(tmpdir(), "runner-chain-ref-"));
271
272
  try {
272
- execFileSync("git", ["-C", repo, "worktree", "add", "--detach", worktree, baseRef], { stdio: ["ignore", "pipe", "pipe"] });
273
+ execFileSync("git", gitArguments(["-C", repo, "worktree", "add", "--detach", worktree, baseRef]), { stdio: ["ignore", "pipe", "pipe"] });
273
274
  const relativedCwd = relative(repo, originalCwd);
274
275
  const mappedCwd = relativedCwd && !relativedCwd.startsWith("..") ? join(worktree, relativedCwd) : worktree;
275
276
  const relativeContract = relative(repo, contractPath);
@@ -283,7 +284,7 @@ export function validateContractAgainstRef(raw, contractPath, context = {}) {
283
284
  return contract;
284
285
  } finally {
285
286
  try {
286
- execFileSync("git", ["-C", repo, "worktree", "remove", "--force", worktree], { stdio: ["ignore", "pipe", "ignore"] });
287
+ execFileSync("git", gitArguments(["-C", repo, "worktree", "remove", "--force", worktree]), { stdio: ["ignore", "pipe", "ignore"] });
287
288
  } catch {
288
289
  // The ref-based validation is done; a cleanup failure must not mask it.
289
290
  }
package/src/cli/brand.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  * untouched.
10
10
  */
11
11
 
12
- import { compareVersions, faberunHome, readUpdateCheck } from "../host/home.mjs";
12
+ import { availableUpdate, faberunHome } from "../host/home.mjs";
13
13
 
14
14
  /** @typedef {"brand"|"ok"|"progress"|"warn"|"fail"|"muted"|"text"} Role */
15
15
  /** @typedef {"terra"|"argila"|"folha"} ColorName */
@@ -150,16 +150,15 @@ export function statusToken(kind, level) {
150
150
  * opening is muted, the wordmark is the brand role, the tagline is plain text
151
151
  * and the last line is muted and filled from the running process. The last line
152
152
  * gains ` · update available: <latest>` when the cached check names a newer
153
- * release; the banner reads only the cache (`update-check.json`) and never
154
- * fetches. ASCII apart from the middle-dot separator, so it survives every
153
+ * release and is still inside `UPDATE_CHECK_MAX_AGE_MS`; the banner reads only
154
+ * the cache (`update-check.json`) and never fetches. ASCII apart from the middle-dot separator, so it survives every
155
155
  * monospace font.
156
156
  *
157
157
  * @param {BannerOptions} options
158
158
  * @returns {string}
159
159
  */
160
160
  export function renderBanner({ version, nodeVersion, harnessCount, level, env = process.env }) {
161
- const cached = readUpdateCheck(faberunHome(env));
162
- const latest = cached && compareVersions(cached.latest, version) > 0 ? cached.latest : null;
161
+ const latest = availableUpdate(faberunHome(env), version);
163
162
  const terra = /** @param {string} text @returns {string} */ (text) => colorize(text, { color: "terra" }, level);
164
163
  const lines = [
165
164
  terra(" .-~~~-."),
@@ -20,7 +20,7 @@ import { existsSync, readFileSync } from "node:fs";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { join, resolve } from "node:path";
22
22
  import { randomUUID } from "node:crypto";
23
- import { readRunNodes } from "../engine/scheduler.mjs";
23
+ import { readRunNodes } from "../run/node-store.mjs";
24
24
  import { spawn } from "node:child_process";
25
25
  import { validateContract } from "../contract/index.mjs";
26
26
  import { runDirectory } from "../run/paths.mjs";
@@ -64,7 +64,16 @@ export function detachArgv(argv, options = {}) {
64
64
  const child = /** @type {DetachedChild} */ (spawn(process.execPath, [CLI_ENTRY, ...argv], {
65
65
  cwd: process.cwd(),
66
66
  env: { ...process.env, ...options.env, FABERUN_BOOTSTRAP_NONCE: nonce },
67
- detached: process.platform !== "win32",
67
+ // Detached on every platform, for a different reason on each. POSIX: a
68
+ // new session, so the controller survives the launcher's terminal and
69
+ // owns a process group its own children can be killed by. Windows: libuv
70
+ // puts every non-detached child in a job object that is killed when the
71
+ // parent exits, so without this the controller dies the instant the
72
+ // launcher returns -- measured 2026-09-21: `run --detach` bootstrapped to
73
+ // `ready` and then died with its launcher, every time, leaving the node
74
+ // pending forever. There it also means DETACHED_PROCESS: no console
75
+ // window, which is what "ignore" stdio already implies.
76
+ detached: true,
68
77
  stdio: "ignore",
69
78
  }));
70
79
  child.unref();
package/src/cli.mjs CHANGED
@@ -46,7 +46,7 @@ import { cancelRun } from "./engine/cancel.mjs";
46
46
 
47
47
  import { errorMessage } from "./util.mjs";
48
48
  import { validateContract } from "./contract/index.mjs";
49
- import { setLaunchBaseRef } from "./engine/run-identity.mjs";
49
+ import { setLaunchBaseRef, setFreshPreflight } from "./engine/run-identity.mjs";
50
50
  import { assertLaunchBaseClean } from "./repo/source-identity.mjs";
51
51
  import { detachSelf, waitForBootstrap, writeBootstrapFailure } from "./cli/launch.mjs";
52
52
  import { DEFAULT_SUPERVISE_INTERVAL_SEC, superviseRun } from "./engine/supervise.mjs";
@@ -96,8 +96,8 @@ export function hasDetachedBootstrapNonce() {
96
96
 
97
97
  /** @type {Record<string, import("node:util").ParseArgsOptionsConfig>} */
98
98
  export const COMMAND_OPTIONS = {
99
- run: { detach: { type: "boolean" }, "base-ref": { type: "string" } },
100
- resume: { detach: { type: "boolean" }, node: { type: "string" }, reconcile: { type: "string" }, answer: { type: "string" } },
99
+ run: { detach: { type: "boolean" }, "base-ref": { type: "string" }, "fresh-preflight": { type: "boolean" } },
100
+ resume: { detach: { type: "boolean" }, node: { type: "string" }, reconcile: { type: "string" }, answer: { type: "string" }, "fresh-preflight": { type: "boolean" } },
101
101
  supervise: { detach: { type: "boolean" }, interval: { type: "string" } },
102
102
  cancel: {},
103
103
  preflight: { static: { type: "boolean" }, json: { type: "boolean" }, "time-verification": { type: "boolean" } },
@@ -323,6 +323,7 @@ async function main(argv) {
323
323
  const contract = validateContractForLaunch(JSON.parse(readFileSync(absolute, "utf8")), absolute, { baseRef });
324
324
  const runDir = runDirectory(contract.cwd, contract.id);
325
325
  setLaunchBaseRef(baseRef);
326
+ setFreshPreflight(values["fresh-preflight"] === true);
326
327
  // The base is what every worktree is cut from; a dirty tree only blocks
327
328
  // when the cwd HEAD *is* that base. A `--base-ref` elsewhere leaves the
328
329
  // operator's checkout out of the run entirely. The contract file being
@@ -334,7 +335,10 @@ async function main(argv) {
334
335
  if (values.detach === true) {
335
336
  if (existsSync(runDir)) throw new Error(`run already exists: ${runDir}`);
336
337
  for (const warning of [...contract.warnings, ...reusedDoneWarnings(contract)]) process.stdout.write(`${advisoryToken()} ${warning}\n`);
337
- const child = detachSelf("run", target, baseRef ? ["--base-ref", baseRef] : []);
338
+ const child = detachSelf("run", target, [
339
+ ...(baseRef ? ["--base-ref", baseRef] : []),
340
+ ...(values["fresh-preflight"] === true ? ["--fresh-preflight"] : []),
341
+ ]);
338
342
  const pid = child.pid;
339
343
  if (pid === undefined) throw new Error("detached child has no pid");
340
344
  await waitForBootstrap(runDir, pid, child);
@@ -348,6 +352,7 @@ async function main(argv) {
348
352
  }
349
353
  if (command === "resume") {
350
354
  const resumeOptions = resumeOptionsOf(values);
355
+ setFreshPreflight(values["fresh-preflight"] === true);
351
356
  if (values.detach === true) {
352
357
  const runDir = resolve(target);
353
358
  if (!existsSync(join(runDir, "contract.json"))) throw new Error(`not a run directory: ${runDir}`);
@@ -355,6 +360,7 @@ async function main(argv) {
355
360
  ...(resumeOptions.node ? ["--node", resumeOptions.node] : []),
356
361
  ...(resumeOptions.reconcile ? ["--reconcile", resumeOptions.reconcile] : []),
357
362
  ...(resumeOptions.answer ? ["--answer", `${resumeOptions.answer.node}=${resumeOptions.answer.path}`] : []),
363
+ ...(values["fresh-preflight"] === true ? ["--fresh-preflight"] : []),
358
364
  ];
359
365
  const child = detachSelf("resume", target, extraArgs);
360
366
  const pid = child.pid;
@@ -15,7 +15,7 @@ import { assertObject, boundedString, nonNegativeInteger, nonNegativeNumber, pos
15
15
  import { validateMetadata } from "./schema-version.mjs";
16
16
  import { assertRuntimeExecutesCommands, requireRuntime, validateRuntime } from "./runtime.mjs";
17
17
  import { validateSourceIdentity } from "../repo/source-identity.mjs";
18
- import { commandCoverageWarnings, unsnapshottedWriteWarnings } from "../repo/declared-paths.mjs";
18
+ import { commandCoverageWarnings, mirrorCoverageWarnings, unsnapshottedWriteWarnings } from "../repo/declared-paths.mjs";
19
19
  import { crossNodeScopeFindings, scopeClosureFindings } from "../repo/scope-closure.mjs";
20
20
 
21
21
  export { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION } from "../harnesses/index.mjs";
@@ -368,8 +368,15 @@ export function validateContract(raw, contractPath, options = {}) {
368
368
  throw new TypeError(`task packet scope does not close; declare in readFiles or writeFiles, or acknowledge in scopeAcknowledged: ${scopeErrors.join("; ")}`);
369
369
  }
370
370
 
371
+ // Validated here rather than inline below, because the mirror warning reads
372
+ // them: a layer covered by a shared or final command is covered.
373
+ const finalVerification = validateFinalVerification(raw.finalVerification, "contract.finalVerification");
374
+ const sharedVerification = validateSharedVerification(raw.sharedVerification, "contract.sharedVerification");
375
+ const contractCommands = [...finalVerification ?? [], ...sharedVerification ?? []].map((command) => command.argv.join(" "));
376
+ const contractWrites = new Set(nodes.flatMap((node) => node.taskPacket.writeFiles ?? []));
371
377
  const warnings = nodes.flatMap((node, index) => [
372
378
  ...commandCoverageWarnings(node, index),
379
+ ...(persisted ? [] : mirrorCoverageWarnings(node, index, cwd, contractCommands, contractWrites)),
373
380
  ...(persisted ? [] : unsnapshottedWriteWarnings(node, index, cwd)),
374
381
  ...(persisted ? [] : writeFileLineBudgetWarnings(node, index, cwd)),
375
382
  ]);
@@ -394,8 +401,8 @@ export function validateContract(raw, contractPath, options = {}) {
394
401
  // count, because it began with 200k tokens of context instead of 45k and
395
402
  // re-read them on every request.
396
403
  phaseSessionReuse: booleanField(raw.phaseSessionReuse, false, "contract.phaseSessionReuse"),
397
- finalVerification: validateFinalVerification(raw.finalVerification, "contract.finalVerification"),
398
- sharedVerification: validateSharedVerification(raw.sharedVerification, "contract.sharedVerification"),
404
+ finalVerification,
405
+ sharedVerification,
399
406
  nodeAdvisory: validateNodeAdvisory(raw.nodeAdvisory),
400
407
  warnings,
401
408
  });
@@ -688,6 +695,12 @@ function dependencyCoversPath(closure, path, cwd) {
688
695
  function writeFileLineBudgetWarnings(node, index, cwd) {
689
696
  const warnings = [];
690
697
  for (const path of node.taskPacket.writeFiles ?? []) {
698
+ // The ceiling is a rule about source modules, and `source-shape` enforces
699
+ // it over `.mjs` alone. Measured 2026-09-22: a packet declaring the
700
+ // generated `docs/COMMANDS.md` was warned that 1141 lines left "-341 from
701
+ // the 800-line ceiling", which is not a budget, not true of that file, and
702
+ // trains the reader to skim past the warnings that are.
703
+ if (!path.endsWith(".mjs")) continue;
691
704
  let text;
692
705
  try {
693
706
  text = readFileSync(resolve(cwd, path), "utf8");
@@ -325,6 +325,14 @@ function validateRelativePath(path, label, cwd, mustExist, options = {}) {
325
325
  }
326
326
  if (!pathInside(realAnchor, realCwd)) throw new TypeError(`${label} escapes cwd`);
327
327
 
328
+ if (!existsSync(absolute) && anchor !== absolute && statSync(anchor).isFile()) {
329
+ // Nothing can ever appear beneath a regular file, so this path is refused
330
+ // outright rather than deferred to a dependency that might produce it.
331
+ // Only POSIX states that through an errno (ENOTDIR, from the walk above);
332
+ // Windows reports the same layout as a plain absence, so the shape is
333
+ // asked here rather than read off a platform error code.
334
+ throw new TypeError(`${label} is under a file, not a directory: ${path}`);
335
+ }
328
336
  if (!mustExist && !existsSync(absolute)) return;
329
337
  if (!existsSync(absolute)) {
330
338
  if (options.deferMissing) return DEFERRED_MISSING;
@@ -390,7 +398,11 @@ function findExistingPath(path) {
390
398
  lstatSync(current);
391
399
  return current;
392
400
  } catch (error) {
393
- if (errorCode(error) !== "ENOENT") throw error;
401
+ // A path whose parent is a regular file is absent, not unreadable:
402
+ // POSIX says ENOTDIR where Windows says ENOENT, and both mean the same
403
+ // thing -- keep walking up to the ancestor that does exist.
404
+ const code = errorCode(error);
405
+ if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
394
406
  }
395
407
  const parent = dirname(current);
396
408
  if (parent === current) return null;
@@ -16,6 +16,7 @@ import { DECLARED_MODEL_CATALOGUES, stableJsonDocument } from "../harnesses/cata
16
16
  import { READ_LINE_LIMIT, normalizeProviderResult, providerCommand } from "../harnesses/index.mjs";
17
17
  import { appendUsageRecord, emptyUsage, priceUsage } from "../run/usage.mjs";
18
18
  import { errorMessage, fail } from "../util.mjs";
19
+ import { spawnInvocation } from "../host/platform.mjs";
19
20
  import { DISCOVERY_RUNTIME_DEFINITIONS } from "./runtime-discovery.mjs";
20
21
 
21
22
  /** Fixed-size copy buffer: the pack is built through this, never through a corpus-sized string. */
@@ -178,10 +179,15 @@ function invokeDelegation(runtime, prompt, cwd) {
178
179
  /** @type {import("node:child_process").ChildProcess} */
179
180
  let child;
180
181
  try {
181
- child = spawn(command.executable, command.args, {
182
+ // The provider binary, reached the way this platform reaches one: a
183
+ // delegation that spawns it raw is ENOENT on Windows against the very
184
+ // CLI the run is configured to use.
185
+ const invocation = spawnInvocation(command.executable, command.args, { cwd });
186
+ child = spawn(invocation.command, invocation.args, {
182
187
  cwd,
183
188
  env: environmentWith(command.env),
184
189
  stdio: ["pipe", "pipe", "pipe"],
190
+ ...invocation.options,
185
191
  });
186
192
  } catch (error) {
187
193
  observation.spawnError = errorMessage(error);
@@ -16,7 +16,7 @@ import { invocationOwned } from "./process-identity.mjs";
16
16
  import { terminateInvocation } from "./process.mjs";
17
17
  import { join, resolve } from "node:path";
18
18
  import { readFileSync } from "node:fs";
19
- import { readRunNodes } from "./scheduler.mjs";
19
+ import { readRunNodes } from "../run/node-store.mjs";
20
20
  import { createPreservedRef, deleteRef, releaseAttemptWorktree, runRefName } from "../repo/worktree.mjs";
21
21
  import { syncAgentSignal } from "../repo/signal.mjs";
22
22
  import { transition, writeNode } from "./state.mjs";
@@ -55,7 +55,7 @@ export async function cancelRun(runDirPath) {
55
55
  }
56
56
  const controllerLock = await acquireStaleLock(runDir);
57
57
  try {
58
- const states = readRunNodes(runDir, contract);
58
+ const states = readRunNodes(runDir, contract, { tolerateMissing: true });
59
59
  /** @type {Error[]} */
60
60
  const failures = [];
61
61
  for (const state of states) {
@@ -196,7 +196,7 @@ async function waitForTerminal(runDir, timeoutMs) {
196
196
  while (Date.now() < deadline) {
197
197
  const contractPath = join(runDir, "contract.json");
198
198
  const contract = validateContract(JSON.parse(readFileSync(contractPath, "utf8")), contractPath, { persisted: true });
199
- const states = readRunNodes(runDir, contract);
199
+ const states = readRunNodes(runDir, contract, { tolerateMissing: true });
200
200
  if (states.every((state) => SETTLED.has(state.status)) && states.every((state) => (state.invocations ?? []).every((invocation) => !invocationOwned(invocation)))) return true;
201
201
  await delay(100);
202
202
  }
@@ -136,15 +136,22 @@ export function failoverEdges(contract) {
136
136
  * The first snapshot wins; every later requirement set is accumulated, so a
137
137
  * runtime reached twice is still checked against both callers' demands.
138
138
  *
139
- * @param {Map<string, {runtime: RuntimeSnapshot, requiredCapabilitySets: import("../harnesses/index.mjs").CapabilityRequirements[]}>} runtimes
139
+ * `routed` says whether some node or default names this runtime, directly or
140
+ * as a declared failover target. A runtime reached only because a role named
141
+ * none -- every catalogue entry is then a candidate availability discovery may
142
+ * pick -- is added with `routed: false`, and one route that names it at all
143
+ * makes it routed for good.
144
+ *
145
+ * @param {Map<string, {runtime: RuntimeSnapshot, requiredCapabilitySets: import("../harnesses/index.mjs").CapabilityRequirements[], routed: boolean}>} runtimes
140
146
  * @param {RuntimeSnapshot} runtime
141
147
  * @param {import("../harnesses/index.mjs").CapabilityRequirements[]} requiredCapabilitySets
148
+ * @param {boolean} [routed]
142
149
  */
143
- export function addRuntimeRequirement(runtimes, runtime, requiredCapabilitySets) {
150
+ export function addRuntimeRequirement(runtimes, runtime, requiredCapabilitySets, routed = true) {
144
151
  const incoming = requiredCapabilitySets.filter((requirements) => requirements && Object.keys(requirements).length);
145
152
  const current = runtimes.get(runtime.id);
146
- if (!current) runtimes.set(runtime.id, { runtime, requiredCapabilitySets: incoming });
147
- else runtimes.set(runtime.id, { runtime: current.runtime, requiredCapabilitySets: [...current.requiredCapabilitySets, ...incoming] });
153
+ if (!current) runtimes.set(runtime.id, { runtime, requiredCapabilitySets: incoming, routed });
154
+ else runtimes.set(runtime.id, { runtime: current.runtime, requiredCapabilitySets: [...current.requiredCapabilitySets, ...incoming], routed: current.routed || routed });
148
155
  }
149
156
 
150
157
  /**
@@ -31,6 +31,7 @@ import { existsSync, readFileSync, statSync, openSync, closeSync, readSync, writ
31
31
  import { dirname } from "node:path";
32
32
  import { spawn } from "node:child_process";
33
33
  import { NOTIFY_ENV_NAMES } from "../notify/index.mjs";
34
+ import { killTarget, spawnInvocation } from "../host/platform.mjs";
34
35
 
35
36
  /** @typedef {{executable: string, args: string[], cwd: string, promptTransport: "stdin"|"argv", harness: string, env: Record<string, string|null>|null, stdoutPath: string, stderrPath: string}} GateConfig */
36
37
 
@@ -92,13 +93,24 @@ if (config.promptTransport === "stdin") {
92
93
 
93
94
  /** @param {NodeJS.Signals} signal */
94
95
  function killGroup(signal) {
95
- try { process.kill(-process.pid, signal); } catch {
96
+ try {
97
+ // `killTarget` reads the negative pid as the group on POSIX and as the tree
98
+ // to walk on Windows, which has no group to signal.
99
+ killTarget(-process.pid, signal);
100
+ } catch {
96
101
  // ESRCH: the process group is already gone, so there is nothing to signal.
97
102
  }
98
103
  }
99
104
 
100
105
  function stopProvider() {
101
- try { provider?.kill("SIGTERM"); } catch {
106
+ try {
107
+ // The tree, not the process: on Windows a harness installed as a `.cmd` is
108
+ // reached through the command interpreter, so the provider this gate holds
109
+ // is `cmd.exe` and the harness is its child. Killing the one it spawned
110
+ // leaves the other running — measured 2026-09-20, as stranded `node`
111
+ // processes outliving a suite that had already stopped waiting for them.
112
+ if (provider?.pid) killTarget(provider.pid, "SIGTERM");
113
+ } catch {
102
114
  // No provider yet, or it already exited: a failed SIGTERM needs no action.
103
115
  }
104
116
  setTimeout(() => killGroup("SIGKILL"), 100).unref();
@@ -181,10 +193,12 @@ const timer = setInterval(() => {
181
193
  clearInterval(timer);
182
194
  const stdoutFd = openSync(config.stdoutPath, "wx", 0o600);
183
195
  const stderrFd = openSync(config.stderrPath, "wx", 0o600);
184
- provider = spawn(config.executable, config.args, {
196
+ const invocation = spawnInvocation(config.executable, config.args, { cwd: config.cwd });
197
+ provider = spawn(invocation.command, invocation.args, {
185
198
  cwd: config.cwd,
186
199
  env: childEnv(),
187
200
  stdio: [config.promptTransport === "stdin" ? "pipe" : "ignore", stdoutFd, stderrFd],
201
+ ...invocation.options,
188
202
  });
189
203
  if (config.promptTransport === "stdin") {
190
204
  for (const chunk of pendingInput) provider.stdin?.write(chunk);
@@ -15,6 +15,8 @@ import { isAbsolute, resolve } from "node:path";
15
15
  import { reviewMode, UNCITED_REJECTION_REASON } from "../contract/review-modes.mjs";
16
16
  import { JUDGE_LIMITS } from "../contract/judge-envelope.mjs";
17
17
  import { sharedVerificationCommands } from "../contract/final-verification.mjs";
18
+ import { killTarget } from "../host/platform.mjs";
19
+ import { shellWords } from "../util.mjs";
18
20
 
19
21
  /** @typedef {import("../contract/definition-of-done.mjs").DefinitionOfDoneItem} DefinitionOfDoneItem */
20
22
  /** @typedef {import("../contract/verification.mjs").VerificationCommand} VerificationCommand */
@@ -173,31 +175,26 @@ function envForFilteredProof() {
173
175
  /**
174
176
  * The node:test filters a command string declares, in argv order, as flag and
175
177
  * value. Presence alone changes behaviour (the appended reporter and the
176
- * zero-plan look-up); the value is read for the refusal detail alone, which is
177
- * why a whitespace split is close enough even though the command runs through
178
- * a shell.
178
+ * zero-plan look-up); the value lands in the refusal detail, and it is read
179
+ * the way the shell this command runs under reads it -- `shellWords` groups a
180
+ * quoted pattern into one word instead of splitting the pattern itself.
179
181
  *
180
182
  * @param {string} ref
181
183
  * @returns {Array<{flag: string, value: string}>}
182
184
  */
183
185
  function declaredTestFilters(ref) {
184
- const tokens = ref.split(/\s+/u).filter(Boolean);
186
+ const tokens = shellWords(ref);
185
187
  /** @type {Array<{flag: string, value: string}>} */
186
188
  const filters = [];
187
189
  for (const [index, token] of tokens.entries()) {
188
190
  for (const flag of TEST_FILTER_FLAGS) {
189
- if (token.startsWith(`${flag}=`)) filters.push({ flag, value: unquote(token.slice(flag.length + 1)) });
190
- else if (token === flag) filters.push({ flag, value: unquote(tokens[index + 1] ?? "") });
191
+ if (token.startsWith(`${flag}=`)) filters.push({ flag, value: token.slice(flag.length + 1) });
192
+ else if (token === flag) filters.push({ flag, value: tokens[index + 1] ?? "" });
191
193
  }
192
194
  }
193
195
  return filters;
194
196
  }
195
197
 
196
- /** @param {string} value @returns {string} */
197
- function unquote(value) {
198
- return value.replace(/^['"]|['"]$/gu, "");
199
- }
200
-
201
198
  /** @param {Array<{flag: string, value: string}>} filters @returns {string} */
202
199
  function filterNames(filters) {
203
200
  return filters.map(({ flag, value }) => `${flag} "${value}"`).join(", ");
@@ -284,8 +281,7 @@ function terminateProofGroup(child) {
284
281
  if (!pid) return;
285
282
  const signal = (/** @type {NodeJS.Signals} */ name) => {
286
283
  try {
287
- if (process.platform !== "win32") process.kill(-pid, name);
288
- else child.kill(name);
284
+ killTarget(process.platform === "win32" ? pid : -pid, name);
289
285
  } catch {
290
286
  try { child.kill(name); } catch {
291
287
  // ESRCH: the group and the leader are already gone.
@@ -17,6 +17,7 @@ import { errorMessage } from "../util.mjs";
17
17
  import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
18
18
  import { join, resolve } from "node:path";
19
19
  import { normalizeProviderResult, probeRuntime, providerCommand } from "../harnesses/index.mjs";
20
+ import { killTarget, spawnInvocation } from "../host/platform.mjs";
20
21
  import { reachableRuntimes } from "../host/preflight.mjs";
21
22
  import { spawn } from "node:child_process";
22
23
  import { boundedGitSync } from "../repo/worktree.mjs";
@@ -34,16 +35,48 @@ import { runsRoot } from "../run/paths.mjs";
34
35
  const LIVE_PREFLIGHT_PROMPT = "Respond with exactly FABERUN_PREFLIGHT_OK and do not use tools.";
35
36
  const LIVE_PREFLIGHT_OUTPUT_LIMIT_BYTES = 512 * 1024;
36
37
  /**
38
+ * `persisted` is for the dispatch gate, which hands over the run's own
39
+ * serialized contract: that one was validated when it was authored, and
40
+ * re-running the filesystem-dependent checks against the checkout reaches a
41
+ * different answer for a run launched with `--base-ref`, whose declared paths
42
+ * live in the ref and not in the working tree. Measured 2026-09-22: without
43
+ * it the gate refused a `--base-ref` run with "readFiles[0] does not exist".
44
+ * The CLI's own `preflight <contract>` leaves it off on purpose -- there the
45
+ * contract is an authored file the operator is asking about, so validating it
46
+ * whole is part of the answer.
47
+ *
37
48
  * @param {string} contractPath
38
- * @param {{static?: boolean, liveTimeoutSec?: number}} [options]
49
+ * @param {{static?: boolean, liveTimeoutSec?: number, persisted?: boolean}} [options]
39
50
  * @returns {Promise<ProbeResult[]>}
40
51
  */
41
52
  export async function preflightContract(contractPath, options = {}) {
42
53
  const absoluteContractPath = resolve(contractPath);
43
- const contract = validateContract(JSON.parse(readFileSync(absoluteContractPath, "utf8")), absoluteContractPath);
44
- const runtimes = reachableRuntimes(contract);
45
- const staticChecks = await Promise.all([...runtimes.values()].map(({ runtime, requiredCapabilitySets }) =>
46
- probeRuntime(runtime, { cwd: contract.cwd, requiredCapabilitySets }),
54
+ const contract = validateContract(JSON.parse(readFileSync(absoluteContractPath, "utf8")), absoluteContractPath, options.persisted === true ? { persisted: true } : {});
55
+ return preflightRuntimes([...reachableRuntimes(contract).values()], { ...options, cwd: contract.cwd });
56
+ }
57
+
58
+ /**
59
+ * The same ask, entered from a set of runtimes rather than a contract.
60
+ *
61
+ * `faberun plan` needs this and a contract cannot give it. Planning contracts
62
+ * carry no gate (`plan/template.mjs`), and `reachableRuntimes` only counts the
63
+ * judge role when a node's gate is enabled -- so preflighting the draft
64
+ * stage's contract asks the planner and never the reviewer, which is reached
65
+ * only as the *worker* of a later stage's own contract. Asking both before the
66
+ * first stage therefore has to name the runtimes directly.
67
+ *
68
+ * One asking function with two entry points, never two: the live probe, the
69
+ * throwaway repository, the budget and the detail wording are decided here
70
+ * once, so a caller cannot accidentally ask a different question.
71
+ *
72
+ * @param {{runtime: RuntimeSnapshot, requiredCapabilitySets?: import("../harnesses/index.mjs").CapabilityRequirements[]}[]} entries
73
+ * @param {{static?: boolean, liveTimeoutSec?: number, cwd?: string}} [options]
74
+ * @returns {Promise<ProbeResult[]>}
75
+ */
76
+ export async function preflightRuntimes(entries, options = {}) {
77
+ const runtimes = entries.map((entry) => entry.runtime);
78
+ const staticChecks = await Promise.all(entries.map(({ runtime, requiredCapabilitySets }) =>
79
+ probeRuntime(runtime, { cwd: options.cwd, requiredCapabilitySets }),
47
80
  ));
48
81
  if (options.static === true) return staticChecks;
49
82
 
@@ -62,8 +95,7 @@ export async function preflightContract(contractPath, options = {}) {
62
95
  }
63
96
  try {
64
97
  return await Promise.all(staticChecks.map(async (check, index) => {
65
- const runtime = [...runtimes.values()][index].runtime;
66
- const live = await livePreflight(runtime, liveRepo, timeoutSec);
98
+ const live = await livePreflight(runtimes[index], liveRepo, timeoutSec);
67
99
  const liveDetail = live.status === "done"
68
100
  ? `live done · usage ${formatUsage(live.usage)} · cost ${formatCost(live.costUsd)}`
69
101
  : `live ${live.status} · ${live.error?.code ?? "provider_error"}: ${redactProviderText(live.error?.message ?? "generation failed")} · usage ${formatUsage(live.usage)} · cost ${formatCost(live.costUsd)}`;
@@ -146,11 +178,13 @@ function livePreflight(runtime, cwd, timeoutSec) {
146
178
  else env[key] = value;
147
179
  }
148
180
  delete env.FABERUN_NOTIFY_BIN;
149
- child = /** @type {import("node:child_process").ChildProcessWithoutNullStreams} */ (spawn(command.executable, command.args, {
181
+ const invocation = spawnInvocation(command.executable, command.args, { cwd });
182
+ child = /** @type {import("node:child_process").ChildProcessWithoutNullStreams} */ (spawn(invocation.command, invocation.args, {
150
183
  cwd,
151
184
  env,
152
185
  detached: process.platform !== "win32",
153
186
  stdio: [command.promptTransport === "stdin" ? "pipe" : "ignore", "pipe", "pipe"],
187
+ ...invocation.options,
154
188
  }));
155
189
  } catch (error) {
156
190
  settle({
@@ -182,8 +216,7 @@ function livePreflight(runtime, cwd, timeoutSec) {
182
216
  /** @param {NodeJS.Signals} name */
183
217
  const signal = (name) => {
184
218
  try {
185
- if (process.platform === "win32") child.kill(name);
186
- else process.kill(-/** @type {number} */ (child.pid), name);
219
+ killTarget(process.platform === "win32" ? /** @type {number} */ (child.pid) : -/** @type {number} */ (child.pid), name);
187
220
  } catch {
188
221
  // ESRCH: the child is already gone, so there is no process to signal.
189
222
  }