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/report/message.mjs
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { renderStatusJson } from "./render.mjs";
|
|
26
26
|
import { buildCampaignProgress, remainingEstimateMs } from "./progress.mjs";
|
|
27
|
-
import {
|
|
27
|
+
import { chooseLanguage, labelsFor } from "./locale.mjs";
|
|
28
28
|
import { readNodeSnapshot } from "../run/node-store.mjs";
|
|
29
29
|
import { campaignDir } from "../campaign/layout.mjs";
|
|
30
30
|
import { readJournal } from "../campaign/journal.mjs";
|
|
@@ -106,7 +106,7 @@ export function renderRunProgress(runDir, event) {
|
|
|
106
106
|
const objectives = readObjectives(runDir);
|
|
107
107
|
const snapshots = new Map(payload.nodes.map((node) => [node.id, readSnapshotSafe(runDir, node.id)]));
|
|
108
108
|
const campaign = campaignSummary(runsDir, payload.campaignId);
|
|
109
|
-
const label = labelsFor(
|
|
109
|
+
const label = labelsFor(chooseLanguage(process.env, [campaign?.goal], journalTexts(runsDir, payload.campaignId), [...objectives.values()]));
|
|
110
110
|
const subject = subjectNode(payload.nodes, event.nodeId ?? null);
|
|
111
111
|
/** @type {View} */
|
|
112
112
|
const view = { runDir, runId: event.runId ?? basename(runDir), payload, objectives, snapshots, campaign, label, subject };
|
package/src/report/next.mjs
CHANGED
|
@@ -432,12 +432,26 @@ function renderLine(item) {
|
|
|
432
432
|
|
|
433
433
|
/**
|
|
434
434
|
* Quote a shell argument only when it needs it, so a normal path stays bare and
|
|
435
|
-
* a path with a space (or any other shell metacharacter) is
|
|
435
|
+
* a path with a space (or any other shell metacharacter) is quoted the way the
|
|
436
|
+
* shell reading this line quotes.
|
|
436
437
|
*
|
|
437
438
|
* @param {string} value
|
|
438
439
|
* @returns {string}
|
|
439
440
|
*/
|
|
440
441
|
function quoteArg(value) {
|
|
441
442
|
if (/^[A-Za-z0-9_@%+=:,./-]+$/u.test(value)) return value;
|
|
443
|
+
// A Windows path is not a POSIX word: every separator is a backslash, so the
|
|
444
|
+
// rule above rejects even a plain run directory. Quoting it the POSIX way
|
|
445
|
+
// would leave the line worse than bare — cmd.exe reads a single quote as a
|
|
446
|
+
// literal character and would look for a directory named with one. There the
|
|
447
|
+
// separator is ordinary, and only a space (or a character the shell reads)
|
|
448
|
+
// needs the quotes that platform does understand.
|
|
449
|
+
// `~` is in the set because a Windows temporary directory is routinely an
|
|
450
|
+
// 8.3 short name — `C:\Users\RUNNER~1\AppData\Local\Temp` on a CI runner —
|
|
451
|
+
// and nothing reads a tilde inside a path: cmd.exe has no expansion for it,
|
|
452
|
+
// and PowerShell expands one only at the start of a path.
|
|
453
|
+
if (process.platform === "win32") {
|
|
454
|
+
return /^[A-Za-z0-9_@%+=:,.~\\/-]+$/u.test(value) ? value : `"${value.replaceAll('"', '\\"')}"`;
|
|
455
|
+
}
|
|
442
456
|
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
443
457
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable store of live-preflight verdicts: which providers answered the
|
|
3
|
+
* dispatch gate's hello, and when.
|
|
4
|
+
*
|
|
5
|
+
* The gate's verdicts used to live only in a run's own env-preflight.json and
|
|
6
|
+
* died with the run, so every launch paid the ask again -- measured
|
|
7
|
+
* 2026-09-22 at about 18s for four runtimes in parallel. This store is the
|
|
8
|
+
* home that outlives the launch, and it is keyed on the provider -- harness,
|
|
9
|
+
* model and the executable actually resolved -- never on a contract's local
|
|
10
|
+
* runtime id: whether `codex` answers is a fact about this machine and this
|
|
11
|
+
* operator, so two contracts naming the same provider share one answer and
|
|
12
|
+
* one provider reached under two local names stays one record.
|
|
13
|
+
*
|
|
14
|
+
* A record is the catalogue's own RuntimeAvailability shape, so reuse is
|
|
15
|
+
* decided by `isRuntimeAvailable` -- the one home of the freshness rule --
|
|
16
|
+
* and the window a record names (`PREFLIGHT_WINDOW`) is the hello's own
|
|
17
|
+
* clock, declared beside the quota windows it must never be derived from.
|
|
18
|
+
*/
|
|
19
|
+
import { validateRuntimeAvailability } from "../contract/runtime.mjs";
|
|
20
|
+
import { isRuntimeAvailable, PREFLIGHT_WINDOW } from "../engine/runtime-discovery.mjs";
|
|
21
|
+
import { mkdirSync, readFileSync } from "node:fs";
|
|
22
|
+
import { dirname } from "node:path";
|
|
23
|
+
import { availabilityPath } from "./paths.mjs";
|
|
24
|
+
import { writeJsonAtomic } from "./store.mjs";
|
|
25
|
+
|
|
26
|
+
/** @typedef {import("../engine/runtime-discovery.mjs").RuntimeAvailability} RuntimeAvailability */
|
|
27
|
+
|
|
28
|
+
/** The store shape this module reads and writes; a foreign shape reads as empty. */
|
|
29
|
+
const STORE_SCHEMA_VERSION = 1;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The key one verdict is stored under. The executable is the path the harness
|
|
33
|
+
* adapter itself resolves -- the same string a probe reports -- so a runtime
|
|
34
|
+
* whose model, harness or binary changed is a different question by
|
|
35
|
+
* construction, and no second catalogue-change mechanism is needed. The
|
|
36
|
+
* contract-local runtime id is absent on purpose.
|
|
37
|
+
*
|
|
38
|
+
* @param {{harness: string, model: string, executable: string}} provider
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
export function availabilityKey(provider) {
|
|
42
|
+
return `${provider.harness}:${provider.model}:${provider.executable}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The fresh verdict stored for one provider, or null whenever nothing may
|
|
47
|
+
* skip the ask: no record, a record that fails its own validator, and an
|
|
48
|
+
* observation outside its window all read as null, because the caller's only
|
|
49
|
+
* fallback is to ask again and asking again is always safe.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} key
|
|
52
|
+
* @param {number} [now] epoch milliseconds; defaults to the current clock
|
|
53
|
+
* @returns {RuntimeAvailability|null}
|
|
54
|
+
*/
|
|
55
|
+
export function readAvailability(key, now = Date.now()) {
|
|
56
|
+
const record = loadVerdicts()[key];
|
|
57
|
+
return record && isRuntimeAvailable(record, now) ? record : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Record that the named providers answered the live preflight at `now`. The
|
|
62
|
+
* only verdict this store ever holds is "answered": silence
|
|
63
|
+
* (preflight_timeout, spawn_error) and a command that never reached a
|
|
64
|
+
* provider are the gate's to report and are never persisted, so the cache can
|
|
65
|
+
* never turn a pass into a block or the reverse -- it decides only whether
|
|
66
|
+
* the next launch asks.
|
|
67
|
+
*
|
|
68
|
+
* The observation instant is fixed when the verdict is recorded, never
|
|
69
|
+
* refreshed on read: a sliding window would let a continuously launching
|
|
70
|
+
* operator keep a long-dead provider admitted forever.
|
|
71
|
+
*
|
|
72
|
+
* A launch racing another on this store loses at most its own entries, and a
|
|
73
|
+
* lost verdict costs one extra ask -- operator-scale launches are not a hot
|
|
74
|
+
* loop, so the read-modify-write takes no lock.
|
|
75
|
+
*
|
|
76
|
+
* @param {string[]} keys
|
|
77
|
+
* @param {number} [now] epoch milliseconds; the instant the verdicts were observed
|
|
78
|
+
* @returns {void}
|
|
79
|
+
*/
|
|
80
|
+
export function recordAvailability(keys, now = Date.now()) {
|
|
81
|
+
if (keys.length === 0) return;
|
|
82
|
+
const store = loadStore();
|
|
83
|
+
for (const key of keys) {
|
|
84
|
+
store.verdicts[key] = {
|
|
85
|
+
available: true,
|
|
86
|
+
exhaustedUntil: null,
|
|
87
|
+
// This store's own verdict name: the provider answered the ask. Nothing
|
|
88
|
+
// here claims spend-readiness -- a refusal is an answer too.
|
|
89
|
+
reason: "answered",
|
|
90
|
+
observedAt: new Date(now).toISOString(),
|
|
91
|
+
window: PREFLIGHT_WINDOW,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const path = availabilityPath();
|
|
95
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
96
|
+
writeJsonAtomic(path, store);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @returns {{schemaVersion: number, verdicts: Record<string, RuntimeAvailability>}}
|
|
101
|
+
*/
|
|
102
|
+
function loadStore() {
|
|
103
|
+
try {
|
|
104
|
+
const parsed = JSON.parse(readFileSync(availabilityPath(), "utf8"));
|
|
105
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
106
|
+
&& parsed.schemaVersion === STORE_SCHEMA_VERSION
|
|
107
|
+
&& parsed.verdicts && typeof parsed.verdicts === "object" && !Array.isArray(parsed.verdicts)) {
|
|
108
|
+
return parsed;
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// ENOENT (the first launch on this machine) and a store a truncated write
|
|
112
|
+
// or a future shape left unparseable mean the same thing here: no verdict
|
|
113
|
+
// is known, so every provider is asked. That is the safe direction.
|
|
114
|
+
}
|
|
115
|
+
return { schemaVersion: STORE_SCHEMA_VERSION, verdicts: {} };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The stored verdicts that pass their own validator. Anything the store
|
|
120
|
+
* cannot vouch for is dropped rather than trusted or repaired: a dropped
|
|
121
|
+
* verdict costs one ask, a trusted one costs a launch gated on bytes nobody
|
|
122
|
+
* can type.
|
|
123
|
+
*
|
|
124
|
+
* @returns {Record<string, RuntimeAvailability>}
|
|
125
|
+
*/
|
|
126
|
+
function loadVerdicts() {
|
|
127
|
+
/** @type {Record<string, RuntimeAvailability>} */
|
|
128
|
+
const verdicts = {};
|
|
129
|
+
for (const [key, record] of Object.entries(loadStore().verdicts)) {
|
|
130
|
+
try {
|
|
131
|
+
validateRuntimeAvailability(record, `availability verdict ${key}`);
|
|
132
|
+
} catch {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
verdicts[key] = /** @type {RuntimeAvailability} */ (record);
|
|
136
|
+
}
|
|
137
|
+
return verdicts;
|
|
138
|
+
}
|
package/src/run/disk-gc.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* what a caller passes in.
|
|
17
17
|
*/
|
|
18
18
|
import { readFileSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
19
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
19
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
20
20
|
import { TERMINAL } from "../engine/prompts.mjs";
|
|
21
21
|
import { lockStale, readLock } from "./lock.mjs";
|
|
22
22
|
|
|
@@ -214,7 +214,11 @@ export function writeRunTextWithDiskPressureRetry(runDir, path, text) {
|
|
|
214
214
|
*/
|
|
215
215
|
function simulateEnospcForTest(path) {
|
|
216
216
|
const match = process.env.FABERUN_SIMULATE_ENOSPC_MATCH;
|
|
217
|
-
|
|
217
|
+
// The substring is authored in the portable spelling — a case names
|
|
218
|
+
// `nodes/build.json` — while the path carries this host's separator, so both
|
|
219
|
+
// are compared in that spelling. Raw, the match never fires on Windows and
|
|
220
|
+
// the injection is inert while the case still reports what it proved.
|
|
221
|
+
if (!match || !posixSpelling(path).includes(posixSpelling(match))) return;
|
|
218
222
|
const remaining = Number(process.env.FABERUN_SIMULATE_ENOSPC_COUNT ?? "0");
|
|
219
223
|
if (!Number.isInteger(remaining) || remaining <= 0) return;
|
|
220
224
|
process.env.FABERUN_SIMULATE_ENOSPC_COUNT = String(remaining - 1);
|
|
@@ -224,6 +228,16 @@ function simulateEnospcForTest(path) {
|
|
|
224
228
|
);
|
|
225
229
|
}
|
|
226
230
|
|
|
231
|
+
/**
|
|
232
|
+
* One path spelled with forward slashes, whatever separator this host uses.
|
|
233
|
+
*
|
|
234
|
+
* @param {string} path
|
|
235
|
+
* @returns {string}
|
|
236
|
+
*/
|
|
237
|
+
function posixSpelling(path) {
|
|
238
|
+
return path.split(sep).join("/");
|
|
239
|
+
}
|
|
240
|
+
|
|
227
241
|
/**
|
|
228
242
|
* Deterministic stand-in for "free space is still below the threshold",
|
|
229
243
|
* paired with `simulateEnospcForTest` so a case can prove the removal loop
|
package/src/run/lock.mjs
CHANGED
|
@@ -57,6 +57,14 @@ export function lockPath(runDir) {
|
|
|
57
57
|
* different one for whatever process next reuses that pid, without a
|
|
58
58
|
* compiled addon or elevated privileges. Every other platform has no cheap
|
|
59
59
|
* equivalent, so the pid probe alone decides there.
|
|
60
|
+
*
|
|
61
|
+
* Windows is that other platform, measured 2026-09-21 on Windows 11 26200:
|
|
62
|
+
* `wmic` — the one cheap process-table reader — is no longer installed, and
|
|
63
|
+
* the PowerShell that replaced it costs about 400 ms per probe cold, on a path
|
|
64
|
+
* a controller walks every time it reads a lock. So the token stays null and
|
|
65
|
+
* ownership falls back to liveness alone: a lock whose pid has been recycled
|
|
66
|
+
* into an unrelated process reads as still held there, and its holder has to
|
|
67
|
+
* be taken over by the stale-heartbeat path rather than recognized as gone.
|
|
60
68
|
* @param {number|null} pid @returns {string|null}
|
|
61
69
|
*/
|
|
62
70
|
export function processStartToken(pid) {
|
package/src/run/paths.mjs
CHANGED
|
@@ -134,6 +134,19 @@ export function runDirectory(cwd, runId) {
|
|
|
134
134
|
return join(runsRoot(cwd), runId);
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/**
|
|
138
|
+
* The live-preflight verdict store, at the top of the home. Whether a
|
|
139
|
+
* provider answers is a fact about this machine and this operator -- the same
|
|
140
|
+
* binary, model and credential whatever repository or contract asks -- so the
|
|
141
|
+
* record outlives any one run and is shared by every project. This module
|
|
142
|
+
* owns every path under the home and is the only place that names this one.
|
|
143
|
+
*
|
|
144
|
+
* @returns {string}
|
|
145
|
+
*/
|
|
146
|
+
export function availabilityPath() {
|
|
147
|
+
return join(faberunHome(), "availability.json");
|
|
148
|
+
}
|
|
149
|
+
|
|
137
150
|
/**
|
|
138
151
|
* The campaigns directory beneath a runs root: `<runs>/campaigns`.
|
|
139
152
|
* Composes `campaign/layout.mjs`'s `campaignsDir`, which owns the shape given
|
package/src/seat/allowance.mjs
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
23
23
|
import { getHarness } from "../harnesses/index.mjs";
|
|
24
|
+
import { spawnInvocation } from "../host/platform.mjs";
|
|
24
25
|
|
|
25
26
|
// `window` is optional on the type (not every construction site names one --
|
|
26
27
|
// `plan/pipeline.mjs` rebuilds a start sample from journal fields that predate
|
|
@@ -70,8 +71,10 @@ export function defaultInvoke(harness, { spawn = nodeSpawn } = {}) {
|
|
|
70
71
|
return new Promise((settle) => {
|
|
71
72
|
let child;
|
|
72
73
|
try {
|
|
73
|
-
|
|
74
|
+
const invocation = spawnInvocation(command.executable, command.args);
|
|
75
|
+
child = spawn(invocation.command, invocation.args, {
|
|
74
76
|
stdio: ["pipe", "pipe", "pipe"],
|
|
77
|
+
...invocation.options,
|
|
75
78
|
});
|
|
76
79
|
} catch {
|
|
77
80
|
settle(null);
|
package/src/seat/tmux.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { execFileSync } from "node:child_process";
|
|
11
11
|
import { NOTIFY_SESSION_ENV } from "../notify/session.mjs";
|
|
12
12
|
import { errorCode, exitStatus } from "../util.mjs";
|
|
13
|
+
import { spawnInvocation } from "../host/platform.mjs";
|
|
13
14
|
|
|
14
15
|
/** The single seat session every campaign window lives in. */
|
|
15
16
|
export const SEAT_SESSION = "faberun-seat";
|
|
@@ -43,8 +44,15 @@ const TMUX_TIMEOUT_MS = 10_000;
|
|
|
43
44
|
*/
|
|
44
45
|
function runTmux(args, options = {}) {
|
|
45
46
|
try {
|
|
46
|
-
|
|
47
|
+
// The seat reaches tmux the way this platform reaches any command: a
|
|
48
|
+
// name through PATHEXT, a shim through the interpreter that runs it.
|
|
49
|
+
// Nothing here claims tmux exists on Windows -- ADR 0009 says it does
|
|
50
|
+
// not -- only that the probe answers `tmux_unavailable` for the right
|
|
51
|
+
// reason instead of failing to spell the name.
|
|
52
|
+
const invocation = spawnInvocation("tmux", args, { cwd: options.cwd });
|
|
53
|
+
const stdout = execFileSync(invocation.command, invocation.args, {
|
|
47
54
|
cwd: options.cwd,
|
|
55
|
+
...invocation.options,
|
|
48
56
|
encoding: "utf8",
|
|
49
57
|
stdio: ["ignore", "pipe", "pipe"],
|
|
50
58
|
timeout: TMUX_TIMEOUT_MS,
|