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.
Files changed (44) hide show
  1. package/integrations/claude-code/statusline.sh +12 -2
  2. package/package.json +2 -2
  3. package/skills/faberun/references/operations.md +14 -7
  4. package/skills/init-agentkit/scripts/install-agentkit.sh +10 -2
  5. package/src/campaign/chain.mjs +5 -4
  6. package/src/campaign/metrics.mjs +3 -1
  7. package/src/cli/launch.mjs +10 -1
  8. package/src/cli.mjs +10 -4
  9. package/src/contract/index.mjs +16 -3
  10. package/src/contract/task-packet.mjs +13 -1
  11. package/src/engine/bulk-read.mjs +7 -1
  12. package/src/engine/gate.mjs +17 -3
  13. package/src/engine/judge-gate.mjs +2 -2
  14. package/src/engine/live-preflight.mjs +43 -10
  15. package/src/engine/live-silence.mjs +49 -0
  16. package/src/engine/process-identity.mjs +12 -1
  17. package/src/engine/process.mjs +2 -1
  18. package/src/engine/run-command.mjs +8 -5
  19. package/src/engine/run-identity.mjs +213 -14
  20. package/src/engine/runtime-discovery.mjs +26 -5
  21. package/src/engine/scheduler.mjs +1 -1
  22. package/src/engine/supervise.mjs +2 -1
  23. package/src/harnesses/catalogue.mjs +8 -1
  24. package/src/harnesses/dsh/runner.mjs +6 -1
  25. package/src/harnesses/index.mjs +35 -7
  26. package/src/host/platform.mjs +204 -1
  27. package/src/host/preflight.mjs +142 -7
  28. package/src/notify/index.mjs +77 -11
  29. package/src/notify/session.mjs +64 -24
  30. package/src/plan/pipeline.mjs +5 -1
  31. package/src/plan/preflight.mjs +77 -0
  32. package/src/repo/declared-paths.mjs +87 -6
  33. package/src/repo/signal.mjs +56 -21
  34. package/src/repo/workspace.mjs +3 -2
  35. package/src/repo/worktree.mjs +2 -1
  36. package/src/report/locale.mjs +20 -0
  37. package/src/report/message.mjs +2 -2
  38. package/src/report/next.mjs +15 -1
  39. package/src/run/availability.mjs +138 -0
  40. package/src/run/disk-gc.mjs +16 -2
  41. package/src/run/lock.mjs +8 -0
  42. package/src/run/paths.mjs +13 -0
  43. package/src/seat/allowance.mjs +4 -1
  44. package/src/seat/tmux.mjs +9 -1
@@ -24,7 +24,7 @@
24
24
  */
25
25
  import { renderStatusJson } from "./render.mjs";
26
26
  import { buildCampaignProgress, remainingEstimateMs } from "./progress.mjs";
27
- import { detectLanguage, labelsFor } from "./locale.mjs";
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(detectLanguage([campaign?.goal], journalTexts(runsDir, payload.campaignId), [...objectives.values()]));
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 };
@@ -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 single-quoted.
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
+ }
@@ -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
- if (!match || !path.includes(match)) return;
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
@@ -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
- child = spawn(command.executable, command.args, {
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
- const stdout = execFileSync("tmux", args, {
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,