faberun 0.3.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 (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/bin/faberun.mjs +25 -0
  4. package/integrations/claude-code/statusline-bench.sh +42 -0
  5. package/integrations/claude-code/statusline.sh +80 -0
  6. package/package.json +33 -0
  7. package/skills/faberun/SKILL.md +24 -0
  8. package/skills/faberun/references/contract.md +380 -0
  9. package/skills/faberun/references/engineering.md +29 -0
  10. package/skills/faberun/references/handoffs.md +26 -0
  11. package/skills/faberun/references/operations.md +184 -0
  12. package/skills/faberun/references/rules.md +35 -0
  13. package/skills/faberun/references/workflow.md +23 -0
  14. package/skills/init-agentkit/SKILL.md +108 -0
  15. package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
  16. package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
  17. package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
  18. package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
  19. package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
  20. package/skills/init-agentkit/templates/AGENTS.md +110 -0
  21. package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
  22. package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
  23. package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
  24. package/skills/init-agentkit/templates/docs/VISION.md +33 -0
  25. package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
  26. package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
  27. package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
  28. package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
  29. package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
  30. package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
  31. package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
  32. package/src/campaign/brief.mjs +394 -0
  33. package/src/campaign/chain.mjs +555 -0
  34. package/src/campaign/handoff.mjs +516 -0
  35. package/src/campaign/index.mjs +300 -0
  36. package/src/campaign/journal.mjs +347 -0
  37. package/src/campaign/layout.mjs +51 -0
  38. package/src/campaign/metrics-evals.mjs +25 -0
  39. package/src/campaign/metrics.mjs +517 -0
  40. package/src/campaign/projection.mjs +250 -0
  41. package/src/campaign/record.mjs +102 -0
  42. package/src/campaign/unpark.mjs +56 -0
  43. package/src/cli/brand.mjs +205 -0
  44. package/src/cli/campaign.mjs +730 -0
  45. package/src/cli/contract.mjs +67 -0
  46. package/src/cli/init.mjs +170 -0
  47. package/src/cli/launch.mjs +239 -0
  48. package/src/cli/seat.mjs +139 -0
  49. package/src/cli/setup.mjs +294 -0
  50. package/src/cli/skills.mjs +105 -0
  51. package/src/cli/update.mjs +216 -0
  52. package/src/cli.mjs +525 -0
  53. package/src/contract/articles.mjs +12 -0
  54. package/src/contract/assert.mjs +162 -0
  55. package/src/contract/definition-of-done.mjs +97 -0
  56. package/src/contract/final-verification.mjs +96 -0
  57. package/src/contract/index.mjs +641 -0
  58. package/src/contract/judge-envelope.mjs +25 -0
  59. package/src/contract/review-modes.mjs +151 -0
  60. package/src/contract/runtime.mjs +204 -0
  61. package/src/contract/schema-version.mjs +25 -0
  62. package/src/contract/scope-findings.mjs +77 -0
  63. package/src/contract/snapshot.mjs +639 -0
  64. package/src/contract/task-packet.mjs +495 -0
  65. package/src/contract/untrusted.mjs +75 -0
  66. package/src/contract/verification.mjs +185 -0
  67. package/src/contract/worker-result.mjs +138 -0
  68. package/src/engine/assignment.mjs +63 -0
  69. package/src/engine/backoff.mjs +492 -0
  70. package/src/engine/bulk-read.mjs +361 -0
  71. package/src/engine/cancel.mjs +177 -0
  72. package/src/engine/detach.mjs +101 -0
  73. package/src/engine/dispatch.mjs +752 -0
  74. package/src/engine/failover.mjs +192 -0
  75. package/src/engine/gate.mjs +183 -0
  76. package/src/engine/judge-gate.mjs +517 -0
  77. package/src/engine/lifecycle.mjs +772 -0
  78. package/src/engine/live-preflight.mjs +299 -0
  79. package/src/engine/mutation.mjs +146 -0
  80. package/src/engine/notify-queue.mjs +327 -0
  81. package/src/engine/process-identity.mjs +72 -0
  82. package/src/engine/process.mjs +774 -0
  83. package/src/engine/prompts.mjs +289 -0
  84. package/src/engine/recover.mjs +300 -0
  85. package/src/engine/result-file.mjs +222 -0
  86. package/src/engine/resume.mjs +635 -0
  87. package/src/engine/retry.mjs +334 -0
  88. package/src/engine/review.mjs +228 -0
  89. package/src/engine/run-command.mjs +287 -0
  90. package/src/engine/run-identity.mjs +411 -0
  91. package/src/engine/runtime-discovery.mjs +235 -0
  92. package/src/engine/scheduler.mjs +526 -0
  93. package/src/engine/scope.mjs +378 -0
  94. package/src/engine/settle.mjs +207 -0
  95. package/src/engine/state.mjs +148 -0
  96. package/src/engine/supervise.mjs +713 -0
  97. package/src/engine/verify.mjs +167 -0
  98. package/src/harnesses/agy/index.mjs +62 -0
  99. package/src/harnesses/catalogue.mjs +509 -0
  100. package/src/harnesses/claude/index.mjs +90 -0
  101. package/src/harnesses/codex/index.mjs +87 -0
  102. package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
  103. package/src/harnesses/dsh/index.mjs +210 -0
  104. package/src/harnesses/dsh/runner.mjs +259 -0
  105. package/src/harnesses/exec-jsonl/index.mjs +788 -0
  106. package/src/harnesses/index.mjs +508 -0
  107. package/src/harnesses/protocol.mjs +531 -0
  108. package/src/harnesses/replay/bin.mjs +386 -0
  109. package/src/harnesses/replay/index.mjs +238 -0
  110. package/src/harnesses/zcode/index.mjs +276 -0
  111. package/src/host/config.mjs +87 -0
  112. package/src/host/home.mjs +149 -0
  113. package/src/host/package.mjs +23 -0
  114. package/src/host/preflight.mjs +520 -0
  115. package/src/host/tool-policy-decisions.mjs +341 -0
  116. package/src/host/tool-policy-hook.mjs +270 -0
  117. package/src/notify/index.mjs +359 -0
  118. package/src/notify/os-macos.mjs +81 -0
  119. package/src/repo/declared-paths.mjs +220 -0
  120. package/src/repo/integrate.mjs +546 -0
  121. package/src/repo/scope-closure.mjs +665 -0
  122. package/src/repo/signal-block.mjs +16 -0
  123. package/src/repo/signal.mjs +222 -0
  124. package/src/repo/source-identity.mjs +295 -0
  125. package/src/repo/workspace.mjs +557 -0
  126. package/src/repo/worktree.mjs +352 -0
  127. package/src/report/final.mjs +200 -0
  128. package/src/report/metrics-report.mjs +99 -0
  129. package/src/report/next.mjs +383 -0
  130. package/src/report/render.mjs +716 -0
  131. package/src/run/disk-gc.mjs +251 -0
  132. package/src/run/lock.mjs +329 -0
  133. package/src/run/node-store.mjs +62 -0
  134. package/src/run/operations.mjs +286 -0
  135. package/src/run/store.mjs +187 -0
  136. package/src/run/usage.mjs +337 -0
  137. package/src/seat/harnesses.mjs +83 -0
  138. package/src/seat/index.mjs +239 -0
  139. package/src/seat/tmux.mjs +208 -0
  140. package/src/util.mjs +0 -0
  141. package/src/web/api.mjs +371 -0
  142. package/src/web/boundary.mjs +88 -0
  143. package/src/web/index.html +299 -0
  144. package/src/web/server.mjs +552 -0
@@ -0,0 +1,361 @@
1
+ /**
2
+ * One question about many files, answered without the files ever entering the
3
+ * asking context: the exact bytes are packed into a single temp file, a
4
+ * delegated provider reads that one file and answers in bullets, and only the
5
+ * bullets come back. Packing, table-driven delegation routing, and the
6
+ * usage.jsonl accounting of a delegation live here because all three are
7
+ * engine work; the CLI shape belongs to cli.mjs and the command build to the
8
+ * adapter registry.
9
+ */
10
+ import { spawn } from "node:child_process";
11
+ import { randomUUID } from "node:crypto";
12
+ import { closeSync, mkdtempSync, openSync, readSync, rmSync, statSync, writeSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { basename, join } from "node:path";
15
+ import { DECLARED_MODEL_CATALOGUES, stableJsonDocument } from "../harnesses/catalogue.mjs";
16
+ import { READ_LINE_LIMIT, normalizeProviderResult, providerCommand } from "../harnesses/index.mjs";
17
+ import { appendUsageRecord, emptyUsage, priceUsage } from "../run/usage.mjs";
18
+ import { errorMessage, fail } from "../util.mjs";
19
+ import { DISCOVERY_RUNTIME_DEFINITIONS } from "./runtime-discovery.mjs";
20
+
21
+ /** Fixed-size copy buffer: the pack is built through this, never through a corpus-sized string. */
22
+ const CHUNK_BYTES = 64 * 1024;
23
+ /** Answer budget: bullets are small, so anything past this is a runaway provider, not evidence. */
24
+ const ANSWER_LIMIT_BYTES = 512 * 1024;
25
+ /** Rough bytes-per-token heuristic, not a measurement; it only sizes windows an order apart. */
26
+ const CORPUS_BYTES_PER_TOKEN = 4;
27
+ /** Wall clock for one delegated read+answer. Not measured; generous on purpose, SIGKILL backs the SIGTERM. */
28
+ const DELEGATION_TIMEOUT_MS = 300_000;
29
+
30
+ /** @typedef {import("../harnesses/index.mjs").HarnessRuntime} HarnessRuntime */
31
+ /** @typedef {import("../harnesses/index.mjs").ProviderEnvelope} ProviderEnvelope */
32
+ /** @typedef {import("./process.mjs").Invocation} Invocation */
33
+
34
+ /**
35
+ * A routing-table entry: the same shape `DISCOVERY_RUNTIME_DEFINITIONS` carries,
36
+ * with `vendor` optional because routing never compares vendors.
37
+ *
38
+ * @typedef {{harness: string, model: string, vendor?: string, tier?: number, costRank?: number, config?: Record<string, unknown>}} BulkReadRuntime
39
+ */
40
+
41
+ /** @typedef {{question: string, paths: string[], cwd?: string, runtimes?: Record<string, BulkReadRuntime>}} BulkReadOptions */
42
+
43
+ /** @typedef {{status: "done"|"refused"|"failed", result: string|null, runtimeId: string|null, usage: {inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}|null, costUsd: number|null, costProvenance?: "priced", reason: string|null, error: {code: string, message: string}|null}} BulkReadResult */
44
+
45
+ /**
46
+ * Copy every path's exact bytes into one pack file through a fixed-size chunk
47
+ * buffer — header, byte-a-byte content, footer, no inserted newlines — so no
48
+ * buffer or string the size of the corpus is ever allocated. Line counts cover
49
+ * content only, the way the delegation floor is measured.
50
+ *
51
+ * @param {string[]} paths
52
+ * @param {string} packPath
53
+ * @returns {{bytes: number, lines: number, files: number}}
54
+ */
55
+ export function writeBulkReadPack(paths, packPath) {
56
+ const chunk = Buffer.alloc(CHUNK_BYTES);
57
+ const out = openSync(packPath, "wx", 0o600);
58
+ let bytes = 0;
59
+ let lines = 0;
60
+ try {
61
+ for (const path of paths) {
62
+ const header = `<file path="${path.replaceAll("\"", "&quot;")}">`;
63
+ bytes += writeSync(out, header, null, "utf8");
64
+ const fd = openSync(path, "r");
65
+ try {
66
+ let read;
67
+ while ((read = readSync(fd, chunk, 0, chunk.length, null)) > 0) {
68
+ for (let index = 0; index < read; index += 1) {
69
+ if (chunk[index] === 0x0a) lines += 1;
70
+ }
71
+ bytes += writeSync(out, chunk, 0, read);
72
+ }
73
+ } finally {
74
+ closeSync(fd);
75
+ }
76
+ bytes += writeSync(out, "</file>", null, "utf8");
77
+ }
78
+ } finally {
79
+ closeSync(out);
80
+ }
81
+ return { bytes, lines, files: paths.length };
82
+ }
83
+
84
+ /**
85
+ * Sort key mirroring the discovery allocation law: tier first, costRank
86
+ * second, declaration order through sort stability.
87
+ *
88
+ * @param {BulkReadRuntime} runtime
89
+ * @returns {[number, number]}
90
+ */
91
+ function bulkReadOrder(runtime) {
92
+ return [runtime.tier ?? runtime.costRank ?? Number.MAX_SAFE_INTEGER, runtime.costRank ?? Number.MAX_SAFE_INTEGER];
93
+ }
94
+
95
+ /**
96
+ * The declared context window of a runtime's model, from the catalogue the
97
+ * `models` report already keeps. `null` means undeclared, and an undeclared
98
+ * window is no reason to refuse a runtime.
99
+ *
100
+ * @param {BulkReadRuntime} runtime
101
+ * @returns {number|null}
102
+ */
103
+ function declaredContextWindow(runtime) {
104
+ const model = (DECLARED_MODEL_CATALOGUES[runtime.harness] ?? []).find((entry) => entry.id === runtime.model);
105
+ return model?.contextWindowTokens ?? null;
106
+ }
107
+
108
+ /**
109
+ * The cheapest table entry whose declared window holds the corpus.
110
+ *
111
+ * @param {Record<string, BulkReadRuntime>} runtimes
112
+ * @param {number} corpusTokens
113
+ * @returns {string|null}
114
+ */
115
+ function selectBulkReadRuntime(runtimes, corpusTokens) {
116
+ return Object.entries(runtimes)
117
+ .filter(([, runtime]) => {
118
+ const window = declaredContextWindow(runtime);
119
+ return window === null || corpusTokens <= window;
120
+ })
121
+ .sort((left, right) => {
122
+ const [leftTier, leftRank] = bulkReadOrder(left[1]);
123
+ const [rightTier, rightRank] = bulkReadOrder(right[1]);
124
+ return leftTier - rightTier || leftRank - rightRank;
125
+ })
126
+ .at(0)?.[0] ?? null;
127
+ }
128
+
129
+ /**
130
+ * @param {string} packPath
131
+ * @param {number} files
132
+ * @param {string} question
133
+ * @returns {string}
134
+ */
135
+ function delegationPrompt(packPath, files, question) {
136
+ return [
137
+ `Answer one question about ${files} files packed into one file.`,
138
+ `Read ${packPath}. It holds each file's exact bytes wrapped as <file path="...">…</file>.`,
139
+ "Answer with bullets only. Every bullet starts with the exact symbol name or file:line number it concerns. No greeting, no prose.",
140
+ `Question: ${question}`,
141
+ ].join("\n");
142
+ }
143
+
144
+ /**
145
+ * @typedef {{startedAt: string, stdout: string, stderr: string, exitCode: number|null, signal: string|null, spawnError: string|null}} DelegationObservation */
146
+
147
+ /**
148
+ * The runner environment plus the command's env overlay — a null overlay value
149
+ * removes the ambient variable, the same contract the spawn gate applies.
150
+ *
151
+ * @param {Record<string, string|null>|undefined} overlay
152
+ * @returns {NodeJS.ProcessEnv}
153
+ */
154
+ function environmentWith(overlay) {
155
+ const env = { ...process.env };
156
+ for (const [key, value] of Object.entries(overlay ?? {})) {
157
+ if (value === null) delete env[key];
158
+ else env[key] = value;
159
+ }
160
+ return env;
161
+ }
162
+
163
+ /**
164
+ * Spawn one delegated provider with the command `providerCommand` builds — the
165
+ * same argv, prompt transport, and env overlay every other invocation uses —
166
+ * and collect a bounded answer.
167
+ *
168
+ * @param {HarnessRuntime} runtime
169
+ * @param {string} prompt
170
+ * @param {string} cwd
171
+ * @returns {Promise<DelegationObservation>}
172
+ */
173
+ function invokeDelegation(runtime, prompt, cwd) {
174
+ const command = providerCommand(runtime, prompt);
175
+ /** @type {DelegationObservation} */
176
+ const observation = { startedAt: new Date().toISOString(), stdout: "", stderr: "", exitCode: null, signal: null, spawnError: null };
177
+ return new Promise((resolve) => {
178
+ /** @type {import("node:child_process").ChildProcess} */
179
+ let child;
180
+ try {
181
+ child = spawn(command.executable, command.args, {
182
+ cwd,
183
+ env: environmentWith(command.env),
184
+ stdio: ["pipe", "pipe", "pipe"],
185
+ });
186
+ } catch (error) {
187
+ observation.spawnError = errorMessage(error);
188
+ resolve(observation);
189
+ return;
190
+ }
191
+ let settled = false;
192
+ /** @type {ReturnType<typeof setTimeout>|null} */
193
+ let timer = null;
194
+ const finish = () => {
195
+ if (settled) return;
196
+ settled = true;
197
+ if (timer) clearTimeout(timer);
198
+ resolve(observation);
199
+ };
200
+ child.stdout?.setEncoding("utf8");
201
+ child.stderr?.setEncoding("utf8");
202
+ child.stdout?.on("data", (chunk) => {
203
+ observation.stdout = (observation.stdout + chunk).slice(-ANSWER_LIMIT_BYTES);
204
+ });
205
+ child.stderr?.on("data", (chunk) => {
206
+ observation.stderr = (observation.stderr + chunk).slice(-4096);
207
+ });
208
+ // stdin adapters own the prompt stream; argv adapters carry it in args and
209
+ // still get their unused stdin pipe closed.
210
+ child.stdin?.on("error", () => {
211
+ // A provider exiting before draining stdin is not a failure of the answer.
212
+ });
213
+ child.stdin?.end(command.promptTransport === "stdin" ? command.input ?? undefined : undefined);
214
+ timer = setTimeout(() => {
215
+ observation.signal = "SIGTERM";
216
+ child.kill("SIGTERM");
217
+ // A delegation that outlives its SIGTERM gets SIGKILL five seconds later.
218
+ setTimeout(() => child.kill("SIGKILL"), 5_000).unref();
219
+ }, DELEGATION_TIMEOUT_MS);
220
+ child.once("error", (error) => {
221
+ observation.spawnError = errorMessage(error);
222
+ finish();
223
+ });
224
+ child.once("close", (exitCode, signal) => {
225
+ observation.exitCode = exitCode;
226
+ observation.signal = observation.signal ?? signal;
227
+ finish();
228
+ });
229
+ });
230
+ }
231
+
232
+ /**
233
+ * Record the delegation in the owning run's usage.jsonl when the controller
234
+ * exported the run directory and node it belongs to. A human terminal exports
235
+ * neither, and the accounting is skipped without error.
236
+ *
237
+ * `priced` is the already-computed `{costUsd, costProvenance}` pair from the
238
+ * one `priceUsage` call in `bulkRead`; this function never reprices.
239
+ *
240
+ * @param {string} runtimeId
241
+ * @param {HarnessRuntime} runtime
242
+ * @param {ProviderEnvelope} envelope
243
+ * @param {{costUsd: number|null, costProvenance: "priced"|undefined}} priced
244
+ * @param {DelegationObservation} observation
245
+ */
246
+ function accountDelegation(runtimeId, runtime, envelope, priced, observation) {
247
+ const runDir = process.env.FABERUN_RUN_DIR;
248
+ const nodeId = process.env.FABERUN_NODE_ID;
249
+ if (!runDir || !nodeId) return;
250
+ appendUsageRecord(runDir, /** @type {Invocation} */ ({
251
+ id: randomUUID(),
252
+ runId: basename(runDir),
253
+ nodeId,
254
+ role: "worker",
255
+ runtimeId,
256
+ model: runtime.model,
257
+ usage: envelope.usage,
258
+ costUsd: priced.costUsd,
259
+ costProvenance: priced.costProvenance,
260
+ startedAt: observation.startedAt,
261
+ closedAt: new Date().toISOString(),
262
+ }));
263
+ }
264
+
265
+ /** @param {string} reason @returns {BulkReadResult} */
266
+ function refused(reason) {
267
+ return { status: "refused", result: null, runtimeId: null, usage: null, costUsd: null, reason, error: null };
268
+ }
269
+
270
+ /**
271
+ * Ask one question about many files. Refuses — never tolerates — a corpus the
272
+ * delegation floor says to read directly, and a corpus no declared window
273
+ * holds.
274
+ *
275
+ * @param {BulkReadOptions} options
276
+ * @returns {Promise<BulkReadResult>}
277
+ */
278
+ export async function bulkRead(options) {
279
+ const question = options.question.trim();
280
+ const paths = [...new Set(options.paths.map((path) => path.trim()).filter(Boolean))];
281
+ if (!question) throw fail("invalid_options", "bulk-read requires a question");
282
+ if (!paths.length) throw fail("invalid_options", "bulk-read requires at least one path");
283
+ for (const path of paths) {
284
+ if (!statSync(path, { throwIfNoEntry: false })?.isFile()) throw fail("invalid_path", `not a readable file: ${path}`);
285
+ }
286
+ const runtimes = options.runtimes ?? DISCOVERY_RUNTIME_DEFINITIONS;
287
+ const directory = mkdtempSync(join(tmpdir(), "faberun-bulk-read-"));
288
+ const packPath = join(directory, "corpus.pack");
289
+ try {
290
+ const pack = writeBulkReadPack(paths, packPath);
291
+ if (pack.lines < READ_LINE_LIMIT) {
292
+ return refused(`corpus is ${pack.lines} lines across ${pack.files} files, below the ${READ_LINE_LIMIT}-line delegation floor — read the files directly`);
293
+ }
294
+ const corpusTokens = Math.ceil(pack.bytes / CORPUS_BYTES_PER_TOKEN);
295
+ const runtimeId = selectBulkReadRuntime(runtimes, corpusTokens);
296
+ if (!runtimeId) {
297
+ return refused(`corpus of ~${corpusTokens} tokens fits no declared runtime context window — split the paths or read the files directly`);
298
+ }
299
+ const runtime = /** @type {HarnessRuntime} */ ({ ...runtimes[runtimeId], id: runtimeId });
300
+ const observation = await invokeDelegation(runtime, delegationPrompt(packPath, pack.files, question), options.cwd ?? process.cwd());
301
+ const envelope = observation.spawnError !== null
302
+ ? {
303
+ status: /** @type {"failed"} */ ("failed"),
304
+ result: null,
305
+ continuationId: null,
306
+ usage: emptyUsage(),
307
+ costUsd: null,
308
+ error: { code: "spawn_error", message: observation.spawnError },
309
+ }
310
+ : normalizeProviderResult(runtime, observation.stdout, observation.exitCode, observation.signal, { stderr: observation.stderr });
311
+ // Price once, here, and thread the same pair into the ledger and every
312
+ // returned result. `costProvenance` is present only as `"priced"`, never as
313
+ // the ledger's separate `"provider"` rule — a provider-reported number
314
+ // leaves it absent on the result while `appendUsageRecord` writes
315
+ // `"provider"` for the record.
316
+ const priced = priceUsage(runtime, envelope.usage, envelope.costUsd);
317
+ const provenance = priced.costProvenance ? { costProvenance: priced.costProvenance } : {};
318
+ accountDelegation(runtimeId, runtime, envelope, priced, observation);
319
+ if (envelope.status !== "done" && envelope.status !== "no-op") {
320
+ return {
321
+ status: "failed",
322
+ result: null,
323
+ runtimeId,
324
+ usage: envelope.usage,
325
+ costUsd: priced.costUsd,
326
+ ...provenance,
327
+ reason: null,
328
+ error: envelope.error ?? { code: `provider_${envelope.status}`, message: observation.stderr.split(/\r?\n/u).filter(Boolean).at(-1) ?? envelope.status },
329
+ };
330
+ }
331
+ return { status: "done", result: envelope.result ?? "", runtimeId, usage: envelope.usage, costUsd: priced.costUsd, ...provenance, reason: null, error: null };
332
+ } finally {
333
+ rmSync(directory, { recursive: true, force: true });
334
+ }
335
+ }
336
+
337
+ /**
338
+ * The CLI shape, in the `modelsCommand` pattern: normalize the parsed options,
339
+ * run the work, write the report. Bullets go to stdout on success; a refusal
340
+ * or failure explains itself on stderr and fails the exit code.
341
+ *
342
+ * @param {{question?: unknown, paths?: unknown, json?: boolean}} options
343
+ * @returns {Promise<void>}
344
+ */
345
+ export async function bulkReadCommand(options = {}) {
346
+ const question = typeof options.question === "string" ? options.question.trim() : "";
347
+ const paths = (Array.isArray(options.paths) ? options.paths : [])
348
+ .flatMap((value) => String(value).split(","))
349
+ .map((value) => value.trim())
350
+ .filter(Boolean);
351
+ if (!question || !paths.length) throw fail("invalid_options", "bulk-read requires --question <text> and --paths <a,b,c>");
352
+ const result = await bulkRead({ question, paths });
353
+ if (options.json === true) {
354
+ process.stdout.write(stableJsonDocument(result));
355
+ } else if (result.status === "done") {
356
+ process.stdout.write(`${(result.result ?? "").trim()}\n`);
357
+ } else {
358
+ process.stderr.write(`[bulk-read] ${result.reason ?? `${result.error?.code ?? "failed"}: ${result.error?.message ?? ""}`}\n`);
359
+ }
360
+ if (result.status !== "done") process.exitCode = 1;
361
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Stopping a run that is still going.
3
+ *
4
+ * `cancel` is not a flag on a loop: the controller may be another process, or
5
+ * gone with its lock still on disk. It signals the holder, waits for the
6
+ * process to actually die rather than assuming, takes over the now-stale lock,
7
+ * terminates every recorded invocation, and only then marks the run terminal.
8
+ * It refuses a lock held by its own process, because that is a bug and not a
9
+ * cancellation.
10
+ */
11
+ import { LockBusyError, acquire as acquireLock, pidAlive, readLock } from "../run/lock.mjs";
12
+ import { SETTLED } from "./prompts.mjs";
13
+ import { assertRunMutable } from "./lifecycle.mjs";
14
+ import { delay, errorCode } from "../util.mjs";
15
+ import { invocationOwned } from "./process-identity.mjs";
16
+ import { terminateInvocation } from "./process.mjs";
17
+ import { join, resolve } from "node:path";
18
+ import { readFileSync } from "node:fs";
19
+ import { readRunNodes } from "./scheduler.mjs";
20
+ import { syncAgentSignal } from "../repo/signal.mjs";
21
+ import { transition, writeNode } from "./state.mjs";
22
+ import { validateContract } from "../contract/index.mjs";
23
+ import { writeJsonAtomic } from "../run/store.mjs";
24
+
25
+ /** @typedef {import("./process.mjs").InvocationProbe} InvocationProbe */
26
+ /** @typedef {import("../cli.mjs").LockHandle} LockHandle */
27
+ /** @typedef {import("../run/lock.mjs").LockRecord} LockRecord */
28
+
29
+ /**
30
+ * @param {string} runDirPath
31
+ * @returns {Promise<boolean>}
32
+ */
33
+ export async function cancelRun(runDirPath) {
34
+ const runDir = resolve(runDirPath);
35
+ assertRunMutable(runDir);
36
+ const contractPath = join(runDir, "contract.json");
37
+ const contract = validateContract(JSON.parse(readFileSync(contractPath, "utf8")), contractPath, { persisted: true });
38
+ writeJsonAtomic(join(runDir, "cancel.request.json"), { requestedAt: new Date().toISOString(), pid: process.pid });
39
+ const current = readLock(runDir);
40
+ const holder = current && !current.invalid ? /** @type {LockRecord} */ (current) : null;
41
+ if (holder && holder.pid === process.pid) {
42
+ throw new Error("cancel cannot take over a controller lock held by this process");
43
+ }
44
+ if (holder) {
45
+ const controller = { pid: holder.pid, processStartToken: holder.processStartToken };
46
+ if (invocationOwned(controller)) {
47
+ signalController(holder, "SIGTERM");
48
+ if (!await waitForProcessDeath(controller, 2_000)) {
49
+ signalController(holder, "SIGKILL");
50
+ if (!await waitForProcessDeath(controller, 2_000)) throw new Error("cancel could not confirm controller termination");
51
+ }
52
+ }
53
+ }
54
+ const controllerLock = await acquireStaleLock(runDir);
55
+ try {
56
+ const states = readRunNodes(runDir, contract);
57
+ /** @type {Error[]} */
58
+ const failures = [];
59
+ for (const state of states) {
60
+ for (const invocation of state.invocations ?? []) {
61
+ if (invocation.status === "active" || invocationOwned(invocation)) {
62
+ try {
63
+ await terminateInvocation(invocation, { runDir });
64
+ } catch (error) {
65
+ failures.push(error instanceof Error ? error : new Error(String(error)));
66
+ }
67
+ }
68
+ }
69
+ for (const invocation of state.invocations ?? []) {
70
+ if (invocationOwned(invocation)) failures.push(new Error(`provider invocation ${invocation.id} is still alive after cancellation`));
71
+ }
72
+ for (const attempt of state.verification?.attempts ?? []) {
73
+ if (attempt.status !== "active" && (!attempt.pid || !invocationOwned(attempt))) continue;
74
+ if (attempt.pid) {
75
+ try {
76
+ await terminateInvocation({
77
+ id: attempt.invocationId,
78
+ pid: attempt.pid,
79
+ processGroupId: attempt.processGroupId,
80
+ processStartToken: attempt.processStartToken,
81
+ }, { runDir });
82
+ } catch (error) {
83
+ failures.push(error instanceof Error ? error : new Error(String(error)));
84
+ }
85
+ }
86
+ if (attempt.pid && invocationOwned(attempt)) failures.push(new Error(`verification attempt ${attempt.invocationId} is still alive after cancellation`));
87
+ const completedAt = new Date().toISOString();
88
+ state.verification = state.verification ?? { passed: false, commands: [], completed: false, attempts: [] };
89
+ state.verification.attempts = (state.verification.attempts ?? []).map((item) => item.invocationId === attempt.invocationId
90
+ ? { ...item, status: "canceled", completedAt, result: { passed: false, stdout: "", stderr: "", error: "verification canceled", exitCode: null, signal: "SIGTERM", timedOut: false, durationMs: null } }
91
+ : item);
92
+ state.verification.completed = true;
93
+ state.verification.passed = false;
94
+ state.verification.error = "verification canceled";
95
+ }
96
+ if (state.verification?.attempts?.length) writeNode(runDir, state, controllerLock);
97
+ if (failures.length) continue;
98
+ const closedAt = new Date().toISOString();
99
+ const invocations = (state.invocations ?? []).map((invocation) => invocation.status === "active"
100
+ ? { ...invocation, status: "terminated", closedAt, updatedAt: closedAt }
101
+ : invocation);
102
+ if (!SETTLED.has(state.status)) transition(runDir, state, "canceled", { phase: "canceled", invocations }, controllerLock);
103
+ }
104
+ if (failures.length) {
105
+ const error = new Error(`cancel could not confirm termination of ${failures.length} invocation${failures.length === 1 ? "" : "s"}`);
106
+ error.cause = failures[0];
107
+ throw error;
108
+ }
109
+ if (!await waitForTerminal(runDir, 1_000)) throw new Error("cancel could not confirm a terminal run state");
110
+ syncAgentSignal(join(runDir, ".."));
111
+ return true;
112
+ } finally {
113
+ controllerLock.release();
114
+ }
115
+ }
116
+ /**
117
+ * The controller pid is already confirmed dead (or was never alive) by the
118
+ * time this is called, so the lock is stale and acquire() takes it over on
119
+ * its own; the retry here only covers a lock file whose write has not
120
+ * settled yet, never a live rival.
121
+ * @param {string} runDir
122
+ * @returns {Promise<LockHandle>}
123
+ */
124
+ async function acquireStaleLock(runDir) {
125
+ for (;;) {
126
+ try {
127
+ return acquireLock(runDir);
128
+ } catch (error) {
129
+ if (!(error instanceof LockBusyError)) throw error;
130
+ const holder = /** @type {LockRecord|null} */ (error.lock);
131
+ if (!holder || pidAlive(holder.pid)) throw error;
132
+ await delay(50);
133
+ }
134
+ }
135
+ }
136
+ /**
137
+ * @param {LockRecord} lock
138
+ * @param {NodeJS.Signals} signal
139
+ */
140
+ function signalController(lock, signal) {
141
+ if (!invocationOwned({ pid: lock.pid, processStartToken: lock.processStartToken })) return;
142
+ try {
143
+ process.kill(lock.pid, signal);
144
+ } catch (error) {
145
+ // ESRCH: the controller is already gone. EPERM: it is not ours to signal.
146
+ if (errorCode(error) !== "ESRCH" && errorCode(error) !== "EPERM") throw error;
147
+ }
148
+ }
149
+ /**
150
+ * @param {InvocationProbe} invocation
151
+ * @param {number} timeoutMs
152
+ * @returns {Promise<boolean>}
153
+ */
154
+ async function waitForProcessDeath(invocation, timeoutMs) {
155
+ const deadline = Date.now() + timeoutMs;
156
+ while (Date.now() < deadline) {
157
+ if (!invocationOwned(invocation)) return true;
158
+ await delay(50);
159
+ }
160
+ return !invocationOwned(invocation);
161
+ }
162
+ /**
163
+ * @param {string} runDir
164
+ * @param {number} timeoutMs
165
+ * @returns {Promise<boolean>}
166
+ */
167
+ async function waitForTerminal(runDir, timeoutMs) {
168
+ const deadline = Date.now() + timeoutMs;
169
+ while (Date.now() < deadline) {
170
+ const contractPath = join(runDir, "contract.json");
171
+ const contract = validateContract(JSON.parse(readFileSync(contractPath, "utf8")), contractPath, { persisted: true });
172
+ const states = readRunNodes(runDir, contract);
173
+ if (states.every((state) => SETTLED.has(state.status)) && states.every((state) => (state.invocations ?? []).every((invocation) => !invocationOwned(invocation)))) return true;
174
+ await delay(100);
175
+ }
176
+ return false;
177
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The detached-bootstrap handshake, from the controller's side.
3
+ *
4
+ * `run --detach` and `resume --detach` spawn a second process that becomes the
5
+ * controller and outlives the launcher. The launcher must not exit until that
6
+ * child has proved it owns the run, and the child must not start burning tokens
7
+ * until the launcher has seen the proof. The two halves meet in three files
8
+ * under the run directory, all named by `run/store.mjs`:
9
+ *
10
+ * bootstrap.json the child's own claim: status, pid, start token
11
+ * bootstrap.<nonce>.json that claim, per attempt, so a stale one is
12
+ * distinguishable from the current one
13
+ * bootstrap.<nonce>.ack.json the launcher's acknowledgement of the claim
14
+ *
15
+ * The nonce travels to the child in `FABERUN_BOOTSTRAP_NONCE`, so the
16
+ * child can recognise its own attempt among the artifacts of earlier ones.
17
+ *
18
+ * This module holds the controller's half, because the controller is what waits
19
+ * on the acknowledgement. Until 2026-09-11 it lived in the CLI, which made
20
+ * `engine/scheduler.mjs` import `cli.mjs` -- the one import cycle in the tree
21
+ * that a layered layout could not express. The launcher's half (writing the
22
+ * acknowledgement, spawning the child, deciding whether *this* process is the
23
+ * detached child at all) stays in the CLI, where the answer depends on which
24
+ * file was the process entry point.
25
+ */
26
+ import { randomUUID } from "node:crypto";
27
+ import { unlinkSync } from "node:fs";
28
+ import { sameProcessStartToken, validBootstrapNonce } from "../run/lock.mjs";
29
+ import { bootstrapAckPath, bootstrapAttemptPath, readJson } from "../run/store.mjs";
30
+ import { delay, errorCode } from "../util.mjs";
31
+
32
+ /** How long a detached child waits for its launcher to acknowledge. */
33
+ const ACKNOWLEDGEMENT_TIMEOUT_MS = 5_000;
34
+
35
+ /** @typedef {{status?: string, nonce?: string, pid?: number, processStartToken?: string|null}} BootstrapRecord */
36
+
37
+ /**
38
+ * The nonce this controller process should stamp on its bootstrap record: the
39
+ * one its launcher handed it, or a fresh one when it was started directly.
40
+ *
41
+ * @returns {string}
42
+ */
43
+ export function bootstrapNonceForProcess() {
44
+ return validBootstrapNonce(process.env.FABERUN_BOOTSTRAP_NONCE)
45
+ ? /** @type {string} */ (process.env.FABERUN_BOOTSTRAP_NONCE)
46
+ : randomUUID();
47
+ }
48
+
49
+ /**
50
+ * Block until the launcher acknowledges this exact claim -- same nonce, same
51
+ * pid, same process start token, so a reused pid cannot be mistaken for it.
52
+ * Returns on timeout rather than failing: an unacknowledged controller has
53
+ * still taken the lock and is still the owner, and the launcher's silence is
54
+ * the launcher's problem.
55
+ *
56
+ * @param {string} runDir
57
+ * @param {{nonce: string, pid: number, processStartToken: string|null}} expected
58
+ * @returns {Promise<void>}
59
+ */
60
+ export async function waitForBootstrapAcknowledgement(runDir, expected) {
61
+ if (!validBootstrapNonce(expected.nonce)) return;
62
+ const deadline = Date.now() + ACKNOWLEDGEMENT_TIMEOUT_MS;
63
+ const path = bootstrapAckPath(runDir, expected.nonce);
64
+ try {
65
+ while (Date.now() < deadline) {
66
+ try {
67
+ const acknowledgement = /** @type {BootstrapRecord} */ (readJson(path));
68
+ if (
69
+ acknowledgement.status === "acknowledged" &&
70
+ acknowledgement.nonce === expected.nonce &&
71
+ acknowledgement.pid === expected.pid &&
72
+ sameProcessStartToken(acknowledgement.processStartToken, expected.processStartToken)
73
+ ) {
74
+ return;
75
+ }
76
+ } catch (error) {
77
+ if (errorCode(error) !== "ENOENT") throw error;
78
+ }
79
+ await delay(25);
80
+ }
81
+ } finally {
82
+ cleanupBootstrapNonce(runDir, expected.nonce);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Remove one attempt's artifacts. Both halves of the handshake call this, so
88
+ * the successful path leaves nothing behind for the next attempt to read.
89
+ *
90
+ * @param {string} runDir
91
+ * @param {string} nonce
92
+ */
93
+ export function cleanupBootstrapNonce(runDir, nonce) {
94
+ if (!validBootstrapNonce(nonce)) return;
95
+ for (const path of [bootstrapAttemptPath(runDir, nonce), bootstrapAckPath(runDir, nonce)]) {
96
+ try { unlinkSync(path); } catch (error) {
97
+ if (errorCode(error) !== "ENOENT") throw error;
98
+ }
99
+ }
100
+ }
101
+