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
|
@@ -31,6 +31,15 @@ ALLOWANCE_WARN_PCT=85
|
|
|
31
31
|
|
|
32
32
|
session=$(cat)
|
|
33
33
|
repo=$(printf '%s' "$session" | sed -n 's/.*"cwd":"\([^"]*\)".*/\1/p;s/.*"current_dir":"\([^"]*\)".*/\1/p' | head -n 1)
|
|
34
|
+
# A Windows session names its cwd with backslashes, and JSON doubles them.
|
|
35
|
+
# Three forms of one path, because three readers want different ones:
|
|
36
|
+
# repo_json as it appears in the file -- what a fixed-string grep matches
|
|
37
|
+
# repo the path itself -- what jq compares a parsed key to
|
|
38
|
+
# repo_path with forward slashes -- what a POSIX shell can stat
|
|
39
|
+
# On a host with no backslashes in its paths all three are the same string.
|
|
40
|
+
repo_json=$repo
|
|
41
|
+
repo=$(printf '%s' "$repo_json" | sed 's|\\\\|\\|g')
|
|
42
|
+
repo_path=$(printf '%s' "$repo" | tr '\\' '/')
|
|
34
43
|
# The pointer follows the state (R2): the project registry under the faberun
|
|
35
44
|
# home maps the repository's resolved path to an opaque id, and the pointer
|
|
36
45
|
# sits in that project's runs directory. A repository whose runs never moved
|
|
@@ -40,6 +49,7 @@ repo=$(printf '%s' "$session" | sed -n 's/.*"cwd":"\([^"]*\)".*/\1/p;s/.*"curren
|
|
|
40
49
|
# no glob, no newest-by-mtime. That is the half of R6 already true and to
|
|
41
50
|
# keep; the one machine-wide pointer is a later phase, not this lookup.
|
|
42
51
|
home=${FABERUN_HOME:-$HOME/.faberun}
|
|
52
|
+
home=$(printf '%s' "$home" | tr '\\' '/')
|
|
43
53
|
index="$home/projects/index.json"
|
|
44
54
|
id=
|
|
45
55
|
if [ -n "$repo" ] && [ -f "$index" ]; then
|
|
@@ -50,10 +60,10 @@ if [ -n "$repo" ] && [ -f "$index" ]; then
|
|
|
50
60
|
# fixed-string grep for the quoted key picks exactly that one line and
|
|
51
61
|
# the id follows its colon. Fixed-string on purpose: a repo path is
|
|
52
62
|
# data, not a regex.
|
|
53
|
-
id=$(grep -F "\"$
|
|
63
|
+
id=$(grep -F "\"$repo_json\"" "$index" 2>/dev/null | sed -n 's/^.*:[[:space:]]*"\([^"]*\)".*/\1/p')
|
|
54
64
|
fi
|
|
55
65
|
fi
|
|
56
|
-
pointer="$
|
|
66
|
+
pointer="$repo_path/.runs/status.json"
|
|
57
67
|
if [ -n "$id" ] && [ -d "$home/projects/$id/runs" ]; then
|
|
58
68
|
pointer="$home/projects/$id/runs/status.json"
|
|
59
69
|
fi
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"scripts": {
|
|
27
27
|
"check": "node -e \"const{readdirSync}=require('node:fs');const{spawnSync}=require('node:child_process');const roots=['bin','.claude/hooks','src','evals','test'];const files=roots.flatMap(r=>readdirSync(r,{recursive:true}).map(String).filter(p=>p.endsWith('.mjs')).map(p=>r+'/'+p));for(const f of files)if(spawnSync(process.execPath,['--check',f],{stdio:'inherit'}).status!==0)process.exit(1);console.log(files.length+' files checked')\"",
|
|
28
28
|
"typecheck": "tsc",
|
|
29
|
-
"test": "node --test --import ./test/scoped-home.mjs --import ./test/setup.mjs test/*.test.mjs test/*/*.test.mjs",
|
|
29
|
+
"test": "node --test --import ./test/scoped-home.mjs --import ./test/git-env.mjs --import ./test/setup.mjs test/*.test.mjs test/*/*.test.mjs",
|
|
30
30
|
"docs": "node src/cli/manual.mjs --write",
|
|
31
31
|
"docs:check": "node src/cli/manual.mjs --check",
|
|
32
32
|
"prepare": "husky"
|
|
@@ -95,9 +95,17 @@ for f in CLAUDE.md GEMINI.md CURSOR.md AGENT.md; do
|
|
|
95
95
|
fi
|
|
96
96
|
if [ "$DRY_RUN" = 0 ]; then
|
|
97
97
|
mkdir -p "$(dirname "$link")"
|
|
98
|
-
|
|
98
|
+
# MSYS's `ln -s` copies the file instead of linking it unless told
|
|
99
|
+
# otherwise, which is exactly how a pointer becomes the real file this
|
|
100
|
+
# script exists to prevent. `winsymlinks:nativestrict` asks for a real
|
|
101
|
+
# symlink and fails rather than copying when the host cannot make one
|
|
102
|
+
# (no Developer Mode, no privilege); the plain `ln -s` after it is that
|
|
103
|
+
# host's honest fallback, and a no-op variable everywhere else.
|
|
104
|
+
( cd "$(dirname "$link")" && rm -f "$(basename "$f")" \
|
|
105
|
+
&& { MSYS=winsymlinks:nativestrict ln -s "$dest" "$(basename "$f")" 2>/dev/null \
|
|
106
|
+
|| ln -s "$dest" "$(basename "$f")"; } )
|
|
99
107
|
fi
|
|
100
|
-
say " + $f → $dest" "$G"
|
|
108
|
+
if [ -L "$link" ]; then say " + $f → $dest" "$G"; else say " + $f (copy of $dest; this host makes no symlink)" "$Y"; fi
|
|
101
109
|
done
|
|
102
110
|
|
|
103
111
|
# --- git hook via core.hooksPath --------------------------------------------
|
package/src/campaign/chain.mjs
CHANGED
|
@@ -42,6 +42,7 @@ import { pidAlive, processStartToken } from "../run/lock.mjs";
|
|
|
42
42
|
import { delay, errorCode, errorMessage } from "../util.mjs";
|
|
43
43
|
import { writeJsonAtomic } from "../run/store.mjs";
|
|
44
44
|
import { runDirectory } from "../run/paths.mjs";
|
|
45
|
+
import { gitArguments, killTarget } from "../host/platform.mjs";
|
|
45
46
|
|
|
46
47
|
/** @typedef {import("../contract/index.mjs").ControllerIdentity} ControllerIdentity */
|
|
47
48
|
/** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
@@ -140,7 +141,7 @@ function groupKill(pid, signal) {
|
|
|
140
141
|
if (errorCode(error) !== "ESRCH") throw error;
|
|
141
142
|
}
|
|
142
143
|
}
|
|
143
|
-
|
|
144
|
+
killTarget(pid, signal);
|
|
144
145
|
} catch (error) {
|
|
145
146
|
if (errorCode(error) !== "ESRCH") throw error;
|
|
146
147
|
}
|
|
@@ -235,7 +236,7 @@ function landBranchRef(repo, landBranch) {
|
|
|
235
236
|
*/
|
|
236
237
|
function gitHead(repo, ref) {
|
|
237
238
|
try {
|
|
238
|
-
return execFileSync("git", ["-C", repo, "rev-parse", ref], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
|
|
239
|
+
return execFileSync("git", gitArguments(["-C", repo, "rev-parse", ref]), { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
|
|
239
240
|
} catch {
|
|
240
241
|
return null;
|
|
241
242
|
}
|
|
@@ -269,7 +270,7 @@ export function validateContractAgainstRef(raw, contractPath, context = {}) {
|
|
|
269
270
|
const originalCwd = resolve(dirname(contractPath), typeof raw.cwd === "string" ? raw.cwd : ".");
|
|
270
271
|
const worktree = mkdtempSync(join(tmpdir(), "runner-chain-ref-"));
|
|
271
272
|
try {
|
|
272
|
-
execFileSync("git", ["-C", repo, "worktree", "add", "--detach", worktree, baseRef], { stdio: ["ignore", "pipe", "pipe"] });
|
|
273
|
+
execFileSync("git", gitArguments(["-C", repo, "worktree", "add", "--detach", worktree, baseRef]), { stdio: ["ignore", "pipe", "pipe"] });
|
|
273
274
|
const relativedCwd = relative(repo, originalCwd);
|
|
274
275
|
const mappedCwd = relativedCwd && !relativedCwd.startsWith("..") ? join(worktree, relativedCwd) : worktree;
|
|
275
276
|
const relativeContract = relative(repo, contractPath);
|
|
@@ -283,7 +284,7 @@ export function validateContractAgainstRef(raw, contractPath, context = {}) {
|
|
|
283
284
|
return contract;
|
|
284
285
|
} finally {
|
|
285
286
|
try {
|
|
286
|
-
execFileSync("git", ["-C", repo, "worktree", "remove", "--force", worktree], { stdio: ["ignore", "pipe", "ignore"] });
|
|
287
|
+
execFileSync("git", gitArguments(["-C", repo, "worktree", "remove", "--force", worktree]), { stdio: ["ignore", "pipe", "ignore"] });
|
|
287
288
|
} catch {
|
|
288
289
|
// The ref-based validation is done; a cleanup failure must not mask it.
|
|
289
290
|
}
|
package/src/cli/launch.mjs
CHANGED
|
@@ -64,7 +64,16 @@ export function detachArgv(argv, options = {}) {
|
|
|
64
64
|
const child = /** @type {DetachedChild} */ (spawn(process.execPath, [CLI_ENTRY, ...argv], {
|
|
65
65
|
cwd: process.cwd(),
|
|
66
66
|
env: { ...process.env, ...options.env, FABERUN_BOOTSTRAP_NONCE: nonce },
|
|
67
|
-
|
|
67
|
+
// Detached on every platform, for a different reason on each. POSIX: a
|
|
68
|
+
// new session, so the controller survives the launcher's terminal and
|
|
69
|
+
// owns a process group its own children can be killed by. Windows: libuv
|
|
70
|
+
// puts every non-detached child in a job object that is killed when the
|
|
71
|
+
// parent exits, so without this the controller dies the instant the
|
|
72
|
+
// launcher returns -- measured 2026-09-21: `run --detach` bootstrapped to
|
|
73
|
+
// `ready` and then died with its launcher, every time, leaving the node
|
|
74
|
+
// pending forever. There it also means DETACHED_PROCESS: no console
|
|
75
|
+
// window, which is what "ignore" stdio already implies.
|
|
76
|
+
detached: true,
|
|
68
77
|
stdio: "ignore",
|
|
69
78
|
}));
|
|
70
79
|
child.unref();
|
package/src/cli.mjs
CHANGED
|
@@ -46,7 +46,7 @@ import { cancelRun } from "./engine/cancel.mjs";
|
|
|
46
46
|
|
|
47
47
|
import { errorMessage } from "./util.mjs";
|
|
48
48
|
import { validateContract } from "./contract/index.mjs";
|
|
49
|
-
import { setLaunchBaseRef } from "./engine/run-identity.mjs";
|
|
49
|
+
import { setLaunchBaseRef, setFreshPreflight } from "./engine/run-identity.mjs";
|
|
50
50
|
import { assertLaunchBaseClean } from "./repo/source-identity.mjs";
|
|
51
51
|
import { detachSelf, waitForBootstrap, writeBootstrapFailure } from "./cli/launch.mjs";
|
|
52
52
|
import { DEFAULT_SUPERVISE_INTERVAL_SEC, superviseRun } from "./engine/supervise.mjs";
|
|
@@ -96,8 +96,8 @@ export function hasDetachedBootstrapNonce() {
|
|
|
96
96
|
|
|
97
97
|
/** @type {Record<string, import("node:util").ParseArgsOptionsConfig>} */
|
|
98
98
|
export const COMMAND_OPTIONS = {
|
|
99
|
-
run: { detach: { type: "boolean" }, "base-ref": { type: "string" } },
|
|
100
|
-
resume: { detach: { type: "boolean" }, node: { type: "string" }, reconcile: { type: "string" }, answer: { type: "string" } },
|
|
99
|
+
run: { detach: { type: "boolean" }, "base-ref": { type: "string" }, "fresh-preflight": { type: "boolean" } },
|
|
100
|
+
resume: { detach: { type: "boolean" }, node: { type: "string" }, reconcile: { type: "string" }, answer: { type: "string" }, "fresh-preflight": { type: "boolean" } },
|
|
101
101
|
supervise: { detach: { type: "boolean" }, interval: { type: "string" } },
|
|
102
102
|
cancel: {},
|
|
103
103
|
preflight: { static: { type: "boolean" }, json: { type: "boolean" }, "time-verification": { type: "boolean" } },
|
|
@@ -323,6 +323,7 @@ async function main(argv) {
|
|
|
323
323
|
const contract = validateContractForLaunch(JSON.parse(readFileSync(absolute, "utf8")), absolute, { baseRef });
|
|
324
324
|
const runDir = runDirectory(contract.cwd, contract.id);
|
|
325
325
|
setLaunchBaseRef(baseRef);
|
|
326
|
+
setFreshPreflight(values["fresh-preflight"] === true);
|
|
326
327
|
// The base is what every worktree is cut from; a dirty tree only blocks
|
|
327
328
|
// when the cwd HEAD *is* that base. A `--base-ref` elsewhere leaves the
|
|
328
329
|
// operator's checkout out of the run entirely. The contract file being
|
|
@@ -334,7 +335,10 @@ async function main(argv) {
|
|
|
334
335
|
if (values.detach === true) {
|
|
335
336
|
if (existsSync(runDir)) throw new Error(`run already exists: ${runDir}`);
|
|
336
337
|
for (const warning of [...contract.warnings, ...reusedDoneWarnings(contract)]) process.stdout.write(`${advisoryToken()} ${warning}\n`);
|
|
337
|
-
const child = detachSelf("run", target,
|
|
338
|
+
const child = detachSelf("run", target, [
|
|
339
|
+
...(baseRef ? ["--base-ref", baseRef] : []),
|
|
340
|
+
...(values["fresh-preflight"] === true ? ["--fresh-preflight"] : []),
|
|
341
|
+
]);
|
|
338
342
|
const pid = child.pid;
|
|
339
343
|
if (pid === undefined) throw new Error("detached child has no pid");
|
|
340
344
|
await waitForBootstrap(runDir, pid, child);
|
|
@@ -348,6 +352,7 @@ async function main(argv) {
|
|
|
348
352
|
}
|
|
349
353
|
if (command === "resume") {
|
|
350
354
|
const resumeOptions = resumeOptionsOf(values);
|
|
355
|
+
setFreshPreflight(values["fresh-preflight"] === true);
|
|
351
356
|
if (values.detach === true) {
|
|
352
357
|
const runDir = resolve(target);
|
|
353
358
|
if (!existsSync(join(runDir, "contract.json"))) throw new Error(`not a run directory: ${runDir}`);
|
|
@@ -355,6 +360,7 @@ async function main(argv) {
|
|
|
355
360
|
...(resumeOptions.node ? ["--node", resumeOptions.node] : []),
|
|
356
361
|
...(resumeOptions.reconcile ? ["--reconcile", resumeOptions.reconcile] : []),
|
|
357
362
|
...(resumeOptions.answer ? ["--answer", `${resumeOptions.answer.node}=${resumeOptions.answer.path}`] : []),
|
|
363
|
+
...(values["fresh-preflight"] === true ? ["--fresh-preflight"] : []),
|
|
358
364
|
];
|
|
359
365
|
const child = detachSelf("resume", target, extraArgs);
|
|
360
366
|
const pid = child.pid;
|
package/src/contract/index.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { assertObject, boundedString, nonNegativeInteger, nonNegativeNumber, pos
|
|
|
15
15
|
import { validateMetadata } from "./schema-version.mjs";
|
|
16
16
|
import { assertRuntimeExecutesCommands, requireRuntime, validateRuntime } from "./runtime.mjs";
|
|
17
17
|
import { validateSourceIdentity } from "../repo/source-identity.mjs";
|
|
18
|
-
import { commandCoverageWarnings, unsnapshottedWriteWarnings } from "../repo/declared-paths.mjs";
|
|
18
|
+
import { commandCoverageWarnings, mirrorCoverageWarnings, unsnapshottedWriteWarnings } from "../repo/declared-paths.mjs";
|
|
19
19
|
import { crossNodeScopeFindings, scopeClosureFindings } from "../repo/scope-closure.mjs";
|
|
20
20
|
|
|
21
21
|
export { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION } from "../harnesses/index.mjs";
|
|
@@ -368,8 +368,15 @@ export function validateContract(raw, contractPath, options = {}) {
|
|
|
368
368
|
throw new TypeError(`task packet scope does not close; declare in readFiles or writeFiles, or acknowledge in scopeAcknowledged: ${scopeErrors.join("; ")}`);
|
|
369
369
|
}
|
|
370
370
|
|
|
371
|
+
// Validated here rather than inline below, because the mirror warning reads
|
|
372
|
+
// them: a layer covered by a shared or final command is covered.
|
|
373
|
+
const finalVerification = validateFinalVerification(raw.finalVerification, "contract.finalVerification");
|
|
374
|
+
const sharedVerification = validateSharedVerification(raw.sharedVerification, "contract.sharedVerification");
|
|
375
|
+
const contractCommands = [...finalVerification ?? [], ...sharedVerification ?? []].map((command) => command.argv.join(" "));
|
|
376
|
+
const contractWrites = new Set(nodes.flatMap((node) => node.taskPacket.writeFiles ?? []));
|
|
371
377
|
const warnings = nodes.flatMap((node, index) => [
|
|
372
378
|
...commandCoverageWarnings(node, index),
|
|
379
|
+
...(persisted ? [] : mirrorCoverageWarnings(node, index, cwd, contractCommands, contractWrites)),
|
|
373
380
|
...(persisted ? [] : unsnapshottedWriteWarnings(node, index, cwd)),
|
|
374
381
|
...(persisted ? [] : writeFileLineBudgetWarnings(node, index, cwd)),
|
|
375
382
|
]);
|
|
@@ -394,8 +401,8 @@ export function validateContract(raw, contractPath, options = {}) {
|
|
|
394
401
|
// count, because it began with 200k tokens of context instead of 45k and
|
|
395
402
|
// re-read them on every request.
|
|
396
403
|
phaseSessionReuse: booleanField(raw.phaseSessionReuse, false, "contract.phaseSessionReuse"),
|
|
397
|
-
finalVerification
|
|
398
|
-
sharedVerification
|
|
404
|
+
finalVerification,
|
|
405
|
+
sharedVerification,
|
|
399
406
|
nodeAdvisory: validateNodeAdvisory(raw.nodeAdvisory),
|
|
400
407
|
warnings,
|
|
401
408
|
});
|
|
@@ -688,6 +695,12 @@ function dependencyCoversPath(closure, path, cwd) {
|
|
|
688
695
|
function writeFileLineBudgetWarnings(node, index, cwd) {
|
|
689
696
|
const warnings = [];
|
|
690
697
|
for (const path of node.taskPacket.writeFiles ?? []) {
|
|
698
|
+
// The ceiling is a rule about source modules, and `source-shape` enforces
|
|
699
|
+
// it over `.mjs` alone. Measured 2026-09-22: a packet declaring the
|
|
700
|
+
// generated `docs/COMMANDS.md` was warned that 1141 lines left "-341 from
|
|
701
|
+
// the 800-line ceiling", which is not a budget, not true of that file, and
|
|
702
|
+
// trains the reader to skim past the warnings that are.
|
|
703
|
+
if (!path.endsWith(".mjs")) continue;
|
|
691
704
|
let text;
|
|
692
705
|
try {
|
|
693
706
|
text = readFileSync(resolve(cwd, path), "utf8");
|
|
@@ -325,6 +325,14 @@ function validateRelativePath(path, label, cwd, mustExist, options = {}) {
|
|
|
325
325
|
}
|
|
326
326
|
if (!pathInside(realAnchor, realCwd)) throw new TypeError(`${label} escapes cwd`);
|
|
327
327
|
|
|
328
|
+
if (!existsSync(absolute) && anchor !== absolute && statSync(anchor).isFile()) {
|
|
329
|
+
// Nothing can ever appear beneath a regular file, so this path is refused
|
|
330
|
+
// outright rather than deferred to a dependency that might produce it.
|
|
331
|
+
// Only POSIX states that through an errno (ENOTDIR, from the walk above);
|
|
332
|
+
// Windows reports the same layout as a plain absence, so the shape is
|
|
333
|
+
// asked here rather than read off a platform error code.
|
|
334
|
+
throw new TypeError(`${label} is under a file, not a directory: ${path}`);
|
|
335
|
+
}
|
|
328
336
|
if (!mustExist && !existsSync(absolute)) return;
|
|
329
337
|
if (!existsSync(absolute)) {
|
|
330
338
|
if (options.deferMissing) return DEFERRED_MISSING;
|
|
@@ -390,7 +398,11 @@ function findExistingPath(path) {
|
|
|
390
398
|
lstatSync(current);
|
|
391
399
|
return current;
|
|
392
400
|
} catch (error) {
|
|
393
|
-
|
|
401
|
+
// A path whose parent is a regular file is absent, not unreadable:
|
|
402
|
+
// POSIX says ENOTDIR where Windows says ENOENT, and both mean the same
|
|
403
|
+
// thing -- keep walking up to the ancestor that does exist.
|
|
404
|
+
const code = errorCode(error);
|
|
405
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
|
|
394
406
|
}
|
|
395
407
|
const parent = dirname(current);
|
|
396
408
|
if (parent === current) return null;
|
package/src/engine/bulk-read.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { DECLARED_MODEL_CATALOGUES, stableJsonDocument } from "../harnesses/cata
|
|
|
16
16
|
import { READ_LINE_LIMIT, normalizeProviderResult, providerCommand } from "../harnesses/index.mjs";
|
|
17
17
|
import { appendUsageRecord, emptyUsage, priceUsage } from "../run/usage.mjs";
|
|
18
18
|
import { errorMessage, fail } from "../util.mjs";
|
|
19
|
+
import { spawnInvocation } from "../host/platform.mjs";
|
|
19
20
|
import { DISCOVERY_RUNTIME_DEFINITIONS } from "./runtime-discovery.mjs";
|
|
20
21
|
|
|
21
22
|
/** Fixed-size copy buffer: the pack is built through this, never through a corpus-sized string. */
|
|
@@ -178,10 +179,15 @@ function invokeDelegation(runtime, prompt, cwd) {
|
|
|
178
179
|
/** @type {import("node:child_process").ChildProcess} */
|
|
179
180
|
let child;
|
|
180
181
|
try {
|
|
181
|
-
|
|
182
|
+
// The provider binary, reached the way this platform reaches one: a
|
|
183
|
+
// delegation that spawns it raw is ENOENT on Windows against the very
|
|
184
|
+
// CLI the run is configured to use.
|
|
185
|
+
const invocation = spawnInvocation(command.executable, command.args, { cwd });
|
|
186
|
+
child = spawn(invocation.command, invocation.args, {
|
|
182
187
|
cwd,
|
|
183
188
|
env: environmentWith(command.env),
|
|
184
189
|
stdio: ["pipe", "pipe", "pipe"],
|
|
190
|
+
...invocation.options,
|
|
185
191
|
});
|
|
186
192
|
} catch (error) {
|
|
187
193
|
observation.spawnError = errorMessage(error);
|
package/src/engine/gate.mjs
CHANGED
|
@@ -31,6 +31,7 @@ import { existsSync, readFileSync, statSync, openSync, closeSync, readSync, writ
|
|
|
31
31
|
import { dirname } from "node:path";
|
|
32
32
|
import { spawn } from "node:child_process";
|
|
33
33
|
import { NOTIFY_ENV_NAMES } from "../notify/index.mjs";
|
|
34
|
+
import { killTarget, spawnInvocation } from "../host/platform.mjs";
|
|
34
35
|
|
|
35
36
|
/** @typedef {{executable: string, args: string[], cwd: string, promptTransport: "stdin"|"argv", harness: string, env: Record<string, string|null>|null, stdoutPath: string, stderrPath: string}} GateConfig */
|
|
36
37
|
|
|
@@ -92,13 +93,24 @@ if (config.promptTransport === "stdin") {
|
|
|
92
93
|
|
|
93
94
|
/** @param {NodeJS.Signals} signal */
|
|
94
95
|
function killGroup(signal) {
|
|
95
|
-
try {
|
|
96
|
+
try {
|
|
97
|
+
// `killTarget` reads the negative pid as the group on POSIX and as the tree
|
|
98
|
+
// to walk on Windows, which has no group to signal.
|
|
99
|
+
killTarget(-process.pid, signal);
|
|
100
|
+
} catch {
|
|
96
101
|
// ESRCH: the process group is already gone, so there is nothing to signal.
|
|
97
102
|
}
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
function stopProvider() {
|
|
101
|
-
try {
|
|
106
|
+
try {
|
|
107
|
+
// The tree, not the process: on Windows a harness installed as a `.cmd` is
|
|
108
|
+
// reached through the command interpreter, so the provider this gate holds
|
|
109
|
+
// is `cmd.exe` and the harness is its child. Killing the one it spawned
|
|
110
|
+
// leaves the other running — measured 2026-09-20, as stranded `node`
|
|
111
|
+
// processes outliving a suite that had already stopped waiting for them.
|
|
112
|
+
if (provider?.pid) killTarget(provider.pid, "SIGTERM");
|
|
113
|
+
} catch {
|
|
102
114
|
// No provider yet, or it already exited: a failed SIGTERM needs no action.
|
|
103
115
|
}
|
|
104
116
|
setTimeout(() => killGroup("SIGKILL"), 100).unref();
|
|
@@ -181,10 +193,12 @@ const timer = setInterval(() => {
|
|
|
181
193
|
clearInterval(timer);
|
|
182
194
|
const stdoutFd = openSync(config.stdoutPath, "wx", 0o600);
|
|
183
195
|
const stderrFd = openSync(config.stderrPath, "wx", 0o600);
|
|
184
|
-
|
|
196
|
+
const invocation = spawnInvocation(config.executable, config.args, { cwd: config.cwd });
|
|
197
|
+
provider = spawn(invocation.command, invocation.args, {
|
|
185
198
|
cwd: config.cwd,
|
|
186
199
|
env: childEnv(),
|
|
187
200
|
stdio: [config.promptTransport === "stdin" ? "pipe" : "ignore", stdoutFd, stderrFd],
|
|
201
|
+
...invocation.options,
|
|
188
202
|
});
|
|
189
203
|
if (config.promptTransport === "stdin") {
|
|
190
204
|
for (const chunk of pendingInput) provider.stdin?.write(chunk);
|
|
@@ -15,6 +15,7 @@ import { isAbsolute, resolve } from "node:path";
|
|
|
15
15
|
import { reviewMode, UNCITED_REJECTION_REASON } from "../contract/review-modes.mjs";
|
|
16
16
|
import { JUDGE_LIMITS } from "../contract/judge-envelope.mjs";
|
|
17
17
|
import { sharedVerificationCommands } from "../contract/final-verification.mjs";
|
|
18
|
+
import { killTarget } from "../host/platform.mjs";
|
|
18
19
|
|
|
19
20
|
/** @typedef {import("../contract/definition-of-done.mjs").DefinitionOfDoneItem} DefinitionOfDoneItem */
|
|
20
21
|
/** @typedef {import("../contract/verification.mjs").VerificationCommand} VerificationCommand */
|
|
@@ -284,8 +285,7 @@ function terminateProofGroup(child) {
|
|
|
284
285
|
if (!pid) return;
|
|
285
286
|
const signal = (/** @type {NodeJS.Signals} */ name) => {
|
|
286
287
|
try {
|
|
287
|
-
|
|
288
|
-
else child.kill(name);
|
|
288
|
+
killTarget(process.platform === "win32" ? pid : -pid, name);
|
|
289
289
|
} catch {
|
|
290
290
|
try { child.kill(name); } catch {
|
|
291
291
|
// ESRCH: the group and the leader are already gone.
|
|
@@ -17,6 +17,7 @@ import { errorMessage } from "../util.mjs";
|
|
|
17
17
|
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
|
18
18
|
import { join, resolve } from "node:path";
|
|
19
19
|
import { normalizeProviderResult, probeRuntime, providerCommand } from "../harnesses/index.mjs";
|
|
20
|
+
import { killTarget, spawnInvocation } from "../host/platform.mjs";
|
|
20
21
|
import { reachableRuntimes } from "../host/preflight.mjs";
|
|
21
22
|
import { spawn } from "node:child_process";
|
|
22
23
|
import { boundedGitSync } from "../repo/worktree.mjs";
|
|
@@ -34,16 +35,48 @@ import { runsRoot } from "../run/paths.mjs";
|
|
|
34
35
|
const LIVE_PREFLIGHT_PROMPT = "Respond with exactly FABERUN_PREFLIGHT_OK and do not use tools.";
|
|
35
36
|
const LIVE_PREFLIGHT_OUTPUT_LIMIT_BYTES = 512 * 1024;
|
|
36
37
|
/**
|
|
38
|
+
* `persisted` is for the dispatch gate, which hands over the run's own
|
|
39
|
+
* serialized contract: that one was validated when it was authored, and
|
|
40
|
+
* re-running the filesystem-dependent checks against the checkout reaches a
|
|
41
|
+
* different answer for a run launched with `--base-ref`, whose declared paths
|
|
42
|
+
* live in the ref and not in the working tree. Measured 2026-09-22: without
|
|
43
|
+
* it the gate refused a `--base-ref` run with "readFiles[0] does not exist".
|
|
44
|
+
* The CLI's own `preflight <contract>` leaves it off on purpose -- there the
|
|
45
|
+
* contract is an authored file the operator is asking about, so validating it
|
|
46
|
+
* whole is part of the answer.
|
|
47
|
+
*
|
|
37
48
|
* @param {string} contractPath
|
|
38
|
-
* @param {{static?: boolean, liveTimeoutSec?: number}} [options]
|
|
49
|
+
* @param {{static?: boolean, liveTimeoutSec?: number, persisted?: boolean}} [options]
|
|
39
50
|
* @returns {Promise<ProbeResult[]>}
|
|
40
51
|
*/
|
|
41
52
|
export async function preflightContract(contractPath, options = {}) {
|
|
42
53
|
const absoluteContractPath = resolve(contractPath);
|
|
43
|
-
const contract = validateContract(JSON.parse(readFileSync(absoluteContractPath, "utf8")), absoluteContractPath);
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
54
|
+
const contract = validateContract(JSON.parse(readFileSync(absoluteContractPath, "utf8")), absoluteContractPath, options.persisted === true ? { persisted: true } : {});
|
|
55
|
+
return preflightRuntimes([...reachableRuntimes(contract).values()], { ...options, cwd: contract.cwd });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The same ask, entered from a set of runtimes rather than a contract.
|
|
60
|
+
*
|
|
61
|
+
* `faberun plan` needs this and a contract cannot give it. Planning contracts
|
|
62
|
+
* carry no gate (`plan/template.mjs`), and `reachableRuntimes` only counts the
|
|
63
|
+
* judge role when a node's gate is enabled -- so preflighting the draft
|
|
64
|
+
* stage's contract asks the planner and never the reviewer, which is reached
|
|
65
|
+
* only as the *worker* of a later stage's own contract. Asking both before the
|
|
66
|
+
* first stage therefore has to name the runtimes directly.
|
|
67
|
+
*
|
|
68
|
+
* One asking function with two entry points, never two: the live probe, the
|
|
69
|
+
* throwaway repository, the budget and the detail wording are decided here
|
|
70
|
+
* once, so a caller cannot accidentally ask a different question.
|
|
71
|
+
*
|
|
72
|
+
* @param {{runtime: RuntimeSnapshot, requiredCapabilitySets?: import("../harnesses/index.mjs").CapabilityRequirements[]}[]} entries
|
|
73
|
+
* @param {{static?: boolean, liveTimeoutSec?: number, cwd?: string}} [options]
|
|
74
|
+
* @returns {Promise<ProbeResult[]>}
|
|
75
|
+
*/
|
|
76
|
+
export async function preflightRuntimes(entries, options = {}) {
|
|
77
|
+
const runtimes = entries.map((entry) => entry.runtime);
|
|
78
|
+
const staticChecks = await Promise.all(entries.map(({ runtime, requiredCapabilitySets }) =>
|
|
79
|
+
probeRuntime(runtime, { cwd: options.cwd, requiredCapabilitySets }),
|
|
47
80
|
));
|
|
48
81
|
if (options.static === true) return staticChecks;
|
|
49
82
|
|
|
@@ -62,8 +95,7 @@ export async function preflightContract(contractPath, options = {}) {
|
|
|
62
95
|
}
|
|
63
96
|
try {
|
|
64
97
|
return await Promise.all(staticChecks.map(async (check, index) => {
|
|
65
|
-
const
|
|
66
|
-
const live = await livePreflight(runtime, liveRepo, timeoutSec);
|
|
98
|
+
const live = await livePreflight(runtimes[index], liveRepo, timeoutSec);
|
|
67
99
|
const liveDetail = live.status === "done"
|
|
68
100
|
? `live done · usage ${formatUsage(live.usage)} · cost ${formatCost(live.costUsd)}`
|
|
69
101
|
: `live ${live.status} · ${live.error?.code ?? "provider_error"}: ${redactProviderText(live.error?.message ?? "generation failed")} · usage ${formatUsage(live.usage)} · cost ${formatCost(live.costUsd)}`;
|
|
@@ -146,11 +178,13 @@ function livePreflight(runtime, cwd, timeoutSec) {
|
|
|
146
178
|
else env[key] = value;
|
|
147
179
|
}
|
|
148
180
|
delete env.FABERUN_NOTIFY_BIN;
|
|
149
|
-
|
|
181
|
+
const invocation = spawnInvocation(command.executable, command.args, { cwd });
|
|
182
|
+
child = /** @type {import("node:child_process").ChildProcessWithoutNullStreams} */ (spawn(invocation.command, invocation.args, {
|
|
150
183
|
cwd,
|
|
151
184
|
env,
|
|
152
185
|
detached: process.platform !== "win32",
|
|
153
186
|
stdio: [command.promptTransport === "stdin" ? "pipe" : "ignore", "pipe", "pipe"],
|
|
187
|
+
...invocation.options,
|
|
154
188
|
}));
|
|
155
189
|
} catch (error) {
|
|
156
190
|
settle({
|
|
@@ -182,8 +216,7 @@ function livePreflight(runtime, cwd, timeoutSec) {
|
|
|
182
216
|
/** @param {NodeJS.Signals} name */
|
|
183
217
|
const signal = (name) => {
|
|
184
218
|
try {
|
|
185
|
-
|
|
186
|
-
else process.kill(-/** @type {number} */ (child.pid), name);
|
|
219
|
+
killTarget(process.platform === "win32" ? /** @type {number} */ (child.pid) : -/** @type {number} */ (child.pid), name);
|
|
187
220
|
} catch {
|
|
188
221
|
// ESRCH: the child is already gone, so there is no process to signal.
|
|
189
222
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What counts as a provider saying nothing at all.
|
|
3
|
+
*
|
|
4
|
+
* This is one rule with three readers -- the dispatch gate
|
|
5
|
+
* (`engine/run-identity.mjs`), planning's pre-stage refusal
|
|
6
|
+
* (`plan/preflight.mjs`) and `doctor` (`host/preflight.mjs`) -- and it is its
|
|
7
|
+
* own module because those three cannot all import each other. `doctor` owns
|
|
8
|
+
* `reachableRuntimes`, which `engine/live-preflight.mjs` imports, so a
|
|
9
|
+
* `doctor` that read the rule out of the engine closed a runtime import
|
|
10
|
+
* cycle. This module imports nothing: it reads a probe's recorded detail and
|
|
11
|
+
* says whether a provider answered, and nothing else.
|
|
12
|
+
*
|
|
13
|
+
* The rule itself is the campaign's claim in one line. Any verdict a provider
|
|
14
|
+
* returned is an answer, a quota refusal included, and the run proceeds onto
|
|
15
|
+
* whatever the contract declares. Only silence blocks.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The causes that mean no runtime said anything at all: the provider was
|
|
20
|
+
* asked and did not answer, or could not be started to be asked.
|
|
21
|
+
*
|
|
22
|
+
* `command_invalid` is deliberately not one of them. A command that could not
|
|
23
|
+
* be constructed never reached a provider, so there is no availability
|
|
24
|
+
* verdict either way -- that is a contract defect and validation already owns
|
|
25
|
+
* it. Measured 2026-09-22: three deterministic evals declare a fallback and a
|
|
26
|
+
* judge runtime they never invoke, so those carry no replay recording and the
|
|
27
|
+
* replay adapter throws when asked to build their command. Blocking there
|
|
28
|
+
* refuses a run over a runtime it would never have used, for a fault the
|
|
29
|
+
* provider never had.
|
|
30
|
+
*/
|
|
31
|
+
const LIVE_SILENCE_CAUSES = new Set(["preflight_timeout", "spawn_error"]);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Whether a live probe is pipeline silence rather than a verdict, and which
|
|
35
|
+
* cause. `preflightContract` embeds the provider envelope's error code in the
|
|
36
|
+
* probe detail (`… · live failed · <code>: …`), and the repository-failure
|
|
37
|
+
* wording means no runtime was even asked. Everything else -- a quota
|
|
38
|
+
* refusal, an auth failure, unparsable output -- is a provider that answered,
|
|
39
|
+
* and an answer is hello enough.
|
|
40
|
+
*
|
|
41
|
+
* @param {import("../harnesses/index.mjs").ProbeResult} probe
|
|
42
|
+
* @returns {string|null}
|
|
43
|
+
*/
|
|
44
|
+
export function liveSilenceCause(probe) {
|
|
45
|
+
if (probe.ok) return null;
|
|
46
|
+
if (/live preflight repository failed/u.test(probe.detail ?? "")) return "spawn_error";
|
|
47
|
+
const match = / · live \S+ · ([a-z_]+):/u.exec(probe.detail ?? "");
|
|
48
|
+
return match !== null && LIVE_SILENCE_CAUSES.has(match[1]) ? match[1] : null;
|
|
49
|
+
}
|
|
@@ -86,6 +86,17 @@ export function processStartTokenMatches(invocation) {
|
|
|
86
86
|
* -- a pid this user cannot signal is not the child this controller spawned --
|
|
87
87
|
* and a null token with no handle is unverifiable, so it is never owned.
|
|
88
88
|
*
|
|
89
|
+
* Except on Windows, which records no token at all: `wmic` is gone from
|
|
90
|
+
* Windows 11 26200 and the PowerShell that replaced it costs about 400 ms a
|
|
91
|
+
* probe (`run/lock.mjs`). Holding POSIX's answer there means every recorded
|
|
92
|
+
* invocation is unverifiable, so a controller never terminates the provider it
|
|
93
|
+
* started: measured 2026-09-20, a suite run left 105 node fixtures alive and
|
|
94
|
+
* then waited on one of them, and `cancel` reported providers it had not
|
|
95
|
+
* stopped. A live pid this controller recorded is the evidence that platform
|
|
96
|
+
* has, and it is the same evidence `killTarget` already acts on there. What is
|
|
97
|
+
* given up is the pid-reuse defence: a pid recycled into an unrelated process
|
|
98
|
+
* between the record and the kill reads as owned.
|
|
99
|
+
*
|
|
89
100
|
* @param {InvocationProbe} invocation
|
|
90
101
|
* @param {{child?: ChildProcess|null}} [options]
|
|
91
102
|
* @returns {boolean}
|
|
@@ -106,6 +117,6 @@ export function invocationOwned(invocation, options = {}) {
|
|
|
106
117
|
const child = options.child;
|
|
107
118
|
if (child && child.exitCode === null && child.signalCode === null) return true;
|
|
108
119
|
const token = invocation.processStartToken;
|
|
109
|
-
if (!token) return
|
|
120
|
+
if (!token) return process.platform === "win32";
|
|
110
121
|
return processStartToken(invocation.pid) === token;
|
|
111
122
|
}
|
package/src/engine/process.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { randomUUID } from "node:crypto";
|
|
|
23
23
|
import { spawn } from "node:child_process";
|
|
24
24
|
import { appendJsonl, writeJsonAtomic } from "../run/store.mjs";
|
|
25
25
|
import { writeNodeSnapshot } from "../run/node-store.mjs";
|
|
26
|
+
import { killTarget } from "../host/platform.mjs";
|
|
26
27
|
|
|
27
28
|
/** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
28
29
|
/** @typedef {import("../contract/index.mjs").ValidatedNode} ValidatedNode */
|
|
@@ -572,7 +573,7 @@ function signalInvocation(invocation, signal, options = {}) {
|
|
|
572
573
|
const pid = invocation.pid;
|
|
573
574
|
if (pid === null || pid === undefined) return false;
|
|
574
575
|
const target = process.platform === "win32" ? pid : -(invocation.processGroupId ?? pid);
|
|
575
|
-
const kill = options.kill ??
|
|
576
|
+
const kill = options.kill ?? killTarget;
|
|
576
577
|
try {
|
|
577
578
|
kill(target, signal);
|
|
578
579
|
return true;
|
|
@@ -17,6 +17,7 @@ import { processStartToken } from "../run/lock.mjs";
|
|
|
17
17
|
import { randomUUID } from "node:crypto";
|
|
18
18
|
import { runMutation } from "./mutation.mjs";
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
20
|
+
import { killTarget, spawnInvocation } from "../host/platform.mjs";
|
|
20
21
|
/** @typedef {import("../contract/verification.mjs").VerificationOptions} VerificationOptions */
|
|
21
22
|
|
|
22
23
|
/** @typedef {import("node:child_process").ChildProcess} ChildProcess */
|
|
@@ -228,7 +229,11 @@ function runCommand(command, baseCwd, commandCwd, attempt, signal, options, comm
|
|
|
228
229
|
};
|
|
229
230
|
options?.onAttemptStart?.({ ...identity });
|
|
230
231
|
const env = verificationEnv(command);
|
|
231
|
-
|
|
232
|
+
// A verification command names a binary the same way a harness runtime
|
|
233
|
+
// does, and on Windows `npm test` is `npm.cmd`: the invocation, not the
|
|
234
|
+
// raw argv, is what can actually be spawned there.
|
|
235
|
+
const invocation = spawnInvocation(command.argv[0], command.argv.slice(1), { cwd });
|
|
236
|
+
child = spawn(invocation.command, invocation.args, { cwd, env, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], ...invocation.options });
|
|
232
237
|
const pid = child.pid ?? null;
|
|
233
238
|
let paused = false;
|
|
234
239
|
if (process.platform !== "win32" && pid) {
|
|
@@ -268,8 +273,7 @@ function runCommand(command, baseCwd, commandCwd, attempt, signal, options, comm
|
|
|
268
273
|
*/
|
|
269
274
|
function terminateGroup(child) {
|
|
270
275
|
try {
|
|
271
|
-
|
|
272
|
-
else child.kill("SIGTERM");
|
|
276
|
+
killTarget(process.platform === "win32" ? /** @type {number} */ (child.pid) : -/** @type {number} */ (child.pid), "SIGTERM");
|
|
273
277
|
} catch {
|
|
274
278
|
try { child.kill("SIGTERM"); } catch {
|
|
275
279
|
// ESRCH: the group kill failed and the leader was already gone.
|
|
@@ -277,8 +281,7 @@ function terminateGroup(child) {
|
|
|
277
281
|
}
|
|
278
282
|
setTimeout(() => {
|
|
279
283
|
try {
|
|
280
|
-
|
|
281
|
-
else child.kill("SIGKILL");
|
|
284
|
+
killTarget(process.platform === "win32" ? /** @type {number} */ (child.pid) : -/** @type {number} */ (child.pid), "SIGKILL");
|
|
282
285
|
} catch {
|
|
283
286
|
try { child.kill("SIGKILL"); } catch {
|
|
284
287
|
// ESRCH: the SIGKILL fallback found no leader left to kill.
|