faberun 0.11.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/engine/cancel.mjs +13 -0
- package/src/engine/dispatch.mjs +3 -2
- package/src/engine/lifecycle.mjs +7 -1
- 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.mjs +1 -1
- 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 +24 -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
|
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));
|
package/src/engine/lifecycle.mjs
CHANGED
|
@@ -521,7 +521,13 @@ export async function finalizeClosedJobs(contract, runDir, states, running, lock
|
|
|
521
521
|
await applyInvalidWorkerResult(contract, job.node, state, runDir, running, lock, errorMessage(error), states, campaignPath);
|
|
522
522
|
continue;
|
|
523
523
|
}
|
|
524
|
-
|
|
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") {
|
|
525
531
|
try {
|
|
526
532
|
parseDiscoveryResult(workerResult, attemptWorkspace(state) ?? contract.cwd);
|
|
527
533
|
} catch (error) {
|
|
@@ -24,6 +24,7 @@ import { tmpdir } from "node:os";
|
|
|
24
24
|
import { validateContract } from "../contract/index.mjs";
|
|
25
25
|
import { validateNodeSnapshot } from "../contract/snapshot.mjs";
|
|
26
26
|
import { compactCost as formatCost } from "../util.mjs";
|
|
27
|
+
import { runsRoot } from "../run/paths.mjs";
|
|
27
28
|
|
|
28
29
|
/** @typedef {import("../harnesses/index.mjs").ProbeResult} ProbeResult */
|
|
29
30
|
/** @typedef {import("../harnesses/index.mjs").ProviderEnvelope} ProviderEnvelope */
|
|
@@ -263,7 +264,7 @@ function redactProviderText(value) {
|
|
|
263
264
|
export function reusedDoneWarnings(contract) {
|
|
264
265
|
/** @type {string[]} */
|
|
265
266
|
const warnings = [];
|
|
266
|
-
const runsDir =
|
|
267
|
+
const runsDir = runsRoot(contract.cwd);
|
|
267
268
|
if (!existsSync(runsDir)) return warnings;
|
|
268
269
|
const ownRunDir = join(runsDir, contract.id);
|
|
269
270
|
for (const name of readdirSync(runsDir)) {
|
|
@@ -222,6 +222,12 @@ export function attentionDedupeKey(runId, anchor, slot) {
|
|
|
222
222
|
* already announced. A receipt is the durable record, so a restarted
|
|
223
223
|
* supervisor reads the same slot as already sent.
|
|
224
224
|
*
|
|
225
|
+
* The message is rendered exactly once, with `runDir` set, and that one
|
|
226
|
+
* string is handed to both the inbox entry and the notify queue: `enqueue`
|
|
227
|
+
* never re-renders a `summary` it is given. Rendering it twice -- once for
|
|
228
|
+
* the inbox with no `runDir` and once inside `enqueue` with `runDir` filled
|
|
229
|
+
* in -- is what let the inbox and the transport disagree before.
|
|
230
|
+
*
|
|
225
231
|
* @param {string} runDir
|
|
226
232
|
* @param {{anchor: string, code?: string|null, campaignId?: string|null, now?: number}} options
|
|
227
233
|
* @returns {Promise<number|null>} the emitted slot, or null when none was due
|
|
@@ -234,10 +240,11 @@ export async function emitScheduledAttention(runDir, options) {
|
|
|
234
240
|
const runId = basename(runDir);
|
|
235
241
|
const dedupeKey = attentionDedupeKey(runId, options.anchor, slot);
|
|
236
242
|
if (alreadyNotified(runDir, dedupeKey)) return null;
|
|
237
|
-
const summary = renderNotification({
|
|
243
|
+
const summary = await renderNotification({
|
|
238
244
|
type: "attention",
|
|
239
245
|
runId,
|
|
240
246
|
errorCode: options.code ?? null,
|
|
247
|
+
runDir,
|
|
241
248
|
});
|
|
242
249
|
// The run-level attention also lands in the campaign-level inbox, which is
|
|
243
250
|
// the append-only record the managed signal block summarises.
|
|
@@ -255,6 +262,7 @@ export async function emitScheduledAttention(runDir, options) {
|
|
|
255
262
|
runId,
|
|
256
263
|
errorCode: options.code ?? null,
|
|
257
264
|
dedupeKey,
|
|
265
|
+
summary,
|
|
258
266
|
});
|
|
259
267
|
return slot;
|
|
260
268
|
}
|