faberun 0.17.2 → 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/faberun/references/operations.md +14 -7
- package/skills/init-agentkit/scripts/install-agentkit.sh +10 -2
- package/src/campaign/chain.mjs +5 -4
- package/src/campaign/metrics.mjs +3 -1
- 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 +142 -7
- package/src/notify/index.mjs +77 -11
- package/src/notify/session.mjs +64 -24
- 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/locale.mjs +20 -0
- package/src/report/message.mjs +2 -2
- 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";
|
|
@@ -26,15 +38,18 @@ import { DISCOVERY_RUNTIME_DEFINITIONS, discoverRuntimes } from "../engine/runti
|
|
|
26
38
|
import { errorMessage } from "../util.mjs";
|
|
27
39
|
import { boundedGitSync } from "../repo/worktree.mjs";
|
|
28
40
|
import { routeRuntime } from "../contract/runtime.mjs";
|
|
29
|
-
import { NOTIFY_BIN_ENV, noTransportWarning } from "../notify/index.mjs";
|
|
41
|
+
import { NOTIFY_BIN_ENV, deliverableEventTypes, noTransportWarning, notifySettingProblems } from "../notify/index.mjs";
|
|
30
42
|
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 */
|
|
@@ -209,10 +224,13 @@ export function environmentPreflight(options) {
|
|
|
209
224
|
* @returns {EnvCheck}
|
|
210
225
|
*/
|
|
211
226
|
export function notifyTransportCheck(env = process.env) {
|
|
227
|
+
const problems = notifySettingProblems(env);
|
|
228
|
+
if (problems.length) return fail("notify transport", problems.join("; "), true);
|
|
212
229
|
const warning = noTransportWarning(env);
|
|
213
230
|
if (warning) return fail("notify transport", warning, true);
|
|
214
231
|
const external = env[NOTIFY_BIN_ENV] ? `${NOTIFY_BIN_ENV}=${env[NOTIFY_BIN_ENV]}` : `${NOTIFY_BIN_ENV} unset`;
|
|
215
|
-
|
|
232
|
+
const events = [...deliverableEventTypes(env)].join(",");
|
|
233
|
+
return pass("notify transport", `${external} · ${sessionWakeNotice(env)} · events: ${events}`);
|
|
216
234
|
}
|
|
217
235
|
|
|
218
236
|
/** @param {EnvReport} report @returns {EnvCheck[]} the checks that block a dispatch */
|
|
@@ -381,6 +399,106 @@ export function reachableRuntimes(contract) {
|
|
|
381
399
|
return runtimes;
|
|
382
400
|
}
|
|
383
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
|
+
|
|
384
502
|
const HARNESS_BIN_OVERRIDES = Object.freeze({
|
|
385
503
|
codex: "FABERUN_CODEX_BIN",
|
|
386
504
|
claude: "FABERUN_CLAUDE_BIN",
|
|
@@ -390,9 +508,17 @@ const HARNESS_BIN_OVERRIDES = Object.freeze({
|
|
|
390
508
|
});
|
|
391
509
|
|
|
392
510
|
/**
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
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.
|
|
396
522
|
*
|
|
397
523
|
* @param {string|undefined} contractPath
|
|
398
524
|
* @param {{cwd?: string, json?: boolean, discover?: boolean}} values
|
|
@@ -400,7 +526,7 @@ const HARNESS_BIN_OVERRIDES = Object.freeze({
|
|
|
400
526
|
*/
|
|
401
527
|
export async function doctorCommand(contractPath, values) {
|
|
402
528
|
const repoDir = resolve(values.cwd ?? ".");
|
|
403
|
-
/** @type {{name: string, ok: boolean, detail: string}[]} */
|
|
529
|
+
/** @type {{name: string, ok: boolean, advisory?: boolean, detail: string}[]} */
|
|
404
530
|
const checks = [];
|
|
405
531
|
const gitRepo = isGitWorkTree(repoDir);
|
|
406
532
|
checks.push({ name: "git repository", ok: gitRepo, detail: gitRepo ? repoDir : "not inside a git work tree" });
|
|
@@ -454,6 +580,10 @@ export async function doctorCommand(contractPath, values) {
|
|
|
454
580
|
harnessVersions[id] = probe.version;
|
|
455
581
|
checks.push({ name: `harness ${probe.id ?? runtime.harness}`, ok: probe.ok, detail: probe.detail ?? (probe.ok ? "ok" : "probe failed") });
|
|
456
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));
|
|
457
587
|
} catch (error) {
|
|
458
588
|
checks.push({ name: "contract", ok: false, detail: errorMessage(error) });
|
|
459
589
|
}
|
|
@@ -494,7 +624,12 @@ export async function doctorCommand(contractPath, values) {
|
|
|
494
624
|
for (const check of environmentPreflight({ cwd: dispatchCwd, runtimes: routedRuntimes, harnessVersions }).checks) {
|
|
495
625
|
checks.push({ name: check.name, ok: check.ok || check.advisory, detail: check.ok ? check.detail : `${check.detail} (advisory)` });
|
|
496
626
|
}
|
|
497
|
-
|
|
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);
|
|
498
633
|
const transportWarning = noTransportWarning(process.env);
|
|
499
634
|
if (transportWarning) process.stderr.write(`${statusToken("warn", colorLevel(process.env, process.stderr.isTTY))} ${transportWarning}\n`);
|
|
500
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
|
/**
|
|
@@ -86,7 +87,59 @@ const MACOS_TRANSPORT = "os-macos";
|
|
|
86
87
|
* fixtures its worker ran. `withoutNotifyEnv` is the boundary every child
|
|
87
88
|
* crosses; `test/setup.mjs` neutralises the same names inside the suite.
|
|
88
89
|
*/
|
|
89
|
-
export const
|
|
90
|
+
export const NOTIFY_EVENTS_ENV = "FABERUN_NOTIFY_EVENTS";
|
|
91
|
+
export const NOTIFY_LANG_ENV = "FABERUN_NOTIFY_LANG";
|
|
92
|
+
export const NOTIFY_ENV_NAMES = Object.freeze([NOTIFY_BIN_ENV, NOTIFY_SESSION_ENV, NOTIFY_EVENTS_ENV, NOTIFY_LANG_ENV]);
|
|
93
|
+
|
|
94
|
+
/** Every event type the dispatcher can be asked to deliver. */
|
|
95
|
+
export const NOTIFY_EVENT_TYPES = Object.freeze(["node.terminal", "run.terminal", "attention", "advisory"]);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* What leaves the controller when `FABERUN_NOTIFY_EVENTS` is unset: a phase
|
|
99
|
+
* settling, a node that waits on a person, and an advisory threshold the
|
|
100
|
+
* operator declared. A node settling stays in `notify.jsonl` as a `filtered`
|
|
101
|
+
* receipt. The operator's own words, 2026-09-22, after a run of two nodes
|
|
102
|
+
* produced four wake-ups: "só fechamento de fase e atenção acordam, nó
|
|
103
|
+
* individual fica no log" -- and before that, after the phone flood, "só
|
|
104
|
+
* milestones e fechamentos de fase". Measured on the campaign that prompted
|
|
105
|
+
* it: five phases of two or three nodes would be about 20 messages with every
|
|
106
|
+
* event, about 7 with these.
|
|
107
|
+
*/
|
|
108
|
+
export const DEFAULT_NOTIFY_EVENTS = Object.freeze(["run.terminal", "attention", "advisory"]);
|
|
109
|
+
|
|
110
|
+
/** The two languages the message renders its wording in; `FABERUN_NOTIFY_LANG` may name either. */
|
|
111
|
+
export const NOTIFY_LANGUAGES = Object.freeze(["en", "pt"]);
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The event types the environment lets out, as a set. Unknown items are left
|
|
115
|
+
* out here and reported by `notifySettingProblems`; an empty value is the
|
|
116
|
+
* default, never "nothing".
|
|
117
|
+
*
|
|
118
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
119
|
+
* @returns {Set<string>}
|
|
120
|
+
*/
|
|
121
|
+
export function deliverableEventTypes(env = process.env) {
|
|
122
|
+
const items = (env[NOTIFY_EVENTS_ENV] ?? "").split(",").map((item) => item.trim()).filter((item) => item.length > 0);
|
|
123
|
+
return new Set(items.length ? items.filter((item) => NOTIFY_EVENT_TYPES.includes(item)) : DEFAULT_NOTIFY_EVENTS);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Every notify setting the environment gets wrong, one sentence each, for
|
|
128
|
+
* `doctor` and `preflight`. Empty when everything parses.
|
|
129
|
+
*
|
|
130
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
131
|
+
* @returns {string[]}
|
|
132
|
+
*/
|
|
133
|
+
export function notifySettingProblems(env = process.env) {
|
|
134
|
+
const problems = [];
|
|
135
|
+
const events = (env[NOTIFY_EVENTS_ENV] ?? "").split(",").map((item) => item.trim()).filter((item) => item.length > 0);
|
|
136
|
+
for (const item of events) {
|
|
137
|
+
if (!NOTIFY_EVENT_TYPES.includes(item)) problems.push(`${NOTIFY_EVENTS_ENV} item "${item}" is not one of ${NOTIFY_EVENT_TYPES.join(", ")}`);
|
|
138
|
+
}
|
|
139
|
+
const lang = (env[NOTIFY_LANG_ENV] ?? "").trim();
|
|
140
|
+
if (lang && !NOTIFY_LANGUAGES.includes(lang)) problems.push(`${NOTIFY_LANG_ENV}=${lang} is not one of ${NOTIFY_LANGUAGES.join(", ")}`);
|
|
141
|
+
return problems;
|
|
142
|
+
}
|
|
90
143
|
|
|
91
144
|
/**
|
|
92
145
|
* @param {NodeJS.ProcessEnv} env
|
|
@@ -156,7 +209,7 @@ export const NOTIFY_NO_TRANSPORT_WARNING = "no human notification transport is c
|
|
|
156
209
|
/** @typedef {Record<string, unknown>} JsonObject */
|
|
157
210
|
/** @typedef {{schemaVersion: number, eventId: string, at: string, type: string, campaignId: string|null, runId: string|null, nodeId: string|null, status: string|null, errorCode: string|null, dedupeKey: string, summary: string}} InboxEntry */
|
|
158
211
|
/** @typedef {{type: string, dedupeKey: string, summary: string, at?: string, campaignId?: string|null, runId?: string|null, nodeId?: string|null, status?: string|null, errorCode?: string|null}} InboxEvent */
|
|
159
|
-
/** @typedef {{type: "node.terminal"|"run.terminal"|"attention", runId: string|null, campaignId?: string|null, nodeId?: string|null, status?: string|null, attempt?: number|null, errorCode?: string|null, done?: number|null, total?: number|null, dedupeKey?: string|null, runDir?: string|null, costUsd?: number|null, summary?: string|null, eventId?: string}} NotifyEvent */
|
|
212
|
+
/** @typedef {{type: "node.terminal"|"run.terminal"|"attention"|"advisory", runId: string|null, campaignId?: string|null, nodeId?: string|null, status?: string|null, attempt?: number|null, errorCode?: string|null, done?: number|null, total?: number|null, dedupeKey?: string|null, runDir?: string|null, costUsd?: number|null, summary?: string|null, eventId?: string}} NotifyEvent */
|
|
160
213
|
/** @typedef {{id: string, ok: boolean, error?: string}} TransportOutcome one transport's own outcome, named so the receipt says which took the message */
|
|
161
214
|
/** @typedef {{ok: boolean, error?: string, noTransport?: boolean, transports?: TransportOutcome[]}} DeliveryResult */
|
|
162
215
|
|
|
@@ -432,7 +485,12 @@ function spawnDeliver(bin, event, { spawn = defaultSpawn, timeoutMs = notificati
|
|
|
432
485
|
return new Promise((resolveDelivery) => {
|
|
433
486
|
let child;
|
|
434
487
|
try {
|
|
435
|
-
|
|
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 });
|
|
436
494
|
} catch (error) {
|
|
437
495
|
resolveDelivery({ ok: false, error: errorMessage(error) });
|
|
438
496
|
return;
|
|
@@ -503,14 +561,22 @@ export class NotifyQueue {
|
|
|
503
561
|
// one), so every delivery carries a stable id derived from the dedupe key.
|
|
504
562
|
const eventId = enriched.eventId
|
|
505
563
|
?? createHash("sha256").update(enriched.dedupeKey ?? JSON.stringify(enriched)).digest("hex");
|
|
564
|
+
// An event type the environment keeps out of every transport is still a
|
|
565
|
+
// receipt -- `filtered`, with the rendered summary -- so the log says what
|
|
566
|
+
// happened to it, and the resume's dedupe sees it as already handled.
|
|
567
|
+
const filtered = !deliverableEventTypes().has(enriched.type);
|
|
506
568
|
/** @type {DeliveryResult} */
|
|
507
569
|
let result;
|
|
508
|
-
|
|
509
|
-
result =
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
570
|
+
if (filtered) {
|
|
571
|
+
result = { ok: false, transports: [] };
|
|
572
|
+
} else {
|
|
573
|
+
try {
|
|
574
|
+
result = await this.deliver({ ...enriched, summary, eventId });
|
|
575
|
+
} catch (error) {
|
|
576
|
+
// A transport that rejects is a failed delivery, not a controller fault:
|
|
577
|
+
// the receipt is still appended and the failure is dropped like any other.
|
|
578
|
+
result = { ok: false, error: errorMessage(error) };
|
|
579
|
+
}
|
|
514
580
|
}
|
|
515
581
|
/** @type {JsonObject} */
|
|
516
582
|
const receipt = {
|
|
@@ -525,13 +591,13 @@ export class NotifyQueue {
|
|
|
525
591
|
dedupeKey: enriched.dedupeKey ?? null,
|
|
526
592
|
summary,
|
|
527
593
|
attempt: 1,
|
|
528
|
-
status: result.ok ? "delivered" : result.noTransport ? "no_transport" : "failed",
|
|
594
|
+
status: filtered ? "filtered" : result.ok ? "delivered" : result.noTransport ? "no_transport" : "failed",
|
|
529
595
|
// One entry per bound transport, so a receipt that says `delivered`
|
|
530
596
|
// also says whether the phone, the session, or both took the message.
|
|
531
597
|
transports: result.transports ?? [],
|
|
532
598
|
at: new Date(this.now()).toISOString(),
|
|
533
599
|
};
|
|
534
|
-
if (!result.ok && !result.noTransport) receipt.error = result.error ?? null;
|
|
600
|
+
if (!filtered && !result.ok && !result.noTransport) receipt.error = result.error ?? null;
|
|
535
601
|
appendFileSync(join(this.runDir, NOTIFY_LOG_FILE), `${JSON.stringify(receipt)}\n`);
|
|
536
602
|
}
|
|
537
603
|
}
|