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.
- package/integrations/claude-code/statusline.sh +12 -2
- package/package.json +2 -2
- package/skills/init-agentkit/scripts/install-agentkit.sh +10 -2
- package/src/campaign/chain.mjs +5 -4
- package/src/cli/launch.mjs +10 -1
- package/src/cli.mjs +10 -4
- package/src/contract/index.mjs +16 -3
- package/src/contract/task-packet.mjs +13 -1
- package/src/engine/bulk-read.mjs +7 -1
- package/src/engine/gate.mjs +17 -3
- package/src/engine/judge-gate.mjs +2 -2
- package/src/engine/live-preflight.mjs +43 -10
- package/src/engine/live-silence.mjs +49 -0
- package/src/engine/process-identity.mjs +12 -1
- package/src/engine/process.mjs +2 -1
- package/src/engine/run-command.mjs +8 -5
- package/src/engine/run-identity.mjs +213 -14
- package/src/engine/runtime-discovery.mjs +26 -5
- package/src/engine/scheduler.mjs +1 -1
- package/src/engine/supervise.mjs +2 -1
- package/src/harnesses/catalogue.mjs +8 -1
- package/src/harnesses/dsh/runner.mjs +6 -1
- package/src/harnesses/index.mjs +35 -7
- package/src/host/platform.mjs +204 -1
- package/src/host/preflight.mjs +137 -5
- package/src/notify/index.mjs +7 -1
- package/src/notify/session.mjs +6 -1
- package/src/plan/pipeline.mjs +5 -1
- package/src/plan/preflight.mjs +77 -0
- package/src/repo/declared-paths.mjs +87 -6
- package/src/repo/signal.mjs +56 -21
- package/src/repo/workspace.mjs +3 -2
- package/src/repo/worktree.mjs +2 -1
- package/src/report/next.mjs +15 -1
- package/src/run/availability.mjs +138 -0
- package/src/run/disk-gc.mjs +16 -2
- package/src/run/lock.mjs +8 -0
- package/src/run/paths.mjs +13 -0
- package/src/seat/allowance.mjs +4 -1
- package/src/seat/tmux.mjs +9 -1
package/src/host/platform.mjs
CHANGED
|
@@ -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 {
|
|
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
|
+
}
|
package/src/host/preflight.mjs
CHANGED
|
@@ -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
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
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
|
-
|
|
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) {
|
package/src/notify/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/notify/session.mjs
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/plan/pipeline.mjs
CHANGED
|
@@ -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
|
+
}
|