faberun 0.18.0 → 0.19.0

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 (40) 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/launch.mjs +10 -1
  6. package/src/cli.mjs +10 -4
  7. package/src/contract/index.mjs +16 -3
  8. package/src/contract/task-packet.mjs +13 -1
  9. package/src/engine/bulk-read.mjs +7 -1
  10. package/src/engine/gate.mjs +17 -3
  11. package/src/engine/judge-gate.mjs +2 -2
  12. package/src/engine/live-preflight.mjs +43 -10
  13. package/src/engine/live-silence.mjs +49 -0
  14. package/src/engine/process-identity.mjs +12 -1
  15. package/src/engine/process.mjs +2 -1
  16. package/src/engine/run-command.mjs +8 -5
  17. package/src/engine/run-identity.mjs +213 -14
  18. package/src/engine/runtime-discovery.mjs +26 -5
  19. package/src/engine/scheduler.mjs +1 -1
  20. package/src/engine/supervise.mjs +2 -1
  21. package/src/harnesses/catalogue.mjs +8 -1
  22. package/src/harnesses/dsh/runner.mjs +6 -1
  23. package/src/harnesses/index.mjs +35 -7
  24. package/src/host/platform.mjs +204 -1
  25. package/src/host/preflight.mjs +137 -5
  26. package/src/notify/index.mjs +7 -1
  27. package/src/notify/session.mjs +6 -1
  28. package/src/plan/pipeline.mjs +5 -1
  29. package/src/plan/preflight.mjs +77 -0
  30. package/src/repo/declared-paths.mjs +87 -6
  31. package/src/repo/signal.mjs +56 -21
  32. package/src/repo/workspace.mjs +3 -2
  33. package/src/repo/worktree.mjs +2 -1
  34. package/src/report/next.mjs +15 -1
  35. package/src/run/availability.mjs +138 -0
  36. package/src/run/disk-gc.mjs +16 -2
  37. package/src/run/lock.mjs +8 -0
  38. package/src/run/paths.mjs +13 -0
  39. package/src/seat/allowance.mjs +4 -1
  40. package/src/seat/tmux.mjs +9 -1
@@ -21,7 +21,9 @@
21
21
  * `node.exe` and there is no extensionless file beside it, so a lookup that
22
22
  * only joins the bare name reports the running interpreter as missing.
23
23
  */
24
- import { existsSync, renameSync, rmSync, symlinkSync } from "node:fs";
24
+ import { Buffer } from "node:buffer";
25
+ import { spawnSync } from "node:child_process";
26
+ import { closeSync, existsSync, openSync, readSync, renameSync, rmSync, symlinkSync } from "node:fs";
25
27
  import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
26
28
 
27
29
  /**
@@ -109,3 +111,204 @@ function executableCandidates(name) {
109
111
  const extensions = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").map((extension) => extension.trim().toLowerCase()).filter(Boolean);
110
112
  return [...extensions.map((extension) => `${name}${extension}`), name];
111
113
  }
114
+
115
+ /** A command script Windows runs through the command interpreter, not directly. */
116
+ const COMMAND_SCRIPT = /\.(?:cmd|bat)$/iu;
117
+
118
+ /**
119
+ * How this platform has to be asked to run `executable` with `args`.
120
+ *
121
+ * POSIX hands both back untouched. Windows has two problems with the binaries
122
+ * a harness actually installs as, and both are this function's business so no
123
+ * caller has to know about either.
124
+ *
125
+ * A command name resolves through `PATHEXT`, and `spawn` does not do it: with
126
+ * only `claude.cmd` on PATH, `spawn("claude")` is ENOENT. So a bare name is
127
+ * resolved here first.
128
+ *
129
+ * And a `.cmd` or `.bat` cannot be spawned directly at all — node refuses it
130
+ * with EINVAL since the argument-injection fix — so it runs through
131
+ * `%ComSpec% /d /s /c`. Every argument is quoted for `CommandLineToArgvW` and
132
+ * then caret-escaped *twice*: the interpreter consumes one layer parsing the
133
+ * command line, and the `%*` that every npm-written shim forwards its
134
+ * arguments with consumes the second. Measured 2026-09-20 on Windows 11 —
135
+ * with one layer, an argument containing `&` is truncated at it; with two,
136
+ * spaces, quotes, `&`, `|`, `%`, `^`, `()` and `!` all arrive intact.
137
+ *
138
+ * A relative executable — `./my-worker.mjs`, the shape a contract names a
139
+ * wrapped harness by — is relative to the directory the child will run in, and
140
+ * the caller is the only one who knows it. Without `cwd` the shebang is looked
141
+ * for beside *this* process instead, found nowhere, and the spawn fails as
142
+ * EFTYPE with nothing said about why.
143
+ *
144
+ * @param {string} executable
145
+ * @param {string[]} args
146
+ * @param {{cwd?: string}} [options] the directory the child will run in
147
+ * @returns {{command: string, args: string[], options: {windowsVerbatimArguments?: boolean}}}
148
+ */
149
+ export function spawnInvocation(executable, args, options = {}) {
150
+ if (process.platform !== "win32") return { command: executable, args, options: {} };
151
+ const named = executable.includes("/") || executable.includes("\\");
152
+ const resolved = named
153
+ ? (options.cwd === undefined ? executable : resolve(options.cwd, executable))
154
+ : (findExecutable(executable) ?? executable);
155
+ if (COMMAND_SCRIPT.test(resolved)) {
156
+ const line = [quoteArgument(resolved), ...args.map((argument) => escapeThroughShim(quoteArgument(argument)))].join(" ");
157
+ return {
158
+ command: process.env.ComSpec ?? "cmd.exe",
159
+ args: ["/d", "/s", "/c", `"${line}"`],
160
+ options: { windowsVerbatimArguments: true },
161
+ };
162
+ }
163
+ const shebang = interpreterOf(resolved);
164
+ if (shebang) return { command: shebang[0], args: [...shebang.slice(1), resolved, ...args], options: {} };
165
+ return { command: resolved, args, options: {} };
166
+ }
167
+
168
+ /**
169
+ * The interpreter a `#!` line names, or null when the file has none.
170
+ *
171
+ * A shebang is a POSIX kernel feature, so Windows hands the same file back as
172
+ * EFTYPE. Reading the line and running the interpreter is what cross-spawn —
173
+ * and therefore npm — does on Windows, and it is the only way an executable
174
+ * handed over as a POSIX script (`FABERUN_CODEX_BIN` pointed at the
175
+ * extensionless file npm installs beside its `.cmd`, a harness wrapper written
176
+ * by hand) runs there at all.
177
+ *
178
+ * @param {string} path
179
+ * @returns {string[]|null} the interpreter and its own arguments
180
+ */
181
+ function interpreterOf(path) {
182
+ /** @type {number|undefined} */
183
+ let handle;
184
+ try {
185
+ handle = openSync(path, "r");
186
+ const head = Buffer.alloc(256);
187
+ const read = readSync(handle, head, 0, 256, 0);
188
+ const first = head.subarray(0, read).toString("utf8").split(/\r?\n/u, 1)[0];
189
+ if (!first.startsWith("#!")) return null;
190
+ const line = first.slice(2).trim();
191
+ if (!line) return null;
192
+ // A whole line that names a real file is the interpreter, spaces and all:
193
+ // every fixture here is written with `#!${process.execPath}`, and on
194
+ // Windows that is `C:\Program Files\nodejs\node.exe`. Splitting it on
195
+ // whitespace the POSIX way would ask for `C:\Program`.
196
+ if (existsSync(line)) return [line];
197
+ // An interpreter named by a POSIX path that this host does not have:
198
+ // `#!/bin/sh` is not a file on Windows, but `sh` is a command there
199
+ // wherever Git for Windows is installed, which is wherever this tool runs.
200
+ const named = line.includes(" ") ? null : findExecutable(line.slice(line.lastIndexOf("/") + 1));
201
+ if (named) return [named];
202
+ // `#!/usr/bin/env node` names the interpreter in its own argument.
203
+ const parts = line.split(/\s+/u).filter(Boolean);
204
+ if (!parts.length) return null;
205
+ if (/(?:^|[\\/])env(?:\.exe)?$/iu.test(parts[0])) return parts.slice(1).length ? parts.slice(1) : null;
206
+ return parts;
207
+ } catch {
208
+ // Unreadable, a directory, or gone: there is no shebang to honour, and the
209
+ // spawn below reports the real reason.
210
+ return null;
211
+ } finally {
212
+ if (handle !== undefined) closeSync(handle);
213
+ }
214
+ }
215
+
216
+ /**
217
+ * One argument, quoted the way `CommandLineToArgvW` reads it back: a backslash
218
+ * run before a quote doubles, and a trailing run doubles so the closing quote
219
+ * survives.
220
+ *
221
+ * @param {string} value
222
+ * @returns {string}
223
+ */
224
+ function quoteArgument(value) {
225
+ return `"${String(value).replace(/(\*)"/gu, '$1$1\\"').replace(/(\+)$/u, "$1$1")}"`;
226
+ }
227
+
228
+ /**
229
+ * @param {string} value
230
+ * @returns {string}
231
+ */
232
+ function escapeThroughShim(value) {
233
+ return value.replace(/[()%!^"<>&|]/gu, "^$&").replace(/[()%!^"<>&|]/gu, "^$&");
234
+ }
235
+
236
+ /**
237
+ * End a process and everything it started, the way this platform can.
238
+ *
239
+ * On POSIX the caller passes a negative pid and the kernel signals the whole
240
+ * process group, which is what every signalling path here already does.
241
+ * Windows has no process group to signal: `process.kill(pid)` ends that one
242
+ * process, and the provider a gate spawned — or the `node` a `.cmd` shim
243
+ * started — keeps running. Measured 2026-09-20: a full suite run on Windows 11
244
+ * stranded 105 `node -e "setInterval(…)"` fixtures this way, and then hung
245
+ * waiting for one of them to die.
246
+ *
247
+ * `taskkill /T` walks the tree from the pid. `/F` is the only ending Windows
248
+ * offers a console process, so there is no graceful step to escalate from —
249
+ * which is why every caller already skips its `SIGKILL` escalation there.
250
+ * A non-zero exit means no such process, read the way a POSIX `ESRCH` is: the
251
+ * target is gone, which is not a failure to report.
252
+ *
253
+ * @param {number} target a pid, or on POSIX a negative process-group id
254
+ * @param {string|number} signal
255
+ * @returns {unknown}
256
+ */
257
+ export function killTarget(target, signal) {
258
+ if (process.platform !== "win32") return process.kill(target, signal);
259
+ const result = spawnSync(join(process.env.SystemRoot ?? "C:\Windows", "System32", "taskkill.exe"), ["/PID", String(Math.abs(target)), "/T", "/F"], { stdio: "ignore" });
260
+ if (result.error) throw result.error;
261
+ if (result.status !== 0) throw Object.assign(new Error(`taskkill found no process ${Math.abs(target)}`), { code: "ESRCH" });
262
+ return true;
263
+ }
264
+
265
+ /**
266
+ * The `git` argument list every invocation here carries.
267
+ *
268
+ * **`core.fsmonitor=false`, on every platform.** A repository with the file
269
+ * system monitor enabled starts `git fsmonitor--daemon --detach` on first use,
270
+ * and the daemon outlives the directory it watched. This tool creates a
271
+ * worktree per attempt and its suite creates a throwaway repository per
272
+ * fixture, so the daemons accumulate: measured 2026-09-20 on Windows 11, one
273
+ * afternoon of suite runs left **4810** of them holding 39 GB, until the
274
+ * machine could not start another test process. Nothing here benefits from a
275
+ * daemon watching a tree that is about to be deleted.
276
+ *
277
+ * **`core.longpaths=true`, on Windows.** It caps a path at 260 characters
278
+ * unless the program opts out, and git's
279
+ * own `core.longpaths` is off by default even where the OS itself allows long
280
+ * paths. The run layout is deep on purpose — `projects/<id>/runs/worktrees/
281
+ * <run>/<node>.<attempt>` before the repository's own tree begins — so a file
282
+ * a worker writes a few directories down reaches the cap easily.
283
+ *
284
+ * What makes this worth a flag on every call is *how* git fails there.
285
+ * Measured 2026-09-20 on Windows 11, a repository holding one file at a
286
+ * 268-character path: `git add --all` exits 0 and tracks nothing. With
287
+ * `core.longpaths=true` it exits 0 and tracks the file. A tool whose gate is
288
+ * the diff of what a worker changed cannot afford a git that silently cannot
289
+ * see part of the tree.
290
+ *
291
+ * It does not lift every limit: `git worktree add` dies with `'$GIT_DIR' too
292
+ * big` once the repository's own `.git/worktrees/<name>` path passes about 160
293
+ * characters, and that guard is a fixed buffer no configuration reaches.
294
+ *
295
+ * **`core.autocrlf=false`, on Windows.** The default there is `true`, and it
296
+ * rewrites line endings on the way into the index and out of a checkout. This
297
+ * tool's gate is a byte comparison of what a worker changed, and a rewrite it
298
+ * did not make is indistinguishable from one it did: measured 2026-09-22 on a
299
+ * GitHub windows-latest runner, a declared read carried into an attempt
300
+ * worktree came back changed after passing through git, the scope gate called
301
+ * it an `unexpected_write`, and the adopted node failed for a file nobody had
302
+ * touched. The attempt worktree is this tool's own directory and its content
303
+ * is what the run commits; the operator's checkout keeps whatever they
304
+ * configured.
305
+ *
306
+ * @param {string[]} args
307
+ * @returns {string[]}
308
+ */
309
+ export function gitArguments(args) {
310
+ const platformArgs = process.platform === "win32"
311
+ ? ["-c", "core.longpaths=true", "-c", "core.autocrlf=false"]
312
+ : [];
313
+ return ["-c", "core.fsmonitor=false", ...platformArgs, ...args];
314
+ }
@@ -8,6 +8,17 @@
8
8
  * report as run evidence and stops, so the operator fixes the host and
9
9
  * resumes instead of starting over and paying for the finished nodes twice.
10
10
  *
11
+ * A version is not a verdict: a binary that answered `--version` can still
12
+ * hold a dead credential or a spent quota. With a contract, `doctor` asks
13
+ * each routed runtime the same live question the dispatch gate asks and
14
+ * reports that verdict beside the version checks — the `models` surfaces
15
+ * name this report the authoritative word on availability — and without a
16
+ * contract it says plainly that it asked nothing. The verdict is the
17
+ * report, not the gate: a runtime with no answer is a finding on its own
18
+ * line, while the exit code keeps answering the host-fact question it
19
+ * always answered, because blocking on silence is the dispatch gate's job
20
+ * and it blocks on exactly those causes.
21
+ *
11
22
  * A check may be advisory, meaning it reports a fact without blocking: a
12
23
  * merely dirty worktree is normal in this repository (the run captures a
13
24
  * dirtyTreeFingerprint for it), while unmerged paths or an interrupted git
@@ -18,6 +29,7 @@ import { spawnSync } from "node:child_process";
18
29
  import { existsSync, readFileSync, statfsSync } from "node:fs";
19
30
  import { join, resolve } from "node:path";
20
31
  import { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION, getHarness, probeRuntime } from "../harnesses/index.mjs";
32
+ import { liveSilenceCause } from "../engine/live-silence.mjs";
21
33
  import { addRuntimeRequirement, failoverTargets, runtimeSnapshot } from "../engine/failover.mjs";
22
34
  import { pricingSeedAge } from "../engine/pricing-seed.mjs";
23
35
  import { validateContract } from "../contract/index.mjs";
@@ -31,10 +43,13 @@ import { NOTIFY_SESSION_ENV, sessionWakeNotice } from "../notify/session.mjs";
31
43
  import { findExecutable } from "./platform.mjs";
32
44
  import { colorLevel, statusToken } from "../cli/brand.mjs";
33
45
  import { RUNS_DIR_NAME } from "../run/paths.mjs";
46
+ import { availabilityKey, readAvailability, recordAvailability } from "../run/availability.mjs";
34
47
 
35
48
  /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
36
49
  /** @typedef {import("../contract/index.mjs").RuntimeSnapshot} RuntimeSnapshot */
37
50
  /** @typedef {import("../harnesses/index.mjs").CapabilityRequirements} CapabilityRequirements */
51
+ /** @typedef {import("../harnesses/index.mjs").ProbeResult} ProbeResult */
52
+ /** @typedef {import("../engine/runtime-discovery.mjs").RuntimeAvailability} RuntimeAvailability */
38
53
  /** @typedef {Map<string, {runtime: RuntimeSnapshot, requiredCapabilitySets: CapabilityRequirements[]}>} ReachableRuntimes */
39
54
  /** @typedef {{name: string, ok: boolean, advisory: boolean, detail: string}} EnvCheck */
40
55
  /** @typedef {{schemaVersion: number, ok: boolean, checks: EnvCheck[]}} EnvReport */
@@ -384,6 +399,106 @@ export function reachableRuntimes(contract) {
384
399
  return runtimes;
385
400
  }
386
401
 
402
+
403
+ /**
404
+ * Read the live verdict off one `preflightContract` probe, in the detail it
405
+ * embeds: `… · live <status> · <code>: …` for an ask that failed, and the
406
+ * repository wording when no runtime could be asked at all. Any verdict a
407
+ * provider produced — a quota refusal, an auth failure, unparsable output —
408
+ * is an answer; a silence is a verdict of nothing and is never recorded, so
409
+ * the operator who fixes the host is never told the fix "already answered".
410
+ *
411
+ * @param {ProbeResult} probe
412
+ * @returns {{answered: boolean, cause: string|null, recorded: boolean}}
413
+ */
414
+ function liveVerdict(probe) {
415
+ if (probe.liveStatus === "done") return { answered: true, cause: null, recorded: true };
416
+ // `liveSilenceCause` is the dispatch gate's own classification, imported
417
+ // rather than restated. A second copy here had `command_invalid` in it,
418
+ // which the gate deliberately does not: a command that could not be
419
+ // constructed never reached a provider, so there is no availability verdict
420
+ // to report either way. Two copies of this rule is how doctor and the gate
421
+ // would come to disagree about whether an answer was an answer.
422
+ const silence = liveSilenceCause(probe);
423
+ if (silence !== null) return { answered: false, cause: silence, recorded: false };
424
+ const match = / · live \S+ · ([a-z_]+):/u.exec(probe.detail ?? "");
425
+ const cause = match?.[1] ?? null;
426
+ if (cause === null) return { answered: false, cause, recorded: false };
427
+ return { answered: true, cause, recorded: true };
428
+ }
429
+
430
+ /**
431
+ * The live half of `doctor`: the verdict, not the version. The two catalogue
432
+ * surfaces (`models`, `models --probe`) name this report the authoritative
433
+ * word on availability, so with a contract doctor asks what the dispatch
434
+ * gate asks — one trivial prompt per routed runtime through
435
+ * `preflightContract` — and reports beside the version checks what
436
+ * answered, naming the cause when nothing did.
437
+ *
438
+ * A verdict this machine recorded inside the preflight window is reused
439
+ * rather than re-bought, and every ask that reached a provider is recorded
440
+ * in turn: the store (`run/availability.mjs`) is the only cache; none is
441
+ * built here.
442
+ *
443
+ * Every check returned here is advisory. `ok` carries the honest verdict —
444
+ * false when nothing answered — but the lines never gate `doctor`'s exit
445
+ * code: the report is where the operator reads what answered, and blocking
446
+ * on silence is the dispatch gate's decision, made on the same causes. A
447
+ * doctor that failed on a runtime it could not ask inside its own sandbox
448
+ * (a relative-executable wrapper) would report the runtime broken when the
449
+ * actual launch may resolve it fine.
450
+ *
451
+ * @param {string} contractPath the authored contract, re-read and re-validated by the ask
452
+ * @param {ReachableRuntimes} runtimes
453
+ * @returns {Promise<EnvCheck[]>}
454
+ */
455
+ async function liveAvailabilityChecks(contractPath, runtimes) {
456
+ /** @type {Map<string, RuntimeAvailability>} */
457
+ const fresh = new Map();
458
+ for (const [id, { runtime }] of runtimes) {
459
+ const verdict = readAvailability(availabilityKey({
460
+ harness: runtime.harness,
461
+ model: runtime.model,
462
+ executable: getHarness(runtime.harness).executable(runtime),
463
+ }));
464
+ if (verdict) fresh.set(id, verdict);
465
+ }
466
+ if (runtimes.size > 0 && fresh.size === runtimes.size) {
467
+ return [...runtimes.keys()].map((id) => {
468
+ const verdict = /** @type {RuntimeAvailability} */ (fresh.get(id));
469
+ return {
470
+ name: `availability ${id}`,
471
+ ok: true,
472
+ advisory: true,
473
+ detail: `answered · verdict reused · observed ${verdict.observedAt ?? "unknown instant"}`,
474
+ };
475
+ });
476
+ }
477
+ // The ask is imported where it runs: live-preflight imports this module's
478
+ // reachableRuntimes, so a static edge here would be the runtime import
479
+ // cycle the source gate bans. At call time both modules are fully
480
+ // evaluated; this is a plain cache hit, not a cycle.
481
+ const { preflightContract } = await import("../engine/live-preflight.mjs");
482
+ // measured 2026-09-22 (dispatch gate): four routed runtimes asked in
483
+ // parallel took about 18s, so 60s is the budget; FABERUN_PREFLIGHT_TIMEOUT_SEC
484
+ // is the same operator override the gate honours.
485
+ const override = Number(process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC);
486
+ const timeoutSec = process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC !== undefined && Number.isFinite(override) && override > 0 ? override : 60;
487
+ const probes = await preflightContract(contractPath, { liveTimeoutSec: timeoutSec });
488
+ recordAvailability(probes.filter((probe) => liveVerdict(probe).recorded).map((probe) => availabilityKey(probe)));
489
+ return probes.map((probe) => {
490
+ const verdict = liveVerdict(probe);
491
+ return {
492
+ name: `availability ${probe.id ?? probe.harness}`,
493
+ ok: verdict.answered,
494
+ advisory: true,
495
+ detail: verdict.answered
496
+ ? `answered · ${probe.detail ?? `the provider answered (${verdict.cause})`}`
497
+ : `no answer · ${probe.detail ?? `cause ${verdict.cause ?? "unknown"}`}`,
498
+ };
499
+ });
500
+ }
501
+
387
502
  const HARNESS_BIN_OVERRIDES = Object.freeze({
388
503
  codex: "FABERUN_CODEX_BIN",
389
504
  claude: "FABERUN_CLAUDE_BIN",
@@ -393,9 +508,17 @@ const HARNESS_BIN_OVERRIDES = Object.freeze({
393
508
  });
394
509
 
395
510
  /**
396
- * Mutation-free environment doctor: repository prerequisites, ignored .runs,
397
- * required binaries, the dispatch environment gate, and (when a contract is
398
- * given) schema and harness versions.
511
+ * Environment doctor: repository prerequisites, ignored .runs, required
512
+ * binaries, the dispatch environment gate, and (when a contract is given)
513
+ * the harness versions beside the live availability verdict per routed
514
+ * runtime — the report `models` defers to. Without a contract it says
515
+ * plainly that it asked nothing.
516
+ *
517
+ * The exit code answers the host-fact question it always answered: the
518
+ * live availability lines carry their verdict (false when nothing
519
+ * answered) but are advisory, because blocking on silence is the dispatch
520
+ * gate's job and a runtime doctor could not ask inside its own sandbox is
521
+ * not thereby a runtime the launch cannot run.
399
522
  *
400
523
  * @param {string|undefined} contractPath
401
524
  * @param {{cwd?: string, json?: boolean, discover?: boolean}} values
@@ -403,7 +526,7 @@ const HARNESS_BIN_OVERRIDES = Object.freeze({
403
526
  */
404
527
  export async function doctorCommand(contractPath, values) {
405
528
  const repoDir = resolve(values.cwd ?? ".");
406
- /** @type {{name: string, ok: boolean, detail: string}[]} */
529
+ /** @type {{name: string, ok: boolean, advisory?: boolean, detail: string}[]} */
407
530
  const checks = [];
408
531
  const gitRepo = isGitWorkTree(repoDir);
409
532
  checks.push({ name: "git repository", ok: gitRepo, detail: gitRepo ? repoDir : "not inside a git work tree" });
@@ -457,6 +580,10 @@ export async function doctorCommand(contractPath, values) {
457
580
  harnessVersions[id] = probe.version;
458
581
  checks.push({ name: `harness ${probe.id ?? runtime.harness}`, ok: probe.ok, detail: probe.detail ?? (probe.ok ? "ok" : "probe failed") });
459
582
  }
583
+ // The verdict beside the version: a binary that answered --version is
584
+ // not thereby a provider that answered, and the report names which of
585
+ // the two failed because the remedies differ.
586
+ checks.push(...await liveAvailabilityChecks(absolute, runtimes));
460
587
  } catch (error) {
461
588
  checks.push({ name: "contract", ok: false, detail: errorMessage(error) });
462
589
  }
@@ -497,7 +624,12 @@ export async function doctorCommand(contractPath, values) {
497
624
  for (const check of environmentPreflight({ cwd: dispatchCwd, runtimes: routedRuntimes, harnessVersions }).checks) {
498
625
  checks.push({ name: check.name, ok: check.ok || check.advisory, detail: check.ok ? check.detail : `${check.detail} (advisory)` });
499
626
  }
500
- const ok = checks.every((check) => check.ok);
627
+ // The live availability lines never gate the verdict: a no-answer is the
628
+ // finding it is on its own line (ok false, cause named), and what still
629
+ // fails doctor is every host fact and static probe — which is why the
630
+ // nonexistent-binary case below exits non-zero while a merely silent
631
+ // provider does not.
632
+ const ok = checks.every((check) => check.ok || check.advisory === true);
501
633
  const transportWarning = noTransportWarning(process.env);
502
634
  if (transportWarning) process.stderr.write(`${statusToken("warn", colorLevel(process.env, process.stderr.isTTY))} ${transportWarning}\n`);
503
635
  if (values.json === true) {
@@ -51,6 +51,7 @@ import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, writeSync
51
51
  import { join } from "node:path";
52
52
  import { createMacosNotifier } from "./os-macos.mjs";
53
53
  import { NOTIFY_SESSION_ENV, deliverToSessions, resolveSessionTargets, sessionWakeNotice } from "./session.mjs";
54
+ import { spawnInvocation } from "../host/platform.mjs";
54
55
  import { errorMessage } from "../util.mjs";
55
56
 
56
57
  /**
@@ -484,7 +485,12 @@ function spawnDeliver(bin, event, { spawn = defaultSpawn, timeoutMs = notificati
484
485
  return new Promise((resolveDelivery) => {
485
486
  let child;
486
487
  try {
487
- child = spawn(bin, [], { stdio: ["pipe", "ignore", "pipe"], env: process.env });
488
+ // The transport is whatever the operator bound, and on Windows that is
489
+ // rarely an .exe: a `.cmd` shim npm wrote, or a script whose shebang
490
+ // names its interpreter. Either spawns as EFTYPE unhandled, which is a
491
+ // notification that silently never arrives.
492
+ const invocation = spawnInvocation(bin, []);
493
+ child = spawn(invocation.command, invocation.args, { stdio: ["pipe", "ignore", "pipe"], env: process.env, ...invocation.options });
488
494
  } catch (error) {
489
495
  resolveDelivery({ ok: false, error: errorMessage(error) });
490
496
  return;
@@ -31,6 +31,7 @@
31
31
  import { spawn as defaultSpawn } from "node:child_process";
32
32
  import { createConnection as defaultConnect } from "node:net";
33
33
  import { errorMessage } from "../util.mjs";
34
+ import { spawnInvocation } from "../host/platform.mjs";
34
35
 
35
36
  export const NOTIFY_SESSION_ENV = "FABERUN_NOTIFY_SESSION";
36
37
  export const CLAUDE_SOCKET_ENV = "CLAUDE_CODE_MESSAGING_SOCKET";
@@ -224,7 +225,11 @@ export function createCodexSessionNotifier({ spawn = /** @type {SpawnFunction} *
224
225
  const executable = env.FABERUN_CODEX_BIN ?? "codex";
225
226
  let child;
226
227
  try {
227
- child = spawn(executable, ["queue", "--thread", target.thread, "--message", messageText(event)], { stdio: ["ignore", "ignore", "pipe"], env });
228
+ // The harness CLI, reached the way this platform reaches one: the
229
+ // `codex` a Windows machine has is `codex.cmd`, and a raw spawn of
230
+ // the bare name is ENOENT — a wake that silently never arrives.
231
+ const invocation = spawnInvocation(executable, ["queue", "--thread", target.thread, "--message", messageText(event)]);
232
+ child = spawn(invocation.command, invocation.args, { stdio: ["ignore", "ignore", "pipe"], env, ...invocation.options });
228
233
  } catch (error) {
229
234
  resolve({ ok: false, error: errorMessage(error) });
230
235
  return;
@@ -24,6 +24,7 @@ import { campaignCli } from "../cli/campaign.mjs";
24
24
  import { appendJsonl, writeJsonAtomic } from "../run/store.mjs";
25
25
  import { stableJson } from "../util.mjs";
26
26
  import { allowanceDelta, allowanceEventFields, sampleAllowance } from "../seat/allowance.mjs";
27
+ import { askPlanningRuntimes, refusePlanningSilence } from "./preflight.mjs";
27
28
  import { parseSpec, validateSpec } from "./spec.mjs";
28
29
  import { collectRepoFacts } from "./repo-facts.mjs";
29
30
  import { RISK_TIERS, buildPlanningContract, validateFindings, validatePlanOutput } from "./template.mjs";
@@ -42,6 +43,7 @@ import { campaignTree, runDirectory } from "../run/paths.mjs";
42
43
  /** @typedef {{sizing: import("./sizing.mjs").SizingResult, routing: import("./routing.mjs").RoutingResult, nodes: JsonObject[]}} AssembledPlan */
43
44
  /** @typedef {"standard"|"high"|"none"} ApproveBelow */
44
45
  /** @typedef {(contractPath: string, contract: ValidatedContract) => Promise<void>|void} LaunchFn */
46
+ /** @typedef {(runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, cwd: string) => Promise<import("../harnesses/index.mjs").ProbeResult[]>} AskFn */
45
47
  /** @typedef {(runDir: string) => Promise<import("../engine/supervise.mjs").RunProgress>|import("../engine/supervise.mjs").RunProgress} WaitFn */
46
48
  /** @typedef {{status: "frozen", plansDir: string, planPath: string, contractPath: string, approved: boolean, findings: PlanFindingOutput[], warnings: string[]}} FrozenPipelineResult */
47
49
  /** @typedef {{status: "contested", plansDir: string, planPath: string, findings: PlanFindingOutput[], round: number}} ContestedPipelineResult */
@@ -80,7 +82,7 @@ export const DEFAULT_NODE_BUDGET_MS = 600_000;
80
82
  const APPROVE_BELOW_VALUES = new Set(["standard", "high", "none"]);
81
83
 
82
84
  /**
83
- * @param {{specPath: string, campaignId: string, phase: string, cwd?: string, reviewRounds?: number, approveBelow?: ApproveBelow, runtimeDefaults?: {worker?: string, judge?: string}, runtimes: Record<string, JsonObject>, verification?: VerificationSuites, launch: LaunchFn, wait: WaitFn}} options
85
+ * @param {{specPath: string, campaignId: string, phase: string, cwd?: string, reviewRounds?: number, approveBelow?: ApproveBelow, runtimeDefaults?: {worker?: string, judge?: string}, runtimes: Record<string, JsonObject>, verification?: VerificationSuites, launch: LaunchFn, wait: WaitFn, ask?: AskFn}} options
84
86
  * @returns {Promise<FrozenPipelineResult|ContestedPipelineResult>}
85
87
  */
86
88
  export async function runPlanningPipeline(options) {
@@ -88,6 +90,7 @@ export async function runPlanningPipeline(options) {
88
90
  specPath, campaignId, phase, runtimes, launch, wait,
89
91
  reviewRounds = 2, runtimeDefaults = {}, verification = {},
90
92
  } = options;
93
+ const ask = options.ask ?? askPlanningRuntimes;
91
94
  const approveBelow = /** @type {ApproveBelow} */ (options.approveBelow ?? "standard");
92
95
  if (!APPROVE_BELOW_VALUES.has(approveBelow)) throw new TypeError(`approveBelow must be one of ${[...APPROVE_BELOW_VALUES].join(", ")}`);
93
96
  if (typeof launch !== "function") throw new TypeError("runPlanningPipeline requires a launch seam");
@@ -97,6 +100,7 @@ export async function runPlanningPipeline(options) {
97
100
  const campaignPath = campaignTree(cwd, campaignId);
98
101
  const campaign = readCampaign(campaignPath);
99
102
  if (campaign.status !== "active") throw new Error(`campaign is closed: ${campaignId}`);
103
+ refusePlanningSilence(await ask(runtimes, runtimeDefaults, cwd), cwd);
100
104
 
101
105
  const relativeSpecPath = repoRelativePath(cwd, specPath, "specPath");
102
106
  const specText = readFileSync(resolve(cwd, relativeSpecPath), "utf8");
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The ask `faberun plan` makes before its first stage.
3
+ *
4
+ * It lives beside the pipeline rather than inside it because asking is its own
5
+ * concern -- which runtimes a planning run will spend, and what counts as one
6
+ * of them not answering -- and because `pipeline.mjs` sits 45 lines from this
7
+ * tree's 800-line ceiling.
8
+ */
9
+ import { preflightRuntimes } from "../engine/live-preflight.mjs";
10
+ import { liveSilenceCause } from "../engine/live-silence.mjs";
11
+ import { harnessCapabilities } from "../harnesses/index.mjs";
12
+ import { validateRuntime } from "../contract/runtime.mjs";
13
+
14
+ /**
15
+ * The runtimes a planning run will spend, asked once before its first stage.
16
+ *
17
+ * Planning routes two roles across nine stages and does not reach them at the
18
+ * same time: the planner is spent at `draft`, the reviewer not until `review`.
19
+ * Every stage does launch through `runContract`, so the dispatch gate asks --
20
+ * but it asks that stage's own contract, and a planning contract carries no
21
+ * gate (`plan/template.mjs` builds them with `gate: false`), so
22
+ * `reachableRuntimes` never counts the judge role: it only adds one when a
23
+ * node's gate is enabled. The reviewer is therefore reached only as the
24
+ * *worker* of a later stage's contract, which is why a reviewer that never
25
+ * answers used to surface after the draft had already been bought. Naming both
26
+ * runtimes directly is the only ask that reaches them before anything is
27
+ * spent.
28
+ *
29
+ * Phase 2's verdict store makes this free at the stage boundary: what is
30
+ * recorded here is what each stage's own gate reuses instead of asking again.
31
+ *
32
+ * @param {Record<string, Record<string, unknown>>} runtimes the catalogue as the pipeline carries it, validated here per entry
33
+ * @param {{worker?: string, judge?: string}} runtimeDefaults
34
+ * @param {string} cwd
35
+ * @returns {Promise<import("../harnesses/index.mjs").ProbeResult[]>}
36
+ */
37
+ export async function askPlanningRuntimes(runtimes, runtimeDefaults, cwd) {
38
+ /** @type {string[]} */
39
+ const ids = [];
40
+ for (const id of [runtimeDefaults.worker, runtimeDefaults.judge]) {
41
+ if (typeof id === "string" && id.length > 0 && !ids.includes(id)) ids.push(id);
42
+ }
43
+ const entries = ids.flatMap((id) => {
44
+ const raw = runtimes[id];
45
+ // A default naming a runtime the catalogue does not carry is the
46
+ // catalogue loader's refusal to make, not this one's.
47
+ if (raw === undefined) return [];
48
+ const runtime = validateRuntime(id, raw);
49
+ return [{ runtime: { ...runtime, id, capabilities: harnessCapabilities(runtime) } }];
50
+ });
51
+ return entries.length === 0 ? [] : preflightRuntimes(entries, { cwd });
52
+ }
53
+
54
+ /**
55
+ * Refuse before the first stage when a runtime said nothing at all.
56
+ *
57
+ * The rule is the dispatch gate's own, imported rather than restated:
58
+ * `liveSilenceCause` decides what silence is, so planning and dispatch cannot
59
+ * drift into disagreeing about whether an answer was an answer. Silence
60
+ * blocks; any verdict a provider returned -- a quota refusal included -- is an
61
+ * answer, and the run proceeds onto whatever the contract declares.
62
+ *
63
+ * @param {import("../harnesses/index.mjs").ProbeResult[]} checks
64
+ * @param {string} cwd
65
+ * @returns {void}
66
+ */
67
+ export function refusePlanningSilence(checks, cwd) {
68
+ const silent = checks.flatMap((check) => {
69
+ const cause = liveSilenceCause(check);
70
+ return cause === null ? [] : [`runtime ${check.id ?? check.harness} did not answer: ${cause}`];
71
+ });
72
+ if (silent.length === 0) return;
73
+ throw Object.assign(
74
+ new Error(`env_preflight_failed: ${silent.join(" · ")} · planning stays resumable: fix the environment and plan again in ${cwd}`),
75
+ { code: "env_preflight_failed" },
76
+ );
77
+ }