faberun 0.3.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/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- package/src/web/server.mjs +552 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment preflight: the host facts a run depends on, checked before the
|
|
3
|
+
* first dispatch and again by `doctor` on demand.
|
|
4
|
+
*
|
|
5
|
+
* Four checks gate a dispatch — free disk, a functional git, the worktree
|
|
6
|
+
* state, and every routed runtime binary present and versioned. A gate that
|
|
7
|
+
* fails leaves the run materialized and resumable: the controller records the
|
|
8
|
+
* report as run evidence and stops, so the operator fixes the host and
|
|
9
|
+
* resumes instead of starting over and paying for the finished nodes twice.
|
|
10
|
+
*
|
|
11
|
+
* A check may be advisory, meaning it reports a fact without blocking: a
|
|
12
|
+
* merely dirty worktree is normal in this repository (the run captures a
|
|
13
|
+
* dirtyTreeFingerprint for it), while unmerged paths or an interrupted git
|
|
14
|
+
* operation are not, because a worker's scope diff cannot be read against
|
|
15
|
+
* them. Set FABERUN_REQUIRE_CLEAN_WORKTREE=1 to make any dirt fatal.
|
|
16
|
+
*/
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { existsSync, readFileSync, statfsSync } from "node:fs";
|
|
19
|
+
import { delimiter, join, resolve } from "node:path";
|
|
20
|
+
import { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION, getHarness, probeRuntime } from "../harnesses/index.mjs";
|
|
21
|
+
import { addRuntimeRequirement, failoverTargets, runtimeSnapshot } from "../engine/failover.mjs";
|
|
22
|
+
import { validateContract } from "../contract/index.mjs";
|
|
23
|
+
import { DISCOVERY_RUNTIME_DEFINITIONS, discoverRuntimes } from "../engine/runtime-discovery.mjs";
|
|
24
|
+
import { errorMessage } from "../util.mjs";
|
|
25
|
+
import { boundedGitSync } from "../repo/worktree.mjs";
|
|
26
|
+
import { routeRuntime } from "../contract/runtime.mjs";
|
|
27
|
+
import { NOTIFY_BIN_ENV, noTransportWarning } from "../notify/index.mjs";
|
|
28
|
+
import { colorLevel, statusToken } from "../cli/brand.mjs";
|
|
29
|
+
|
|
30
|
+
/** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
31
|
+
/** @typedef {import("../contract/index.mjs").RuntimeSnapshot} RuntimeSnapshot */
|
|
32
|
+
/** @typedef {import("../harnesses/index.mjs").CapabilityRequirements} CapabilityRequirements */
|
|
33
|
+
/** @typedef {Map<string, {runtime: RuntimeSnapshot, requiredCapabilitySets: CapabilityRequirements[]}>} ReachableRuntimes */
|
|
34
|
+
/** @typedef {{name: string, ok: boolean, advisory: boolean, detail: string}} EnvCheck */
|
|
35
|
+
/** @typedef {{schemaVersion: number, ok: boolean, checks: EnvCheck[]}} EnvReport */
|
|
36
|
+
|
|
37
|
+
const ENV_PREFLIGHT_SCHEMA_VERSION = 1;
|
|
38
|
+
|
|
39
|
+
/** Free space below this leaves no room for logs, capsules, and snapshots. */
|
|
40
|
+
const DEFAULT_MIN_FREE_DISK_BYTES = 512 * 1024 * 1024;
|
|
41
|
+
|
|
42
|
+
/** Worktree states in which a scope diff is not readable. */
|
|
43
|
+
const GIT_IN_PROGRESS = Object.freeze({
|
|
44
|
+
MERGE_HEAD: "merge",
|
|
45
|
+
CHERRY_PICK_HEAD: "cherry-pick",
|
|
46
|
+
REVERT_HEAD: "revert",
|
|
47
|
+
BISECT_LOG: "bisect",
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** @param {string} name @param {string} detail @returns {EnvCheck} */
|
|
51
|
+
const pass = (name, detail) => ({ name, ok: true, advisory: false, detail });
|
|
52
|
+
|
|
53
|
+
/** @param {string} name @param {string} detail @param {boolean} [advisory] @returns {EnvCheck} */
|
|
54
|
+
const fail = (name, detail, advisory = false) => ({ name, ok: false, advisory, detail });
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {string} dir
|
|
58
|
+
* @param {string[]} args
|
|
59
|
+
* @returns {{status: number|null, stdout: string}}
|
|
60
|
+
*/
|
|
61
|
+
function git(dir, args) {
|
|
62
|
+
const result = boundedGitSync(["-C", dir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
63
|
+
return { status: result.error ? null : result.status, stdout: String(result.stdout ?? "") };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @param {NodeJS.ProcessEnv} env @returns {number} */
|
|
67
|
+
export function minFreeDiskBytes(env) {
|
|
68
|
+
const raw = env.FABERUN_MIN_FREE_DISK_BYTES;
|
|
69
|
+
if (raw === undefined) return DEFAULT_MIN_FREE_DISK_BYTES;
|
|
70
|
+
const parsed = Number(raw);
|
|
71
|
+
if (!Number.isFinite(parsed) || parsed < 0) throw new TypeError("FABERUN_MIN_FREE_DISK_BYTES must be a non-negative number of bytes");
|
|
72
|
+
return parsed;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @param {number} bytes */
|
|
76
|
+
function formatBytes(bytes) {
|
|
77
|
+
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
|
78
|
+
let value = bytes;
|
|
79
|
+
let unit = 0;
|
|
80
|
+
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; }
|
|
81
|
+
return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Free space on the filesystem holding the run. A filesystem that cannot
|
|
86
|
+
* report statfs is advisory: an unknown figure must not block a dispatch.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} cwd
|
|
89
|
+
* @param {number} minFreeBytes
|
|
90
|
+
* @returns {EnvCheck}
|
|
91
|
+
*/
|
|
92
|
+
export function checkDisk(cwd, minFreeBytes) {
|
|
93
|
+
let free;
|
|
94
|
+
try {
|
|
95
|
+
const stats = statfsSync(cwd);
|
|
96
|
+
free = Number(stats.bsize) * Number(stats.bavail);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
return fail("disk", `free space unavailable: ${error instanceof Error ? error.message : String(error)}`, true);
|
|
99
|
+
}
|
|
100
|
+
const detail = `${formatBytes(free)} free · threshold ${formatBytes(minFreeBytes)}`;
|
|
101
|
+
return free >= minFreeBytes ? pass("disk", detail) : fail("disk", `${detail} · free at least ${formatBytes(minFreeBytes - free)} more`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A functional git, not merely a git on PATH: the run reads HEAD and diffs
|
|
106
|
+
* the worktree through it, so a git that cannot execute is fatal.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} cwd
|
|
109
|
+
* @returns {EnvCheck}
|
|
110
|
+
*/
|
|
111
|
+
export function checkGit(cwd) {
|
|
112
|
+
const version = boundedGitSync(["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
113
|
+
if (version.error || version.status !== 0) return fail("git", `git is not executable: ${version.error ? version.error.message : `exit ${version.status}`}`);
|
|
114
|
+
const label = String(version.stdout ?? "").trim() || "git";
|
|
115
|
+
if (!existsSync(cwd)) return fail("git", `${label} · cwd does not exist: ${cwd}`);
|
|
116
|
+
const inside = git(cwd, ["rev-parse", "--is-inside-work-tree"]);
|
|
117
|
+
if (inside.status !== 0 || inside.stdout.trim() !== "true") return fail("git", `${label} · execution requires a git work tree with at least one commit`);
|
|
118
|
+
const head = git(cwd, ["rev-parse", "HEAD"]);
|
|
119
|
+
if (head.status !== 0) return fail("git", `${label} · repository must have at least one commit before an isolated execution can start`);
|
|
120
|
+
return pass("git", `${label} · HEAD ${head.stdout.trim().slice(0, 12)}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @param {string} cwd
|
|
125
|
+
* @param {boolean} requireClean
|
|
126
|
+
* @returns {EnvCheck}
|
|
127
|
+
*/
|
|
128
|
+
export function checkWorktree(cwd, requireClean) {
|
|
129
|
+
const inside = git(cwd, ["rev-parse", "--is-inside-work-tree"]);
|
|
130
|
+
if (inside.status !== 0 || inside.stdout.trim() !== "true") return pass("worktree", `${cwd} is not a git work tree; nothing to compare`);
|
|
131
|
+
const gitDir = git(cwd, ["rev-parse", "--git-dir"]);
|
|
132
|
+
const root = gitDir.status === 0 ? resolve(cwd, gitDir.stdout.trim()) : null;
|
|
133
|
+
if (root) {
|
|
134
|
+
if (existsSync(join(root, "rebase-merge")) || existsSync(join(root, "rebase-apply"))) return fail("worktree", "a rebase is in progress; finish or abort it before dispatching");
|
|
135
|
+
for (const [file, operation] of Object.entries(GIT_IN_PROGRESS)) {
|
|
136
|
+
if (existsSync(join(root, file))) return fail("worktree", `a ${operation} is in progress; finish or abort it before dispatching`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const status = git(cwd, ["status", "--porcelain"]);
|
|
140
|
+
if (status.status !== 0) return fail("worktree", "git status failed; the worktree state is unknown");
|
|
141
|
+
const lines = status.stdout.split("\n").filter((line) => line.trim());
|
|
142
|
+
const conflicted = lines.filter((line) => /^(DD|AU|UD|UA|DU|AA|UU)/u.test(line));
|
|
143
|
+
if (conflicted.length) return fail("worktree", `${conflicted.length} unmerged path${conflicted.length === 1 ? "" : "s"}; resolve the conflict before dispatching`);
|
|
144
|
+
if (!lines.length) return pass("worktree", "clean");
|
|
145
|
+
const detail = `${lines.length} dirty path${lines.length === 1 ? "" : "s"}`;
|
|
146
|
+
return requireClean
|
|
147
|
+
? fail("worktree", `${detail}; FABERUN_REQUIRE_CLEAN_WORKTREE demands a clean tree`)
|
|
148
|
+
: fail("worktree", `${detail}; recorded in the run's dirtyTreeFingerprint`, true);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Every runtime the run can route to — initial and failover — must resolve to
|
|
153
|
+
* a binary that exists and reports a version. A version-less runtime is fatal
|
|
154
|
+
* up front because a resume refuses a runtime whose probe came back null.
|
|
155
|
+
*
|
|
156
|
+
* @param {ReachableRuntimes} runtimes
|
|
157
|
+
* @param {Record<string, string|null>} harnessVersions
|
|
158
|
+
* @param {string} [cwd] the run cwd a relative executable is resolved against
|
|
159
|
+
* @returns {EnvCheck}
|
|
160
|
+
*/
|
|
161
|
+
export function checkRuntimeBinaries(runtimes, harnessVersions, cwd = ".") {
|
|
162
|
+
/** @type {string[]} */
|
|
163
|
+
const problems = [];
|
|
164
|
+
/** @type {string[]} */
|
|
165
|
+
const resolved = [];
|
|
166
|
+
for (const [id, { runtime }] of runtimes) {
|
|
167
|
+
// The harness owns the resolution: a per-runtime executable, an
|
|
168
|
+
// FABERUN_*_BIN override, and each harness's default binary all
|
|
169
|
+
// land here, and a relative path belongs to the run cwd, not to ours.
|
|
170
|
+
const executable = getHarness(runtime.harness).executable(runtime);
|
|
171
|
+
const found = findExecutable(executable.includes("/") || executable.includes("\\") ? resolve(cwd, executable) : executable);
|
|
172
|
+
const version = harnessVersions[id] ?? null;
|
|
173
|
+
if (found === null) problems.push(`${id}: ${executable} not found on PATH`);
|
|
174
|
+
else if (version === null) problems.push(`${id}: ${executable} reported no version`);
|
|
175
|
+
else resolved.push(`${id} ${version}`);
|
|
176
|
+
}
|
|
177
|
+
if (problems.length) return fail("runtime binaries", problems.join(" · "));
|
|
178
|
+
return pass("runtime binaries", resolved.length ? resolved.join(" · ") : "no routed runtime");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* @param {{cwd: string, runtimes: ReachableRuntimes, harnessVersions?: Record<string, string|null>, env?: NodeJS.ProcessEnv}} options
|
|
183
|
+
* @returns {EnvReport}
|
|
184
|
+
*/
|
|
185
|
+
export function environmentPreflight(options) {
|
|
186
|
+
const env = options.env ?? process.env;
|
|
187
|
+
const cwd = options.cwd;
|
|
188
|
+
const checks = [
|
|
189
|
+
checkDisk(cwd, minFreeDiskBytes(env)),
|
|
190
|
+
checkGit(cwd),
|
|
191
|
+
checkWorktree(cwd, env.FABERUN_REQUIRE_CLEAN_WORKTREE === "1"),
|
|
192
|
+
checkRuntimeBinaries(options.runtimes, options.harnessVersions ?? {}, cwd),
|
|
193
|
+
];
|
|
194
|
+
return { schemaVersion: ENV_PREFLIGHT_SCHEMA_VERSION, ok: checks.every((check) => check.ok || check.advisory), checks };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The named no-transport check, advisory so it never gates a dispatch. It is
|
|
199
|
+
* rendered by `preflight` and warned about by `doctor` and the foreground
|
|
200
|
+
* launch, but it is kept out of `environmentPreflight`'s own check set so the
|
|
201
|
+
* dispatch gate stays exactly the host facts it always was.
|
|
202
|
+
*
|
|
203
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
204
|
+
* @returns {EnvCheck}
|
|
205
|
+
*/
|
|
206
|
+
export function notifyTransportCheck(env = process.env) {
|
|
207
|
+
const warning = noTransportWarning(env);
|
|
208
|
+
return warning
|
|
209
|
+
? fail("notify transport", warning, true)
|
|
210
|
+
: pass("notify transport", `${NOTIFY_BIN_ENV}=${env[NOTIFY_BIN_ENV]}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** @param {EnvReport} report @returns {EnvCheck[]} the checks that block a dispatch */
|
|
214
|
+
export function blockingChecks(report) {
|
|
215
|
+
return report.checks.filter((check) => !check.ok && !check.advisory);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The share of its declared timeout a command may take before it is a warning. */
|
|
219
|
+
const VERIFICATION_DURATION_WARN_RATIO = 0.8;
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Every distinct verification command the contract declares, with the
|
|
223
|
+
* strictest timeout any node gives it and the nodes that share it.
|
|
224
|
+
*
|
|
225
|
+
* Commands are keyed by argv and cwd, never merged across different argv, so
|
|
226
|
+
* one measurement stands in for every node that declares the same command —
|
|
227
|
+
* a contract that puts `npm test` on three nodes gets timed once.
|
|
228
|
+
*
|
|
229
|
+
* @param {ValidatedContract} contract
|
|
230
|
+
* @returns {{argv: string[], cwd?: string, timeoutSec: number, nodes: string[]}[]}
|
|
231
|
+
*/
|
|
232
|
+
export function declaredVerificationCommands(contract) {
|
|
233
|
+
/** @type {Map<string, {argv: string[], cwd?: string, timeoutSec: number, nodes: string[]}>} */
|
|
234
|
+
const commands = new Map();
|
|
235
|
+
const declarations = [
|
|
236
|
+
...contract.nodes.flatMap((node) => (node.taskPacket?.verification ?? []).map((command) => ({ command, node: node.id }))),
|
|
237
|
+
...(contract.finalVerification ?? []).map((command) => ({ command, node: "finalVerification" })),
|
|
238
|
+
];
|
|
239
|
+
for (const { command, node } of declarations) {
|
|
240
|
+
const timeoutSec = command.timeoutSec ?? 120;
|
|
241
|
+
const key = JSON.stringify([command.argv, command.cwd ?? null]);
|
|
242
|
+
const existing = commands.get(key);
|
|
243
|
+
if (existing) {
|
|
244
|
+
existing.timeoutSec = Math.min(existing.timeoutSec, timeoutSec);
|
|
245
|
+
if (!existing.nodes.includes(node)) existing.nodes.push(node);
|
|
246
|
+
} else {
|
|
247
|
+
commands.set(key, { argv: command.argv, cwd: command.cwd, timeoutSec, nodes: [node] });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return [...commands.values()];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Run every declared verification command once and report what it actually
|
|
255
|
+
* costs against the timeout the contract gives it.
|
|
256
|
+
*
|
|
257
|
+
* A verification entry is capped at 600s by the schema, and nothing else in
|
|
258
|
+
* the toolchain measures whether a command fits: a suite that grows past its
|
|
259
|
+
* declared timeout only announces itself by failing a node that did its work
|
|
260
|
+
* correctly, after the tokens are spent. Campaign
|
|
261
|
+
* faberun-suite-speed-20260909 lost roughly 49 minutes and two nodes
|
|
262
|
+
* to exactly that — `npm test` at 644s against a declared 600s.
|
|
263
|
+
*
|
|
264
|
+
* A non-zero exit is reported, never failed on: a node may legitimately be
|
|
265
|
+
* the thing that turns a red command green. Only duration decides `ok`.
|
|
266
|
+
*
|
|
267
|
+
* @param {ValidatedContract} contract
|
|
268
|
+
* @param {{now?: () => number, run?: typeof spawnSync}} [probes] injectable for tests
|
|
269
|
+
* @returns {EnvCheck[]}
|
|
270
|
+
*/
|
|
271
|
+
export function timeVerificationCommands(contract, probes = {}) {
|
|
272
|
+
const now = probes.now ?? (() => Date.now());
|
|
273
|
+
const run = probes.run ?? spawnSync;
|
|
274
|
+
return declaredVerificationCommands(contract).map((command) => {
|
|
275
|
+
const label = command.argv.join(" ");
|
|
276
|
+
const name = `verification timing · ${label}`;
|
|
277
|
+
const shared = command.nodes.length > 1 ? ` · declared by ${command.nodes.join(", ")}` : "";
|
|
278
|
+
// Let a slow command overrun its declared timeout so the report can say by
|
|
279
|
+
// how much; killing it at the declared value would only prove "at least".
|
|
280
|
+
const ceilingSec = Math.min(Math.max(command.timeoutSec * 2, command.timeoutSec + 120), 1800);
|
|
281
|
+
const startedAt = now();
|
|
282
|
+
const result = run(command.argv[0], command.argv.slice(1), {
|
|
283
|
+
cwd: command.cwd ? resolve(contract.cwd, command.cwd) : contract.cwd,
|
|
284
|
+
timeout: ceilingSec * 1_000,
|
|
285
|
+
stdio: "ignore",
|
|
286
|
+
encoding: "utf8",
|
|
287
|
+
});
|
|
288
|
+
const seconds = (now() - startedAt) / 1_000;
|
|
289
|
+
const measured = `${seconds.toFixed(1)}s measured against ${command.timeoutSec}s declared`;
|
|
290
|
+
const exit = result.status === null ? `killed by ${result.signal ?? "timeout"}` : `exit ${result.status}`;
|
|
291
|
+
if (result.error && /** @type {{code?: string}} */ (result.error).code === "ENOENT") {
|
|
292
|
+
return fail(name, `${command.argv[0]} is not on PATH${shared}`);
|
|
293
|
+
}
|
|
294
|
+
if (seconds >= command.timeoutSec) {
|
|
295
|
+
return fail(name, `${measured}: this command cannot pass its own verification entry${shared} · ${exit}`);
|
|
296
|
+
}
|
|
297
|
+
if (seconds >= command.timeoutSec * VERIFICATION_DURATION_WARN_RATIO) {
|
|
298
|
+
return fail(name, `${measured}: within ${Math.round((1 - VERIFICATION_DURATION_WARN_RATIO) * 100)}% of the cap, so growth will break it${shared} · ${exit}`, true);
|
|
299
|
+
}
|
|
300
|
+
return pass(name, `${measured}${shared} · ${exit}`);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Collect initial worker/judge runtimes and every runtime reachable through
|
|
306
|
+
* the one declared fallback hop, preserving each capability requirement so a
|
|
307
|
+
* runtime a run might fall over to is checked before it runs.
|
|
308
|
+
*
|
|
309
|
+
* The enumeration is exactly the reachable-state set the run can actually
|
|
310
|
+
* occupy: a node's role starts on its assigned runtime, and — if that
|
|
311
|
+
* runtime declares a `fallback` — may take exactly one hop to it. It never
|
|
312
|
+
* re-derives `failoverTargets` from the hop target itself, so a chain like
|
|
313
|
+
* A.fallback=B, B.fallback=C never probes C for a node assigned A: that node
|
|
314
|
+
* can take only one hop, and its reachable set stops at B.
|
|
315
|
+
*
|
|
316
|
+
* @param {ValidatedContract} contract
|
|
317
|
+
* @returns {Map<string, {runtime: RuntimeSnapshot, requiredCapabilitySets: import("../harnesses/index.mjs").CapabilityRequirements[]}>}
|
|
318
|
+
*/
|
|
319
|
+
export function reachableRuntimes(contract) {
|
|
320
|
+
/** @type {Map<string, {runtime: RuntimeSnapshot, requiredCapabilitySets: import("../harnesses/index.mjs").CapabilityRequirements[]}>} */
|
|
321
|
+
const runtimes = new Map();
|
|
322
|
+
for (const node of contract.nodes) {
|
|
323
|
+
for (const role of /** @type {("worker"|"judge")[]} */ (["worker", ...(node.gate.enabled ? ["judge"] : [])])) {
|
|
324
|
+
const explicit = role === "judge" ? node.gate.runtime ?? contract.runtimeDefaults?.judge : node.runtime ?? contract.runtimeDefaults?.worker;
|
|
325
|
+
const fallbackId = role === "worker"
|
|
326
|
+
? Object.keys(contract.runtimes)[0]
|
|
327
|
+
: Object.entries(contract.runtimes).find(([, candidate]) => candidate.vendor !== contract.runtimes[Object.keys(contract.runtimes)[0]]?.vendor)?.[0]
|
|
328
|
+
?? Object.keys(contract.runtimes)[0];
|
|
329
|
+
if (!fallbackId) throw new Error("runtime discovery catalogue is empty");
|
|
330
|
+
const runtime = explicit
|
|
331
|
+
? /** @type {RuntimeSnapshot} */ (routeRuntime(contract, node, role))
|
|
332
|
+
: runtimeSnapshot(contract, fallbackId);
|
|
333
|
+
// The judge role carries no extra capability set: the verdict contract
|
|
334
|
+
// is enforced at the review boundary (judgePrompt embeds the schema in
|
|
335
|
+
// the prompt text, parseJudge validates, the bounded re-ask arbiters),
|
|
336
|
+
// so a harness without a schema channel — zcode — can still judge.
|
|
337
|
+
const required = role === "judge"
|
|
338
|
+
? [runtime.requiredCapabilities, node.gate.requiredCapabilities]
|
|
339
|
+
: [runtime.requiredCapabilities, node.requiredCapabilities];
|
|
340
|
+
const requiredCapabilitySets = required.filter((item) => item !== undefined);
|
|
341
|
+
addRuntimeRequirement(runtimes, runtime, requiredCapabilitySets);
|
|
342
|
+
const current = { node, role, runtimeId: /** @type {string} */ (runtime.id) };
|
|
343
|
+
for (const fallbackRuntime of failoverTargets(contract, current)) {
|
|
344
|
+
addRuntimeRequirement(runtimes, fallbackRuntime, requiredCapabilitySets);
|
|
345
|
+
}
|
|
346
|
+
if (!explicit) {
|
|
347
|
+
for (const candidate of Object.keys(contract.runtimes)) {
|
|
348
|
+
addRuntimeRequirement(runtimes, runtimeSnapshot(contract, candidate), requiredCapabilitySets);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return runtimes;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const HARNESS_BIN_OVERRIDES = Object.freeze({
|
|
357
|
+
codex: "FABERUN_CODEX_BIN",
|
|
358
|
+
claude: "FABERUN_CLAUDE_BIN",
|
|
359
|
+
agy: "FABERUN_AGY_BIN",
|
|
360
|
+
zcode: "FABERUN_ZCODE_BIN",
|
|
361
|
+
"exec-jsonl": "FABERUN_EXEC_JSONL_BIN",
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Mutation-free environment doctor: repository prerequisites, ignored .runs,
|
|
366
|
+
* required binaries, the dispatch environment gate, and (when a contract is
|
|
367
|
+
* given) schema and harness versions.
|
|
368
|
+
*
|
|
369
|
+
* @param {string|undefined} contractPath
|
|
370
|
+
* @param {{cwd?: string, json?: boolean, discover?: boolean}} values
|
|
371
|
+
* @returns {Promise<boolean>}
|
|
372
|
+
*/
|
|
373
|
+
export async function doctorCommand(contractPath, values) {
|
|
374
|
+
const repoDir = resolve(values.cwd ?? ".");
|
|
375
|
+
/** @type {{name: string, ok: boolean, detail: string}[]} */
|
|
376
|
+
const checks = [];
|
|
377
|
+
const gitRepo = isGitWorkTree(repoDir);
|
|
378
|
+
checks.push({ name: "git repository", ok: gitRepo, detail: gitRepo ? repoDir : "not inside a git work tree" });
|
|
379
|
+
const runsIgnored = isRunsIgnored(repoDir);
|
|
380
|
+
checks.push({
|
|
381
|
+
name: ".runs ignored",
|
|
382
|
+
ok: runsIgnored,
|
|
383
|
+
detail: runsIgnored ? ".runs/ is git-ignored" : ".runs/ is not git-ignored; add .runs/ to .gitignore",
|
|
384
|
+
});
|
|
385
|
+
for (const binary of ["node", "npm"]) {
|
|
386
|
+
const found = findExecutable(binary);
|
|
387
|
+
checks.push({ name: `binary ${binary}`, ok: found !== null, detail: found ?? "not found on PATH" });
|
|
388
|
+
}
|
|
389
|
+
checks.push({ name: "runner schema", ok: true, detail: `protocol ${PROTOCOL_SCHEMA_VERSION} · runner ${CONTRACT_VERSION}` });
|
|
390
|
+
/** @type {Set<string>} */
|
|
391
|
+
let usedHarnesses = new Set();
|
|
392
|
+
/** @type {Set<string>} */
|
|
393
|
+
const overriddenHarnesses = new Set();
|
|
394
|
+
/** @type {ReachableRuntimes} */
|
|
395
|
+
let routedRuntimes = new Map();
|
|
396
|
+
/** @type {Record<string, string|null>} */
|
|
397
|
+
const harnessVersions = {};
|
|
398
|
+
/** @type {Record<string, import("../engine/runtime-discovery.mjs").RuntimeAvailability>} */
|
|
399
|
+
let discovered = {};
|
|
400
|
+
let dispatchCwd = repoDir;
|
|
401
|
+
if (contractPath) {
|
|
402
|
+
const absolute = resolve(contractPath);
|
|
403
|
+
try {
|
|
404
|
+
const contract = validateContract(JSON.parse(readFileSync(absolute, "utf8")), absolute);
|
|
405
|
+
checks.push({ name: "contract", ok: true, detail: `${contract.id} · ${contract.nodes.length} node${contract.nodes.length === 1 ? "" : "s"}` });
|
|
406
|
+
const runtimes = reachableRuntimes(contract);
|
|
407
|
+
routedRuntimes = runtimes;
|
|
408
|
+
dispatchCwd = contract.cwd;
|
|
409
|
+
usedHarnesses = new Set([...runtimes.values()].map(({ runtime }) => runtime.harness));
|
|
410
|
+
for (const runtime of Object.values(contract.runtimes)) {
|
|
411
|
+
if (typeof runtime.executable === "string") overriddenHarnesses.add(runtime.harness);
|
|
412
|
+
}
|
|
413
|
+
for (const [id, { runtime, requiredCapabilitySets }] of runtimes) {
|
|
414
|
+
const probe = await probeRuntime(runtime, { cwd: contract.cwd, requiredCapabilitySets });
|
|
415
|
+
harnessVersions[id] = probe.version;
|
|
416
|
+
checks.push({ name: `harness ${probe.id ?? runtime.harness}`, ok: probe.ok, detail: probe.detail ?? (probe.ok ? "ok" : "probe failed") });
|
|
417
|
+
}
|
|
418
|
+
} catch (error) {
|
|
419
|
+
checks.push({ name: "contract", ok: false, detail: errorMessage(error) });
|
|
420
|
+
}
|
|
421
|
+
} else {
|
|
422
|
+
checks.push({ name: "contract", ok: true, detail: "no contract.json provided; skipping runtime probes" });
|
|
423
|
+
}
|
|
424
|
+
if (values.discover === true) {
|
|
425
|
+
const discoveryRuntimes = contractPath
|
|
426
|
+
? (() => {
|
|
427
|
+
try { return validateContract(JSON.parse(readFileSync(resolve(contractPath), "utf8")), resolve(contractPath)).runtimes; } catch { return {}; }
|
|
428
|
+
})()
|
|
429
|
+
: /** @type {Record<string, import("../contract/index.mjs").ValidatedRuntime>} */ (DISCOVERY_RUNTIME_DEFINITIONS);
|
|
430
|
+
discovered = await discoverRuntimes(discoveryRuntimes, { cwd: dispatchCwd });
|
|
431
|
+
const available = Object.values(discovered).filter((entry) => entry.available).length;
|
|
432
|
+
checks.push({
|
|
433
|
+
name: "runtime discovery",
|
|
434
|
+
ok: available > 0,
|
|
435
|
+
detail: Object.entries(discovered).map(([id, entry]) => `${id}: ${entry.available ? "available" : `unavailable (${entry.reason})`}${entry.exhaustedUntil ? ` until ${entry.exhaustedUntil}` : ""}`).join(" · ") || "no runtimes discovered",
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
// A PATH-only check must not fail a runtime whose binary is supplied through
|
|
439
|
+
// an explicit executable or a FABERUN_*_BIN override; the harness probe above
|
|
440
|
+
// already validated whatever the runtime actually resolves to. `zcode` is
|
|
441
|
+
// absent on purpose — its binary is a shim the harness writes on first use, so
|
|
442
|
+
// a PATH miss here is the normal state of a fresh machine, not a missing
|
|
443
|
+
// dependency; `dsh` runs through its own SDK client, not a PATH binary.
|
|
444
|
+
for (const binary of ["codex", "claude", "agy", "exec-jsonl"]) {
|
|
445
|
+
const overrideName = /** @type {Record<string, string>} */ (HARNESS_BIN_OVERRIDES)[binary];
|
|
446
|
+
const overridden = overriddenHarnesses.has(binary) || Boolean(process.env[overrideName]);
|
|
447
|
+
const found = findExecutable(binary);
|
|
448
|
+
const required = usedHarnesses.has(binary) && !overridden;
|
|
449
|
+
checks.push({
|
|
450
|
+
name: `binary ${binary}`,
|
|
451
|
+
ok: !required || found !== null,
|
|
452
|
+
detail: overridden && !found ? "resolved via executable or env override" : required ? (found ?? "required by contract but not found on PATH") : (found ? "present" : "not on PATH (not required by this contract)"),
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
for (const check of environmentPreflight({ cwd: dispatchCwd, runtimes: routedRuntimes, harnessVersions }).checks) {
|
|
456
|
+
checks.push({ name: check.name, ok: check.ok || check.advisory, detail: check.ok ? check.detail : `${check.detail} (advisory)` });
|
|
457
|
+
}
|
|
458
|
+
const ok = checks.every((check) => check.ok);
|
|
459
|
+
const transportWarning = noTransportWarning(process.env);
|
|
460
|
+
if (transportWarning) process.stderr.write(`${statusToken("warn", colorLevel(process.env, process.stderr.isTTY))} ${transportWarning}\n`);
|
|
461
|
+
if (values.json === true) {
|
|
462
|
+
process.stdout.write(`${JSON.stringify({ schemaVersion: 1, repo: repoDir, ok, checks, ...(values.discover === true ? { runtimes: discovered } : {}) }, null, 2)}\n`);
|
|
463
|
+
} else {
|
|
464
|
+
const level = colorLevel(process.env, process.stdout.isTTY);
|
|
465
|
+
for (const check of checks) process.stdout.write(`${statusToken(check.ok ? "ok" : "fail", level)} ${check.name} · ${check.detail}\n`);
|
|
466
|
+
}
|
|
467
|
+
return ok;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* @param {string} repoDir
|
|
472
|
+
* @returns {boolean}
|
|
473
|
+
*/
|
|
474
|
+
function isGitWorkTree(repoDir) {
|
|
475
|
+
if (existsSync(join(repoDir, ".git"))) return true;
|
|
476
|
+
try {
|
|
477
|
+
const result = boundedGitSync(["-C", repoDir, "rev-parse", "--is-inside-work-tree"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
478
|
+
return result.status === 0 && String(result.stdout).trim() === "true";
|
|
479
|
+
} catch {
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* @param {string} repoDir
|
|
486
|
+
* @returns {boolean}
|
|
487
|
+
*/
|
|
488
|
+
function isRunsIgnored(repoDir) {
|
|
489
|
+
try {
|
|
490
|
+
const result = boundedGitSync(["-C", repoDir, "check-ignore", "-q", ".runs"], { stdio: ["ignore", "ignore", "ignore"] });
|
|
491
|
+
if (result.status === 0) return true;
|
|
492
|
+
} catch {
|
|
493
|
+
// A git that cannot run leaves the check-ignore answer unknown; fall through to reading .gitignore directly.
|
|
494
|
+
}
|
|
495
|
+
try {
|
|
496
|
+
const gitignore = readFileSync(join(repoDir, ".gitignore"), "utf8");
|
|
497
|
+
return gitignore.split(/\r?\n/u).some((line) => /^\.runs\/?$/u.test(line.trim()));
|
|
498
|
+
} catch {
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* The first directory on PATH that holds `name`, or null. Exported because the
|
|
505
|
+
* banner counts the harness binaries with the same lookup the doctor uses,
|
|
506
|
+
* rather than a second copy that can disagree.
|
|
507
|
+
*
|
|
508
|
+
* @param {string} name
|
|
509
|
+
* @returns {string|null}
|
|
510
|
+
*/
|
|
511
|
+
export function findExecutable(name) {
|
|
512
|
+
if (name.includes("/") || name.includes("\\")) return existsSync(name) ? name : null;
|
|
513
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
514
|
+
if (!dir) continue;
|
|
515
|
+
const candidate = join(dir, name);
|
|
516
|
+
if (existsSync(candidate)) return candidate;
|
|
517
|
+
}
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
|