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,526 @@
1
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { basename, join, resolve } from "node:path";
3
+ import { syncAgentSignal } from "../repo/signal.mjs";
4
+ import { JUDGE_SCHEMA, PARKED, SETTLED, retryPrompt } from "./prompts.mjs";
5
+ import {
6
+ applyJudgeProtocolFailure,
7
+ applyJudgeRound,
8
+ } from "./review.mjs";
9
+ import { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION } from "../harnesses/index.mjs";
10
+ import { routingBackoffActive } from "./failover.mjs";
11
+
12
+ import {
13
+ bootstrapAttemptPath,
14
+ bootstrapPath,
15
+ cleanupBootstrapAttempts,
16
+ readJson,
17
+ writeJsonAtomic,
18
+ } from "../run/store.mjs";
19
+ import {
20
+ acquire as acquireLock,
21
+ LockLostError,
22
+ processStartToken,
23
+ } from "../run/lock.mjs";
24
+ import { registerRun, resolveCampaign } from "../campaign/index.mjs";
25
+ import { createRunRef, runRefName } from "../repo/worktree.mjs";
26
+ import { bootstrapNonceForProcess, waitForBootstrapAcknowledgement } from "./detach.mjs";
27
+ import {
28
+ autoRetryNode,
29
+ autoRetryParkedNodes,
30
+ finalizeClosedJobs,
31
+ terminalErrorCode,
32
+ } from "./lifecycle.mjs";
33
+ import { delay, errorCode } from "../util.mjs";
34
+ import { alreadyNotified, notifyQueueFor, notifyQueuesByRun, renderCampaignHandoffSafely } from "./notify-queue.mjs";
35
+ import { detectStalls, terminateProcess } from "./process.mjs";
36
+ import { transition, writeNode } from "./state.mjs";
37
+ import { listNodeSnapshots, readNodeSnapshot } from "../run/node-store.mjs";
38
+ import { render, renderFinalReport, writeFindingsArtifact } from "../report/final.mjs";
39
+ import { operationNextState, providerReceipts, settleInvocation } from "../run/operations.mjs";
40
+ import { appendUsageRecord, invocationCost, invocationUsage, recordInvocationUsage } from "../run/usage.mjs";
41
+ import { captureNodeScopeBoundaries, checkWorkerScope, emptyScope } from "./scope.mjs";
42
+ import { validateContract } from "../contract/index.mjs";
43
+ import { validateNodeSnapshot } from "../contract/snapshot.mjs";
44
+ import { finalVerificationCommands } from "../contract/final-verification.mjs";
45
+ import { startJudge, startWorker } from "./dispatch.mjs";
46
+ import { assertEnvironmentReady, captureRunIdentity, createRunMetadata, serializableContract, statesFingerprint } from "./run-identity.mjs";
47
+ import { blockDependents, runtimeAssignments } from "./assignment.mjs";
48
+ import { createHeartbeat, HEARTBEAT_INTERVAL_MS } from "./supervise.mjs";
49
+
50
+ /** @typedef {import("../contract/index.mjs").WorkspaceScopeBoundary} WorkspaceScopeBoundary */
51
+
52
+ /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
53
+ /** @typedef {import("../contract/index.mjs").ValidatedNode} ValidatedNode */
54
+ /** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
55
+ /** @typedef {import("../contract/index.mjs").RuntimeSnapshot} RuntimeSnapshot */
56
+ /** @typedef {import("../contract/index.mjs").RunMetadata} RunMetadata */
57
+ /** @typedef {import("../contract/index.mjs").SourceIdentity} SourceIdentity */
58
+ /** @typedef {import("../contract/index.mjs").EventRecord} EventRecord */
59
+ /** @typedef {import("../contract/index.mjs").Usage} Usage */
60
+ /** @typedef {import("../contract/index.mjs").GateResult} GateResult */
61
+ /** @typedef {import("../contract/index.mjs").SnapshotError} SnapshotError */
62
+ /** @typedef {import("../contract/index.mjs").BoundedScope} BoundedScope */
63
+ /** @typedef {import("../run/lock.mjs").LockRecord} LockRecord */
64
+ /** @typedef {ReturnType<typeof acquireLock>} LockHandle */
65
+ /** @typedef {import("../harnesses/index.mjs").HarnessRuntime} HarnessRuntime */
66
+ /** @typedef {import("../harnesses/index.mjs").ProviderEnvelope} ProviderEnvelope */
67
+ /** @typedef {import("../campaign/index.mjs").Campaign} Campaign */
68
+ /** @typedef {{path: string, campaign: Campaign}} CampaignRef */
69
+ /** @typedef {import("./prompts.mjs").JudgeVerdict} JudgeVerdict */
70
+ /** @typedef {import("./lifecycle.mjs").Job} Job */
71
+ /** @typedef {import("./lifecycle.mjs").Invocation} Invocation */
72
+ /** @typedef {import("./lifecycle.mjs").RecoveryOutcome} RecoveryOutcome */
73
+ /** @typedef {import("./lifecycle.mjs").InvocationProbe} InvocationProbe */
74
+ /** @typedef {import("../contract/worker-result.mjs").WorkerResult} WorkerResult */
75
+ /** @typedef {{runDir: string, states: Map<string, NodeSnapshot>, ok: boolean, error?: Error}} RunOutcome */
76
+
77
+ /**
78
+ * The wall-clock milliseconds one verification-command set may legitimately
79
+ * occupy: each command's own `timeoutSec` repeated `repeat` times. The schema
80
+ * already normalizes `timeoutSec` to 120 and `repeat` to 1, but a caller may
81
+ * hand an un-normalized command, so both are defaulted here too.
82
+ *
83
+ * @param {import("../contract/index.mjs").VerificationCommand[]|undefined} commands
84
+ * @returns {number}
85
+ */
86
+ export function verificationBudgetMs(commands) {
87
+ return (commands ?? []).reduce((total, command) => {
88
+ const timeoutSec = typeof command?.timeoutSec === "number" ? command.timeoutSec : 120;
89
+ const repeat = Number.isInteger(command?.repeat) && /** @type {number} */ (command.repeat) > 0 ? /** @type {number} */ (command.repeat) : 1;
90
+ return total + timeoutSec * repeat * 1_000;
91
+ }, 0);
92
+ }
93
+
94
+ /**
95
+ * The budget a node is judged against, in milliseconds. It is the sum of every
96
+ * bounded phase the node can legitimately occupy without a state transition:
97
+ * its worker invocation (`timeoutSec`), its packet verification and the
98
+ * contract's `finalVerification` when it is phase-terminal, an integration
99
+ * candidate run of that same set, and the bounded command proofs of its gate.
100
+ * A frozen node is one that has been silent longer than this, not merely
101
+ * longer than the worker timeout, because a legitimate verification can be
102
+ * minutes long and must not be mistaken for a freeze.
103
+ *
104
+ * @param {ValidatedContract} contract
105
+ * @param {ValidatedNode} node
106
+ * @returns {number}
107
+ */
108
+ export function nodeBudgetBasisMs(contract, node) {
109
+ const defaultTimeoutMs = (node.timeoutSec ?? contract.timeoutSec ?? 60) * 1_000;
110
+ const packetMs = verificationBudgetMs(node.taskPacket?.verification);
111
+ const finalMs = verificationBudgetMs(finalVerificationCommands(contract, node));
112
+ // The controller runs the packet set once after the worker and once against
113
+ // the integration candidate, and the finalVerification set with each.
114
+ const candidateMs = packetMs + finalMs;
115
+ const gateTimeoutMs = Math.max(1_000, Math.min(defaultTimeoutMs, 120_000));
116
+ const commandProofs = (node.definitionOfDone ?? []).filter((item) => item.proof?.kind === "command").length;
117
+ return defaultTimeoutMs + packetMs + candidateMs + commandProofs * gateTimeoutMs + finalMs;
118
+ }
119
+
120
+ /**
121
+ * The loop invariant that keeps a controller from spinning: a node that says it
122
+ * is `running` while the controller holds no invocation for it can never be
123
+ * finalized by anything, and the loop would tick forever at the poll interval.
124
+ * Park it as `blocked`/`integration_unresolved` so the run reports attention.
125
+ *
126
+ * It is deliberately narrow — `running` and absent from `running` only — so it
127
+ * never touches phase 2's parked `blocked`/`failed`/`exhausted`/`stalled`
128
+ * states, and never overwrites the `runtime_tier_exhausted` waiting shape.
129
+ *
130
+ * @param {string} runDir
131
+ * @param {Map<string, NodeSnapshot>} states
132
+ * @param {Map<string, Job>} running
133
+ * @param {LockHandle|null} lock
134
+ * @returns {string[]} the node ids this pass parked
135
+ */
136
+ export function enforceRunningInvariant(runDir, states, running, lock) {
137
+ const parked = [];
138
+ for (const [nodeId, state] of states) {
139
+ if (state.status !== "running" || running.has(nodeId)) continue;
140
+ transition(runDir, state, "blocked", {
141
+ phase: state.phase,
142
+ error: {
143
+ code: "integration_unresolved",
144
+ message: "node is running but the controller holds no invocation for it",
145
+ },
146
+ }, lock);
147
+ parked.push(nodeId);
148
+ }
149
+ return parked;
150
+ }
151
+
152
+ /**
153
+ * @param {string} contractPath
154
+ * @param {{detachedBootstrap?: boolean}} [options] `detachedBootstrap` is set
155
+ * only by the CLI entry when this process is its own detached child, and
156
+ * makes the controller wait for the launcher's acknowledgement
157
+ * @returns {Promise<RunOutcome>}
158
+ */
159
+ export async function runContract(contractPath, options = {}) {
160
+ const absoluteContractPath = resolve(contractPath);
161
+ const contract = validateContract(JSON.parse(readFileSync(absoluteContractPath, "utf8")), absoluteContractPath);
162
+ const runDir = join(contract.cwd, ".runs", contract.id);
163
+ if (existsSync(runDir)) throw new Error(`run already exists: ${runDir}`);
164
+ mkdirSync(join(contract.cwd, ".runs"), { recursive: true });
165
+ try {
166
+ mkdirSync(runDir);
167
+ } catch (error) {
168
+ if (errorCode(error) === "EEXIST") throw new Error(`run already exists: ${runDir}`);
169
+ throw error;
170
+ }
171
+ const lock = acquireLock(runDir);
172
+ try {
173
+ const runtimePlan = await runtimeAssignments(contract);
174
+ const scopeBoundaries = captureNodeScopeBoundaries(contract);
175
+ const sourceIdentity = await captureRunIdentity(contract, scopeBoundaries);
176
+ const integrationRef = createRunRef(contract.cwd, contract.id, sourceIdentity.gitHead);
177
+ lock.assert();
178
+ const runsDir = join(contract.cwd, ".runs");
179
+ const campaign = resolveCampaign(runsDir, contract.campaignId);
180
+ mkdirSync(join(runDir, "nodes"), { recursive: true });
181
+ mkdirSync(join(runDir, "logs"), { recursive: true });
182
+ writeJsonAtomic(join(runDir, "contract.json"), serializableContract(contract));
183
+ writeJsonAtomic(join(runDir, "judge.schema.json"), JUDGE_SCHEMA);
184
+ writeJsonAtomic(join(runDir, "run.json"), createRunMetadata(lock, sourceIdentity, {}, integrationRef));
185
+ registerRun(campaign.path, contract.id);
186
+ renderCampaignHandoffSafely(campaign, runsDir, runDir);
187
+
188
+ const states = new Map();
189
+ for (const node of contract.nodes) {
190
+ /** @type {NodeSnapshot} */
191
+ const state = {
192
+ schemaVersion: PROTOCOL_SCHEMA_VERSION,
193
+ contractVersion: CONTRACT_VERSION,
194
+ id: node.id,
195
+ type: node.type,
196
+ sourceIdentity: node.sourceIdentity,
197
+ packetHash: node.packetHash,
198
+ status: "pending",
199
+ phase: "waiting",
200
+ attempt: 0,
201
+ revisions: 0,
202
+ runtime: null,
203
+ blockedBy: [],
204
+ startedAt: null,
205
+ updatedAt: new Date().toISOString(),
206
+ result: null,
207
+ verification: null,
208
+ scope: emptyScope(/** @type {import("../repo/workspace.mjs").WorkspaceScopeBoundary} */ (scopeBoundaries.get(node.id))),
209
+ gate: null,
210
+ error: null,
211
+ judgeFailures: 0,
212
+ routing: {
213
+ history: [],
214
+ currentOverride: null,
215
+ assignments: runtimePlan.assignments[node.id],
216
+ availability: runtimePlan.availability,
217
+ },
218
+ progress: null,
219
+ invocations: [],
220
+ executionOverrides: [],
221
+ worktree: { status: "unassigned", path: null, branch: null, commit: null, baseSha: null },
222
+ integratedHead: null,
223
+ };
224
+ states.set(node.id, state);
225
+ writeNode(runDir, state, lock);
226
+ }
227
+ syncAgentSignal(runsDir);
228
+ const outcome = await driveRun(contract, runDir, states, campaign, lock, sourceIdentity, {}, options);
229
+ syncAgentSignal(runsDir);
230
+ return outcome;
231
+ } catch (error) {
232
+ lock.release();
233
+ throw error;
234
+ }
235
+ }
236
+
237
+ /**
238
+ * @param {ValidatedContract} contract
239
+ * @param {string} runDir
240
+ * @param {Map<string, NodeSnapshot>} states
241
+ * @param {CampaignRef} campaign
242
+ * @param {LockHandle} lock
243
+ * @param {SourceIdentity} sourceIdentity
244
+ * @param {{identityWarnings?: string[], relaunchCount?: number, lastRelaunchProgressAt?: string|null, attention?: {code: string, message: string, at: string}|null}} [resume] resume-only records persisted on the run metadata
245
+ * @param {{detachedBootstrap?: boolean}} [options] set by the CLI entry alone
246
+ * @returns {Promise<RunOutcome>}
247
+ */
248
+ export async function driveRun(contract, runDir, states, campaign, lock, sourceIdentity, resume = {}, options = {}) {
249
+ lock.assert();
250
+ assertEnvironmentReady(contract, runDir, sourceIdentity);
251
+ const runsDir = join(contract.cwd, ".runs");
252
+ const bootstrapNonce = bootstrapNonceForProcess();
253
+ // Only the CLI entry can answer this: a nonce inherited by evals/run.mjs or
254
+ // by a test must not make the controller wait for an acknowledgement nobody
255
+ // is going to write.
256
+ const detachedBootstrap = options.detachedBootstrap === true;
257
+ // A controller restart must not erase the supervisor's durable relaunch
258
+ // guard. `resume.mjs` does not thread those fields, so the controller reads
259
+ // the run it is rewriting and carries them itself.
260
+ const persistedMetadata = existsSync(join(runDir, "run.json")) ? readJson(join(runDir, "run.json")) : {};
261
+ const runMetadata = createRunMetadata(lock, sourceIdentity, {
262
+ ...resume,
263
+ ...(persistedMetadata.relaunchCount !== undefined ? { relaunchCount: /** @type {number} */ (persistedMetadata.relaunchCount) } : {}),
264
+ ...(persistedMetadata.lastRelaunchProgressAt !== undefined ? { lastRelaunchProgressAt: /** @type {string|null} */ (persistedMetadata.lastRelaunchProgressAt) } : {}),
265
+ // A resume that actually changed node state may clear attention by passing
266
+ // `attention: null`; only then does the persisted record lose.
267
+ ...(persistedMetadata.attention !== undefined && resume.attention === undefined ? { attention: /** @type {{code: string, message: string, at: string}|null} */ (persistedMetadata.attention) } : {}),
268
+ }, runRefName(contract.id));
269
+ writeJsonAtomic(join(runDir, "run.json"), runMetadata);
270
+ writeJsonAtomic(bootstrapPath(runDir), {
271
+ status: "ready",
272
+ nonce: bootstrapNonce,
273
+ pid: process.pid,
274
+ processStartToken: processStartToken(process.pid),
275
+ runDir,
276
+ metadataPath: join(runDir, "run.json"),
277
+ at: new Date().toISOString(),
278
+ });
279
+ writeJsonAtomic(bootstrapAttemptPath(runDir, bootstrapNonce), readJson(bootstrapPath(runDir)));
280
+ cleanupBootstrapAttempts(runDir, bootstrapNonce);
281
+ if (detachedBootstrap) await waitForBootstrapAcknowledgement(runDir, {
282
+ nonce: bootstrapNonce,
283
+ pid: process.pid,
284
+ processStartToken: processStartToken(process.pid),
285
+ });
286
+ lock.assert();
287
+ renderCampaignHandoffSafely(campaign, runsDir, runDir);
288
+
289
+ /** @type {string|null} */
290
+ let statusFingerprint = null;
291
+ /** @param {boolean} force @param {LockHandle|null} [renderLock] */
292
+ const renderStatusIfChanged = (force = false, renderLock = lock) => {
293
+ const fingerprint = statesFingerprint(states);
294
+ if (!force && fingerprint === statusFingerprint) return;
295
+ statusFingerprint = fingerprint;
296
+ render(runDir, runsDir, contract, states, renderLock);
297
+ };
298
+ let handoffFingerprint = statesFingerprint(states);
299
+ const renderHandoffIfChanged = () => {
300
+ const fingerprint = statesFingerprint(states);
301
+ if (fingerprint === handoffFingerprint) return;
302
+ handoffFingerprint = fingerprint;
303
+ renderCampaignHandoffSafely(campaign, runsDir, runDir);
304
+ };
305
+ // Progress never notifies (TECH-SPEC lean, rule 6): only a node reaching a
306
+ // terminal state wakes the notify queue. status.json (written every render)
307
+ // is the progress surface now.
308
+ const notifyQueue = notifyQueueFor(runDir);
309
+ /** @type {string|null} */
310
+ let notificationFingerprint = null;
311
+ const notifyStateChanges = async () => {
312
+ const fingerprint = statesFingerprint(states);
313
+ if (fingerprint === notificationFingerprint) return;
314
+ notificationFingerprint = fingerprint;
315
+ for (const state of states.values()) {
316
+ if (!SETTLED.has(state.status)) continue;
317
+ const runId = basename(runDir);
318
+ const dedupeKey = `node.terminal:${runId}:${state.id}:${state.status}:${state.attempt ?? 0}:${state.revisions ?? 0}`;
319
+ if (alreadyNotified(runDir, dedupeKey)) continue;
320
+ await notifyQueue.enqueue({
321
+ type: "node.terminal",
322
+ campaignId: campaign.campaign.id,
323
+ runId,
324
+ nodeId: state.id,
325
+ status: state.status,
326
+ attempt: state.attempt ?? 0,
327
+ errorCode: terminalErrorCode(state),
328
+ dedupeKey,
329
+ });
330
+ }
331
+ };
332
+
333
+ /** @type {Map<string, Job>} */
334
+ const running = new Map();
335
+ let canceled = false;
336
+ const cancel = () => { canceled = true; };
337
+ process.once("SIGINT", cancel);
338
+ process.once("SIGTERM", cancel);
339
+ process.once("SIGHUP", cancel);
340
+ // The heartbeat's `at` is owned by an unref'd timer inside this writer, never
341
+ // by the loop body below: the loop awaits controller verification on its own
342
+ // critical path and must keep answering "the process is alive" while it does.
343
+ const heartbeat = createHeartbeat({ runDir, intervalMs: HEARTBEAT_INTERVAL_MS });
344
+ const heartbeatNodes = new Map(contract.nodes.map((node) => [node.id, node]));
345
+ let heartbeatFingerprint = statesFingerprint(states);
346
+ /** @type {Map<string, number>} */
347
+ const heartbeatOutputAt = new Map();
348
+ const activeHeartbeatNodes = () => [...states.values()]
349
+ .filter((state) => state.status === "running" && heartbeatNodes.has(state.id))
350
+ .map((state) => ({ nodeId: state.id, budgetBasis: nodeBudgetBasisMs(contract, /** @type {ValidatedNode} */ (heartbeatNodes.get(state.id))) }));
351
+ try {
352
+ while ([...states.values()].some((state) => !SETTLED.has(state.status))) {
353
+ lock.assert();
354
+ if (existsSync(join(runDir, "cancel.request.json"))) canceled = true;
355
+ if (canceled) {
356
+ const jobs = [...running.values()];
357
+ await Promise.all(jobs.map((job) => terminateProcess(job)));
358
+ const envelopes = new Map();
359
+ for (const job of jobs) envelopes.set(job.invocation.id, recordInvocationUsage(job, { accumulate: false }));
360
+ for (const job of jobs) {
361
+ const invocation = job.state.invocations?.find((item) => item.id === job.invocation.id) ?? job.invocation;
362
+ const scopeOk = job.phase !== "worker" || checkWorkerScope(contract, runDir, job, lock);
363
+ settleInvocation(runDir, invocation, {
364
+ status: scopeOk ? "canceled" : "failed",
365
+ usage: invocation.usage ?? null,
366
+ costUsd: typeof invocation.costUsd === "number" ? invocation.costUsd : null,
367
+ receipts: providerReceipts(envelopes.get(job.invocation.id)),
368
+ error: scopeOk ? null : job.state.error ?? { code: "scope_check_failed", message: "worker scope check failed" },
369
+ nextState: operationNextState(job.state),
370
+ });
371
+ }
372
+ running.clear();
373
+ for (const state of states.values()) {
374
+ if (!SETTLED.has(state.status)) transition(runDir, state, "canceled", { phase: "canceled" }, lock);
375
+ }
376
+ break;
377
+ }
378
+
379
+ const parkedBefore = new Set([...states.values()].filter((state) => PARKED.has(state.status)).map((state) => state.id));
380
+ await finalizeClosedJobs(contract, runDir, states, running, lock, campaign.path);
381
+ await detectStalls(contract, running, async (job, status, error) => {
382
+ const envelope = recordInvocationUsage(job);
383
+ job.state.usage = invocationUsage(job.state);
384
+ job.state.costUsd = invocationCost(job.state);
385
+ const invocation = job.state.invocations?.find((item) => item.id === job.invocation.id) ?? job.invocation;
386
+ appendUsageRecord(runDir, invocation);
387
+ if (job.phase === "worker" && !checkWorkerScope(contract, runDir, job, lock)) {
388
+ settleInvocation(runDir, invocation, {
389
+ status: "failed",
390
+ usage: invocation.usage ?? null,
391
+ costUsd: typeof invocation.costUsd === "number" ? invocation.costUsd : null,
392
+ receipts: providerReceipts(envelope),
393
+ error: job.state.error ?? { code: "scope_check_failed", message: "worker scope check failed" },
394
+ nextState: operationNextState(job.state),
395
+ });
396
+ return;
397
+ }
398
+ settleInvocation(runDir, invocation, {
399
+ status,
400
+ usage: invocation.usage ?? null,
401
+ costUsd: typeof invocation.costUsd === "number" ? invocation.costUsd : null,
402
+ receipts: providerReceipts(envelope),
403
+ error,
404
+ nextState: operationNextState(job.state),
405
+ });
406
+ // A judge killed on its own wall clock produced no verdict. That is a
407
+ // judge protocol defect, not a node outcome: it earns the one bounded
408
+ // re-ask, and only then the review mode settles the node.
409
+ if (job.phase === "judge" && error.code === "wall_clock_timeout") {
410
+ await applyJudgeProtocolFailure(contract, job.node, job.state, runDir, running, lock, states, campaign.path, error.message);
411
+ return;
412
+ }
413
+ // A timeout whose attempt seal is non-empty earns the one automatic
414
+ // retry (phase 5b supplies the seal); with no seal it parks here.
415
+ if (autoRetryNode(runDir, job.state, error.code, lock)) return;
416
+ transition(runDir, job.state, status, { phase: job.phase, error }, lock);
417
+ }, async (job) => {
418
+ writeNode(runDir, job.state, lock);
419
+ });
420
+ // A node left `running` with no job is a dead end; park it before the
421
+ // dispatch pass so it cannot hide behind a healthy sibling.
422
+ enforceRunningInvariant(runDir, states, running, lock);
423
+ // Before dependants are blocked, a node that parked on this tick gets
424
+ // its one automatic retry: it becomes pending, so `blockDependents` sees
425
+ // nothing to block and the dependants stay `pending`/`phase: "waiting"`
426
+ // until it parks for good.
427
+ autoRetryParkedNodes(contract, runDir, states, lock, parkedBefore);
428
+ blockDependents(contract, runDir, states, lock);
429
+
430
+ const slots = contract.maxParallel - running.size;
431
+ if (slots > 0) {
432
+ const ready = contract.nodes.filter((node) => {
433
+ const state = states.get(node.id);
434
+ return state?.status === "pending" && node.dependsOn.every((id) => states.get(id)?.status === "done");
435
+ });
436
+ for (const node of ready.slice(0, slots)) {
437
+ const state = states.get(node.id);
438
+ if (!state || routingBackoffActive(state, state.phase)) continue;
439
+ if (state.phase === "judge" && state.result) {
440
+ await applyJudgeRound(await startJudge(contract, node, state, runDir, running, state.result, lock, states, campaign.path),
441
+ contract, node, state, runDir, running, lock, states, campaign.path, state.result);
442
+ continue;
443
+ }
444
+ state.attempt += 1;
445
+ const prompt = state.gate?.verdict === "fail" ? retryPrompt(node, state.gate) : node.prompt;
446
+ startWorker(contract, node, state, runDir, running, prompt, lock, states, campaign.path);
447
+ }
448
+ }
449
+
450
+ renderHandoffIfChanged();
451
+ renderStatusIfChanged();
452
+ await notifyStateChanges();
453
+ // A node state transition is progress; provider output is progress; a
454
+ // node merely still existing is neither, which is what keeps a frozen
455
+ // sibling from hiding behind a healthy one.
456
+ const heartbeatFingerprintNow = statesFingerprint(states);
457
+ if (heartbeatFingerprintNow !== heartbeatFingerprint) {
458
+ heartbeatFingerprint = heartbeatFingerprintNow;
459
+ heartbeat.progress();
460
+ }
461
+ for (const [nodeId, job] of running) {
462
+ const observed = typeof job.lastOutputAt === "number" ? job.lastOutputAt : 0;
463
+ if (observed > (heartbeatOutputAt.get(nodeId) ?? 0)) {
464
+ heartbeatOutputAt.set(nodeId, observed);
465
+ const node = heartbeatNodes.get(nodeId);
466
+ if (node) heartbeat.progress(nodeId, nodeBudgetBasisMs(contract, node));
467
+ }
468
+ }
469
+ heartbeat.setActive(activeHeartbeatNodes());
470
+ if ([...states.values()].some((state) => !SETTLED.has(state.status))) await delay(contract.pollIntervalMs);
471
+ }
472
+ } catch (error) {
473
+ if (!(error instanceof LockLostError)) throw error;
474
+ await Promise.all([...running.values()].map((job) => terminateProcess(job)));
475
+ notifyQueuesByRun.delete(runDir);
476
+ return { runDir, states, ok: false, error };
477
+ } finally {
478
+ process.removeListener("SIGINT", cancel);
479
+ process.removeListener("SIGTERM", cancel);
480
+ process.removeListener("SIGHUP", cancel);
481
+ heartbeat.stop();
482
+ lock.release();
483
+ }
484
+ renderStatusIfChanged(false, null);
485
+ renderCampaignHandoffSafely(campaign, runsDir, runDir);
486
+ writeFindingsArtifact(runDir, contract, states);
487
+ const failed = [...states.values()].filter((state) => state.status !== "done");
488
+ const runId = basename(runDir);
489
+ const runDedupeKey = `run.terminal:${runId}:${failed.length ? "attention" : "done"}`;
490
+ if (!alreadyNotified(runDir, runDedupeKey)) {
491
+ await notifyQueue.enqueue({
492
+ type: "run.terminal",
493
+ campaignId: campaign.campaign.id,
494
+ runId,
495
+ done: states.size - failed.length,
496
+ total: states.size,
497
+ dedupeKey: runDedupeKey,
498
+ });
499
+ }
500
+ // Delivery is lossy: there is no retry budget to wait out, so the controller
501
+ // returns as soon as the terminal notification has been attempted once.
502
+ notifyQueuesByRun.delete(runDir);
503
+ process.stdout.write(`[run] ${contract.id} ${failed.length ? `failed · ${runDir} · findings.json` : `done · ${runDir}`}\n`);
504
+ if ([...states.values()].some((state) => state.usage)) {
505
+ const report = renderFinalReport(runDir, contract, states);
506
+ process.stdout.write(report);
507
+ }
508
+ return { runDir, states, ok: failed.length === 0 };
509
+ }
510
+
511
+ /**
512
+ * @param {string} runDir
513
+ * @param {ValidatedContract} contract
514
+ * @returns {NodeSnapshot[]}
515
+ */
516
+ export function readRunNodes(runDir, contract) {
517
+ const names = listNodeSnapshots(runDir);
518
+ const expected = new Map(contract.nodes.map((node) => [`${node.id}.json`, node]));
519
+ for (const name of names) if (!expected.has(name)) throw new TypeError(`unexpected persisted node snapshot ${name}`);
520
+ return contract.nodes.map((node) => {
521
+ const name = `${node.id}.json`;
522
+ if (!names.includes(name)) throw new TypeError(`missing persisted node snapshot ${name}`);
523
+ return validateNodeSnapshot(readNodeSnapshot(runDir, node.id), node);
524
+ });
525
+ }
526
+