faberun 0.10.0 → 0.12.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 +6 -0
- package/integrations/claude-code/statusline.sh +31 -2
- package/package.json +2 -2
- package/skills/faberun/references/spec-format.md +9 -0
- package/src/campaign/chain.mjs +2 -1
- package/src/campaign/index.mjs +102 -1
- package/src/campaign/metrics.mjs +2 -2
- package/src/cli/brand.mjs +2 -0
- package/src/cli/campaign-contract.mjs +55 -0
- package/src/cli/campaign.mjs +48 -13
- package/src/cli/init.mjs +8 -2
- package/src/cli/launch.mjs +2 -1
- package/src/cli/plan.mjs +3 -2
- package/src/cli/project.mjs +150 -0
- package/src/cli/skills.mjs +2 -3
- package/src/cli/update.mjs +7 -10
- package/src/cli.mjs +30 -4
- package/src/contract/index.mjs +44 -0
- package/src/contract/task-packet.mjs +12 -3
- package/src/engine/cancel.mjs +13 -0
- package/src/engine/dispatch.mjs +20 -2
- package/src/engine/lifecycle.mjs +38 -61
- package/src/engine/live-preflight.mjs +2 -1
- package/src/engine/notify-queue.mjs +9 -1
- package/src/engine/pricing-seed.json +543 -0
- package/src/engine/pricing-seed.mjs +54 -0
- package/src/engine/process.mjs +8 -1
- package/src/engine/result-file.mjs +2 -1
- package/src/engine/resume.mjs +6 -2
- package/src/engine/run-identity.mjs +9 -2
- package/src/engine/scheduler.mjs +40 -6
- package/src/engine/settle-judge.mjs +141 -0
- package/src/engine/settle.mjs +1 -1
- package/src/harnesses/protocol.mjs +46 -10
- package/src/harnesses/replay/bin.mjs +10 -1
- package/src/host/home.mjs +10 -3
- package/src/host/platform.mjs +111 -0
- package/src/host/preflight.mjs +43 -21
- package/src/host/projects.mjs +155 -0
- package/src/notify/index.mjs +88 -10
- package/src/plan/pipeline.mjs +32 -9
- package/src/plan/repo-facts.mjs +80 -2
- package/src/plan/spec.mjs +2 -1
- package/src/repo/declared-paths.mjs +7 -1
- package/src/repo/integrate.mjs +1 -1
- package/src/repo/scope-closure.mjs +2 -1
- package/src/repo/signal.mjs +24 -7
- package/src/repo/source-identity.mjs +7 -0
- package/src/repo/workspace.mjs +12 -2
- package/src/repo/worktree.mjs +89 -22
- package/src/report/next.mjs +66 -6
- package/src/report/progress.mjs +724 -0
- package/src/report/render.mjs +3 -3
- package/src/run/migrate.mjs +278 -0
- package/src/run/paths.mjs +189 -0
- package/src/seat/index.mjs +2 -1
- package/src/web/app.css +170 -0
- package/src/web/app.mjs +690 -0
- package/src/web/index.html +38 -283
- package/src/web/server.mjs +197 -182
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `faberun project`: re-associate a project whose repository moved, keeping
|
|
3
|
+
* its id -- and everything that will hang off that id -- untouched.
|
|
4
|
+
*
|
|
5
|
+
* `src/host/projects.mjs` is out of this node's write scope, and its public
|
|
6
|
+
* surface (`registerProject`, `findProjectByPath`, `readProject`) has no
|
|
7
|
+
* operation that keeps an existing id while giving it a new path:
|
|
8
|
+
* `registerProject` either finds the id already at a path or mints a fresh
|
|
9
|
+
* one, and a fresh id is exactly what a move must not produce. This module
|
|
10
|
+
* therefore reads and rewrites `projects/index.json` and
|
|
11
|
+
* `projects/<id>/project.json` directly, with the same temp-then-rename
|
|
12
|
+
* discipline the registry itself uses, rather than inventing a second
|
|
13
|
+
* on-disk format for the same directory.
|
|
14
|
+
*/
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { readJson, writeJsonAtomic } from "../run/store.mjs";
|
|
17
|
+
import { errorCode } from "../util.mjs";
|
|
18
|
+
import { findProjectByPath, projectsDir, readProject, resolveIdentity } from "../host/projects.mjs";
|
|
19
|
+
import { boundedGitSync } from "../repo/worktree.mjs";
|
|
20
|
+
|
|
21
|
+
/** @typedef {import("../host/projects.mjs").ProjectRecord} ProjectRecord */
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The path-to-id index, read straight off `index.json`. Inlined at each of
|
|
25
|
+
* its two call sites rather than pulled out as a shared top-level helper: the
|
|
26
|
+
* identical helper already lives, unexported, in `host/projects.mjs`, which
|
|
27
|
+
* this module cannot import from without that file entering its write scope.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} indexPath
|
|
30
|
+
* @returns {Record<string, string>}
|
|
31
|
+
*/
|
|
32
|
+
function readIndexAt(indexPath) {
|
|
33
|
+
try {
|
|
34
|
+
return /** @type {Record<string, string>} */ (readJson(indexPath));
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (errorCode(error) === "ENOENT") return {};
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Every registered project, read straight off disk. Used only to search by
|
|
43
|
+
* remote when the operator names no `--from`.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} home
|
|
46
|
+
* @returns {ProjectRecord[]}
|
|
47
|
+
*/
|
|
48
|
+
function listProjects(home) {
|
|
49
|
+
const index = readIndexAt(join(projectsDir(home), "index.json"));
|
|
50
|
+
const ids = new Set(Object.values(index));
|
|
51
|
+
/** @type {ProjectRecord[]} */
|
|
52
|
+
const projects = [];
|
|
53
|
+
for (const id of ids) {
|
|
54
|
+
const project = readProject(home, id);
|
|
55
|
+
if (project) projects.push(project);
|
|
56
|
+
}
|
|
57
|
+
return projects;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The remote URLs a git repository at `path` currently reports, or `[]` when
|
|
62
|
+
* `path` is not a git repository, has no remote, or git is unavailable. An
|
|
63
|
+
* operator who names `--from` never needs this at all.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} path
|
|
66
|
+
* @returns {string[]}
|
|
67
|
+
*/
|
|
68
|
+
export function readRemotes(path) {
|
|
69
|
+
const result = boundedGitSync(["-C", path, "remote", "-v"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
70
|
+
if (result.error || result.status !== 0) return [];
|
|
71
|
+
/** @type {Set<string>} */
|
|
72
|
+
const urls = new Set();
|
|
73
|
+
for (const line of /** @type {string} */ (result.stdout).split("\n")) {
|
|
74
|
+
const match = /^\S+\s+(\S+)\s+\(/u.exec(line);
|
|
75
|
+
if (match) urls.add(match[1]);
|
|
76
|
+
}
|
|
77
|
+
return [...urls];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The registered project whose remotes overlap what `remotesOf(path)` reports
|
|
82
|
+
* right now, or null when none does, or when `path` reports no remote at all.
|
|
83
|
+
* More than one match is refused by the caller, not decided here.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} home
|
|
86
|
+
* @param {string} path
|
|
87
|
+
* @param {(path: string) => string[]} remotesOf
|
|
88
|
+
* @returns {ProjectRecord[]}
|
|
89
|
+
*/
|
|
90
|
+
function projectsMatchingRemotes(home, path, remotesOf) {
|
|
91
|
+
const remotes = new Set(remotesOf(path));
|
|
92
|
+
if (remotes.size === 0) return [];
|
|
93
|
+
return listProjects(home).filter((project) => project.remotes.some((remote) => remotes.has(remote)));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @param {string} home
|
|
98
|
+
* @param {ProjectRecord} project
|
|
99
|
+
* @param {string} newPath
|
|
100
|
+
* @returns {ProjectRecord}
|
|
101
|
+
*/
|
|
102
|
+
function moveProject(home, project, newPath) {
|
|
103
|
+
const indexPath = join(projectsDir(home), "index.json");
|
|
104
|
+
const index = readIndexAt(indexPath);
|
|
105
|
+
delete index[project.path];
|
|
106
|
+
index[newPath] = project.id;
|
|
107
|
+
writeJsonAtomic(indexPath, index);
|
|
108
|
+
/** @type {ProjectRecord} */
|
|
109
|
+
const record = { ...project, path: newPath, updatedAt: new Date().toISOString() };
|
|
110
|
+
writeJsonAtomic(join(projectsDir(home), project.id, "project.json"), record);
|
|
111
|
+
return record;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Re-associate a project with the path its repository moved to.
|
|
116
|
+
*
|
|
117
|
+
* Idempotent by construction: when `newPath` is already the project's
|
|
118
|
+
* registered path -- whether this is the first call or a repeat of one that
|
|
119
|
+
* already landed -- the current record comes back untouched and nothing is
|
|
120
|
+
* written. That is deliberate: an operator unsure whether a previous attempt
|
|
121
|
+
* succeeded runs the same command again rather than inspecting the registry
|
|
122
|
+
* first.
|
|
123
|
+
*
|
|
124
|
+
* With `options.from`, the project once registered at that path is moved.
|
|
125
|
+
* Without it, the project is found by matching the git remotes `newPath`
|
|
126
|
+
* reports now against the remotes every registered project last recorded;
|
|
127
|
+
* no match, or more than one, is refused with a message naming what was
|
|
128
|
+
* looked for, rather than guessed.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} home
|
|
131
|
+
* @param {string} newPath
|
|
132
|
+
* @param {{from?: string, remotesOf?: (path: string) => string[]}} [options]
|
|
133
|
+
* @returns {ProjectRecord}
|
|
134
|
+
*/
|
|
135
|
+
export function reassociateProject(home, newPath, options = {}) {
|
|
136
|
+
const resolvedNew = resolveIdentity(newPath);
|
|
137
|
+
const already = findProjectByPath(home, resolvedNew);
|
|
138
|
+
if (already) return already;
|
|
139
|
+
if (options.from) {
|
|
140
|
+
const resolvedFrom = resolveIdentity(options.from);
|
|
141
|
+
const found = findProjectByPath(home, resolvedFrom);
|
|
142
|
+
if (!found) throw new Error(`no project is registered at ${resolvedFrom}`);
|
|
143
|
+
return moveProject(home, found, resolvedNew);
|
|
144
|
+
}
|
|
145
|
+
const remotesOf = options.remotesOf ?? readRemotes;
|
|
146
|
+
const matches = projectsMatchingRemotes(home, resolvedNew, remotesOf);
|
|
147
|
+
if (matches.length === 0) throw new Error(`no registered project has the remotes reported at ${resolvedNew}`);
|
|
148
|
+
if (matches.length > 1) throw new Error(`${matches.length} registered projects share a remote reported at ${resolvedNew}; pass --from to disambiguate`);
|
|
149
|
+
return moveProject(home, matches[0], resolvedNew);
|
|
150
|
+
}
|
package/src/cli/skills.mjs
CHANGED
|
@@ -20,7 +20,6 @@ import {
|
|
|
20
20
|
readdirSync,
|
|
21
21
|
realpathSync,
|
|
22
22
|
rmSync,
|
|
23
|
-
symlinkSync,
|
|
24
23
|
} from "node:fs";
|
|
25
24
|
import { homedir } from "node:os";
|
|
26
25
|
import { join, resolve, sep } from "node:path";
|
|
@@ -28,7 +27,7 @@ import { fileURLToPath } from "node:url";
|
|
|
28
27
|
import { parseArgs as parseFlags } from "node:util";
|
|
29
28
|
|
|
30
29
|
import { faberunHome, installedVersionDir } from "../host/home.mjs";
|
|
31
|
-
import { findExecutable } from "../host/
|
|
30
|
+
import { findExecutable, linkDirectory } from "../host/platform.mjs";
|
|
32
31
|
import { colorLevel, statusToken } from "./brand.mjs";
|
|
33
32
|
|
|
34
33
|
const SKILLS_DIR = fileURLToPath(new URL("../../skills", import.meta.url));
|
|
@@ -304,7 +303,7 @@ function linkSkill(source, destination, force) {
|
|
|
304
303
|
if (!existing.isSymbolicLink() && !force) return "skipped";
|
|
305
304
|
rmSync(destination, { recursive: true, force: true });
|
|
306
305
|
}
|
|
307
|
-
|
|
306
|
+
linkDirectory(destination, source);
|
|
308
307
|
return "linked";
|
|
309
308
|
}
|
|
310
309
|
|
package/src/cli/update.mjs
CHANGED
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
* rather than something to compare as if it were a release.
|
|
14
14
|
*/
|
|
15
15
|
import { spawnSync } from "node:child_process";
|
|
16
|
-
import { mkdirSync, renameSync, rmSync,
|
|
16
|
+
import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
import { compareVersions, currentLink, faberunHome, installedVersionDir, tmpDir, versionsDir, writeUpdateCheck } from "../host/home.mjs";
|
|
19
|
+
import { linkDirectory, tarExecutable } from "../host/platform.mjs";
|
|
19
20
|
import { packageVersion } from "../host/package.mjs";
|
|
20
21
|
import { errorMessage } from "../util.mjs";
|
|
21
22
|
|
|
@@ -170,7 +171,7 @@ async function downloadTarball(fetchImpl, url, destination) {
|
|
|
170
171
|
* @returns {void}
|
|
171
172
|
*/
|
|
172
173
|
function extractTarball(tarball, destination) {
|
|
173
|
-
const result = spawnSync(
|
|
174
|
+
const result = spawnSync(tarExecutable(), ["-xzf", tarball, "--strip-components=1", "-C", destination], { encoding: "utf8" });
|
|
174
175
|
if (result.error) throw result.error;
|
|
175
176
|
if (result.status !== 0) throw new Error(`tar failed: ${String(result.stderr ?? "").trim() || `exit ${result.status}`}`);
|
|
176
177
|
}
|
|
@@ -193,21 +194,17 @@ function verifyVersion(partial, version) {
|
|
|
193
194
|
}
|
|
194
195
|
|
|
195
196
|
/**
|
|
196
|
-
* Point `current` at the new version
|
|
197
|
-
*
|
|
198
|
-
*
|
|
197
|
+
* Point `current` at the new version. `linkDirectory` owns how: a rename over
|
|
198
|
+
* a sibling temporary link where the platform makes that atomic, a remove and
|
|
199
|
+
* remake on Windows where it does not.
|
|
199
200
|
*
|
|
200
201
|
* @param {string} home
|
|
201
202
|
* @param {string} version
|
|
202
203
|
* @returns {void}
|
|
203
204
|
*/
|
|
204
205
|
function repointCurrent(home, version) {
|
|
205
|
-
const link = currentLink(home);
|
|
206
|
-
const temporary = `${link}.tmp`;
|
|
207
|
-
rmSync(temporary, { force: true });
|
|
208
206
|
// SPEC.md's layout: `current -> versions/<v>`, relative to the home.
|
|
209
|
-
|
|
210
|
-
renameSync(temporary, link);
|
|
207
|
+
linkDirectory(currentLink(home), join("versions", version));
|
|
211
208
|
}
|
|
212
209
|
|
|
213
210
|
/** @returns {Record<string, string>} */
|
package/src/cli.mjs
CHANGED
|
@@ -9,8 +9,11 @@ import { parseArgs } from "node:util";
|
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
import { modelsCommand } from "./harnesses/catalogue.mjs";
|
|
11
11
|
import { bulkReadCommand } from "./engine/bulk-read.mjs";
|
|
12
|
-
import { doctorCommand, environmentPreflight,
|
|
12
|
+
import { doctorCommand, environmentPreflight, notifyTransportCheck, reachableRuntimes, timeVerificationCommands } from "./host/preflight.mjs";
|
|
13
|
+
import { findExecutable } from "./host/platform.mjs";
|
|
13
14
|
import { packageName, packageVersion } from "./host/package.mjs";
|
|
15
|
+
import { faberunHome } from "./host/home.mjs";
|
|
16
|
+
import { reassociateProject } from "./cli/project.mjs";
|
|
14
17
|
import { colorLevel, renderBanner, renderUsage, statusToken } from "./cli/brand.mjs";
|
|
15
18
|
import { noTransportWarning } from "./notify/index.mjs";
|
|
16
19
|
import { renderFindings, renderReport, renderReportJson, renderStatus, renderStatusJson } from "./report/render.mjs";
|
|
@@ -19,6 +22,8 @@ import { renderNext, renderNextJson } from "./report/next.mjs";
|
|
|
19
22
|
import {
|
|
20
23
|
writeTextAtomic,
|
|
21
24
|
} from "./run/store.mjs";
|
|
25
|
+
import { runDirectory, runsRoot } from "./run/paths.mjs";
|
|
26
|
+
import { migrateRunState } from "./run/migrate.mjs";
|
|
22
27
|
import {
|
|
23
28
|
acquire as acquireLock,
|
|
24
29
|
validBootstrapNonce,
|
|
@@ -105,6 +110,8 @@ export const COMMAND_OPTIONS = {
|
|
|
105
110
|
"bulk-read": { question: { type: "string" }, paths: { type: "string", multiple: true }, json: { type: "boolean" } },
|
|
106
111
|
next: { cwd: { type: "string" }, json: { type: "boolean" } },
|
|
107
112
|
update: { check: { type: "boolean" }, json: { type: "boolean" } },
|
|
113
|
+
project: { from: { type: "string" } },
|
|
114
|
+
migrate: { cwd: { type: "string" } },
|
|
108
115
|
setup: { yes: { type: "boolean" }, harnesses: { type: "string" }, worker: { type: "string" }, judge: { type: "string" }, "no-skill": { type: "boolean" }, json: { type: "boolean" } },
|
|
109
116
|
init: { cwd: { type: "string" }, yes: { type: "boolean" }, "no-skill": { type: "boolean" }, agentkit: { type: "boolean" }, greenfield: { type: "boolean" }, stable: { type: "boolean" }, json: { type: "boolean" } },
|
|
110
117
|
metrics: METRICS_OPTIONS,
|
|
@@ -145,7 +152,8 @@ function parseCli(argv, quiet = false) {
|
|
|
145
152
|
if (command === "update" && parsed.positionals.length !== 0) return null;
|
|
146
153
|
if (command === "setup" && parsed.positionals.length !== 0) return null;
|
|
147
154
|
if (command === "init" && parsed.positionals.length !== 0) return null;
|
|
148
|
-
if (command
|
|
155
|
+
if (command === "migrate" && parsed.positionals.length !== 0) return null;
|
|
156
|
+
if (command !== "doctor" && command !== "models" && command !== "bulk-read" && command !== "next" && command !== "update" && command !== "setup" && command !== "init" && command !== "migrate" && parsed.positionals.length !== 1) return null;
|
|
149
157
|
return {
|
|
150
158
|
command,
|
|
151
159
|
target: parsed.positionals[0],
|
|
@@ -246,7 +254,7 @@ async function main(argv) {
|
|
|
246
254
|
}
|
|
247
255
|
if (command === "next") {
|
|
248
256
|
const cwd = resolve(typeof values.cwd === "string" ? values.cwd : ".");
|
|
249
|
-
const runsDir =
|
|
257
|
+
const runsDir = runsRoot(cwd);
|
|
250
258
|
process.stdout.write(values.json === true ? renderNextJson(runsDir, cwd) : renderNext(runsDir, cwd));
|
|
251
259
|
return;
|
|
252
260
|
}
|
|
@@ -288,13 +296,31 @@ async function main(argv) {
|
|
|
288
296
|
});
|
|
289
297
|
return;
|
|
290
298
|
}
|
|
299
|
+
if (command === "migrate") {
|
|
300
|
+
const result = migrateRunState(resolve(typeof values.cwd === "string" ? values.cwd : "."), { home: faberunHome(process.env) });
|
|
301
|
+
if (!result.moved) {
|
|
302
|
+
process.stdout.write(`[migrate] nothing to move · ${result.legacy} does not exist\n`);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const runs = `${result.runs} run${result.runs === 1 ? "" : "s"}`;
|
|
306
|
+
const campaigns = `${result.campaigns} campaign${result.campaigns === 1 ? "" : "s"}`;
|
|
307
|
+
process.stdout.write(`[migrate] ${result.legacy} -> ${result.target} · ${runs}, ${campaigns}\n`);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
291
310
|
if (!target) { usage(); return; }
|
|
311
|
+
if (command === "project") {
|
|
312
|
+
const record = reassociateProject(faberunHome(process.env), target, {
|
|
313
|
+
from: typeof values.from === "string" && values.from ? values.from : undefined,
|
|
314
|
+
});
|
|
315
|
+
process.stdout.write(`[project] ${record.id} · ${record.path}\n`);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
292
318
|
if (command === "run") {
|
|
293
319
|
warnIfNoTransport();
|
|
294
320
|
const absolute = resolve(target);
|
|
295
321
|
const baseRef = typeof values["base-ref"] === "string" && values["base-ref"] ? values["base-ref"] : undefined;
|
|
296
322
|
const contract = validateContractForLaunch(JSON.parse(readFileSync(absolute, "utf8")), absolute, { baseRef });
|
|
297
|
-
const runDir =
|
|
323
|
+
const runDir = runDirectory(contract.cwd, contract.id);
|
|
298
324
|
setLaunchBaseRef(baseRef);
|
|
299
325
|
// The base is what every worktree is cut from; a dirty tree only blocks
|
|
300
326
|
// when the cwd HEAD *is* that base. A `--base-ref` elsewhere leaves the
|
package/src/contract/index.mjs
CHANGED
|
@@ -20,6 +20,16 @@ import { crossNodeScopeFindings, scopeClosureFindings } from "../repo/scope-clos
|
|
|
20
20
|
|
|
21
21
|
export { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION } from "../harnesses/index.mjs";
|
|
22
22
|
|
|
23
|
+
// Mirrors test/repo/source-shape.test.mjs's LINE_CEILING; that test refuses a
|
|
24
|
+
// file over it, at commit time. The margin is 100 lines: measured against
|
|
25
|
+
// this tree (`find src -name '*.mjs' | xargs wc -l`, 2026-09-18), it is the
|
|
26
|
+
// smallest round number that would have flagged both write targets a real
|
|
27
|
+
// node overran -- 783 and 749 lines, both later landing at 801 -- while
|
|
28
|
+
// catching only about 8 of this tree's ~230 modules today, so the warning
|
|
29
|
+
// stays rare enough to read instead of becoming routine noise.
|
|
30
|
+
const WRITE_FILE_LINE_CEILING = 800;
|
|
31
|
+
const WRITE_FILE_LINE_WARN_MARGIN = 100;
|
|
32
|
+
|
|
23
33
|
const CONTRACT_FIELDS = new Set([
|
|
24
34
|
"schemaVersion", "contractVersion", "id", "campaignId", "goal", "cwd", "sourceIdentity",
|
|
25
35
|
"maxParallel", "pollIntervalMs", "stallTimeoutSec", "timeoutSec",
|
|
@@ -337,6 +347,7 @@ export function validateContract(raw, contractPath, options = {}) {
|
|
|
337
347
|
const warnings = nodes.flatMap((node, index) => [
|
|
338
348
|
...commandCoverageWarnings(node, index),
|
|
339
349
|
...(persisted ? [] : unsnapshottedWriteWarnings(node, index, cwd)),
|
|
350
|
+
...(persisted ? [] : writeFileLineBudgetWarnings(node, index, cwd)),
|
|
340
351
|
]);
|
|
341
352
|
const contract = /** @type {ValidatedContract} */ ({
|
|
342
353
|
...raw,
|
|
@@ -620,6 +631,39 @@ function dependencyCoversPath(closure, path, cwd) {
|
|
|
620
631
|
return false;
|
|
621
632
|
}
|
|
622
633
|
|
|
634
|
+
/**
|
|
635
|
+
* A `writeFiles` entry naming a file already close to the line ceiling is
|
|
636
|
+
* legal -- the ceiling refuses the file itself, at commit time, not the
|
|
637
|
+
* contract that names it -- but a worker cannot discover the file has no
|
|
638
|
+
* room for its diff until an attempt has already spent an invocation
|
|
639
|
+
* finding out. This warns, never refuses, so the author decides whether the
|
|
640
|
+
* write set needs a split before dispatch. A missing file has no current
|
|
641
|
+
* count to warn about, so it is skipped, not treated as zero.
|
|
642
|
+
*
|
|
643
|
+
* @param {ValidatedNode} node
|
|
644
|
+
* @param {number} index
|
|
645
|
+
* @param {string} cwd
|
|
646
|
+
* @returns {string[]}
|
|
647
|
+
*/
|
|
648
|
+
function writeFileLineBudgetWarnings(node, index, cwd) {
|
|
649
|
+
const warnings = [];
|
|
650
|
+
for (const path of node.taskPacket.writeFiles ?? []) {
|
|
651
|
+
let text;
|
|
652
|
+
try {
|
|
653
|
+
text = readFileSync(resolve(cwd, path), "utf8");
|
|
654
|
+
} catch {
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
const lines = text.split("\n").length;
|
|
658
|
+
const remaining = WRITE_FILE_LINE_CEILING - lines;
|
|
659
|
+
if (remaining > WRITE_FILE_LINE_WARN_MARGIN) continue;
|
|
660
|
+
warnings.push(
|
|
661
|
+
`nodes[${index}] (${node.id}): writeFiles ${path} is already ${lines} lines, ${remaining} from the ${WRITE_FILE_LINE_CEILING}-line ceiling; confirm this node's write has room before it starts`,
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
return warnings;
|
|
665
|
+
}
|
|
666
|
+
|
|
623
667
|
/**
|
|
624
668
|
* @param {string} root
|
|
625
669
|
* @param {string} cwd
|
|
@@ -27,6 +27,15 @@ const PROMPT_MAX_BYTES = 64 * 1024;
|
|
|
27
27
|
*/
|
|
28
28
|
const VERIFICATION_PARAGRAPH = "The controller runs every command below after you report; its recorded results are the proof of this node. Running a command yourself is optional and only for one that finishes in seconds and spawns no long-lived process. Keep output bounded (pipe through `| tail -n 200`). Never wait on a background job, never run the whole test suite, and never run tests that start and terminate other processes.";
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The `## Required output` schema every mode states: literally every key
|
|
32
|
+
* `validateWorkerResult` requires, so a worker never loses a valid result to
|
|
33
|
+
* a field this paragraph failed to name. What belongs in `artifacts` (if
|
|
34
|
+
* anything) is this packet's own content contract, stated in its own
|
|
35
|
+
* `instructions`, not repeated or guessed here.
|
|
36
|
+
*/
|
|
37
|
+
const REQUIRED_OUTPUT_SCHEMA = 'Return exactly one JSON object, with no markdown or prose, matching this shape: {"status":"done"|"blocked_context","summary":"string","verification":["string"],"artifacts":["string"],"missingContext":["string"]}. status is "done" when the node is complete or "blocked_context" when required context is missing; summary is prose for a human reader; verification and artifacts are string arrays, sent as [] when this node ran no command or has nothing to put there; missingContext lists exactly what is absent, non-empty only for blocked_context. Use blocked_context only when missingContext is non-empty; use done only when missingContext is empty.';
|
|
38
|
+
|
|
30
39
|
/**
|
|
31
40
|
* `validateRelativePath`'s answer when a path is absent and the caller asked to
|
|
32
41
|
* defer the missing-path verdict rather than throw it. Only contract loading
|
|
@@ -128,7 +137,7 @@ export function renderWorkerPrompt(packet, nodeId) {
|
|
|
128
137
|
...packet.verification.map((command) => `- ${command.argv.join(" ")}`),
|
|
129
138
|
"",
|
|
130
139
|
"## Required output",
|
|
131
|
-
|
|
140
|
+
REQUIRED_OUTPUT_SCHEMA,
|
|
132
141
|
];
|
|
133
142
|
const prompt = `${lines.join("\n")}\n`;
|
|
134
143
|
if (Buffer.byteLength(prompt, "utf8") > PROMPT_MAX_BYTES) {
|
|
@@ -432,7 +441,7 @@ function renderDiscoveryPrompt(packet, nodeId) {
|
|
|
432
441
|
...bulletOrNone(packet.nonGoals),
|
|
433
442
|
"",
|
|
434
443
|
"## Required output",
|
|
435
|
-
|
|
444
|
+
`${REQUIRED_OUTPUT_SCHEMA} Put structured findings meant to inform this packet's own instructions in \`output\` instead (a JSON object, at most 65536 bytes).`,
|
|
436
445
|
"",
|
|
437
446
|
"## Verification",
|
|
438
447
|
VERIFICATION_PARAGRAPH,
|
|
@@ -481,7 +490,7 @@ function renderAutonomousPrompt(packet, nodeId) {
|
|
|
481
490
|
...packet.verification.map((command) => `- ${command.argv.join(" ")}`),
|
|
482
491
|
"",
|
|
483
492
|
"## Required output",
|
|
484
|
-
|
|
493
|
+
REQUIRED_OUTPUT_SCHEMA,
|
|
485
494
|
];
|
|
486
495
|
const prompt = `${lines.join("\n")}\n`;
|
|
487
496
|
if (Buffer.byteLength(prompt, "utf8") > PROMPT_MAX_BYTES) throw new TypeError(`worker prompt exceeds ${PROMPT_MAX_BYTES} bytes`);
|
package/src/engine/cancel.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { terminateInvocation } from "./process.mjs";
|
|
|
17
17
|
import { join, resolve } from "node:path";
|
|
18
18
|
import { readFileSync } from "node:fs";
|
|
19
19
|
import { readRunNodes } from "./scheduler.mjs";
|
|
20
|
+
import { deleteRef, releaseAttemptWorktree, runRefName } from "../repo/worktree.mjs";
|
|
20
21
|
import { syncAgentSignal } from "../repo/signal.mjs";
|
|
21
22
|
import { transition, writeNode } from "./state.mjs";
|
|
22
23
|
import { validateContract } from "../contract/index.mjs";
|
|
@@ -107,6 +108,18 @@ export async function cancelRun(runDirPath) {
|
|
|
107
108
|
throw error;
|
|
108
109
|
}
|
|
109
110
|
if (!await waitForTerminal(runDir, 1_000)) throw new Error("cancel could not confirm a terminal run state");
|
|
111
|
+
// The run directory is evidence a campaign ledger may still want, so it
|
|
112
|
+
// stays; the run ref and every node's attempt branch are just git names
|
|
113
|
+
// the next launch of this same contract id needs back, and cancel is the
|
|
114
|
+
// operator saying this run is over. Releasing a name is not destroying a
|
|
115
|
+
// record -- each state's `worktree.branch`/`commit` fields, and the sha
|
|
116
|
+
// this ref pointed at, remain in the persisted snapshot regardless.
|
|
117
|
+
// Idempotent both ways: `removeWorktree` and `deleteRef` already tolerate
|
|
118
|
+
// an artefact a previous cancel (or the run itself) already released.
|
|
119
|
+
for (const state of states) {
|
|
120
|
+
if (state.worktree?.branch) releaseAttemptWorktree(contract.cwd, state.worktree.path, state.worktree.branch);
|
|
121
|
+
}
|
|
122
|
+
deleteRef(contract.cwd, runRefName(contract.id));
|
|
110
123
|
syncAgentSignal(join(runDir, ".."));
|
|
111
124
|
return true;
|
|
112
125
|
} finally {
|
package/src/engine/dispatch.mjs
CHANGED
|
@@ -21,8 +21,8 @@ import {
|
|
|
21
21
|
readWorkerResultFile,
|
|
22
22
|
workerProtocolPrompt,
|
|
23
23
|
} from "./result-file.mjs";
|
|
24
|
-
import { attemptWorkspace } from "../repo/worktree.mjs";
|
|
25
|
-
import { attemptWorktreePath
|
|
24
|
+
import { attemptWorkspace, createAttemptWorktree, sealAttempt } from "../repo/worktree.mjs";
|
|
25
|
+
import { attemptWorktreePath } from "../run/paths.mjs";
|
|
26
26
|
import { basename, dirname, join } from "node:path";
|
|
27
27
|
import { boundedUtf8, errorCode, errorMessage, stableJson } from "../util.mjs";
|
|
28
28
|
import { captureWorkspaceScope, captureWorkspaceSnapshot } from "../repo/workspace.mjs";
|
|
@@ -387,6 +387,7 @@ function ensureAttemptWorkspace(contract, node, state, runDir, lock) {
|
|
|
387
387
|
runId: contract.id,
|
|
388
388
|
nodeId: node.id,
|
|
389
389
|
attempt: state.attempt,
|
|
390
|
+
declaredReads: node.taskPacket.readFiles,
|
|
390
391
|
base: previous?.sha,
|
|
391
392
|
});
|
|
392
393
|
const boundary = captureWorkspaceScope(worktree.path, workerScope(node.taskPacket));
|
|
@@ -668,6 +669,19 @@ export async function startJudge(contract, node, state, runDir, running, workerR
|
|
|
668
669
|
error.code = "judge_prompt_too_large";
|
|
669
670
|
throw error;
|
|
670
671
|
}
|
|
672
|
+
// Captured at the last possible instant before the judge can touch
|
|
673
|
+
// anything, so the settlement pass's comparison proves what the judge
|
|
674
|
+
// itself wrote rather than racing whatever ran just before dispatch. A
|
|
675
|
+
// capture failure must not block dispatch -- the write check is a
|
|
676
|
+
// controller invariant on top of whatever the judge does, not a
|
|
677
|
+
// precondition for running it -- so it degrades to unchecked instead of
|
|
678
|
+
// to a refusal.
|
|
679
|
+
let judgeBaseline;
|
|
680
|
+
try {
|
|
681
|
+
judgeBaseline = captureWorkspaceSnapshot(workspace);
|
|
682
|
+
} catch {
|
|
683
|
+
judgeBaseline = null;
|
|
684
|
+
}
|
|
671
685
|
const job = startProcess({
|
|
672
686
|
contract, node, state, runtime, workspace,
|
|
673
687
|
prompt: phasePlan.prompt,
|
|
@@ -678,6 +692,10 @@ export async function startJudge(contract, node, state, runDir, running, workerR
|
|
|
678
692
|
}),
|
|
679
693
|
onInvocation: (invocation, currentJob) => {
|
|
680
694
|
stampInvocation(invocation, contract, node, runtime, state, runDir, "judge", phasePlan.mode, phasePlan.continuationId);
|
|
695
|
+
// Reusing the worker phase's own scratch field: a job is never both a
|
|
696
|
+
// worker and a judge, and this field carries no persisted shape of
|
|
697
|
+
// its own that a judge borrowing it would have to match.
|
|
698
|
+
currentJob.scopeBaseline = judgeBaseline;
|
|
681
699
|
persistInvocation(runDir, state, invocation, currentJob, lock);
|
|
682
700
|
persistInvocationIntent(runDir, invocation, {
|
|
683
701
|
nodeId: node.id,
|
package/src/engine/lifecycle.mjs
CHANGED
|
@@ -17,17 +17,8 @@ import {
|
|
|
17
17
|
SETTLED,
|
|
18
18
|
} from "./prompts.mjs";
|
|
19
19
|
import {
|
|
20
|
-
judgeReaskOutstanding,
|
|
21
|
-
} from "./judge-gate.mjs";
|
|
22
|
-
import {
|
|
23
|
-
judgeVerdictEvidence,
|
|
24
|
-
} from "../contract/review-modes.mjs";
|
|
25
|
-
import {
|
|
26
|
-
JUDGE_MAX_FAILURES,
|
|
27
|
-
applyJudgeProtocolFailure,
|
|
28
20
|
applyJudgeResult,
|
|
29
21
|
applyJudgeRound,
|
|
30
|
-
settleUnavailableJudge,
|
|
31
22
|
} from "./review.mjs";
|
|
32
23
|
import { routeRuntimeForState, routingBackoffActive, runtimeSnapshot } from "./failover.mjs";
|
|
33
24
|
import {
|
|
@@ -65,6 +56,7 @@ import { startJudge, startResultMaterialization } from "./dispatch.mjs";
|
|
|
65
56
|
import { raiseNodeAttention, settleDone } from "./settle.mjs";
|
|
66
57
|
import { applyRejection, applyVerificationFailure } from "./settle.mjs";
|
|
67
58
|
import { emitNodeAdvisories } from "./notify-queue.mjs";
|
|
59
|
+
import { judgeWorkspaceWriteViolation, settleJudgeRound } from "./settle-judge.mjs";
|
|
68
60
|
|
|
69
61
|
/** @typedef {import("../repo/integrate.mjs").IntegrationResult} IntegrationResult */
|
|
70
62
|
/** @typedef {import("./backoff.mjs").Transition} Transition */
|
|
@@ -80,7 +72,6 @@ import { emitNodeAdvisories } from "./notify-queue.mjs";
|
|
|
80
72
|
/** @typedef {import("../contract/index.mjs").GateResult} GateResult */
|
|
81
73
|
/** @typedef {import("../contract/index.mjs").SnapshotError} SnapshotError */
|
|
82
74
|
/** @typedef {import("../contract/index.mjs").BoundedScope} BoundedScope */
|
|
83
|
-
/** @typedef {import("../repo/workspace.mjs").WorkspaceSnapshot} WorkspaceSnapshot */
|
|
84
75
|
/** @typedef {import("../run/lock.mjs").LockRecord} LockRecord */
|
|
85
76
|
/** @typedef {ReturnType<typeof acquireLock>} LockHandle */
|
|
86
77
|
/** @typedef {import("../harnesses/index.mjs").HarnessRuntime} HarnessRuntime */
|
|
@@ -90,7 +81,6 @@ import { emitNodeAdvisories } from "./notify-queue.mjs";
|
|
|
90
81
|
/** @typedef {import("../contract/verification.mjs").VerificationAttempt} VerificationAttempt */
|
|
91
82
|
/** @typedef {import("../contract/verification.mjs").VerificationAttemptResult} VerificationAttemptResult */
|
|
92
83
|
/** @typedef {import("../contract/verification.mjs").VerificationResult} VerificationResult */
|
|
93
|
-
/** @typedef {import("../repo/workspace.mjs").ScopeComparison} ScopeComparison */
|
|
94
84
|
/** @typedef {import("../contract/worker-result.mjs").WorkerResult} WorkerResult */
|
|
95
85
|
/** @typedef {import("../campaign/index.mjs").Campaign} Campaign */
|
|
96
86
|
/** @typedef {{path: string, campaign: Campaign}} CampaignRef */
|
|
@@ -277,7 +267,7 @@ export function autoRetryParkedNodes(contract, runDir, states, lock, previouslyP
|
|
|
277
267
|
*
|
|
278
268
|
* @param {NodeSnapshot} state
|
|
279
269
|
*/
|
|
280
|
-
function clearTierExhaustion(state) {
|
|
270
|
+
export function clearTierExhaustion(state) {
|
|
281
271
|
if (!state.routing || state.routing.tierExhaustion === undefined) return;
|
|
282
272
|
const routing = { ...state.routing };
|
|
283
273
|
delete routing.tierExhaustion;
|
|
@@ -393,6 +383,25 @@ export async function finalizeClosedJobs(contract, runDir, states, running, lock
|
|
|
393
383
|
appendUsageRecord(runDir, state.invocations.find((invocation) => invocation.id === job.invocation.id));
|
|
394
384
|
state.usage = invocationUsage(state);
|
|
395
385
|
state.costUsd = invocationCost(state);
|
|
386
|
+
// Checked before any other branch can act on how this invocation closed --
|
|
387
|
+
// a done verdict, a provider failure, a bounded re-dispatch, or exhaustion
|
|
388
|
+
// -- so a judge that wrote into its own workspace is caught here rather
|
|
389
|
+
// than laundered through a re-dispatch whose fresh baseline would already
|
|
390
|
+
// contain the write (the whole point of TECH-SPEC's write check: blocked
|
|
391
|
+
// outright, never re-asked). A comparison that cannot even be completed --
|
|
392
|
+
// an edited ignore source, a symlink escaping the tree, too many entries --
|
|
393
|
+
// is the same violation as an ordinary write, never silence: the worker
|
|
394
|
+
// path (`engine/scope.mjs`'s `checkWorkerScope`) already fails closed on
|
|
395
|
+
// exactly the same throw.
|
|
396
|
+
if (job.phase === "judge") {
|
|
397
|
+
const violation = judgeWorkspaceWriteViolation(job);
|
|
398
|
+
if (violation) {
|
|
399
|
+
clearTierExhaustion(state);
|
|
400
|
+
transition(runDir, state, "blocked", { phase: "judge", result: state.result, usage: state.usage, error: { code: "judge_protocol", message: violation.message } }, lock);
|
|
401
|
+
await raiseNodeAttention(campaignPath, runDir, state, "judge_protocol");
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
396
405
|
// A closed worker whose canonical result file is valid and whose scope
|
|
397
406
|
// passed is completed work, no matter what the provider envelope or the
|
|
398
407
|
// exit code said. The durable file was read above, before the scope gate,
|
|
@@ -425,55 +434,17 @@ export async function finalizeClosedJobs(contract, runDir, states, running, lock
|
|
|
425
434
|
continue;
|
|
426
435
|
}
|
|
427
436
|
// A judge provider that failed outright (its turn died, its tool host was
|
|
428
|
-
// gone) is a provider failure, never a verdict
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
// warmed, and spends none of the one re-dispatch counted below.
|
|
438
|
-
const network = networkTransition(contract, job.node, state, "judge", envelope, job.exitCode);
|
|
439
|
-
if (network && handleProviderExhaustion(contract, runDir, job.node, state, "judge", envelope, job.runtime.id, lock, states, campaignPath, network)) continue;
|
|
440
|
-
clearTierExhaustion(state);
|
|
441
|
-
// The provider died on the bounded re-ask itself, so the one permitted
|
|
442
|
-
// re-ask is spent: settle by review mode here rather than dispatch a
|
|
443
|
-
// third judge invocation behind a fresh failure count.
|
|
444
|
-
// The provider died on the bounded re-ask itself, so the one permitted
|
|
445
|
-
// re-ask is spent: settle by review mode here rather than dispatch a
|
|
446
|
-
// third judge invocation behind a fresh failure count.
|
|
447
|
-
if (judgeReaskOutstanding(state)) {
|
|
448
|
-
await applyJudgeProtocolFailure(contract, job.node, state, runDir, running, lock, states, campaignPath, envelope.error?.message ?? "judge provider failed");
|
|
449
|
-
continue;
|
|
450
|
-
}
|
|
451
|
-
state.judgeFailures = (state.judgeFailures ?? 0) + 1;
|
|
452
|
-
if (state.judgeFailures < JUDGE_MAX_FAILURES) {
|
|
453
|
-
writeNode(runDir, state, lock);
|
|
454
|
-
await applyJudgeRound(await startJudge(contract, job.node, state, runDir, running, state.result, lock, states, campaignPath),
|
|
455
|
-
contract, job.node, state, runDir, running, lock, states, campaignPath, state.result);
|
|
456
|
-
continue;
|
|
457
|
-
}
|
|
458
|
-
await settleUnavailableJudge(contract, job.node, state, runDir, lock, states, campaignPath, envelope.error?.message ?? "judge provider failed");
|
|
459
|
-
continue;
|
|
460
|
-
} // Whatever else this invocation produced, it is not exactly one usable
|
|
461
|
-
// verdict: no verdict at all, several of them in separate agent messages,
|
|
462
|
-
// an unparseable one, a stream cut off before its terminal envelope, or a
|
|
463
|
-
// phase killed on its wall clock. One bounded re-ask, then the review mode
|
|
464
|
-
// decides — advisory completes, blocking enters attention with the work
|
|
465
|
-
// preserved so a retry in place can re-judge it.
|
|
437
|
+
// gone) is a provider failure, never a verdict, and a verdict that arrived
|
|
438
|
+
// but is not exactly one usable one (none at all, several of them, an
|
|
439
|
+
// unparseable one, a stream cut off before its terminal envelope, or a
|
|
440
|
+
// phase killed on its wall clock) is a protocol defect rather than a
|
|
441
|
+
// pass. Both are settled by `settleJudgeRound`, moved out of this file
|
|
442
|
+
// for the same reason `engine/settle.mjs` was: this file and
|
|
443
|
+
// `test/engine/judge.test.mjs` both sit on the 800-line ceiling
|
|
444
|
+
// `test/repo/source-shape.test.mjs` enforces. The write check above has
|
|
445
|
+
// already run, so nothing here can adopt or launder a judge's own write.
|
|
466
446
|
if (job.phase === "judge") {
|
|
467
|
-
|
|
468
|
-
if (!evidence.ok) {
|
|
469
|
-
const network = networkTransition(contract, job.node, state, "judge", envelope, job.exitCode);
|
|
470
|
-
if (network && handleProviderExhaustion(contract, runDir, job.node, state, "judge", envelope, job.runtime.id, lock, states, campaignPath, network)) continue;
|
|
471
|
-
clearTierExhaustion(state);
|
|
472
|
-
await applyJudgeProtocolFailure(contract, job.node, state, runDir, running, lock, states, campaignPath, evidence.reason);
|
|
473
|
-
continue;
|
|
474
|
-
}
|
|
475
|
-
clearTierExhaustion(state);
|
|
476
|
-
await applyJudgeResult(contract, job.node, state, evidence.result, runDir, lock, running, states, campaignPath);
|
|
447
|
+
await settleJudgeRound(contract, job, state, runDir, running, lock, states, campaignPath, envelope, { clearTierExhaustion, handleProviderExhaustion });
|
|
477
448
|
continue;
|
|
478
449
|
}
|
|
479
450
|
// An empty final message is a missing worker result, not a no-op worker:
|
|
@@ -550,7 +521,13 @@ export async function finalizeClosedJobs(contract, runDir, states, running, lock
|
|
|
550
521
|
await applyInvalidWorkerResult(contract, job.node, state, runDir, running, lock, errorMessage(error), states, campaignPath);
|
|
551
522
|
continue;
|
|
552
523
|
}
|
|
553
|
-
|
|
524
|
+
// The artifact demand follows the documented discovery contract, not the
|
|
525
|
+
// mode alone: only a packet with no read files -- the one exception to
|
|
526
|
+
// closed scope, allowed to read the repository to produce an execution
|
|
527
|
+
// packet -- owes that artifact. A discovery packet closed to its listed
|
|
528
|
+
// read files delivers through `output` (a planning node's plan or
|
|
529
|
+
// findings) and carries none.
|
|
530
|
+
if (job.node.taskPacket.mode === "discovery" && job.node.taskPacket.readFiles.length === 0 && workerResult.status === "done") {
|
|
554
531
|
try {
|
|
555
532
|
parseDiscoveryResult(workerResult, attemptWorkspace(state) ?? contract.cwd);
|
|
556
533
|
} catch (error) {
|