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,555 @@
1
+ /**
2
+ * The campaign chain: the loop that launches each contract in a campaign's
3
+ * ordered manifest, advances only on a run that actually succeeded, and parks
4
+ * the campaign on anything else.
5
+ *
6
+ * It is not a resident daemon. A frozen process cannot detect its own freeze:
7
+ * an unref'd timer inside it stops too. So the coordinator is an **idempotent
8
+ * re-invocation**. `acquireCoordinator` takes `coordinator.lock` in the
9
+ * campaign directory and reads the campaign heartbeat Phase 1 already defined;
10
+ * a second invocation of the same command finds a fresh heartbeat and exits
11
+ * having written nothing, finds a stale one and terminates that process group
12
+ * and takes over, or finds no coordinator and becomes one. The re-invoker is
13
+ * the host scheduler (launchd or cron) or the operator session's own Monitor.
14
+ *
15
+ * Advancement is `runOutcome`, never node counts: `succeeded` advances,
16
+ * `waiting` is a self-resuming tier exhaustion and is neither advanced nor
17
+ * parked, and `parked`/`canceled` park the campaign with attention. An
18
+ * in-flight run (`state: unfinished`/`unknown`) is awaited, not relaunched.
19
+ *
20
+ * Validation of contract N+1 happens here, at launch, against the `landBranch`
21
+ * N's promotion just advanced. Validating the manifest up front would reject
22
+ * every contract that reads a file its predecessor creates, because there is
23
+ * no deferral between contracts. The manifest entry's authored-bytes digest is
24
+ * checked first so tampering between authoring and launch is caught before the
25
+ * branch-aware validation runs.
26
+ *
27
+ * The chain takes no run lock and writes no node state. Its own artifacts are
28
+ * `coordinator.lock`, the campaign `heartbeat.json`, and the campaign attention
29
+ * record. Every run lock and node snapshot belongs to a child controller.
30
+ */
31
+ import { execFileSync } from "node:child_process";
32
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
33
+ import { hostname, tmpdir } from "node:os";
34
+ import { dirname, join, relative, resolve } from "node:path";
35
+ import { assertContractManifestIntact, parkCampaign, promoteRunInCampaign } from "./index.mjs";
36
+ import { readCampaign } from "./record.mjs";
37
+ import { validateContract } from "../contract/index.mjs";
38
+ import { defaultControllerIdentity, storedContractDigest, verifyControllerIdentity } from "../engine/run-identity.mjs";
39
+ import { HEARTBEAT_INTERVAL_MS, createHeartbeat, groupAlive, heartbeatBreach, readHeartbeat, runProgress, waitForGroupGone } from "../engine/supervise.mjs";
40
+ import { pidAlive, processStartToken } from "../run/lock.mjs";
41
+ import { delay, errorCode, errorMessage } from "../util.mjs";
42
+ import { writeJsonAtomic } from "../run/store.mjs";
43
+
44
+ /** @typedef {import("../contract/index.mjs").ControllerIdentity} ControllerIdentity */
45
+ /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
46
+ /** @typedef {import("../engine/supervise.mjs").RunProgress} RunProgress */
47
+ /** @typedef {import("./index.mjs").Campaign} Campaign */
48
+ /** @typedef {import("./index.mjs").CampaignContract} CampaignContract */
49
+ /** @typedef {import("./index.mjs").CampaignAttention} CampaignAttention */
50
+
51
+ /** The campaign-level coordinator lock file name. */
52
+ export const COORDINATOR_LOCK_FILE = "coordinator.lock";
53
+
54
+ /** How long the chain waits between two reads of a child run's outcome. */
55
+ export const DEFAULT_CHAIN_POLL_MS = 1_000;
56
+
57
+ /** How long the coordinator's process group gets to honour `SIGTERM` before `SIGKILL`. */
58
+ export const DEFAULT_COORDINATOR_TERMINATE_GRACE_MS = 5_000;
59
+
60
+ /** How long the group gets after `SIGKILL` before the takeover stops waiting. */
61
+ export const DEFAULT_COORDINATOR_KILL_GRACE_MS = 5_000;
62
+
63
+ /**
64
+ * @param {string} campaignPath
65
+ * @returns {string}
66
+ */
67
+ export function coordinatorLockPath(campaignPath) {
68
+ return join(campaignPath, COORDINATOR_LOCK_FILE);
69
+ }
70
+
71
+ /**
72
+ * @param {string} campaignPath
73
+ * @returns {Record<string, unknown>|{invalid: true}|null}
74
+ */
75
+ export function readCoordinatorLock(campaignPath) {
76
+ try {
77
+ const parsed = JSON.parse(readFileSync(coordinatorLockPath(campaignPath), "utf8"));
78
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : { invalid: true };
79
+ } catch (error) {
80
+ if (errorCode(error) === "ENOENT") return null;
81
+ if (error instanceof SyntaxError) return { invalid: true };
82
+ throw error;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * A coordinator lock is stale only once its holder is proven dead, exactly the
88
+ * rule the run controller lock uses. A live pid with a mismatched start token
89
+ * is a recycled pid, not the holder.
90
+ *
91
+ * @param {Record<string, unknown>|{invalid: true}|null} lock
92
+ * @param {(pid: number) => boolean} [alive]
93
+ * @param {(pid: number|null) => string|null} [startToken]
94
+ * @returns {boolean}
95
+ */
96
+ export function coordinatorLockStale(lock, alive = pidAlive, startToken = processStartToken) {
97
+ if (!lock || /** @type {{invalid?: true}} */ (lock).invalid) return true;
98
+ const record = /** @type {Record<string, unknown>} */ (lock);
99
+ const pid = /** @type {number} */ (record.pid);
100
+ if (!alive(pid)) return true;
101
+ const recorded = /** @type {string|null|undefined} */ (record.processStartToken);
102
+ return Boolean(recorded) && startToken(pid) !== recorded;
103
+ }
104
+
105
+ /** @param {string} campaignPath @param {Record<string, unknown>} record */
106
+ export function writeCoordinatorLock(campaignPath, record) {
107
+ writeJsonAtomic(coordinatorLockPath(campaignPath), record);
108
+ }
109
+
110
+ /**
111
+ * Remove the lock only when it is still the caller's own record, so a takeover
112
+ * that already replaced it is never undone by the previous holder's exit.
113
+ *
114
+ * @param {string} campaignPath
115
+ * @param {Record<string, unknown>|null} record
116
+ */
117
+ export function releaseCoordinatorLock(campaignPath, record) {
118
+ const current = readCoordinatorLock(campaignPath);
119
+ if (!current || /** @type {{invalid?: true}} */ (current).invalid) return;
120
+ const actual = /** @type {Record<string, unknown>} */ (current);
121
+ if (record && actual.pid === record.pid && actual.startedAt === record.startedAt) {
122
+ try {
123
+ rmSync(coordinatorLockPath(campaignPath), { force: true });
124
+ } catch {
125
+ // A lock already removed by a takeover is exactly the state we wanted.
126
+ }
127
+ }
128
+ }
129
+
130
+ /** @param {number} pid @param {string} signal */
131
+ function groupKill(pid, signal) {
132
+ try {
133
+ if (process.platform !== "win32") {
134
+ try {
135
+ process.kill(-pid, signal);
136
+ return;
137
+ } catch (error) {
138
+ if (errorCode(error) !== "ESRCH") throw error;
139
+ }
140
+ }
141
+ process.kill(pid, signal);
142
+ } catch (error) {
143
+ if (errorCode(error) !== "ESRCH") throw error;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Terminate the coordinator's process group, bounded: `SIGTERM`, then `SIGKILL`
149
+ * after the named grace. The takeover writes its own lock only once the group
150
+ * is gone, so two coordinators never overlap.
151
+ *
152
+ * @param {string} campaignPath
153
+ * @param {{lock?: Record<string, unknown>|null, kill?: (pid: number, signal: string) => void, alive?: (pid: number) => boolean, sleep?: (ms: number) => Promise<void>, now?: () => number, graceMs?: number, killGraceMs?: number}} [options]
154
+ * @returns {Promise<boolean>}
155
+ */
156
+ export async function terminateCoordinatorGroup(campaignPath, options = {}) {
157
+ const lock = options.lock ?? readCoordinatorLock(campaignPath);
158
+ if (!lock || /** @type {{invalid?: true}} */ (lock).invalid) return false;
159
+ const record = /** @type {Record<string, unknown>} */ (lock);
160
+ const pid = /** @type {number} */ (record.pid);
161
+ const kill = options.kill ?? groupKill;
162
+ const alive = options.alive ?? ((target) => pidAlive(target) || groupAlive(target));
163
+ const sleep = options.sleep ?? delay;
164
+ const now = options.now ?? Date.now;
165
+ const graceMs = options.graceMs ?? DEFAULT_COORDINATOR_TERMINATE_GRACE_MS;
166
+ const killGraceMs = options.killGraceMs ?? DEFAULT_COORDINATOR_KILL_GRACE_MS;
167
+ if (!alive(pid)) return false;
168
+ kill(pid, "SIGTERM");
169
+ if (await waitForGroupGone(pid, alive, graceMs, sleep, now)) return true;
170
+ kill(pid, "SIGKILL");
171
+ await waitForGroupGone(pid, alive, killGraceMs, sleep, now);
172
+ return true;
173
+ }
174
+
175
+ /**
176
+ * The three re-invocation outcomes. A fresh heartbeat behind a live lock is
177
+ * observed and the caller must write nothing; a stale heartbeat behind a live
178
+ * lock is terminated and taken over; no live coordinator is simply become.
179
+ *
180
+ * @param {string} campaignPath
181
+ * @param {{now?: () => number, heartbeatIntervalMs?: number, alive?: (pid: number) => boolean, startTokenOf?: (pid: number|null) => string|null, kill?: (pid: number, signal: string) => void, sleep?: (ms: number) => Promise<void>, graceMs?: number, killGraceMs?: number, pid?: number, processStartToken?: string|null}} [options]
182
+ * @returns {Promise<{role: "observed"|"took-over"|"became", lock: Record<string, unknown>}>}
183
+ */
184
+ export async function acquireCoordinator(campaignPath, options = {}) {
185
+ const now = options.now ?? Date.now;
186
+ const heartbeatIntervalMs = options.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS;
187
+ const alive = options.alive ?? pidAlive;
188
+ const startToken = options.startTokenOf ?? processStartToken;
189
+ const lock = readCoordinatorLock(campaignPath);
190
+ const liveLock = lock && !coordinatorLockStale(lock, alive, startToken) ? /** @type {Record<string, unknown>} */ (lock) : null;
191
+ const heartbeat = readHeartbeat(campaignPath);
192
+ // A missing heartbeat behind a live lock is a coordinator that has just
193
+ // started, not a frozen one: `createHeartbeat` writes immediately, and Phase
194
+ // 1's `controllerAlive` reads absence the same way. Only a present heartbeat
195
+ // that breaches its threshold is stale enough to take over.
196
+ const fresh = heartbeat === null || heartbeatBreach(heartbeat, now(), heartbeatIntervalMs) === null;
197
+ if (liveLock && fresh) return { role: "observed", lock: liveLock };
198
+ if (liveLock) {
199
+ await terminateCoordinatorGroup(campaignPath, { ...options, lock: liveLock });
200
+ releaseCoordinatorLock(campaignPath, liveLock);
201
+ }
202
+ const pid = options.pid ?? process.pid;
203
+ const record = {
204
+ schemaVersion: 1,
205
+ pid,
206
+ processStartToken: options.processStartToken !== undefined ? options.processStartToken : startToken(pid),
207
+ startedAt: new Date(now()).toISOString(),
208
+ hostname: hostname(),
209
+ };
210
+ writeCoordinatorLock(campaignPath, record);
211
+ return { role: liveLock ? "took-over" : "became", lock: record };
212
+ }
213
+
214
+ /**
215
+ * The ref a contract's run is cut from: the campaign's landing branch once it
216
+ * exists, nothing (the checkout's HEAD) for the first run that creates it.
217
+ *
218
+ * @param {string} repo
219
+ * @param {string} landBranch
220
+ * @returns {string|undefined}
221
+ */
222
+ function landBranchRef(repo, landBranch) {
223
+ return gitHead(repo, landBranch) ? landBranch : undefined;
224
+ }
225
+
226
+ /**
227
+ * @param {string} repo
228
+ * @param {string} ref
229
+ * @returns {string|null}
230
+ */
231
+ function gitHead(repo, ref) {
232
+ try {
233
+ return execFileSync("git", ["-C", repo, "rev-parse", ref], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
234
+ } catch {
235
+ return null;
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Validate a manifest entry at launch against the ref its run is cut from. The
241
+ * authored bytes are checked first (tamper), then the contract is fully
242
+ * validated in a throwaway checkout of `baseRef`, where a predecessor's files
243
+ * exist, with the runtime workspace pointed back at the operator's tree.
244
+ *
245
+ * @param {CampaignContract} entry
246
+ * @param {{repo?: string, baseRef?: string}} [context]
247
+ * @returns {ValidatedContract}
248
+ */
249
+ export function validateManifestEntryAtLaunch(entry, context = {}) {
250
+ assertContractManifestIntact(entry);
251
+ const raw = /** @type {Record<string, unknown>} */ (JSON.parse(readFileSync(entry.path, "utf8")));
252
+ return validateContractAgainstRef(raw, entry.path, context);
253
+ }
254
+
255
+ /**
256
+ * @param {Record<string, unknown>} raw
257
+ * @param {string} contractPath
258
+ * @param {{repo?: string, baseRef?: string}} [context]
259
+ * @returns {ValidatedContract}
260
+ */
261
+ export function validateContractAgainstRef(raw, contractPath, context = {}) {
262
+ const { repo, baseRef } = context;
263
+ if (!baseRef || !repo) return validateContract(raw, contractPath);
264
+ const originalCwd = resolve(dirname(contractPath), typeof raw.cwd === "string" ? raw.cwd : ".");
265
+ const worktree = mkdtempSync(join(tmpdir(), "runner-chain-ref-"));
266
+ try {
267
+ execFileSync("git", ["-C", repo, "worktree", "add", "--detach", worktree, baseRef], { stdio: ["ignore", "pipe", "pipe"] });
268
+ const relativedCwd = relative(repo, originalCwd);
269
+ const mappedCwd = relativedCwd && !relativedCwd.startsWith("..") ? join(worktree, relativedCwd) : worktree;
270
+ const relativeContract = relative(repo, contractPath);
271
+ const tempContractPath = relativeContract && !relativeContract.startsWith("..")
272
+ ? join(worktree, relativeContract)
273
+ : join(worktree, "contract.json");
274
+ mkdirSync(dirname(tempContractPath), { recursive: true });
275
+ writeFileSync(tempContractPath, JSON.stringify({ ...raw, cwd: mappedCwd }));
276
+ const contract = validateContract(/** @type {Record<string, unknown>} */ ({ ...raw, cwd: mappedCwd }), tempContractPath);
277
+ contract.cwd = originalCwd;
278
+ return contract;
279
+ } finally {
280
+ try {
281
+ execFileSync("git", ["-C", repo, "worktree", "remove", "--force", worktree], { stdio: ["ignore", "pipe", "ignore"] });
282
+ } catch {
283
+ // The ref-based validation is done; a cleanup failure must not mask it.
284
+ }
285
+ rmSync(worktree, { recursive: true, force: true });
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Reduce a run's progress to the one decision the chain makes. An in-flight run
291
+ * is `unfinished` even though `reduceRunOutcome` already names its running nodes
292
+ * `parked`: only a settled, unsuccessful run parks the campaign.
293
+ *
294
+ * @param {RunProgress} progress
295
+ * @returns {"succeeded"|"waiting"|"parked"|"canceled"|"unfinished"}
296
+ */
297
+ export function classifyRunProgress(progress) {
298
+ if (progress.runOutcome === "canceled") return "canceled";
299
+ if (progress.runOutcome === "succeeded") return "succeeded";
300
+ if (progress.state === "waiting") return "waiting";
301
+ if (progress.state === "done") return "parked";
302
+ return "unfinished";
303
+ }
304
+
305
+ /**
306
+ * @param {string} contractPath
307
+ * @returns {{id: string, cwd: string, runDir: string}}
308
+ */
309
+ function runIdentityFor(contractPath) {
310
+ const raw = /** @type {Record<string, unknown>} */ (JSON.parse(readFileSync(contractPath, "utf8")));
311
+ const cwd = resolve(dirname(contractPath), typeof raw.cwd === "string" ? raw.cwd : ".");
312
+ const id = String(raw.id);
313
+ return { id, cwd, runDir: join(cwd, ".runs", id) };
314
+ }
315
+
316
+ /**
317
+ * @param {string} campaignPath
318
+ * @returns {string}
319
+ */
320
+ function defaultRepo(campaignPath) {
321
+ return resolve(dirname(dirname(campaignPath)), "..");
322
+ }
323
+
324
+ /**
325
+ * @param {unknown} value
326
+ * @returns {value is Record<string, unknown>}
327
+ */
328
+ function isRecord(value) {
329
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
330
+ }
331
+
332
+ /**
333
+ * The contract a stored contract.json names: its source identity's
334
+ * `contractId` when it has one, otherwise its own `id`. A run directory whose
335
+ * stored contract belongs to a different contract is refused even when its
336
+ * digest matches run.json.
337
+ *
338
+ * @param {Record<string, unknown>} stored
339
+ * @returns {string|null}
340
+ */
341
+ function storedContractId(stored) {
342
+ const identity = isRecord(stored.sourceIdentity) ? stored.sourceIdentity : null;
343
+ if (identity && typeof identity.contractId === "string") return identity.contractId;
344
+ return typeof stored.id === "string" ? stored.id : null;
345
+ }
346
+
347
+ /**
348
+ * Drive a campaign's manifest to completion, watching the current run and
349
+ * launching the next one only after the previous one succeeded and promoted.
350
+ *
351
+ * @param {string} campaignPath
352
+ * @param {{
353
+ * repo?: string,
354
+ * now?: () => number,
355
+ * sleep?: (ms: number) => Promise<void>,
356
+ * emit?: (line: string) => void,
357
+ * pollMs?: number,
358
+ * heartbeatIntervalMs?: number,
359
+ * allowMain?: boolean,
360
+ * maxTicks?: number,
361
+ * coordination?: boolean,
362
+ * controllerIdentity?: ControllerIdentity,
363
+ * launch?: (contractPath: string, context: {baseRef: string|undefined, controllerIdentity: ControllerIdentity, runDir: string, contract: ValidatedContract}) => Promise<void>|void,
364
+ * validate?: (entry: CampaignContract, context: {repo: string|undefined, baseRef: string|undefined, contractPath: string}) => ValidatedContract,
365
+ * progress?: (runDir: string) => RunProgress,
366
+ * heartbeat?: {progress: (nodeId?: string, budgetBasis?: number) => void, setActive: (nodes: {nodeId: string, budgetBasis: number}[]) => void, stop: () => void},
367
+ * acquire?: (campaignPath: string, options: Record<string, unknown>) => Promise<{role: "observed"|"took-over"|"became", lock: Record<string, unknown>}>,
368
+ * kill?: (pid: number, signal: string) => void,
369
+ * alive?: (pid: number) => boolean,
370
+ * graceMs?: number,
371
+ * killGraceMs?: number,
372
+ * pid?: number,
373
+ * processStartToken?: string|null,
374
+ * }} [options]
375
+ * @returns {Promise<{state: "done"|"parked"|"closed"|"already-running"|"stopped", ticks: number, launches: number, reason?: string, attention?: CampaignAttention|null}>}
376
+ */
377
+ export async function driveCampaignChain(campaignPath, options = {}) {
378
+ const now = options.now ?? Date.now;
379
+ const sleep = options.sleep ?? delay;
380
+ const emit = options.emit ?? ((line) => process.stdout.write(`${line}\n`));
381
+ const repo = options.repo ?? defaultRepo(campaignPath);
382
+ const pollMs = options.pollMs ?? DEFAULT_CHAIN_POLL_MS;
383
+ const heartbeatIntervalMs = options.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS;
384
+ const allowMain = options.allowMain === true;
385
+ const progressOf = options.progress ?? ((runDir) => runProgress(runDir, now()));
386
+ const validate = options.validate ?? ((entry, context) => validateManifestEntryAtLaunch(entry, context));
387
+ const launch = options.launch ?? (() => { throw new Error("driveCampaignChain requires a launch seam"); });
388
+
389
+ /** @type {{role: "observed"|"took-over"|"became", lock: Record<string, unknown>}|null} */
390
+ let acquired = null;
391
+ if (options.coordination !== false) {
392
+ acquired = options.acquire
393
+ ? await options.acquire(campaignPath, /** @type {Record<string, unknown>} */ (options))
394
+ : await acquireCoordinator(campaignPath, /** @type {Record<string, unknown>} */ (options));
395
+ if (acquired.role === "observed") {
396
+ emit(`[campaign] already supervised by pid ${acquired.lock.pid}; leaving it alone`);
397
+ return { state: "already-running", ticks: 0, launches: 0 };
398
+ }
399
+ }
400
+ const heartbeat = options.heartbeat ?? createHeartbeat({ runDir: campaignPath, intervalMs: heartbeatIntervalMs, now });
401
+ try {
402
+ let ticks = 0;
403
+ let launches = 0;
404
+ let index = 0;
405
+ /** @type {ControllerIdentity} */
406
+ let controllerIdentity = options.controllerIdentity ?? defaultControllerIdentity();
407
+ // Validation is cached per manifest index: the landing branch does not move
408
+ // until this contract succeeds, so re-validating against a fresh checkout
409
+ // every poll would burn a worktree per second for no new information.
410
+ let validatedIndex = -1;
411
+ /** @type {ValidatedContract|null} */
412
+ let validatedContract = null;
413
+
414
+ /** @param {Omit<CampaignAttention, "at">} attention */
415
+ const park = (attention) => {
416
+ const parked = parkCampaign(campaignPath, /** @type {CampaignAttention} */ ({ ...attention, at: new Date(now()).toISOString() }));
417
+ emit(`[campaign] ${parked.id} parked · ${attention.message}`);
418
+ return { state: /** @type {const} */ ("parked"), ticks, launches, attention: parked.attention ?? null };
419
+ };
420
+
421
+ for (;;) {
422
+ if (options.maxTicks !== undefined && ticks >= options.maxTicks) {
423
+ return { state: "stopped", ticks, launches, reason: "tick budget exhausted" };
424
+ }
425
+ ticks += 1;
426
+ const campaign = readCampaign(campaignPath);
427
+ if (campaign.status !== "active") return { state: "closed", ticks, launches, attention: campaign.attention ?? null };
428
+ if (campaign.attention) return { state: "parked", ticks, launches, attention: campaign.attention };
429
+ if (index >= campaign.contracts.length) return { state: "done", ticks, launches };
430
+
431
+ const entry = campaign.contracts[index];
432
+ const baseRef = landBranchRef(repo, campaign.landBranch);
433
+ /** @type {string} */
434
+ let id = entry.path;
435
+ /** @type {string} */
436
+ let runDir = "";
437
+ /** @type {ValidatedContract} */
438
+ let contract;
439
+ try {
440
+ const identity = runIdentityFor(entry.path);
441
+ id = identity.id;
442
+ runDir = identity.runDir;
443
+ if (validatedIndex === index && validatedContract !== null) {
444
+ contract = validatedContract;
445
+ } else {
446
+ contract = validate(entry, { repo, baseRef, contractPath: entry.path });
447
+ validatedIndex = index;
448
+ validatedContract = contract;
449
+ }
450
+ } catch (error) {
451
+ const tampered = errorCode(error) === "contract_authored_bytes_changed";
452
+ return park({
453
+ code: tampered ? "contract_authored_bytes_changed" : "contract_validation_failed",
454
+ message: errorMessage(error),
455
+ contractPath: entry.path,
456
+ contractId: id,
457
+ runId: id,
458
+ });
459
+ }
460
+
461
+ if (existsSync(join(runDir, "run.json"))) {
462
+ const metadata = /** @type {Record<string, unknown>} */ (JSON.parse(readFileSync(join(runDir, "run.json"), "utf8")));
463
+ const recorded = typeof metadata.contractDigest === "string" ? metadata.contractDigest : null;
464
+ const stored = /** @type {Record<string, unknown>} */ (JSON.parse(readFileSync(join(runDir, "contract.json"), "utf8")));
465
+ const storedDigest = storedContractDigest(runDir);
466
+ const storedId = storedContractId(stored);
467
+ if (recorded !== storedDigest || storedId !== id) {
468
+ return park({
469
+ code: "contract_digest_mismatch",
470
+ message: `contract ${id} does not match its run directory: stored contract ${storedId ?? "unknown"} (${storedDigest}) vs run.json (${recorded}); packet hashes alone cannot prove the DAG, gate, runtime, timeout, definition of done, or finalVerification are unchanged`,
471
+ contractPath: entry.path,
472
+ contractId: id,
473
+ runId: id,
474
+ });
475
+ }
476
+ if (isRecord(metadata.controllerIdentity)) {
477
+ controllerIdentity = /** @type {ControllerIdentity} */ (metadata.controllerIdentity);
478
+ }
479
+ const progress = progressOf(runDir);
480
+ const classification = classifyRunProgress(progress);
481
+ if (classification === "succeeded") {
482
+ try {
483
+ promoteRunInCampaign({
484
+ campaignPath,
485
+ repo,
486
+ runId: id,
487
+ baseSha: isRecord(metadata.sourceIdentity) && typeof metadata.sourceIdentity.gitHead === "string" ? metadata.sourceIdentity.gitHead : null,
488
+ finalVerificationPassed: true,
489
+ allowMain,
490
+ contractPath: entry.path,
491
+ });
492
+ } catch (error) {
493
+ return park({
494
+ code: errorCode(error) ?? "promotion_failed",
495
+ message: errorMessage(error),
496
+ contractPath: entry.path,
497
+ contractId: id,
498
+ runId: id,
499
+ });
500
+ }
501
+ heartbeat.progress();
502
+ index += 1;
503
+ continue;
504
+ }
505
+ if (classification === "parked" || classification === "canceled") {
506
+ const failed = progress.outcomeNodes?.[0];
507
+ return park({
508
+ code: classification === "canceled" ? "run_canceled" : "run_parked",
509
+ message: `contract ${id} run ${classification}: node ${failed?.id ?? "unknown"} ${failed?.status ?? classification}${failed?.errorCode ? ` [${failed.errorCode}]` : ""}`,
510
+ contractPath: entry.path,
511
+ contractId: id,
512
+ runId: id,
513
+ node: failed?.id ?? null,
514
+ status: failed?.status ?? classification,
515
+ resume: `resume ${runDir}`,
516
+ });
517
+ }
518
+ // waiting / unfinished / unknown: the run resumes itself, so await it.
519
+ // `lastProgressAt` is deliberately not refreshed: a wait is not work,
520
+ // and a self-resuming tier exhaustion must not look like progress.
521
+ await sleep(pollMs);
522
+ continue;
523
+ }
524
+
525
+ try {
526
+ verifyControllerIdentity(controllerIdentity);
527
+ } catch (error) {
528
+ return park({
529
+ code: errorCode(error) ?? "controller_snapshot_changed",
530
+ message: errorMessage(error),
531
+ contractPath: entry.path,
532
+ contractId: id,
533
+ runId: id,
534
+ });
535
+ }
536
+ try {
537
+ await launch(entry.path, { baseRef, controllerIdentity, runDir, contract });
538
+ } catch (error) {
539
+ return park({
540
+ code: errorCode(error) ?? "launch_failed",
541
+ message: errorMessage(error),
542
+ contractPath: entry.path,
543
+ contractId: id,
544
+ runId: id,
545
+ });
546
+ }
547
+ heartbeat.progress();
548
+ launches += 1;
549
+ await sleep(pollMs);
550
+ }
551
+ } finally {
552
+ heartbeat.stop();
553
+ if (acquired) releaseCoordinatorLock(campaignPath, acquired.lock);
554
+ }
555
+ }