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.
Files changed (40) hide show
  1. package/integrations/claude-code/statusline.sh +12 -2
  2. package/package.json +2 -2
  3. package/skills/init-agentkit/scripts/install-agentkit.sh +10 -2
  4. package/src/campaign/chain.mjs +5 -4
  5. package/src/cli/launch.mjs +10 -1
  6. package/src/cli.mjs +10 -4
  7. package/src/contract/index.mjs +16 -3
  8. package/src/contract/task-packet.mjs +13 -1
  9. package/src/engine/bulk-read.mjs +7 -1
  10. package/src/engine/gate.mjs +17 -3
  11. package/src/engine/judge-gate.mjs +2 -2
  12. package/src/engine/live-preflight.mjs +43 -10
  13. package/src/engine/live-silence.mjs +49 -0
  14. package/src/engine/process-identity.mjs +12 -1
  15. package/src/engine/process.mjs +2 -1
  16. package/src/engine/run-command.mjs +8 -5
  17. package/src/engine/run-identity.mjs +213 -14
  18. package/src/engine/runtime-discovery.mjs +26 -5
  19. package/src/engine/scheduler.mjs +1 -1
  20. package/src/engine/supervise.mjs +2 -1
  21. package/src/harnesses/catalogue.mjs +8 -1
  22. package/src/harnesses/dsh/runner.mjs +6 -1
  23. package/src/harnesses/index.mjs +35 -7
  24. package/src/host/platform.mjs +204 -1
  25. package/src/host/preflight.mjs +137 -5
  26. package/src/notify/index.mjs +7 -1
  27. package/src/notify/session.mjs +6 -1
  28. package/src/plan/pipeline.mjs +5 -1
  29. package/src/plan/preflight.mjs +77 -0
  30. package/src/repo/declared-paths.mjs +87 -6
  31. package/src/repo/signal.mjs +56 -21
  32. package/src/repo/workspace.mjs +3 -2
  33. package/src/repo/worktree.mjs +2 -1
  34. package/src/report/next.mjs +15 -1
  35. package/src/run/availability.mjs +138 -0
  36. package/src/run/disk-gc.mjs +16 -2
  37. package/src/run/lock.mjs +8 -0
  38. package/src/run/paths.mjs +13 -0
  39. package/src/seat/allowance.mjs +4 -1
  40. package/src/seat/tmux.mjs +9 -1
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { errorCode, exitStatus } from "../util.mjs";
14
14
  import { execFileSync } from "node:child_process";
15
+ import { gitArguments } from "../host/platform.mjs";
15
16
  import { join, resolve } from "node:path";
16
17
  import { lstatSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
17
18
  import { tmpdir } from "node:os";
@@ -70,7 +71,7 @@ function isUnobservableDeclaredPath(cwd, declaredPath, kind) {
70
71
  function gitDeclaredPathState(cwd, path, kind) {
71
72
  const literals = kind === "writeRoots" ? [path, `${path}/`] : [path];
72
73
  try {
73
- execFileSync("git", ["-C", cwd, "ls-files", "--cached", "--error-unmatch", "--", path], {
74
+ execFileSync("git", gitArguments(["-C", cwd, "ls-files", "--cached", "--error-unmatch", "--", path]), {
74
75
  stdio: ["ignore", "ignore", "ignore"],
75
76
  });
76
77
  return "tracked";
@@ -118,7 +119,7 @@ function checkCombinedGitIgnore(cwd, path, extraExclude) {
118
119
  const args = ["-C", cwd, "ls-files", "--others", "--exclude-standard"];
119
120
  if (extraExclude) args.push(`--exclude-from=${extraExclude}`);
120
121
  args.push("-z", "--", path);
121
- const output = execFileSync("git", args, { encoding: "buffer", stdio: ["ignore", "pipe", "ignore"] });
122
+ const output = execFileSync("git", gitArguments(args), { encoding: "buffer", stdio: ["ignore", "pipe", "ignore"] });
122
123
  return output.length === 0;
123
124
  } catch {
124
125
  return "unknown";
@@ -140,13 +141,13 @@ function checkMissingCombinedGitIgnore(cwd, path, extraExclude) {
140
141
  if (!extraExclude || standard === "unknown") return standard;
141
142
  const temporaryWorktree = mkdtempSync(join(tmpdir(), "faberun-ignore-check-"));
142
143
  try {
143
- execFileSync("git", ["init", "-q", temporaryWorktree], { stdio: ["ignore", "ignore", "ignore"] });
144
+ execFileSync("git", gitArguments(["init", "-q", temporaryWorktree]), { stdio: ["ignore", "ignore", "ignore"] });
144
145
  const temporaryGit = resolve(temporaryWorktree, ".git");
145
146
  writeFileSync(resolve(temporaryGit, "info", "exclude"), readFileSync(extraExclude), { mode: 0o600 });
146
147
  const args = ["--git-dir", temporaryGit, "--work-tree", temporaryWorktree, "-c", `core.excludesFile=${process.platform === "win32" ? "NUL" : "/dev/null"}`, "check-ignore", "--no-index", "--verbose", "--", path];
147
148
  let customMatched;
148
149
  try {
149
- execFileSync("git", args, { stdio: ["ignore", "ignore", "ignore"] });
150
+ execFileSync("git", gitArguments(args), { stdio: ["ignore", "ignore", "ignore"] });
150
151
  customMatched = true;
151
152
  } catch (error) {
152
153
  if (exitStatus(error) !== 1) return "unknown";
@@ -155,7 +156,7 @@ function checkMissingCombinedGitIgnore(cwd, path, extraExclude) {
155
156
 
156
157
  if (!customMatched) return standard;
157
158
  try {
158
- execFileSync("git", args.toSpliced(-3, 1, "--quiet"), { stdio: ["ignore", "ignore", "ignore"] });
159
+ execFileSync("git", gitArguments(args.toSpliced(-3, 1, "--quiet")), { stdio: ["ignore", "ignore", "ignore"] });
159
160
  return true;
160
161
  } catch (error) {
161
162
  return exitStatus(error) === 1 ? false : "unknown";
@@ -181,7 +182,7 @@ function checkGitIgnore(cwd, path, extraExclude) {
181
182
  if (extraExclude) args.push("-c", `core.excludesFile=${extraExclude}`);
182
183
  args.push("check-ignore", "--no-index", "--quiet", "--", path);
183
184
  try {
184
- execFileSync("git", args, { stdio: ["ignore", "ignore", "ignore"] });
185
+ execFileSync("git", gitArguments(args), { stdio: ["ignore", "ignore", "ignore"] });
185
186
  return true;
186
187
  } catch (error) {
187
188
  return exitStatus(error) === 1 ? false : "unknown";
@@ -224,3 +225,83 @@ function extractCommandTarget(line) {
224
225
  }
225
226
  return null;
226
227
  }
228
+
229
+ /**
230
+ * The `src/` layers a node writes whose mirrored `test/` directory no
231
+ * verification command touches.
232
+ *
233
+ * `test/` mirrors `src/` by directory, and that is an enforced rule of this
234
+ * tree rather than a habit, so "this node writes the engine and nothing runs
235
+ * the engine's tests" is a mechanical question with a mechanical answer.
236
+ *
237
+ * Measured 2026-09-22, and this is why it exists: a node rewrote the dispatch
238
+ * gate in `src/engine/run-identity.mjs`, verified `test/engine/live-gate`,
239
+ * `test/host/preflight` and `test/harnesses/replay-run`, passed every one of
240
+ * them, passed its judge, and broke 56 of the 385 tests in `test/engine/` --
241
+ * the directory its own module lives in. No command it ran opened that
242
+ * directory. The whole contract is searched, not only the node's own
243
+ * commands, because a shared or final command covering the layer is coverage
244
+ * just the same.
245
+ *
246
+ * A test any node of the contract writes does not count, and that distinction
247
+ * is the whole detector. The node above *did* name
248
+ * `test/engine/live-gate.test.mjs` on a command line -- the file it had just
249
+ * created -- and the run's final verification added
250
+ * `test/engine/live-verdict.test.mjs`, which its sibling had just created.
251
+ * Running the tests the campaign is adding proves those tests run, never that
252
+ * the layer still works. Coverage means naming the directory, or a file in it
253
+ * that no node in this contract writes.
254
+ *
255
+ * A warning, never a refusal: a layer can be honestly verified from another
256
+ * directory, and only the author knows. But an author who meant it reads one
257
+ * line, and an author who forgot is handed back a day.
258
+ *
259
+ * @param {ValidatedNode} node
260
+ * @param {number} index
261
+ * @param {string} cwd
262
+ * @param {string[]} contractCommands shared and final verification, already joined
263
+ * @param {Set<string>} contractWrites every path any node of the contract writes
264
+ * @returns {string[]}
265
+ */
266
+ export function mirrorCoverageWarnings(node, index, cwd, contractCommands = [], contractWrites = new Set()) {
267
+ const written = new Set(node.taskPacket.writeFiles ?? []);
268
+ const authored = new Set([...written, ...contractWrites]);
269
+ const tokens = [
270
+ ...node.taskPacket.verification.flatMap((command) => command.argv),
271
+ ...contractCommands.flatMap((line) => line.split(/\s+/u)),
272
+ ].filter((token) => !authored.has(token));
273
+ /** @type {Set<string>} */
274
+ const layers = new Set();
275
+ for (const path of written) {
276
+ const layer = mirroredLayer(path, cwd);
277
+ if (layer) layers.add(layer);
278
+ }
279
+ return [...layers].sort().flatMap((layer) => (
280
+ tokens.some((token) => token.includes(`test/${layer}/`))
281
+ ? []
282
+ : [`nodes[${index}] (${node.id}): writes src/${layer}/ but no verification command runs a test under test/${layer}/ that this contract does not itself write`]
283
+ ));
284
+ }
285
+
286
+ /**
287
+ * The `test/` directory mirroring a written `src/` path, or null when the path
288
+ * is not source or has no mirror. `src/cli.mjs` mirrors to `test/cli/` because
289
+ * the entry point is the layer; `src/util.mjs` has no mirror directory and by
290
+ * the tree's own rule owns no domain, so it names none.
291
+ *
292
+ * @param {string} path
293
+ * @param {string} cwd
294
+ * @returns {string|null}
295
+ */
296
+ function mirroredLayer(path, cwd) {
297
+ const match = /^src\/(?:([^/]+)\/|cli\.mjs$)/u.exec(path);
298
+ if (!match) return null;
299
+ const layer = match[1] ?? "cli";
300
+ try {
301
+ return lstatSync(join(cwd, "test", layer)).isDirectory() ? layer : null;
302
+ } catch {
303
+ // ENOENT: a layer with no mirrored test directory cannot be uncovered by
304
+ // one, so there is nothing to warn about.
305
+ return null;
306
+ }
307
+ }
@@ -17,9 +17,21 @@
17
17
  * `attention` entry from `.runs/inbox.jsonl`. An attention belongs to one
18
18
  * campaign or none: an explicit `campaignId` decides, a null one is resolved
19
19
  * from the entry's `runId` (a run belongs to at most one campaign), and an
20
- * entry whose run no active campaign owns is shown once at run level instead
20
+ * entry whose run no campaign at all owns is shown once at run level instead
21
21
  * of under every campaign at once. It is bounded, because every session pays
22
22
  * for it in its first tokens.
23
+ *
24
+ * `parked` is decided by `classifyRunProgress`, never by reading `runOutcome`
25
+ * here. `reduceRunOutcome` is a reduction over snapshots with no notion of
26
+ * in-flight: a node whose status is `running` is not a success, so it reduces
27
+ * to `parked` while the controller is still working on it. Measured
28
+ * 2026-09-22 on a live run: `runOutcome "parked"`, one node `running`,
29
+ * `controllerAlive true`. The linked-run renderer read that field raw and told
30
+ * a takeover session to `resume` a run that was 23 minutes into its second
31
+ * node. The standalone renderer had the conjunct (`&& state === "done"`) and
32
+ * was right; two copies of one rule, one of them incomplete, so now there is
33
+ * one copy and it lives in `chain.mjs` with the campaign decision it also
34
+ * governs.
23
35
  */
24
36
 
25
37
  import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -30,6 +42,7 @@ import { readInbox } from "../notify/index.mjs";
30
42
  import { repositoryForRunsDir } from "../run/paths.mjs";
31
43
  import { SIGNAL_END, SIGNAL_START } from "./signal-block.mjs";
32
44
  import { HANDOFF_FILE } from "../campaign/layout.mjs";
45
+ import { classifyRunProgress } from "../campaign/chain.mjs";
33
46
 
34
47
  export { SIGNAL_END, SIGNAL_START } from "./signal-block.mjs";
35
48
 
@@ -51,12 +64,15 @@ export function renderAgentSignalBlock(runsDir) {
51
64
  const lines = [];
52
65
  /** @type {Set<string>} */
53
66
  const linked = new Set();
54
- const active = discoverCampaigns(runsDir).campaigns.filter(({ campaign }) => campaign.status !== "closed");
55
- const ownerByRun = runOwnerIndex(active);
67
+ const discovered = discoverCampaigns(runsDir).campaigns;
68
+ // A run any campaign linked is that campaign's business, open or settled.
69
+ // Only a run no campaign ever named reaches the standalone list.
70
+ for (const { campaign } of discovered) for (const runId of campaign.linkedRunIds) linked.add(runId);
71
+ const active = discovered.filter(({ campaign }) => campaign.status !== "closed");
72
+ const ownerByRun = runOwnerIndex(discovered);
56
73
  for (const { campaign } of active) {
57
74
  lines.push(`- faberun campaign \`${campaign.id}\`: active — read \`.runs/campaigns/${campaign.id}/${HANDOFF_FILE}\``);
58
75
  for (const runId of campaign.linkedRunIds) {
59
- linked.add(runId);
60
76
  lines.push(...runSignalLines(runsDir, runId));
61
77
  }
62
78
  const attention = campaignAttentionLine(runsDir, campaign, ownerByRun);
@@ -70,19 +86,26 @@ export function renderAgentSignalBlock(runsDir) {
70
86
  }
71
87
 
72
88
  /**
73
- * Which active campaign owns each run, read from the campaigns' own
74
- * `linkedRunIds`. A run belongs to at most one campaign, so the first
75
- * campaign naming a run wins; a run no active campaign names is absent from
76
- * the map, and that absence — never a guess — is what makes an inbox entry
77
- * unattributable.
89
+ * Which campaign owns each run, read from the campaigns' own `linkedRunIds`.
90
+ * A run belongs to at most one campaign, so the first campaign naming a run
91
+ * wins; a run no campaign names is absent from the map, and that absence —
92
+ * never a guess — is what makes an inbox entry unattributable.
93
+ *
94
+ * Closed campaigns are indexed too, and that is the point: an attention from
95
+ * a closed campaign's run is owned, not orphaned. Indexing only the active
96
+ * ones made every such attention unattributable, so it was surfaced at run
97
+ * level and stayed there for good. Measured 2026-09-22: the live block
98
+ * carried one from `harden-chain-and-verification`, a campaign closed on
99
+ * 2026-09-16. An owned attention under no active campaign is simply not
100
+ * shown, which is the correct answer for work its campaign already settled.
78
101
  *
79
- * @param {{campaign: import("../campaign/index.mjs").Campaign}[]} active
102
+ * @param {{campaign: import("../campaign/index.mjs").Campaign}[]} campaigns
80
103
  * @returns {Map<string, string>}
81
104
  */
82
- function runOwnerIndex(active) {
105
+ function runOwnerIndex(campaigns) {
83
106
  /** @type {Map<string, string>} */
84
107
  const ownerByRun = new Map();
85
- for (const { campaign } of active) {
108
+ for (const { campaign } of campaigns) {
86
109
  for (const runId of campaign.linkedRunIds) {
87
110
  if (!ownerByRun.has(runId)) ownerByRun.set(runId, campaign.id);
88
111
  }
@@ -91,9 +114,9 @@ function runOwnerIndex(active) {
91
114
  }
92
115
 
93
116
  /**
94
- * One linked run's outcome as block lines. `runProgress` folds the phase-2
95
- * `runOutcome`, so a parked run is rendered by its own declared nodes rather
96
- * than being dropped for having no non-terminal one.
117
+ * One linked run's outcome as block lines. A parked run is rendered by its own
118
+ * declared nodes rather than being dropped for having no non-terminal one --
119
+ * but only once `classifyRunProgress` agrees it settled.
97
120
  *
98
121
  * @param {string} runsDir
99
122
  * @param {string} runId
@@ -106,7 +129,7 @@ function runSignalLines(runsDir, runId) {
106
129
  return [` - run \`${runId}\`: missing — resume \`${resume}\``];
107
130
  }
108
131
  const progress = runProgress(runDir);
109
- switch (progress.runOutcome) {
132
+ switch (classifyRunProgress(progress)) {
110
133
  case "succeeded":
111
134
  return [` - run \`${runId}\`: succeeded (${progress.terminal}/${progress.total} nodes)`];
112
135
  case "canceled":
@@ -116,8 +139,8 @@ function runSignalLines(runsDir, runId) {
116
139
  case "parked":
117
140
  break;
118
141
  default:
119
- // No snapshot yet: the run exists but has not proved an outcome. It is
120
- // active work, not parked work, so it must not claim parked nodes.
142
+ // `unfinished`: either no snapshot yet, or a node still running. Both are
143
+ // active work, not parked work, so neither may claim parked nodes.
121
144
  return [` - run \`${runId}\`: active — read \`.runs/${runId}/STATUS.md\`; \`resume\` or \`supervise\` it`];
122
145
  }
123
146
  const nodes = progress.outcomeNodes ?? [];
@@ -127,9 +150,20 @@ function runSignalLines(runsDir, runId) {
127
150
  }
128
151
 
129
152
  /**
130
- * Standalone runs that no active campaign links. A parked run is included, so
153
+ * Standalone runs that no campaign links at all. A parked run is included, so
131
154
  * a run with no campaign at all still blocks a naive "start fresh".
132
155
  *
156
+ * `linked` spans closed campaigns too. Closing a campaign is the operator
157
+ * saying its work is settled, but it used to *promote* that campaign's parked
158
+ * runs: they stopped being indented children of a campaign and became
159
+ * top-level standalone entries, then stayed there forever. Measured
160
+ * 2026-09-22: 26 closed campaigns in this project, and the block carried
161
+ * seven parked runs from three of them -- adversarial-planner,
162
+ * become-faberun, chain-ergonomics-and-fairness -- against one live run,
163
+ * each with a `resume` command for work a closed campaign had already
164
+ * settled. Every session in the repository paid for those lines in its first
165
+ * tokens and had to decide, one by one, not to act on them.
166
+ *
133
167
  * @param {string} runsDir
134
168
  * @param {Set<string>} linked
135
169
  * @returns {string[]}
@@ -147,9 +181,10 @@ function activeRunLines(runsDir, linked) {
147
181
  // A nodes directory with no committed snapshot is a creation in progress,
148
182
  // not evidence of work; the old renderer left it off and so does this one.
149
183
  if (progress.runOutcome === undefined && progress.total === 0) continue;
150
- if (progress.runOutcome === "succeeded" || progress.runOutcome === "canceled") continue;
184
+ const classified = classifyRunProgress(progress);
185
+ if (classified === "succeeded" || classified === "canceled") continue;
151
186
  const resume = `node src/cli.mjs resume ${runDir}`;
152
- if (progress.runOutcome === "parked" && progress.state === "done") {
187
+ if (classified === "parked") {
153
188
  const nodes = (progress.outcomeNodes ?? []).slice(0, MAX_PARKED_NODES).map(nodeText).join(", ");
154
189
  lines.push(`- faberun run \`${name}\`: parked — ${nodes || "no nodes named"} — resume \`${resume}\``);
155
190
  } else {
@@ -14,6 +14,7 @@
14
14
  * because the verification schema happened to be in the same file.
15
15
  */
16
16
  import { Buffer } from "node:buffer";
17
+ import { gitArguments } from "../host/platform.mjs";
17
18
  import { VERIFICATION_LIMITS } from "../contract/verification.mjs";
18
19
  import { basename, isAbsolute, relative, resolve } from "node:path";
19
20
  import { closeSync, lstatSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, statSync } from "node:fs";
@@ -278,7 +279,7 @@ function captureIgnoreSources(root) {
278
279
  /** @param {string} name @param {string} logical @returns {string|null} */
279
280
  const resolveGitPath = (name, logical) => {
280
281
  try {
281
- const value = execFileSync("git", ["-C", root, "rev-parse", "--git-path", name], {
282
+ const value = execFileSync("git", gitArguments(["-C", root, "rev-parse", "--git-path", name]), {
282
283
  encoding: "utf8",
283
284
  stdio: ["ignore", "pipe", "ignore"],
284
285
  }).trim();
@@ -460,7 +461,7 @@ function relevantWorkspacePaths(cwd) {
460
461
  args.push("-z");
461
462
  let output;
462
463
  try {
463
- output = execFileSync("git", args, {
464
+ output = execFileSync("git", gitArguments(args), {
464
465
  cwd,
465
466
  encoding: "buffer",
466
467
  maxBuffer: VERIFICATION_LIMITS.snapshotEntries * (VERIFICATION_LIMITS.snapshotPathBytes + 1) + 1,
@@ -1,4 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { gitArguments } from "../host/platform.mjs";
2
3
  import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
3
4
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
5
  import { RUNS_DIR_NAME, attemptWorktreePath, candidateWorktreePath } from "../run/paths.mjs";
@@ -46,7 +47,7 @@ function gitSyncTimeoutMs(optionMs) {
46
47
  */
47
48
  export function boundedGitSync(args, options = {}) {
48
49
  const timeoutMs = gitSyncTimeoutMs(options.timeoutMs);
49
- const result = spawnSync("git", args, {
50
+ const result = spawnSync("git", gitArguments(args), {
50
51
  encoding: options.encoding ?? "utf8",
51
52
  stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
52
53
  timeout: timeoutMs,
@@ -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,