faberun 0.26.3 → 0.28.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/README.md +11 -4
- package/package.json +1 -1
- package/skills/faberun/SKILL.md +3 -3
- package/skills/faberun/references/contract.md +9 -9
- package/skills/faberun/references/local-env.md +11 -0
- package/skills/faberun/references/operations.md +26 -33
- package/src/campaign/brief.mjs +41 -9
- package/src/campaign/campaign-brief.mjs +18 -3
- package/src/campaign/index.mjs +22 -9
- package/src/campaign/ledger.mjs +72 -4
- package/src/campaign/metrics.mjs +29 -2
- package/src/campaign/record.mjs +34 -0
- package/src/cli/campaign.mjs +35 -12
- package/src/cli/contract.mjs +11 -1
- package/src/cli/launch.mjs +12 -2
- package/src/cli/manual.mjs +24 -8
- package/src/cli/setup.mjs +6 -1
- package/src/cli/skills.mjs +18 -7
- package/src/cli/uninstall.mjs +542 -0
- package/src/cli/walkthrough.mjs +462 -0
- package/src/cli.mjs +68 -8
- package/src/contract/articles.mjs +30 -0
- package/src/contract/definition-of-done.mjs +109 -0
- package/src/contract/index.mjs +7 -4
- package/src/contract/judge-independence.mjs +18 -0
- package/src/contract/runtime.mjs +6 -2
- package/src/contract/scope-findings.mjs +120 -0
- package/src/contract/snapshot.mjs +19 -5
- package/src/contract/task-packet.mjs +49 -1
- package/src/contract/worker-result.mjs +10 -0
- package/src/engine/assignment.mjs +45 -0
- package/src/engine/detach.mjs +18 -0
- package/src/engine/gate.mjs +10 -2
- package/src/engine/judge-gate.mjs +24 -9
- package/src/engine/live-preflight.mjs +114 -18
- package/src/engine/prompts.mjs +41 -3
- package/src/engine/resume.mjs +119 -12
- package/src/engine/retry.mjs +11 -11
- package/src/engine/run-command.mjs +80 -4
- package/src/engine/run-identity.mjs +27 -14
- package/src/engine/runtime-discovery.mjs +61 -13
- package/src/engine/worker-env.mjs +179 -0
- package/src/harnesses/agy/index.mjs +11 -0
- package/src/harnesses/catalogue.mjs +54 -2
- package/src/harnesses/claude/index.mjs +177 -2
- package/src/harnesses/codex/index.mjs +15 -0
- package/src/harnesses/dsh/index.mjs +18 -116
- package/src/harnesses/dsh/runner.mjs +1 -17
- package/src/harnesses/exec-jsonl/index.mjs +12 -0
- package/src/harnesses/fx/home.mjs +57 -0
- package/src/harnesses/fx/index.mjs +137 -0
- package/src/harnesses/fx/native/build.zig +20 -0
- package/src/harnesses/fx/native/build.zig.zon +7 -0
- package/src/harnesses/fx/native/home.zig +85 -0
- package/src/harnesses/fx/native/main.zig +503 -0
- package/src/harnesses/fx/native/permissions.zig +51 -0
- package/src/harnesses/fx/native/relay.zig +253 -0
- package/src/harnesses/fx/native/usage.zig +112 -0
- package/src/harnesses/fx/permissions.mjs +35 -0
- package/src/harnesses/fx/runner.mjs +350 -0
- package/src/harnesses/fx/usage-proxy.mjs +176 -0
- package/src/harnesses/index.mjs +29 -14
- package/src/harnesses/replay/index.mjs +12 -0
- package/src/harnesses/runner-io.mjs +25 -0
- package/src/harnesses/runner-transcript.mjs +127 -0
- package/src/harnesses/session-metrics.mjs +38 -13
- package/src/harnesses/zcode/index.mjs +19 -0
- package/src/host/config.mjs +8 -2
- package/src/host/environment-report.mjs +85 -0
- package/src/host/home.mjs +27 -0
- package/src/host/install-registry.mjs +511 -0
- package/src/host/preflight.mjs +34 -10
- package/src/host/uninstall-roots.mjs +196 -0
- package/src/plan/proof-check.mjs +15 -0
- package/src/plan/repo-facts.mjs +95 -11
- package/src/plan/rounds.mjs +27 -0
- package/src/repo/declared-paths.mjs +74 -6
- package/src/repo/integrate.mjs +1 -1
- package/src/repo/scope-closure.mjs +59 -0
- package/src/repo/signal-block.mjs +26 -0
- package/src/repo/signal.mjs +9 -24
- package/src/repo/workspace.mjs +189 -14
- package/src/report/campaign-brief.mjs +4 -0
- package/src/report/message.mjs +37 -6
- package/src/report/progress.mjs +13 -2
- package/src/run/paths.mjs +14 -1
- package/src/run/usage.mjs +66 -1
- package/src/util.mjs +18 -2
package/src/repo/workspace.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { Buffer } from "node:buffer";
|
|
|
17
17
|
import { gitArguments } from "../host/platform.mjs";
|
|
18
18
|
import { VERIFICATION_LIMITS } from "../contract/verification.mjs";
|
|
19
19
|
import { basename, isAbsolute, relative, resolve } from "node:path";
|
|
20
|
-
import { closeSync, lstatSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, statSync } from "node:fs";
|
|
20
|
+
import { closeSync, lstatSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
21
21
|
import { createHash } from "node:crypto";
|
|
22
22
|
import { errorCode } from "../util.mjs";
|
|
23
23
|
import { execFileSync } from "node:child_process";
|
|
@@ -28,10 +28,14 @@ import { RUNS_DIR_NAME } from "../run/paths.mjs";
|
|
|
28
28
|
/** A `node_modules` directory at any depth, matched as a whole path segment. */
|
|
29
29
|
const NODE_MODULES_SEGMENT = /(?:^|\/)node_modules(?:\/|$)/u;
|
|
30
30
|
|
|
31
|
+
/** Ignore sources at most this large are kept verbatim so base rules can be replayed. */
|
|
32
|
+
const IGNORE_SOURCE_CONTENT_LIMIT = 64 * 1024;
|
|
33
|
+
|
|
31
34
|
/**
|
|
32
|
-
* One entry of a workspace snapshot.
|
|
35
|
+
* One entry of a workspace snapshot. `content` is present on text ignore
|
|
36
|
+
* sources small enough to replay the base ignore rules for a declared edit.
|
|
33
37
|
*
|
|
34
|
-
* @typedef {{path: string, kind: "file"|"symlink"|"missing", digest?: string, size?: number}} SnapshotEntry
|
|
38
|
+
* @typedef {{path: string, kind: "file"|"symlink"|"missing", digest?: string, size?: number, content?: string}} SnapshotEntry
|
|
35
39
|
*/
|
|
36
40
|
/** @typedef {{entries: SnapshotEntry[], ignoreSources: SnapshotEntry[], truncated: boolean}} WorkspaceSnapshot */
|
|
37
41
|
/** @typedef {{literal: string, paths: string[]}} WorkspaceScopeOrigin */
|
|
@@ -55,15 +59,29 @@ export function captureWorkspaceSnapshot(cwd, expectedIgnoreSources) {
|
|
|
55
59
|
if (expectedIgnoreSources !== undefined && !sameSnapshotEntries(expectedIgnoreSources, ignoreSources)) {
|
|
56
60
|
throw ignoreSourcesChanged(expectedIgnoreSources, ignoreSources);
|
|
57
61
|
}
|
|
62
|
+
return { entries: captureWorkspaceEntries(root, relevantWorkspacePaths(root)), ignoreSources, truncated: false };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Hash the observable workspace paths into snapshot entries. This is separate
|
|
66
|
+
* from `captureWorkspaceSnapshot` so a declared ignore-source edit can be
|
|
67
|
+
* enumerated with the base ignore rules and still hash the current file
|
|
68
|
+
* content, which is what makes the edit count as a declared write.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} root
|
|
71
|
+
* @param {string[]} paths
|
|
72
|
+
* @returns {SnapshotEntry[]}
|
|
73
|
+
*/
|
|
74
|
+
function captureWorkspaceEntries(root, paths) {
|
|
58
75
|
/** @type {SnapshotEntry[]} */
|
|
59
76
|
const entries = [];
|
|
60
|
-
|
|
77
|
+
/** @param {SnapshotEntry} entry */
|
|
78
|
+
const add = (entry) => {
|
|
61
79
|
if (entries.length >= VERIFICATION_LIMITS.snapshotEntries) {
|
|
62
80
|
throw fail("snapshot_too_large", `workspace snapshot exceeds ${VERIFICATION_LIMITS.snapshotEntries} entries`);
|
|
63
81
|
}
|
|
64
82
|
entries.push(entry);
|
|
65
83
|
};
|
|
66
|
-
for (const rel of
|
|
84
|
+
for (const rel of paths) {
|
|
67
85
|
if (Buffer.byteLength(rel, "utf8") > VERIFICATION_LIMITS.snapshotPathBytes) {
|
|
68
86
|
throw fail("snapshot_path_too_long", `workspace path exceeds ${VERIFICATION_LIMITS.snapshotPathBytes} bytes: ${rel.slice(0, 128)}`);
|
|
69
87
|
}
|
|
@@ -99,7 +117,7 @@ export function captureWorkspaceSnapshot(cwd, expectedIgnoreSources) {
|
|
|
99
117
|
throw fail("snapshot_unsupported_entry", `unsupported workspace entry: ${rel}`);
|
|
100
118
|
}
|
|
101
119
|
}
|
|
102
|
-
return
|
|
120
|
+
return entries;
|
|
103
121
|
}
|
|
104
122
|
/**
|
|
105
123
|
* @param {WorkspaceSnapshot|undefined} before
|
|
@@ -121,10 +139,22 @@ export function compareWorkspaceSnapshot(before, cwd, scope = {}) {
|
|
|
121
139
|
files: expandScopePaths(root, normalizeScopePaths(scope.files ?? [], "files")),
|
|
122
140
|
roots: expandScopePaths(root, normalizeScopePaths(scope.roots ?? [], "roots")),
|
|
123
141
|
});
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
142
|
+
const currentIgnoreSources = captureIgnoreSources(root);
|
|
143
|
+
const changedIgnoreSources = changedSnapshotPaths(before.ignoreSources, currentIgnoreSources);
|
|
144
|
+
// A changed ignore source inside the declared scope is the node's own work:
|
|
145
|
+
// it is judged under the base rules instead of failing. Anything outside the
|
|
146
|
+
// scope still fails closed before scope matching.
|
|
147
|
+
if (changedIgnoreSources.some((path) => !isDeclaredScopePath(allowed, path))) {
|
|
148
|
+
throw ignoreSourcesChanged(before.ignoreSources, currentIgnoreSources);
|
|
127
149
|
}
|
|
150
|
+
/** @type {WorkspaceSnapshot} */
|
|
151
|
+
const after = {
|
|
152
|
+
entries: changedIgnoreSources.length
|
|
153
|
+
? captureWorkspaceEntriesUnderBaseRules(root, before.ignoreSources, changedIgnoreSources)
|
|
154
|
+
: captureWorkspaceEntries(root, relevantWorkspacePaths(root)),
|
|
155
|
+
ignoreSources: currentIgnoreSources,
|
|
156
|
+
truncated: false,
|
|
157
|
+
};
|
|
128
158
|
const prior = new Map(before.entries.map((/** @type {SnapshotEntry} */ entry) => [entry.path, JSON.stringify(entry)]));
|
|
129
159
|
const current = new Map(after.entries.map((/** @type {SnapshotEntry} */ entry) => [entry.path, JSON.stringify(entry)]));
|
|
130
160
|
const changed = new Set();
|
|
@@ -142,6 +172,143 @@ export function compareWorkspaceSnapshot(before, cwd, scope = {}) {
|
|
|
142
172
|
!directoryRoots.some((scopeRoot) => path === scopeRoot || path.startsWith(`${scopeRoot}/`)));
|
|
143
173
|
return { after, changedPaths, unexpectedPaths };
|
|
144
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Whether a workspace-relative path falls inside the captured scope boundary,
|
|
177
|
+
* using the same file-root versus directory-root rule as the entry comparison.
|
|
178
|
+
*
|
|
179
|
+
* @param {WorkspaceScopeBoundary} allowed
|
|
180
|
+
* @param {string} path
|
|
181
|
+
* @returns {boolean}
|
|
182
|
+
*/
|
|
183
|
+
function isDeclaredScopePath(allowed, path) {
|
|
184
|
+
const fileRoots = new Set(allowed.fileRoots ?? []);
|
|
185
|
+
if (allowed.files.includes(path) || fileRoots.has(path)) return true;
|
|
186
|
+
return allowed.roots.some((scopeRoot) => !fileRoots.has(scopeRoot) && (path === scopeRoot || path.startsWith(`${scopeRoot}/`)));
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The ignore sources whose fingerprint moved between two snapshots.
|
|
190
|
+
*
|
|
191
|
+
* @param {SnapshotEntry[]} before
|
|
192
|
+
* @param {SnapshotEntry[]} after
|
|
193
|
+
* @returns {string[]}
|
|
194
|
+
*/
|
|
195
|
+
function changedSnapshotPaths(before, after) {
|
|
196
|
+
const prior = new Map(before.map((entry) => [entry.path, snapshotEntryKey(entry)]));
|
|
197
|
+
const current = new Map(after.map((entry) => [entry.path, snapshotEntryKey(entry)]));
|
|
198
|
+
return [...new Set([...prior.keys(), ...current.keys()])].filter((path) => prior.get(path) !== current.get(path)).sort();
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The identity of a snapshot entry for comparison. `content` is deliberately
|
|
202
|
+
* excluded: it is only carried to replay base ignore rules, so a persisted
|
|
203
|
+
* snapshot captured before that field existed still compares equal.
|
|
204
|
+
*
|
|
205
|
+
* @param {SnapshotEntry} entry
|
|
206
|
+
* @returns {string}
|
|
207
|
+
*/
|
|
208
|
+
function snapshotEntryKey(entry) {
|
|
209
|
+
return JSON.stringify({ path: entry.path, kind: entry.kind, digest: entry.digest, size: entry.size });
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The after entries for a declared ignore-source edit. Paths are enumerated
|
|
213
|
+
* with the ignore rules captured before the attempt, so a worker cannot edit a
|
|
214
|
+
* declared ignore file to hide a change the base rules would have shown. The
|
|
215
|
+
* changed sources are put back afterwards and hashed from their current
|
|
216
|
+
* content, so the edit itself still counts as a declared write.
|
|
217
|
+
*
|
|
218
|
+
* @param {string} root
|
|
219
|
+
* @param {SnapshotEntry[]} beforeIgnoreSources
|
|
220
|
+
* @param {string[]} changedIgnoreSources
|
|
221
|
+
* @returns {SnapshotEntry[]}
|
|
222
|
+
*/
|
|
223
|
+
function captureWorkspaceEntriesUnderBaseRules(root, beforeIgnoreSources, changedIgnoreSources) {
|
|
224
|
+
const beforeByPath = new Map(beforeIgnoreSources.map((entry) => [entry.path, entry]));
|
|
225
|
+
/** @type {{path: string, saved: ReplayableFileState}[]} */
|
|
226
|
+
const restorations = [];
|
|
227
|
+
/** @type {string[]} */
|
|
228
|
+
let paths = [];
|
|
229
|
+
try {
|
|
230
|
+
for (const path of changedIgnoreSources) {
|
|
231
|
+
const beforeEntry = beforeByPath.get(path);
|
|
232
|
+
if (beforeEntry !== undefined && typeof beforeEntry.content !== "string") continue;
|
|
233
|
+
const child = ignoreSourceAbsolutePath(root, path);
|
|
234
|
+
const saved = readReplayableFileState(child);
|
|
235
|
+
if (saved === undefined) continue;
|
|
236
|
+
restorations.push({ path: child, saved });
|
|
237
|
+
if (beforeEntry === undefined) removeReplayableFile(child);
|
|
238
|
+
else if (typeof beforeEntry.content === "string") writeReplayableFile(child, beforeEntry.content);
|
|
239
|
+
}
|
|
240
|
+
paths = relevantWorkspacePaths(root);
|
|
241
|
+
} finally {
|
|
242
|
+
for (const { path, saved } of restorations.reverse()) restoreReplayableFileState(path, saved);
|
|
243
|
+
}
|
|
244
|
+
const observable = new Set(paths);
|
|
245
|
+
// A source the worker created is new under the base rules, so the walk above
|
|
246
|
+
// cannot list it; the declared edit still has to appear as a write.
|
|
247
|
+
for (const path of changedIgnoreSources) {
|
|
248
|
+
if (!path.startsWith(".git/")) observable.add(path);
|
|
249
|
+
}
|
|
250
|
+
return captureWorkspaceEntries(root, [...observable].sort());
|
|
251
|
+
}
|
|
252
|
+
/** @typedef {{missing: true}|{missing: false, content: string, mode: number}} ReplayableFileState */
|
|
253
|
+
/**
|
|
254
|
+
* @param {string} path
|
|
255
|
+
* @returns {ReplayableFileState|undefined} undefined when replay would be unsafe
|
|
256
|
+
*/
|
|
257
|
+
function readReplayableFileState(path) {
|
|
258
|
+
let metadata;
|
|
259
|
+
try {
|
|
260
|
+
metadata = lstatSync(path);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") return { missing: true };
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
if (!metadata.isFile()) return undefined;
|
|
266
|
+
try {
|
|
267
|
+
return { missing: false, content: readFileSync(path, "utf8"), mode: metadata.mode };
|
|
268
|
+
} catch {
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/** @param {string} path @param {string} content @returns {void} */
|
|
273
|
+
function writeReplayableFile(path, content) {
|
|
274
|
+
writeFileSync(path, content);
|
|
275
|
+
}
|
|
276
|
+
/** @param {string} path @returns {void} */
|
|
277
|
+
function removeReplayableFile(path) {
|
|
278
|
+
rmSync(path, { force: true });
|
|
279
|
+
}
|
|
280
|
+
/** @param {string} path @param {ReplayableFileState} saved @returns {void} */
|
|
281
|
+
function restoreReplayableFileState(path, saved) {
|
|
282
|
+
if (saved.missing) {
|
|
283
|
+
rmSync(path, { force: true });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
writeFileSync(path, saved.content, { mode: saved.mode });
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* The on-disk file behind an ignore source. `.git/config` and
|
|
290
|
+
* `.git/info/exclude` may live outside the workspace in a linked worktree, so
|
|
291
|
+
* ask Git for their real path.
|
|
292
|
+
*
|
|
293
|
+
* @param {string} root
|
|
294
|
+
* @param {string} path
|
|
295
|
+
* @returns {string}
|
|
296
|
+
*/
|
|
297
|
+
function ignoreSourceAbsolutePath(root, path) {
|
|
298
|
+
if (path === ".git/config" || path === ".git/info/exclude") {
|
|
299
|
+
const name = path === ".git/config" ? "config" : "info/exclude";
|
|
300
|
+
try {
|
|
301
|
+
const value = execFileSync("git", gitArguments(["-C", root, "rev-parse", "--git-path", name]), {
|
|
302
|
+
encoding: "utf8",
|
|
303
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
304
|
+
}).trim();
|
|
305
|
+
if (value) return isAbsolute(value) ? value : resolve(root, value);
|
|
306
|
+
} catch {
|
|
307
|
+
// Fall through to the literal workspace path.
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return resolve(root, path);
|
|
311
|
+
}
|
|
145
312
|
/**
|
|
146
313
|
* Resolve a declared worker scope before an untrusted worker starts. The
|
|
147
314
|
* returned paths are the literal declarations plus the contained target paths
|
|
@@ -295,9 +462,7 @@ export function isIgnoreSource(path) {
|
|
|
295
462
|
* @returns {Error}
|
|
296
463
|
*/
|
|
297
464
|
function ignoreSourcesChanged(before, after) {
|
|
298
|
-
const
|
|
299
|
-
const current = new Map(after.map((entry) => [entry.path, JSON.stringify(entry)]));
|
|
300
|
-
const changed = [...new Set([...prior.keys(), ...current.keys()])].filter((path) => prior.get(path) !== current.get(path)).sort();
|
|
465
|
+
const changed = changedSnapshotPaths(before, after);
|
|
301
466
|
return fail("snapshot_ignore_changed", `workspace ignore sources changed during worker execution: ${changed.join(", ")}`);
|
|
302
467
|
}
|
|
303
468
|
|
|
@@ -437,14 +602,24 @@ function captureIgnoreSources(root) {
|
|
|
437
602
|
const bytes = Buffer.byteLength(relevant, "utf8");
|
|
438
603
|
return [{ path, kind: "file", digest: `config:${createHash("sha256").update(relevant).digest("hex")}:${bytes}`, size: bytes }];
|
|
439
604
|
}
|
|
440
|
-
|
|
605
|
+
const identity = fileIdentity(child, metadata);
|
|
606
|
+
/** @type {SnapshotEntry} */
|
|
607
|
+
const entry = { path, kind: "file", digest: identity.digest, size: metadata.size };
|
|
608
|
+
if (metadata.size <= IGNORE_SOURCE_CONTENT_LIMIT) {
|
|
609
|
+
try {
|
|
610
|
+
entry.content = readFileSync(child, "utf8");
|
|
611
|
+
} catch {
|
|
612
|
+
// Content is only needed to replay base rules for a declared edit.
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return [entry];
|
|
441
616
|
};
|
|
442
617
|
return [...paths].sort().flatMap(capturePath);
|
|
443
618
|
}
|
|
444
619
|
/** @param {SnapshotEntry[]} before @param {SnapshotEntry[]} after @returns {boolean} */
|
|
445
620
|
function sameSnapshotEntries(before, after) {
|
|
446
621
|
if (before.length !== after.length) return false;
|
|
447
|
-
return before.every((entry, index) =>
|
|
622
|
+
return before.every((entry, index) => snapshotEntryKey(entry) === snapshotEntryKey(after[index]));
|
|
448
623
|
}
|
|
449
624
|
/** @param {string} root @param {string[]} paths @returns {string[]} */
|
|
450
625
|
function expandScopePaths(root, paths) {
|
|
@@ -90,6 +90,10 @@ function renderIdentity(model) {
|
|
|
90
90
|
`- Frozen plan: ${link(identity.planPath)}`,
|
|
91
91
|
`- Plan digest: \`${identity.planDigest}\``,
|
|
92
92
|
`- Contract digest: \`${identity.contractDigest}\``,
|
|
93
|
+
// R36: the effective mode the launch recorded on the run snapshot, read
|
|
94
|
+
// back here rather than re-derived from the contract; absent reads as the
|
|
95
|
+
// cross-vendor default.
|
|
96
|
+
`- Judge independence: \`${identity.judgeIndependence ?? "cross-vendor"}\``,
|
|
93
97
|
`- Journal cursor: \`${identity.journalCursor}\``,
|
|
94
98
|
`- Usage sample cutoff: \`${identity.usageSampleCutoff ?? "not recorded"}\``,
|
|
95
99
|
].join("\n");
|
package/src/report/message.mjs
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { renderStatusJson } from "./render.mjs";
|
|
26
26
|
import { buildCampaignProgress, remainingEstimateMs } from "./progress.mjs";
|
|
27
|
+
import { attemptCosts, retriesOutspendFirst } from "../run/usage.mjs";
|
|
27
28
|
import { chooseLanguage, labelsFor } from "./locale.mjs";
|
|
28
29
|
import { readNodeSnapshot } from "../run/node-store.mjs";
|
|
29
30
|
import { campaignDir } from "../campaign/layout.mjs";
|
|
@@ -39,9 +40,11 @@ import { basename, dirname, join } from "node:path";
|
|
|
39
40
|
/** @typedef {import("./render.mjs").RoleUsage} RoleUsage */
|
|
40
41
|
/** @typedef {import("../notify/index.mjs").NotifyEvent} NotifyEvent */
|
|
41
42
|
/** @typedef {import("./locale.mjs").Language} Language */
|
|
42
|
-
/** @typedef {{
|
|
43
|
+
/** @typedef {{attempt: number, costUsd: number|null}} AttemptCost */
|
|
44
|
+
/** @typedef {StatusPayloadNode & {attemptCosts: AttemptCost[], retriesOutspendFirst: boolean}} MessageNode */
|
|
45
|
+
/** @typedef {{campaignId: string, usage: {inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null}, roles: {worker: RoleUsage, judge: RoleUsage}, nodes: MessageNode[]}} Payload */
|
|
43
46
|
/** @typedef {{id: string, goal: string|null, percentDone: number, nodesTotal: number, phasesDone: number, phasesTotal: number, costTotalUsd: number|null, elapsedMs: number|null, nextCommand: string|null}} CampaignSummary */
|
|
44
|
-
/** @typedef {{runDir: string, runId: string, payload: Payload, objectives: Map<string, string|null>, snapshots: Map<string, Record<string, unknown>>, campaign: CampaignSummary|null, label: (key: string) => string, subject:
|
|
47
|
+
/** @typedef {{runDir: string, runId: string, payload: Payload, objectives: Map<string, string|null>, snapshots: Map<string, Record<string, unknown>>, campaign: CampaignSummary|null, label: (key: string) => string, subject: MessageNode}} View */
|
|
45
48
|
|
|
46
49
|
/**
|
|
47
50
|
* The message's byte ceiling. Measured 2026-09-21 on the live campaign's
|
|
@@ -105,6 +108,15 @@ export function renderRunProgress(runDir, event) {
|
|
|
105
108
|
const runsDir = dirname(runDir);
|
|
106
109
|
const objectives = readObjectives(runDir);
|
|
107
110
|
const snapshots = new Map(payload.nodes.map((node) => [node.id, readSnapshotSafe(runDir, node.id)]));
|
|
111
|
+
// R31: the status payload's nodes carry their per-attempt cost, the same
|
|
112
|
+
// `usage.mjs` reader the campaign roll-up uses, so the message's text line
|
|
113
|
+
// can mark a node whose retries out-spent the first attempt without
|
|
114
|
+
// re-deriving the comparison here.
|
|
115
|
+
for (const node of payload.nodes) {
|
|
116
|
+
const snapshot = /** @type {import("../contract/index.mjs").NodeSnapshot} */ (/** @type {unknown} */ (snapshots.get(node.id) ?? {}));
|
|
117
|
+
node.attemptCosts = attemptCosts(snapshot);
|
|
118
|
+
node.retriesOutspendFirst = retriesOutspendFirst(snapshot);
|
|
119
|
+
}
|
|
108
120
|
const campaign = campaignSummary(runsDir, payload.campaignId);
|
|
109
121
|
const label = labelsFor(chooseLanguage(process.env, [campaign?.goal], journalTexts(runsDir, payload.campaignId), [...objectives.values()]));
|
|
110
122
|
const subject = subjectNode(payload.nodes, event.nodeId ?? null);
|
|
@@ -130,7 +142,7 @@ function nodeTerminal(view) {
|
|
|
130
142
|
const ending = QUIET_STATES.has(subject.status) && !SUCCESS.has(subject.status)
|
|
131
143
|
? label(subject.status === "running" ? "running" : "pending")
|
|
132
144
|
: `${label(OUTCOME_LABEL[subject.status] ?? "doneIn")} ${shortSpan(subject)}`;
|
|
133
|
-
const headline = `${OUTCOME[subject.status] ?? "❔"} ${subject.id} · ${ending} · ${formatUsd(subject.costUsd)}${model}${error}`;
|
|
145
|
+
const headline = `${OUTCOME[subject.status] ?? "❔"} ${subject.id} · ${ending} · ${formatUsd(subject.costUsd)}${model}${error}${retryOutspendMark(subject)}`;
|
|
134
146
|
const pad = padder(label, ["asked", "did", "proof"]);
|
|
135
147
|
return [
|
|
136
148
|
headline,
|
|
@@ -199,7 +211,7 @@ function attention(view) {
|
|
|
199
211
|
const { subject, label, snapshots, objectives, runDir } = view;
|
|
200
212
|
const snapshot = snapshots.get(subject.id) ?? {};
|
|
201
213
|
const reason = subject.errorCode ?? subject.status;
|
|
202
|
-
const headline = `👀 ${subject.id} ${label("needsYou")} · ${reason} · ${label("attempt")} ${subject.attempt} · ${shortSpan(subject)} · ${formatUsd(subject.costUsd)}`;
|
|
214
|
+
const headline = `👀 ${subject.id} ${label("needsYou")} · ${reason} · ${label("attempt")} ${subject.attempt} · ${shortSpan(subject)} · ${formatUsd(subject.costUsd)}${retryOutspendMark(subject)}`;
|
|
203
215
|
const pad = padder(label, ["why", "asked", "do"]);
|
|
204
216
|
return [
|
|
205
217
|
headline,
|
|
@@ -461,9 +473,10 @@ function workerSummary(snapshot) {
|
|
|
461
473
|
* most recently settled node (a `run.terminal` event names the run, not a
|
|
462
474
|
* node), else the run's first node.
|
|
463
475
|
*
|
|
464
|
-
* @
|
|
476
|
+
* @template {{id: string, status: import("../contract/index.mjs").NodeStatus, updatedAt?: string|null}} T
|
|
477
|
+
* @param {T[]} nodes
|
|
465
478
|
* @param {string|null} nodeId
|
|
466
|
-
* @returns {
|
|
479
|
+
* @returns {T}
|
|
467
480
|
*/
|
|
468
481
|
function subjectNode(nodes, nodeId) {
|
|
469
482
|
const named = nodeId ? nodes.find((node) => node.id === nodeId) : undefined;
|
|
@@ -577,6 +590,24 @@ function formatUsd(value) {
|
|
|
577
590
|
return `$${value.toFixed(2)}`;
|
|
578
591
|
}
|
|
579
592
|
|
|
593
|
+
/**
|
|
594
|
+
* R31's mark for a node's text line: when the attempts after the first
|
|
595
|
+
* together out-spend the first, the line says so and quotes both sides, so a
|
|
596
|
+
* reader sees the size of the overrun rather than only that one exists. Empty
|
|
597
|
+
* when the comparison cannot be made -- a single attempt, or an attempt on
|
|
598
|
+
* either side that is not fully priced -- because an unknown comparison is
|
|
599
|
+
* not a warning.
|
|
600
|
+
*
|
|
601
|
+
* @param {MessageNode} node
|
|
602
|
+
* @returns {string}
|
|
603
|
+
*/
|
|
604
|
+
function retryOutspendMark(node) {
|
|
605
|
+
if (!node.retriesOutspendFirst) return "";
|
|
606
|
+
const [first, ...retries] = node.attemptCosts;
|
|
607
|
+
const retryTotal = retries.reduce((total, entry) => total + /** @type {number} */ (entry.costUsd), 0);
|
|
608
|
+
return ` · ⚠️ retries out-spend the first attempt (${formatUsd(retryTotal)} vs ${formatUsd(/** @type {number} */ (first.costUsd))})`;
|
|
609
|
+
}
|
|
610
|
+
|
|
580
611
|
/** Whole dollars once a campaign passes a hundred: `$132`, not `$132.21`, in a footer. @param {number|null} value @returns {string} */
|
|
581
612
|
function formatUsdCompact(value) {
|
|
582
613
|
if (typeof value === "number" && Number.isFinite(value) && value >= 100) return `$${Math.round(value)}`;
|
package/src/report/progress.mjs
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
*/
|
|
33
33
|
import { formatDuration, readRunUsage, renderStatusJson, roleUsage } from "./render.mjs";
|
|
34
34
|
import { nodeSnapshotPath, readNodeSnapshot } from "../run/node-store.mjs";
|
|
35
|
+
import { attemptCosts, retriesOutspendFirst } from "../run/usage.mjs";
|
|
35
36
|
import { finite } from "../util.mjs";
|
|
36
37
|
import { SETTLED, SUCCESS } from "../engine/prompts.mjs";
|
|
37
38
|
import { campaignDir } from "../campaign/layout.mjs";
|
|
@@ -128,7 +129,7 @@ function campaignCumulativeCostUsd(runsDir, campaignId) {
|
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
/**
|
|
131
|
-
* @typedef {{id: string, dependsOn: string[], status: string, attempt: number, workerRuntime: string|null, judgeRuntime: string|null, elapsedSpan: string|null, costUsd: number|null, runDir: string|null, workerLogPath: string|null, judgeLogPaths: string[], verificationRecordPath: string|null, attemptBranch: string|null, sealCommit: string|null}} RollupNode
|
|
132
|
+
* @typedef {{id: string, dependsOn: string[], status: string, attempt: number, workerRuntime: string|null, judgeRuntime: string|null, elapsedSpan: string|null, costUsd: number|null, attemptCosts: import("../run/usage.mjs").AttemptCost[], retriesOutspendFirst: boolean, runDir: string|null, workerLogPath: string|null, judgeLogPaths: string[], verificationRecordPath: string|null, attemptBranch: string|null, sealCommit: string|null}} RollupNode
|
|
132
133
|
* `status` carries every `NodeStatus` plus two the manifest alone can
|
|
133
134
|
* explain: `not_started` (the contract has no run yet) and `unreadable` (a
|
|
134
135
|
* run exists but this node's own snapshot does not).
|
|
@@ -138,6 +139,10 @@ function campaignCumulativeCostUsd(runsDir, campaignId) {
|
|
|
138
139
|
* three points), so a single `runtime` field reads as though the judge did
|
|
139
140
|
* the worker's job. `judgeRuntime` is `null` for a node with no gate, or one
|
|
140
141
|
* whose gate never ran -- never the worker's own runtime repeated.
|
|
142
|
+
* `attemptCosts` is the per-attempt cost R31 adds to the payload, derived
|
|
143
|
+
* from the snapshot's own invocations by the ledger's shared reader;
|
|
144
|
+
* `retriesOutspendFirst` is the flag that marks the notification's text line
|
|
145
|
+
* when those retries together cost more than the first attempt.
|
|
141
146
|
*/
|
|
142
147
|
/**
|
|
143
148
|
* `counts.done` and `counts.settled` are two different numbers: `done` is
|
|
@@ -411,7 +416,7 @@ function judgeRuntimeLabel(invocations) {
|
|
|
411
416
|
function rollupNode(node, contractId, runDir, hasRun, settledCandidates, workerRuntimeById) {
|
|
412
417
|
const id = String(node.id);
|
|
413
418
|
const dependsOn = Array.isArray(node.dependsOn) ? node.dependsOn.map(String) : [];
|
|
414
|
-
const empty = { id, dependsOn, attempt: 0, workerRuntime: null, judgeRuntime: null, elapsedSpan: null, costUsd: null, workerLogPath: null, judgeLogPaths: /** @type {string[]} */ ([]), attemptBranch: null, sealCommit: null };
|
|
419
|
+
const empty = { id, dependsOn, attempt: 0, workerRuntime: null, judgeRuntime: null, elapsedSpan: null, costUsd: null, attemptCosts: /** @type {import("../run/usage.mjs").AttemptCost[]} */ ([]), retriesOutspendFirst: false, workerLogPath: null, judgeLogPaths: /** @type {string[]} */ ([]), attemptBranch: null, sealCommit: null };
|
|
415
420
|
if (!hasRun) return { ...empty, status: "not_started", runDir: null, verificationRecordPath: null };
|
|
416
421
|
|
|
417
422
|
let snapshot;
|
|
@@ -429,6 +434,10 @@ function rollupNode(node, contractId, runDir, hasRun, settledCandidates, workerR
|
|
|
429
434
|
.map((invocation) => invocation.stdoutPath)
|
|
430
435
|
.filter((path) => typeof path === "string");
|
|
431
436
|
const worktree = snapshot.worktree && typeof snapshot.worktree === "object" ? /** @type {Record<string, unknown>} */ (snapshot.worktree) : null;
|
|
437
|
+
// The snapshot reader returns the raw persisted object; the ledger's
|
|
438
|
+
// per-attempt reader wants the validated shape, and only its `invocations`
|
|
439
|
+
// are read here.
|
|
440
|
+
const attemptState = /** @type {import("../contract/index.mjs").NodeSnapshot} */ (/** @type {unknown} */ (snapshot));
|
|
432
441
|
|
|
433
442
|
return {
|
|
434
443
|
id,
|
|
@@ -439,6 +448,8 @@ function rollupNode(node, contractId, runDir, hasRun, settledCandidates, workerR
|
|
|
439
448
|
judgeRuntime: judgeRuntimeLabel(invocations),
|
|
440
449
|
elapsedSpan: spanOf({ startedAt: typeof snapshot.startedAt === "string" ? snapshot.startedAt : null, updatedAt: typeof snapshot.updatedAt === "string" ? snapshot.updatedAt : null }),
|
|
441
450
|
costUsd: typeof snapshot.costUsd === "number" ? snapshot.costUsd : null,
|
|
451
|
+
attemptCosts: attemptCosts(attemptState),
|
|
452
|
+
retriesOutspendFirst: retriesOutspendFirst(attemptState),
|
|
442
453
|
runDir,
|
|
443
454
|
workerLogPath: typeof worker?.stdoutPath === "string" ? worker.stdoutPath : null,
|
|
444
455
|
judgeLogPaths,
|
package/src/run/paths.mjs
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* run, because answering the home side of a not-yet-migrated repository would
|
|
18
18
|
* split its state across two layouts.
|
|
19
19
|
*/
|
|
20
|
-
import { existsSync } from "node:fs";
|
|
20
|
+
import { existsSync, rmSync } from "node:fs";
|
|
21
21
|
import { dirname, join, resolve, sep } from "node:path";
|
|
22
22
|
import { campaignDir, campaignsDir } from "../campaign/layout.mjs";
|
|
23
23
|
import { faberunHome } from "../host/home.mjs";
|
|
@@ -134,6 +134,19 @@ export function runDirectory(cwd, runId) {
|
|
|
134
134
|
return join(runsRoot(cwd), runId);
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Remove one run's directory and everything under it. The caller owns the
|
|
139
|
+
* decision that the directory may go; keeping the erasure here means no other
|
|
140
|
+
* module re-derives `<runs>/<id>` to delete it and the layout stays in one
|
|
141
|
+
* place.
|
|
142
|
+
*
|
|
143
|
+
* @param {string} runDir
|
|
144
|
+
* @returns {void}
|
|
145
|
+
*/
|
|
146
|
+
export function removeRunDirectory(runDir) {
|
|
147
|
+
rmSync(runDir, { recursive: true, force: true });
|
|
148
|
+
}
|
|
149
|
+
|
|
137
150
|
/**
|
|
138
151
|
* The live-preflight verdict store, at the top of the home. Whether a
|
|
139
152
|
* provider answers is a fact about this machine and this operator -- the same
|
package/src/run/usage.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { appendJsonl, writeJsonAtomic } from "./store.mjs";
|
|
10
10
|
import { basename, join } from "node:path";
|
|
11
|
-
import { errorMessage, stableJson } from "../util.mjs";
|
|
11
|
+
import { errorMessage, finite, stableJson } from "../util.mjs";
|
|
12
12
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
13
13
|
import { liveUsage } from "../harnesses/session-metrics.mjs";
|
|
14
14
|
|
|
@@ -129,6 +129,71 @@ export function invocationCost(state) {
|
|
|
129
129
|
.filter((cost) => typeof cost === "number" && Number.isFinite(cost)));
|
|
130
130
|
return costs.length ? costs.reduce((total, cost) => total + cost, 0) : undefined;
|
|
131
131
|
}
|
|
132
|
+
/** @typedef {{attempt: number, costUsd: number|null}} AttemptCost */
|
|
133
|
+
/**
|
|
134
|
+
* The cost of each attempt a node made, derived from the invocation ledger the
|
|
135
|
+
* snapshot already stores and nothing else: every invocation carrying the same
|
|
136
|
+
* `attempt` is one attempt, and its cost is the sum of the prices those
|
|
137
|
+
* invocations recorded. An attempt with any unpriced invocation reports `null`
|
|
138
|
+
* rather than a partial sum -- the same refusal `roleUsage` makes for a role,
|
|
139
|
+
* because dollars that are partly missing are not a total. An invocation that
|
|
140
|
+
* predates the `attempt` field is read as the first attempt rather than
|
|
141
|
+
* dropped.
|
|
142
|
+
*
|
|
143
|
+
* Attempts come back in the order they ran; a snapshot with no invocations
|
|
144
|
+
* yields an empty list, never a fabricated zero-attempt.
|
|
145
|
+
*
|
|
146
|
+
* @param {NodeSnapshot} state
|
|
147
|
+
* @returns {AttemptCost[]}
|
|
148
|
+
*/
|
|
149
|
+
export function attemptCosts(state) {
|
|
150
|
+
/** @type {Map<number, {costUsd: number, unpriced: number}>} */
|
|
151
|
+
const byAttempt = new Map();
|
|
152
|
+
for (const invocation of state.invocations ?? []) {
|
|
153
|
+
const attempt = typeof invocation.attempt === "number" && Number.isFinite(invocation.attempt) ? invocation.attempt : 1;
|
|
154
|
+
const bucket = byAttempt.get(attempt) ?? { costUsd: 0, unpriced: 0 };
|
|
155
|
+
const cost = finite(invocation.costUsd);
|
|
156
|
+
if (cost === null) bucket.unpriced += 1;
|
|
157
|
+
else bucket.costUsd += cost;
|
|
158
|
+
byAttempt.set(attempt, bucket);
|
|
159
|
+
}
|
|
160
|
+
return [...byAttempt.entries()]
|
|
161
|
+
.sort((left, right) => left[0] - right[0])
|
|
162
|
+
.map(([attempt, bucket]) => ({ attempt, costUsd: bucket.unpriced > 0 ? null : bucket.costUsd }));
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The two sides of R31's comparison: the first attempt's cost and the sum of
|
|
166
|
+
* every attempt after it. `null` when they cannot be compared -- fewer than
|
|
167
|
+
* two attempts, or any attempt on either side is not fully priced -- so a
|
|
168
|
+
* caller never reads an invented number.
|
|
169
|
+
*
|
|
170
|
+
* @param {NodeSnapshot} state
|
|
171
|
+
* @returns {{firstCostUsd: number, retryCostUsd: number}|null}
|
|
172
|
+
*/
|
|
173
|
+
export function retryOutspend(state) {
|
|
174
|
+
const costs = attemptCosts(state);
|
|
175
|
+
if (costs.length < 2) return null;
|
|
176
|
+
const [first, ...retries] = costs;
|
|
177
|
+
if (first.costUsd === null || retries.some((entry) => entry.costUsd === null)) return null;
|
|
178
|
+
return {
|
|
179
|
+
firstCostUsd: first.costUsd,
|
|
180
|
+
retryCostUsd: retries.reduce((total, entry) => total + /** @type {number} */ (entry.costUsd), 0),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Whether the retries together out-spend the first attempt. The one predicate
|
|
185
|
+
* every surface shares -- the campaign payload and the notification's text
|
|
186
|
+
* line -- so a node that cost more to redo than to do is named the same way
|
|
187
|
+
* everywhere, against the first attempt's own recorded cost rather than a
|
|
188
|
+
* guessed threshold.
|
|
189
|
+
*
|
|
190
|
+
* @param {NodeSnapshot} state
|
|
191
|
+
* @returns {boolean}
|
|
192
|
+
*/
|
|
193
|
+
export function retriesOutspendFirst(state) {
|
|
194
|
+
const outspend = retryOutspend(state);
|
|
195
|
+
return outspend !== null && outspend.retryCostUsd > outspend.firstCostUsd;
|
|
196
|
+
}
|
|
132
197
|
/**
|
|
133
198
|
* Invocation ids already present in the run's usage.jsonl. The append path
|
|
134
199
|
* uses this to stay idempotent across resume and replay.
|
package/src/util.mjs
CHANGED
|
@@ -239,8 +239,13 @@ export function finite(value) {
|
|
|
239
239
|
* outside them separates.
|
|
240
240
|
*
|
|
241
241
|
* Deliberately not a shell: no expansion, no substitution, no operators, and
|
|
242
|
-
* no backslash escape -- on Windows a backslash is a
|
|
243
|
-
* `C:\Users\x` must survive this intact.
|
|
242
|
+
* no backslash escape outside double quotes -- on Windows a backslash is a
|
|
243
|
+
* path separator and `C:\Users\x` must survive this intact. Inside double
|
|
244
|
+
* quotes a backslash before `"` or `\` is the escape POSIX gives it, and any
|
|
245
|
+
* other backslash stays literal, so a quoted Windows path is still intact.
|
|
246
|
+
* Measured 2026-09-27: a `node -e "<script with \"fs\">"` proof, the shape
|
|
247
|
+
* `JSON.stringify` writes, split at the escaped quote and read as a script
|
|
248
|
+
* that did not parse. The question both callers ask is
|
|
244
249
|
* "which words does this command name", and a quoted path holding a space is
|
|
245
250
|
* one word: splitting it on whitespace produced two fragments that matched no
|
|
246
251
|
* file, so a proof naming a real path read as naming none.
|
|
@@ -255,6 +260,7 @@ export function shellWords(text) {
|
|
|
255
260
|
let current = null;
|
|
256
261
|
/** @type {string|null} */
|
|
257
262
|
let quote = null;
|
|
263
|
+
let escaped = false;
|
|
258
264
|
for (const character of text) {
|
|
259
265
|
if (quote === null && /\s/u.test(character)) {
|
|
260
266
|
if (current !== null) words.push(current);
|
|
@@ -266,12 +272,22 @@ export function shellWords(text) {
|
|
|
266
272
|
current ??= "";
|
|
267
273
|
continue;
|
|
268
274
|
}
|
|
275
|
+
if (escaped) {
|
|
276
|
+
current = (current ?? "") + (character === '"' || character === "\\" ? character : `\\${character}`);
|
|
277
|
+
escaped = false;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (quote === '"' && character === "\\") {
|
|
281
|
+
escaped = true;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
269
284
|
if (quote === character) {
|
|
270
285
|
quote = null;
|
|
271
286
|
continue;
|
|
272
287
|
}
|
|
273
288
|
current = (current ?? "") + character;
|
|
274
289
|
}
|
|
290
|
+
if (escaped) current = `${current ?? ""}\\`;
|
|
275
291
|
if (current !== null) words.push(current);
|
|
276
292
|
return words;
|
|
277
293
|
}
|