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.
Files changed (57) hide show
  1. package/README.md +6 -0
  2. package/integrations/claude-code/statusline.sh +31 -2
  3. package/package.json +2 -2
  4. package/skills/faberun/references/spec-format.md +9 -0
  5. package/src/campaign/chain.mjs +2 -1
  6. package/src/campaign/index.mjs +102 -1
  7. package/src/campaign/metrics.mjs +2 -2
  8. package/src/cli/brand.mjs +2 -0
  9. package/src/cli/campaign-contract.mjs +55 -0
  10. package/src/cli/campaign.mjs +48 -13
  11. package/src/cli/init.mjs +8 -2
  12. package/src/cli/launch.mjs +2 -1
  13. package/src/cli/plan.mjs +3 -2
  14. package/src/cli/project.mjs +150 -0
  15. package/src/cli/skills.mjs +2 -3
  16. package/src/cli/update.mjs +7 -10
  17. package/src/cli.mjs +30 -4
  18. package/src/contract/index.mjs +44 -0
  19. package/src/engine/cancel.mjs +13 -0
  20. package/src/engine/dispatch.mjs +3 -2
  21. package/src/engine/lifecycle.mjs +7 -1
  22. package/src/engine/live-preflight.mjs +2 -1
  23. package/src/engine/notify-queue.mjs +9 -1
  24. package/src/engine/pricing-seed.json +543 -0
  25. package/src/engine/pricing-seed.mjs +54 -0
  26. package/src/engine/process.mjs +8 -1
  27. package/src/engine/result-file.mjs +2 -1
  28. package/src/engine/resume.mjs +6 -2
  29. package/src/engine/run-identity.mjs +9 -2
  30. package/src/engine/scheduler.mjs +40 -6
  31. package/src/engine/settle.mjs +1 -1
  32. package/src/harnesses/replay/bin.mjs +10 -1
  33. package/src/host/home.mjs +10 -3
  34. package/src/host/platform.mjs +111 -0
  35. package/src/host/preflight.mjs +24 -21
  36. package/src/host/projects.mjs +155 -0
  37. package/src/notify/index.mjs +88 -10
  38. package/src/plan/pipeline.mjs +32 -9
  39. package/src/plan/repo-facts.mjs +80 -2
  40. package/src/plan/spec.mjs +2 -1
  41. package/src/repo/declared-paths.mjs +7 -1
  42. package/src/repo/integrate.mjs +1 -1
  43. package/src/repo/scope-closure.mjs +2 -1
  44. package/src/repo/signal.mjs +24 -7
  45. package/src/repo/source-identity.mjs +7 -0
  46. package/src/repo/workspace.mjs +12 -2
  47. package/src/repo/worktree.mjs +89 -22
  48. package/src/report/next.mjs +66 -6
  49. package/src/report/progress.mjs +724 -0
  50. package/src/report/render.mjs +3 -3
  51. package/src/run/migrate.mjs +278 -0
  52. package/src/run/paths.mjs +189 -0
  53. package/src/seat/index.mjs +2 -1
  54. package/src/web/app.css +170 -0
  55. package/src/web/app.mjs +690 -0
  56. package/src/web/index.html +38 -283
  57. 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
+ }
@@ -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/preflight.mjs";
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
- symlinkSync(source, destination);
306
+ linkDirectory(destination, source);
308
307
  return "linked";
309
308
  }
310
309
 
@@ -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, symlinkSync, writeFileSync } from "node:fs";
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("tar", ["-xzf", tarball, "--strip-components=1", "-C", destination], { encoding: "utf8" });
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 atomically: a `current.tmp` symlink then a
197
- * rename over `current`, so a concurrent reader sees either the old target or
198
- * the new one, never a missing link.
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
- symlinkSync(join("versions", version), temporary);
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, findExecutable, notifyTransportCheck, reachableRuntimes, timeVerificationCommands } from "./host/preflight.mjs";
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 !== "doctor" && command !== "models" && command !== "bulk-read" && command !== "next" && command !== "update" && command !== "setup" && command !== "init" && parsed.positionals.length !== 1) return null;
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 = join(cwd, ".runs");
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 = join(contract.cwd, ".runs", contract.id);
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
@@ -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
@@ -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 {
@@ -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, createAttemptWorktree, sealAttempt } from "../repo/worktree.mjs";
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));
@@ -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
- if (job.node.taskPacket.mode === "discovery" && workerResult.status === "done") {
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 = join(contract.cwd, ".runs");
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
  }