faberun 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/integrations/claude-code/statusline.sh +12 -2
- package/package.json +2 -2
- package/skills/init-agentkit/scripts/install-agentkit.sh +10 -2
- package/src/campaign/chain.mjs +5 -4
- package/src/cli/launch.mjs +10 -1
- package/src/cli.mjs +10 -4
- package/src/contract/index.mjs +16 -3
- package/src/contract/task-packet.mjs +13 -1
- package/src/engine/bulk-read.mjs +7 -1
- package/src/engine/gate.mjs +17 -3
- package/src/engine/judge-gate.mjs +2 -2
- package/src/engine/live-preflight.mjs +43 -10
- package/src/engine/live-silence.mjs +49 -0
- package/src/engine/process-identity.mjs +12 -1
- package/src/engine/process.mjs +2 -1
- package/src/engine/run-command.mjs +8 -5
- package/src/engine/run-identity.mjs +213 -14
- package/src/engine/runtime-discovery.mjs +26 -5
- package/src/engine/scheduler.mjs +1 -1
- package/src/engine/supervise.mjs +2 -1
- package/src/harnesses/catalogue.mjs +8 -1
- package/src/harnesses/dsh/runner.mjs +6 -1
- package/src/harnesses/index.mjs +35 -7
- package/src/host/platform.mjs +204 -1
- package/src/host/preflight.mjs +137 -5
- package/src/notify/index.mjs +7 -1
- package/src/notify/session.mjs +6 -1
- package/src/plan/pipeline.mjs +5 -1
- package/src/plan/preflight.mjs +77 -0
- package/src/repo/declared-paths.mjs +87 -6
- package/src/repo/signal.mjs +56 -21
- package/src/repo/workspace.mjs +3 -2
- package/src/repo/worktree.mjs +2 -1
- package/src/report/next.mjs +15 -1
- package/src/run/availability.mjs +138 -0
- package/src/run/disk-gc.mjs +16 -2
- package/src/run/lock.mjs +8 -0
- package/src/run/paths.mjs +13 -0
- package/src/seat/allowance.mjs +4 -1
- package/src/seat/tmux.mjs +9 -1
|
@@ -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
|
+
}
|
package/src/repo/signal.mjs
CHANGED
|
@@ -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
|
|
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
|
|
55
|
-
|
|
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
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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}[]}
|
|
102
|
+
* @param {{campaign: import("../campaign/index.mjs").Campaign}[]} campaigns
|
|
80
103
|
* @returns {Map<string, string>}
|
|
81
104
|
*/
|
|
82
|
-
function runOwnerIndex(
|
|
105
|
+
function runOwnerIndex(campaigns) {
|
|
83
106
|
/** @type {Map<string, string>} */
|
|
84
107
|
const ownerByRun = new Map();
|
|
85
|
-
for (const { campaign } of
|
|
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.
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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
|
|
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
|
-
//
|
|
120
|
-
// active work, not parked work, so
|
|
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
|
|
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
|
-
|
|
184
|
+
const classified = classifyRunProgress(progress);
|
|
185
|
+
if (classified === "succeeded" || classified === "canceled") continue;
|
|
151
186
|
const resume = `node src/cli.mjs resume ${runDir}`;
|
|
152
|
-
if (
|
|
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 {
|
package/src/repo/workspace.mjs
CHANGED
|
@@ -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,
|
package/src/repo/worktree.mjs
CHANGED
|
@@ -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,
|
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,
|