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,752 @@
1
+ /**
2
+ * Putting a worker or a judge in front of a node: resolve the runtime, choose
3
+ * the continuation, build the prompt and the attempt workspace, spawn, and
4
+ * record the invocation intent before a token is spent.
5
+ *
6
+ * It decides nothing about outcomes. `startJudge` runs the mechanical gate and
7
+ * returns what it found -- `rejected`, `settle` or `dispatched` -- for the
8
+ * caller to act on. It used to call `applyRejection` and `settleDone` itself,
9
+ * which made dispatch depend on review policy and on settlement, and that is
10
+ * the shape that kept `engine/` a web instead of a stack.
11
+ */
12
+ import { JUDGE_SCHEMA, judgePrompt } from "./prompts.mjs";
13
+ import { LockLostError } from "../run/lock.mjs";
14
+ import { TOOL_OUTPUT_LIMIT_BYTES } from "../harnesses/exec-jsonl/index.mjs";
15
+ import { appendPreviousAttempt } from "./retry.mjs";
16
+ import {
17
+ RESULT_MATERIALIZATION_PROMPT_HEADER,
18
+ attemptWorkerResultPath,
19
+ clearAttemptWorkerResult,
20
+ clearWorkerResultFile,
21
+ readWorkerResultFile,
22
+ workerProtocolPrompt,
23
+ } from "./result-file.mjs";
24
+ import { attemptWorkspace } from "../repo/worktree.mjs";
25
+ import { attemptWorktreePath, createAttemptWorktree, sealAttempt } from "../repo/worktree.mjs";
26
+ import { basename, dirname, join } from "node:path";
27
+ import { boundedUtf8, errorCode, errorMessage, stableJson } from "../util.mjs";
28
+ import { captureWorkspaceScope, captureWorkspaceSnapshot } from "../repo/workspace.mjs";
29
+ import { createHash } from "node:crypto";
30
+ import { deterministicGate, judgeReaskReason, judgeRequired, judgeSkippedByScope } from "./judge-gate.mjs";
31
+ import { emptyScope, persistedScopeBoundary, workerScope } from "./scope.mjs";
32
+ import { hasOperationIntent, hasOperationSettlement, operationNeedsRecovery, operationNextState, persistInvocationIntent, providerReceipts, settleInvocation } from "../run/operations.mjs";
33
+ import { invocationCost, invocationUsage } from "../run/usage.mjs";
34
+ import { logPaths, readBoundedTail, startProcess } from "./process.mjs";
35
+ import { mkdirSync } from "node:fs";
36
+ import { READ_LINE_LIMIT, normalizeProviderResult, providerCommand } from "../harnesses/index.mjs";
37
+ import { readJson, writeJsonAtomic } from "../run/store.mjs";
38
+ import { judgeReaskInstruction, reviewMode } from "../contract/review-modes.mjs";
39
+ import { routeRuntimeForState, runtimeSnapshot } from "./failover.mjs";
40
+ import { transition, writeNode } from "./state.mjs";
41
+ import { validateNodeSnapshot } from "../contract/snapshot.mjs";
42
+
43
+ /**
44
+ * What a judge round decided, for the caller to act on.
45
+ * `rejected`: the mechanical gate failed, so nothing was dispatched.
46
+ * `settle`: the gate passed and no judgment item needs arbitrating.
47
+ * `dispatched`: a judge is running (or failed to start and the node is already
48
+ * marked failed).
49
+ *
50
+ * @typedef {{kind: "rejected", verdict: JudgeVerdict}|{kind: "settle", gate: JudgeVerdict}|{kind: "dispatched"}} JudgeRound
51
+ */
52
+ /** @typedef {import("./prompts.mjs").JudgeVerdict} JudgeVerdict */
53
+
54
+ /** @typedef {import("../harnesses/index.mjs").CommandOptions} CommandOptions */
55
+ /** @typedef {import("../harnesses/index.mjs").HarnessRuntime} HarnessRuntime */
56
+ /** @typedef {import("./lifecycle.mjs").Invocation} Invocation */
57
+ /** @typedef {import("./lifecycle.mjs").Job} Job */
58
+ /** @typedef {import("../cli.mjs").LockHandle} LockHandle */
59
+ /** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
60
+ /** @typedef {import("../contract/index.mjs").RuntimeSnapshot} RuntimeSnapshot */
61
+ /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
62
+ /** @typedef {import("../contract/index.mjs").ValidatedNode} ValidatedNode */
63
+ /** @typedef {import("../contract/index.mjs").VerificationState} VerificationState */
64
+ /** @typedef {import("../contract/index.mjs").WorkspaceScopeBoundary} WorkspaceScopeBoundary */
65
+
66
+ /** @typedef {{forceFresh?: boolean}} SessionPolicy */
67
+
68
+ /**
69
+ * Resolve the session policy one dispatch runs under, then consume the copy the
70
+ * node persisted. The explicit argument comes from a caller that is dispatching
71
+ * on the spot; `state.sessionPolicy` is the copy a rejection decision left when
72
+ * it handed the node back to the scheduler, whose own `startWorker` call passes
73
+ * nothing at all.
74
+ *
75
+ * Persisting is the whole point: `phaseInvocationPlan` rediscovers a compatible
76
+ * continuation from the persisted ledger, so nulling a local continuation id at
77
+ * the call site would let the scheduler's later dispatch find it again. Clearing
78
+ * the stored policy here makes it one-shot — it governs exactly the dispatch it
79
+ * was recorded for, and the next unrelated attempt reuses normally.
80
+ *
81
+ * @param {{sessionPolicy?: SessionPolicy|null}} state
82
+ * @param {SessionPolicy} [explicit]
83
+ * @returns {SessionPolicy}
84
+ */
85
+ export function forceFreshSession(state, explicit = {}) {
86
+ const persisted = /** @type {SessionPolicy|undefined} */ (state?.sessionPolicy ?? undefined);
87
+ const policy = { ...(persisted ?? {}), ...explicit };
88
+ if (state && state.sessionPolicy !== undefined && state.sessionPolicy !== null) state.sessionPolicy = null;
89
+ return policy;
90
+ }
91
+
92
+ /**
93
+ * Select the only continuation that is allowed for this plan phase and role.
94
+ * The search is intentionally limited to persisted node snapshots in this run.
95
+ *
96
+ * `policy.forceFresh` is the explicit session policy a rejection decision
97
+ * carries: it short-circuits the search before it can rediscover a compatible
98
+ * continuation, so a retry after a gate rejection starts a fresh provider
99
+ * session instead of re-reading the failed transcript. Nulling a local id at
100
+ * the call site is not enough, because this function rediscovers the prior
101
+ * continuation from the persisted ledger.
102
+ *
103
+ * @param {ValidatedContract} contract
104
+ * @param {ValidatedNode} node
105
+ * @param {NodeSnapshot} state
106
+ * @param {string} runDir
107
+ * @param {"worker"|"judge"} role
108
+ * @param {string} prompt
109
+ * @param {SessionPolicy} [policy]
110
+ * @returns {{prompt: string, continuationId: string|null, mode: "fresh"|"reuse"|"rotate"}}
111
+ */
112
+ function phaseInvocationPlan(contract, node, state, runDir, role, prompt, policy = {}) {
113
+ if (policy.forceFresh === true) {
114
+ return { prompt, continuationId: null, mode: "fresh" };
115
+ }
116
+ const runId = basename(runDir);
117
+ const session = phaseSessionCandidates(contract, node, state, runDir, role).at(-1);
118
+ const runtime = routeRuntimeForState(contract, node, state, role);
119
+ const identityMatches = session && session.invocation.runId === runId
120
+ && session.invocation.campaignId === contract.campaignId
121
+ && session.invocation.planPhase === node.phase
122
+ && session.invocation.role === role
123
+ && session.invocation.harness === runtime.harness
124
+ && session.invocation.runtimeId === runtime.id
125
+ && session.invocation.runtimeFingerprint === fingerprintRuntime(runtime)
126
+ && session.invocation.model === runtime.model
127
+ && session.invocation.reasoning === (runtime.reasoning ?? null)
128
+ && session.invocation.sandbox === (runtime.sandbox ?? null);
129
+ const canContinue = runtime.capabilities.continuation === true;
130
+ if (identityMatches && canContinue) {
131
+ return { prompt, continuationId: session.invocation.continuationId ?? null, mode: "reuse" };
132
+ }
133
+ // A harness that cannot continue at all, or a session picked up from a
134
+ // different phase-sibling node whose identity does not match this one, has
135
+ // no native continuity: the fresh attempt carries the prior nodes'
136
+ // structured summaries forward instead of starting blind.
137
+ if (session && (!canContinue || session.nodeId !== node.id)) {
138
+ return {
139
+ prompt: phaseHandoffPrompt(contract, node, state, runDir, role),
140
+ continuationId: null,
141
+ mode: "rotate",
142
+ };
143
+ }
144
+ // A capable harness continuing its own node whose identity merely drifted
145
+ // (the run directory moved, or a runtime edge) still gets the caller's own
146
+ // prompt — already carrying the node's bounded "Previous attempt" section —
147
+ // in a fresh session, never a synthesized handoff.
148
+ return { prompt, continuationId: null, mode: session ? "rotate" : "fresh" };
149
+ }
150
+ /**
151
+ * Continuation ids a live invocation is already driving, anywhere in the run.
152
+ *
153
+ * This is what makes concurrent nodes of one phase safe, and it is read from
154
+ * the persisted ledger rather than from an in-memory registry so a controller
155
+ * that took over a run inherits the claims instead of racing them.
156
+ *
157
+ * @param {ValidatedContract} contract
158
+ * @param {NodeSnapshot} currentState
159
+ * @param {string} runDir
160
+ * @returns {Set<string>}
161
+ */
162
+ function claimedContinuations(contract, currentState, runDir) {
163
+ /** @type {Set<string>} */
164
+ const claimed = new Set();
165
+ for (const candidate of contract.nodes) {
166
+ let state = candidate.id === currentState.id ? currentState : null;
167
+ if (!state) {
168
+ try { state = validateNodeSnapshot(readJson(join(runDir, "nodes", `${candidate.id}.json`)), candidate); } catch { continue; }
169
+ }
170
+ for (const invocation of state.invocations ?? []) {
171
+ if (invocation.status === "active" && invocation.continuationId) claimed.add(invocation.continuationId);
172
+ }
173
+ }
174
+ return claimed;
175
+ }
176
+ /**
177
+ * @param {ValidatedContract} contract
178
+ * @param {ValidatedNode} node
179
+ * @param {NodeSnapshot} currentState
180
+ * @param {string} runDir
181
+ * @param {"worker"|"judge"} role
182
+ * @returns {{nodeId: string, invocation: Invocation}[]}
183
+ */
184
+ function phaseSessionCandidates(contract, node, currentState, runDir, role) {
185
+ /** @type {{nodeId: string, invocation: Invocation}[]} */
186
+ const candidates = [];
187
+ const claimed = claimedContinuations(contract, currentState, runDir);
188
+ for (const candidate of contract.nodes) {
189
+ if (candidate.phase !== node.phase) continue;
190
+ let state = candidate.id === currentState.id ? currentState : null;
191
+ if (!state) {
192
+ try { state = validateNodeSnapshot(readJson(join(runDir, "nodes", `${candidate.id}.json`)), candidate); } catch { continue; }
193
+ }
194
+ for (const invocation of state.invocations ?? []) {
195
+ if (invocation.role !== role || invocation.planPhase !== node.phase || !invocation.continuationId) continue;
196
+ if (invocation.nodeId !== candidate.id || invocation.attempt !== state.attempt || invocation.workspace !== state.worktree?.path) continue;
197
+ // One provider session, one live turn. With `maxParallel` above one,
198
+ // two nodes of a phase can be dispatched in the same tick, and without
199
+ // this both would hand the same continuation id to their own provider
200
+ // process. The claim is read from the persisted ledger, which the
201
+ // in-tick dispatch already wrote for the node that went first.
202
+ if (claimed.has(invocation.continuationId)) continue;
203
+ candidates.push({ nodeId: candidate.id, invocation });
204
+ }
205
+ }
206
+ return candidates.sort((left, right) => {
207
+ const leftStarted = Date.parse(left.invocation.startedAt);
208
+ const rightStarted = Date.parse(right.invocation.startedAt);
209
+ if (leftStarted !== rightStarted) return leftStarted - rightStarted;
210
+ const leftUpdated = Date.parse(left.invocation.updatedAt);
211
+ const rightUpdated = Date.parse(right.invocation.updatedAt);
212
+ if (leftUpdated !== rightUpdated) return leftUpdated - rightUpdated;
213
+ return left.invocation.id.localeCompare(right.invocation.id);
214
+ });
215
+ }
216
+ /** @param {Invocation} invocation @param {ValidatedContract} contract @param {ValidatedNode} node @param {RuntimeSnapshot} runtime @param {NodeSnapshot} state @param {string} runDir @param {"worker"|"judge"} role @param {"fresh"|"reuse"|"rotate"} mode @param {string|null} continuationId */
217
+ function stampInvocation(invocation, contract, node, runtime, state, runDir, role, mode, continuationId) {
218
+ invocation.runId = basename(runDir);
219
+ invocation.campaignId = contract.campaignId;
220
+ invocation.nodeId = node.id;
221
+ invocation.attempt = state.attempt;
222
+ invocation.workspace = attemptWorkspace(state) ?? contract.cwd;
223
+ invocation.worktreeBranch = state.worktree?.branch ?? null;
224
+ invocation.worktreeBaseSha = state.worktree?.baseSha ?? null;
225
+ invocation.planPhase = node.phase;
226
+ invocation.role = role;
227
+ invocation.runtimeFingerprint = fingerprintRuntime(runtime);
228
+ invocation.model = runtime.model;
229
+ invocation.reasoning = runtime.reasoning ?? null;
230
+ invocation.sandbox = runtime.sandbox ?? null;
231
+ invocation.continuationId = continuationId;
232
+ invocation.continuationMode = mode;
233
+ // The tier-exhaustion generation this invocation belongs to, stamped exactly
234
+ // like `revision` so `planRoute` can scope its attempted set to the current
235
+ // generation across a controller crash.
236
+ /** @type {{cycle?: number}} */ (invocation).cycle = state.routing?.tierExhaustionCycle ?? 0;
237
+ }
238
+ /** @param {RuntimeSnapshot} runtime @returns {string} */
239
+ function fingerprintRuntime(runtime) {
240
+ const executable = providerCommand(runtime, "").executable;
241
+ return createHash("sha256").update(stableJson({ runtime, executable })).digest("hex");
242
+ }
243
+ /** @param {ValidatedContract} contract @param {ValidatedNode} node @param {NodeSnapshot} state @param {string} runDir @param {"worker"|"judge"} role @returns {string} */
244
+ function phaseHandoffPrompt(contract, node, state, runDir, role) {
245
+ const summaries = phaseSessionCandidates(contract, node, state, runDir, role)
246
+ .map(({ nodeId }) => {
247
+ const candidate = contract.nodes.find((item) => item.id === nodeId);
248
+ let snapshot = null;
249
+ try { snapshot = readJson(join(runDir, "nodes", `${nodeId}.json`)); } catch {
250
+ // ENOENT or unreadable snapshot: this prior node contributes no summary.
251
+ }
252
+ const result = snapshot?.result;
253
+ const record = result && typeof result === "object" && !Array.isArray(result)
254
+ ? /** @type {Record<string, unknown>} */ (result)
255
+ : null;
256
+ const summary = typeof record?.summary === "string" ? record.summary : null;
257
+ return summary && candidate ? `${candidate.id}: ${boundedUtf8(summary, 1024)}` : null;
258
+ })
259
+ .filter(Boolean)
260
+ .slice(-8);
261
+ const handoff = [
262
+ `Continue phase ${node.phase} as the ${role} agent in a fresh provider session.`,
263
+ "Prior structured node summaries:",
264
+ summaries.length ? summaries.map((summary) => `- ${summary}`).join("\n") : "- (none)",
265
+ "Current closed task packet:",
266
+ boundedUtf8(node.prompt, 48 * 1024),
267
+ ].join("\n\n");
268
+ return boundedUtf8(handoff, 60 * 1024);
269
+ }
270
+ /**
271
+ * The mechanical worker tool policy for the provider boundary: hook settings
272
+ * on Claude-compatible commands. Only an adapter whose surface can prove
273
+ * enforcement (`capabilities.toolPolicy`) receives it; prompt text is not
274
+ * enforcement.
275
+ *
276
+ * @param {RuntimeSnapshot} runtime
277
+ * @param {ValidatedNode} node
278
+ * @param {string} workspace
279
+ * @returns {import("../harnesses/index.mjs").ToolPolicy|undefined}
280
+ */
281
+ function workerToolPolicy(runtime, node, workspace) {
282
+ if (runtime.capabilities.toolPolicy !== true) return undefined;
283
+ return {
284
+ foregroundOnly: true,
285
+ maxToolOutputBytes: TOOL_OUTPUT_LIMIT_BYTES,
286
+ workspace,
287
+ writeFiles: node.taskPacket.writeFiles ?? [],
288
+ writeRoots: node.taskPacket.writeRoots ?? [],
289
+ maxReadLines: READ_LINE_LIMIT,
290
+ };
291
+ }
292
+ /**
293
+ * Build the bounded options shared by workers, judges, and gate revisions.
294
+ * Only provider session continuation travels here; time is the controller's
295
+ * only attempt control.
296
+ *
297
+ * @param {ValidatedContract} contract
298
+ * @param {ValidatedNode} node
299
+ * @param {NodeSnapshot} state
300
+ * @param {RuntimeSnapshot} runtime
301
+ * @param {{prompt: string, continuationId: string|null, mode: "fresh"|"reuse"|"rotate"}} phasePlan
302
+ * @param {string} runDir
303
+ * @param {LockHandle} lock
304
+ * @param {import("../harnesses/index.mjs").CommandOptions} [extra]
305
+ * @returns {import("../harnesses/index.mjs").CommandOptions}
306
+ */
307
+ function invocationCommandOptions(contract, node, state, runtime, phasePlan, runDir, lock, extra = {}) {
308
+ return {
309
+ ...extra,
310
+ continuationId: runtime.capabilities.continuation === true ? phasePlan.continuationId : null,
311
+ };
312
+ }
313
+ /**
314
+ * Seal the worktree the previous attempt left behind so its edits become the
315
+ * base of the next attempt instead of being abandoned in a discarded
316
+ * worktree (TECH-SPEC lean v0.3 section 3 rule 4). By the time this runs,
317
+ * `state.worktree` still points at the previous attempt — the caller always
318
+ * increments `state.attempt` before dispatching the next one — so that
319
+ * attempt's number is `state.attempt - 1`. Returns null when there is no
320
+ * previous worktree to seal, or it carries no diff from its own base: the
321
+ * next attempt is then cut from the integration head as before.
322
+ *
323
+ * @param {ValidatedContract} contract
324
+ * @param {ValidatedNode} node
325
+ * @param {NodeSnapshot} state
326
+ * @returns {{sha: string, attempt: number}|null}
327
+ */
328
+ function sealPreviousAttempt(contract, node, state) {
329
+ const path = attemptWorkspace(state);
330
+ if (!path || !state.worktree?.branch || !state.worktree.baseSha) return null;
331
+ const attempt = state.attempt - 1;
332
+ const sealed = sealAttempt({
333
+ repo: contract.cwd,
334
+ path,
335
+ baseSha: state.worktree.baseSha,
336
+ runId: contract.id,
337
+ nodeId: node.id,
338
+ attempt,
339
+ });
340
+ return sealed.empty ? null : { sha: sealed.sha, attempt };
341
+ }
342
+ /**
343
+ * Create the isolated workspace for the current attempt, or reuse the exact
344
+ * one already recorded for a controller restart. A retried attempt continues
345
+ * from the previous attempt's sealed sha rather than a fresh cut from the
346
+ * integration head, so sealed work is never abandoned in a discarded
347
+ * worktree.
348
+ *
349
+ * @param {ValidatedContract} contract
350
+ * @param {ValidatedNode} node
351
+ * @param {NodeSnapshot} state
352
+ * @param {string} runDir
353
+ * @param {LockHandle} lock
354
+ * @returns {string}
355
+ */
356
+ function ensureAttemptWorkspace(contract, node, state, runDir, lock) {
357
+ const expectedPath = attemptWorktreePath(runDir, contract.id, node.id, state.attempt);
358
+ if (state.worktree?.path === expectedPath && attemptWorkspace(state)) return expectedPath;
359
+ const previous = sealPreviousAttempt(contract, node, state);
360
+ const worktree = createAttemptWorktree({
361
+ repo: contract.cwd,
362
+ runDir,
363
+ runId: contract.id,
364
+ nodeId: node.id,
365
+ attempt: state.attempt,
366
+ base: previous?.sha,
367
+ });
368
+ const boundary = captureWorkspaceScope(worktree.path, workerScope(node.taskPacket));
369
+ state.worktree = previous ? { ...worktree, previousAttempt: previous.attempt } : worktree;
370
+ state.scope = emptyScope(boundary);
371
+ writeNode(runDir, state, lock);
372
+ return worktree.path;
373
+ }
374
+ /**
375
+ * @param {ValidatedContract} contract
376
+ * @param {ValidatedNode} node
377
+ * @param {NodeSnapshot} state
378
+ * @param {string} runDir
379
+ * @param {Map<string, Job>} running
380
+ * @param {string} prompt
381
+ * @param {LockHandle} lock
382
+ * @param {Map<string, NodeSnapshot>} states
383
+ * @param {string} campaignPath
384
+ * @param {SessionPolicy} [policy] the session policy the rejection decision
385
+ * carried into dispatch; `forceFresh` starts a fresh provider session instead
386
+ * of reusing the failed attempt's continuation. A policy persisted on the
387
+ * snapshot by `applyRejection` is merged in and consumed.
388
+ */
389
+ export function startWorker(contract, node, state, runDir, running, prompt, lock, states, campaignPath, policy = {}) {
390
+ // Resolve before the workspace is created: the one-shot policy governs this
391
+ // dispatch whether the caller passed it or `applyRejection` left it behind.
392
+ const sessionPolicy = forceFreshSession(state, policy);
393
+ let workspace;
394
+ try {
395
+ workspace = ensureAttemptWorkspace(contract, node, state, runDir, lock);
396
+ } catch (error) {
397
+ state.worktree = { ...(state.worktree ?? {}), status: "failed", path: state.worktree?.path ?? null, branch: state.worktree?.branch ?? null, commit: state.worktree?.commit ?? null, baseSha: state.worktree?.baseSha ?? null };
398
+ transition(runDir, state, "failed", { phase: "worker", error: { code: errorCode(error) ?? "worktree_create_failed", message: errorMessage(error) } }, lock);
399
+ return;
400
+ }
401
+ const runtime = routeRuntimeForState(contract, node, state, "worker");
402
+ const phasePlan = phaseInvocationPlan(contract, node, state, runDir, "worker", prompt, sessionPolicy);
403
+ // The previous-attempt section still has to survive on a retried attempt,
404
+ // so it is appended to the resolved prompt rather than the candidate handed
405
+ // to phaseInvocationPlan.
406
+ phasePlan.prompt = appendPreviousAttempt(phasePlan.prompt, state.previousAttempt);
407
+ // The worker prompt directs the provider to write the canonical result file;
408
+ // make sure the directory exists before the provider is asked to.
409
+ const resultPath = attemptWorkerResultPath(runDir, node.id, workspace);
410
+ mkdirSync(dirname(resultPath), { recursive: true });
411
+ const effectivePrompt = workerProtocolPrompt(phasePlan.prompt, resultPath);
412
+ const paths = logPaths(runDir, node.id, "worker", state.attempt);
413
+ if (Buffer.byteLength(effectivePrompt, "utf8") > 64 * 1024) {
414
+ transition(runDir, state, "failed", { phase: "worker", error: { code: "worker_prompt_too_large", message: "worker prompt exceeds 65536 bytes" } }, lock);
415
+ return;
416
+ }
417
+ /** @type {import("../repo/workspace.mjs").WorkspaceScopeBoundary} */
418
+ let boundary;
419
+ /** @type {unknown} */
420
+ let baseline;
421
+ try {
422
+ boundary = persistedScopeBoundary(contract, node, state, workspace);
423
+ baseline = captureWorkspaceSnapshot(workspace);
424
+ } catch (error) {
425
+ transition(runDir, state, "failed", { phase: "worker", error: { code: /** @type {string} */ (errorCode(error) ?? "scope_snapshot_invalid"), message: errorMessage(error) } }, lock);
426
+ return;
427
+ }
428
+ const snapshotPath = `${paths.prompt}.snapshot.json`;
429
+ writeJsonAtomic(snapshotPath, baseline);
430
+ state.phase = "worker";
431
+ state.runtime = runtime;
432
+ // A new worker attempt has no accepted result yet. The canonical result
433
+ // file is cleared when the previous attempt was explicitly rejected (failed
434
+ // gate verdict), when no valid canonical file exists, or when the stale file
435
+ // is a blocked_context result. A blocked_context result never reached a gate
436
+ // and is still a valid, non-null canonical file, so the failed-gate and
437
+ // missing-file conditions leave it in place and the re-dispatched node would
438
+ // adopt its own stale blocked_context result again. A valid non-blocked file
439
+ // at the start of a continuation attempt is durable evidence and must stay
440
+ // in place so the completion path can adopt it.
441
+ state.result = null;
442
+ state.verification = null;
443
+ state.scope = null;
444
+ let existingCanonicalResult = null;
445
+ try {
446
+ existingCanonicalResult = readWorkerResultFile(runDir, node.id);
447
+ } catch {
448
+ existingCanonicalResult = null;
449
+ }
450
+ clearAttemptWorkerResult(workspace, node.id);
451
+ if (state.gate?.verdict === "fail" || existingCanonicalResult === null || existingCanonicalResult?.status === "blocked_context") {
452
+ clearWorkerResultFile(runDir, node.id);
453
+ }
454
+ const previousInvocation = state.invocations?.at(-1);
455
+ if (previousInvocation && hasOperationSettlement(runDir, previousInvocation.id)) {
456
+ settleInvocation(runDir, previousInvocation, { nextState: operationNextState(state) });
457
+ }
458
+ state.startedAt ??= new Date().toISOString();
459
+ state.error = null;
460
+ state.scope = emptyScope(boundary);
461
+ writeNode(runDir, state, lock);
462
+ try {
463
+ const job = startProcess({
464
+ contract, node, state, runtime, workspace, prompt: effectivePrompt, paths, phase: "worker",
465
+ commandOptions: invocationCommandOptions(contract, node, state, runtime, phasePlan, runDir, lock, {
466
+ toolPolicy: workerToolPolicy(runtime, node, workspace),
467
+ // The pair a `bulk-read` delegation inside the worker accounts itself
468
+ // against; providerCommand merges it into the spawned environment.
469
+ env: { FABERUN_RUN_DIR: runDir, FABERUN_NODE_ID: node.id },
470
+ }),
471
+ onInvocation: (invocation, currentJob) => {
472
+ stampInvocation(invocation, contract, node, runtime, state, runDir, "worker", phasePlan.mode, phasePlan.continuationId);
473
+ invocation.snapshotPath = snapshotPath;
474
+ currentJob.scopeBaseline = baseline;
475
+ persistInvocation(runDir, state, invocation, currentJob, lock);
476
+ persistInvocationIntent(runDir, invocation, {
477
+ nodeId: node.id,
478
+ role: "worker",
479
+ attempt: state.attempt,
480
+ runtimeFingerprint: fingerprintRuntime(runtime),
481
+ prompt: effectivePrompt,
482
+ });
483
+ },
484
+ onInvocationUpdate: (invocation) => persistInvocationUpdate(runDir, state, invocation, lock),
485
+ onProgress: () => writeNode(runDir, state, lock),
486
+ });
487
+ transition(runDir, state, "running", { phase: "worker", runtime, error: null }, lock);
488
+ running.set(node.id, job);
489
+ } catch (error) {
490
+ const invocation = state.invocations?.at(-1);
491
+ if (invocation && hasOperationIntent(runDir, invocation.id) && operationNeedsRecovery(runDir, invocation.id)) {
492
+ settleInvocation(runDir, invocation, {
493
+ status: "failed",
494
+ error: { code: "spawn_error", message: errorMessage(error) },
495
+ reason: "provider did not start",
496
+ nextState: operationNextState(state),
497
+ });
498
+ }
499
+ transition(runDir, state, "failed", { phase: "worker", error: { code: "spawn_error", message: errorMessage(error) } }, lock);
500
+ }
501
+ }
502
+ /**
503
+ * A completed implementation may have omitted only its durable result. Resume
504
+ * the exact provider session for one turn to materialize that file; never use
505
+ * this path to restart implementation work.
506
+ *
507
+ * @param {ValidatedContract} contract
508
+ * @param {ValidatedNode} node
509
+ * @param {NodeSnapshot} state
510
+ * @param {string} runDir
511
+ * @param {Map<string, Job>} running
512
+ * @param {Invocation} sourceInvocation
513
+ * @param {HarnessRuntime & {id: string|null}} runtime
514
+ * @param {string|null} continuationId
515
+ * @param {LockHandle} lock
516
+ */
517
+ export function startResultMaterialization(contract, node, state, runDir, running, sourceInvocation, runtime, continuationId, lock) {
518
+ const materializationRuntime = runtime.id ? runtimeSnapshot(contract, runtime.id) : null;
519
+ if (!materializationRuntime || materializationRuntime.capabilities.continuation !== true || !continuationId) {
520
+ transition(runDir, state, "failed", {
521
+ phase: "worker",
522
+ error: { code: "missing_worker_result", message: "worker completed without a canonical result file and this runtime did not provide a resumable session for the one-turn materialization" },
523
+ }, lock);
524
+ return;
525
+ }
526
+ const paths = logPaths(runDir, node.id, "worker", state.attempt);
527
+ const workspace = attemptWorkspace(state) ?? contract.cwd;
528
+ const resultPath = attemptWorkerResultPath(runDir, node.id, workspace);
529
+ const prompt = [
530
+ `${RESULT_MATERIALIZATION_PROMPT_HEADER} Do not inspect, implement, verify, or invoke tools.`,
531
+ `Your only job in this single bounded turn is to write the required worker-result JSON object to: ${resultPath}`,
532
+ "Then return that same JSON object as the final message.",
533
+ ].join("\n\n");
534
+ let baseline;
535
+ try {
536
+ baseline = captureWorkspaceSnapshot(workspace);
537
+ writeJsonAtomic(`${paths.prompt}.snapshot.json`, baseline);
538
+ } catch (error) {
539
+ transition(runDir, state, "failed", { phase: "worker", error: { code: "result_materialization_snapshot_invalid", message: errorMessage(error) } }, lock);
540
+ return;
541
+ }
542
+ state.phase = "worker";
543
+ state.runtime = materializationRuntime;
544
+ state.error = { code: "result_materialization_pending", message: "awaiting one-turn canonical worker-result materialization" };
545
+ writeNode(runDir, state, lock);
546
+ try {
547
+ const job = startProcess({
548
+ contract, node, state, runtime: materializationRuntime, workspace, prompt, paths, phase: "worker",
549
+ commandOptions: invocationCommandOptions(contract, node, state, materializationRuntime, {
550
+ prompt,
551
+ continuationId,
552
+ mode: "reuse",
553
+ }, runDir, lock, {
554
+ toolPolicy: workerToolPolicy(materializationRuntime, node, workspace),
555
+ }),
556
+ onInvocation: (invocation, currentJob) => {
557
+ stampInvocation(invocation, contract, node, materializationRuntime, state, runDir, "worker", "reuse", continuationId);
558
+ invocation.snapshotPath = `${paths.prompt}.snapshot.json`;
559
+ currentJob.resultMaterialization = true;
560
+ currentJob.recoveryBaseline = baseline;
561
+ persistInvocation(runDir, state, invocation, currentJob, lock);
562
+ persistInvocationIntent(runDir, invocation, {
563
+ nodeId: node.id,
564
+ role: "worker",
565
+ attempt: state.attempt,
566
+ runtimeFingerprint: fingerprintRuntime(materializationRuntime),
567
+ prompt,
568
+ });
569
+ },
570
+ onInvocationUpdate: (invocation) => persistInvocationUpdate(runDir, state, invocation, lock),
571
+ });
572
+ transition(runDir, state, "running", { phase: "worker", runtime: materializationRuntime }, lock);
573
+ running.set(node.id, job);
574
+ } catch (error) {
575
+ transition(runDir, state, "failed", { phase: "worker", error: { code: "result_materialization_failed", message: errorMessage(error) } }, lock);
576
+ }
577
+ }
578
+ /**
579
+ * Gate a completed worker: mechanical proofs gate first, the judge arbitrates
580
+ * only judgment items and is skipped when the review mode is `none` or no
581
+ * judgment item exists. A judge protocol re-ask never re-runs the round's
582
+ * mechanical proofs.
583
+ *
584
+ * Returns what it decided rather than acting on it. Calling `applyRejection` or
585
+ * `settleDone` from here made dispatch depend on review policy and on
586
+ * settlement, which is the shape that kept `engine/` a web instead of a stack:
587
+ * every caller of this function already reaches both, and now it is their call.
588
+ *
589
+ * @param {ValidatedContract} contract
590
+ * @param {ValidatedNode} node
591
+ * @param {NodeSnapshot} state
592
+ * @param {string} runDir
593
+ * @param {Map<string, Job>} running
594
+ * @param {unknown} workerResult
595
+ * @param {LockHandle} lock
596
+ * @param {Map<string, NodeSnapshot>} states
597
+ * @param {string} campaignPath
598
+ * @returns {Promise<JudgeRound>}
599
+ */
600
+ export async function startJudge(contract, node, state, runDir, running, workerResult, lock, states, campaignPath) {
601
+ const reaskReason = judgeReaskReason(state);
602
+ const reask = reaskReason !== undefined;
603
+ const workspace = attemptWorkspace(state) ?? contract.cwd;
604
+ const { verdict, results } = await deterministicGate(
605
+ node,
606
+ workspace,
607
+ reask,
608
+ Math.max(1_000, Math.min((node.timeoutSec ?? contract.timeoutSec ?? 60) * 1000, 120_000)),
609
+ /** @type {import("../contract/index.mjs").VerificationState|null} */ (state.verification),
610
+ );
611
+ state.review = reviewMode(node.gate);
612
+ if (verdict.verdict === "fail") return { kind: "rejected", verdict };
613
+ // `skipWhen` is checked before the ordinary judgment rule so a green-and-small
614
+ // change settles mechanically even when a Definition of Done item carries
615
+ // `judgment: true`. Either condition failing falls through to `judgeRequired`,
616
+ // and a gate whose review mode is `none` is skipped there exactly as before.
617
+ if (judgeSkippedByScope(node, state)) return { kind: "settle", gate: verdict };
618
+ if (!judgeRequired(node)) return { kind: "settle", gate: verdict };
619
+ const runtime = routeRuntimeForState(contract, node, state, "judge");
620
+ const paths = logPaths(runDir, node.id, "judge", state.attempt);
621
+ state.phase = "judge";
622
+ state.runtime = runtime;
623
+ const previousInvocation = state.invocations?.at(-1);
624
+ if (previousInvocation && hasOperationSettlement(runDir, previousInvocation.id)) {
625
+ settleInvocation(runDir, previousInvocation, { nextState: operationNextState(state) });
626
+ }
627
+ try {
628
+ const prompt = `${judgePrompt(node, workerResult, {
629
+ diff: state.scope?.changedPaths,
630
+ verification: state.verification,
631
+ deterministic: results,
632
+ scopeFindings: state.scopeFindings,
633
+ previousAttempt: state.previousAttempt,
634
+ })}${reask ? judgeReaskInstruction(reaskReason) : ""}`;
635
+ const phasePlan = phaseInvocationPlan(contract, node, state, runDir, "judge", prompt);
636
+ // judgePrompt already carries the section when phaseInvocationPlan reuses
637
+ // that candidate; appendPreviousAttempt is a no-op then.
638
+ phasePlan.prompt = appendPreviousAttempt(phasePlan.prompt, state.previousAttempt);
639
+ if (Buffer.byteLength(phasePlan.prompt, "utf8") > 64 * 1024) {
640
+ const error = /** @type {Error & {code: string}} */ (new Error("judge prompt exceeds 65536 bytes"));
641
+ error.code = "judge_prompt_too_large";
642
+ throw error;
643
+ }
644
+ const job = startProcess({
645
+ contract, node, state, runtime, workspace,
646
+ prompt: phasePlan.prompt,
647
+ paths, phase: "judge",
648
+ commandOptions: invocationCommandOptions(contract, node, state, runtime, phasePlan, runDir, lock, {
649
+ schema: JUDGE_SCHEMA,
650
+ schemaPath: join(runDir, "judge.schema.json"),
651
+ }),
652
+ onInvocation: (invocation, currentJob) => {
653
+ stampInvocation(invocation, contract, node, runtime, state, runDir, "judge", phasePlan.mode, phasePlan.continuationId);
654
+ persistInvocation(runDir, state, invocation, currentJob, lock);
655
+ persistInvocationIntent(runDir, invocation, {
656
+ nodeId: node.id,
657
+ role: "judge",
658
+ attempt: state.attempt,
659
+ runtimeFingerprint: fingerprintRuntime(runtime),
660
+ prompt: phasePlan.prompt,
661
+ });
662
+ },
663
+ onInvocationUpdate: (invocation) => persistInvocationUpdate(runDir, state, invocation, lock),
664
+ });
665
+ transition(runDir, state, "running", { phase: "judge", runtime }, lock);
666
+ running.set(node.id, job);
667
+ } catch (error) {
668
+ const invocation = state.invocations?.at(-1);
669
+ if (invocation && hasOperationIntent(runDir, invocation.id) && operationNeedsRecovery(runDir, invocation.id)) {
670
+ settleInvocation(runDir, invocation, {
671
+ status: "failed",
672
+ error: { code: /** @type {string} */ (errorCode(error) ?? "spawn_error"), message: errorMessage(error) },
673
+ reason: "provider did not start",
674
+ nextState: operationNextState(state),
675
+ });
676
+ }
677
+ transition(runDir, state, "failed", { phase: "judge", error: { code: /** @type {string} */ (errorCode(error) ?? "spawn_error"), message: errorMessage(error) } }, lock);
678
+ }
679
+ return { kind: "dispatched" };
680
+ }
681
+ /**
682
+ * @param {string} runDir
683
+ * @param {NodeSnapshot} state
684
+ * @param {Invocation} invocation
685
+ * @param {Job} job
686
+ * @param {LockHandle} lock
687
+ */
688
+ function persistInvocation(runDir, state, invocation, job, lock) {
689
+ state.invocations = [...(state.invocations ?? []), invocation];
690
+ state.updatedAt = invocation.updatedAt;
691
+ writeNode(runDir, state, lock);
692
+ job.onClose = (closed) => {
693
+ try {
694
+ let continuationId = closed.continuationId ?? null;
695
+ let usage = closed.usage;
696
+ let costUsd = closed.costUsd;
697
+ let envelopeStatus = "closed";
698
+ let structuredResult = null;
699
+ let envelopeResult = null;
700
+ let envelopeError = null;
701
+ try {
702
+ const envelope = normalizeProviderResult(job.runtime, readBoundedTail(job.paths.stdout), job.exitCode, null, { preferStructured: job.phase === "judge" });
703
+ continuationId = envelope.continuationId ?? continuationId;
704
+ usage = envelope.usage;
705
+ costUsd = envelope.costUsd;
706
+ envelopeStatus = envelope.status;
707
+ structuredResult = Boolean(envelope.result);
708
+ envelopeResult = envelope.result ?? null;
709
+ envelopeError = envelope.error ?? null;
710
+ } catch {
711
+ // Unparseable provider envelope: settle with the raw closed invocation instead.
712
+ }
713
+ const completed = { ...closed, continuationId, usage, costUsd };
714
+ state.invocations = (state.invocations ?? []).map((item) => item.id === completed.id ? completed : item);
715
+ state.usage = invocationUsage(state);
716
+ state.costUsd = invocationCost(state);
717
+ state.updatedAt = closed.updatedAt;
718
+ writeNode(runDir, state, lock);
719
+ settleInvocation(runDir, completed, {
720
+ status: envelopeStatus,
721
+ usage,
722
+ costUsd: typeof costUsd === "number" ? costUsd : null,
723
+ structuredResult,
724
+ result: envelopeResult,
725
+ receipts: providerReceipts({ continuationId }),
726
+ error: envelopeError,
727
+ nextState: operationNextState(state),
728
+ });
729
+ } catch (error) {
730
+ if (!(error instanceof LockLostError)) throw error;
731
+ }
732
+ };
733
+ }
734
+ /**
735
+ * Persist live provider observations without creating a second invocation
736
+ * record. Continuation identity is authoritative before the provider log is
737
+ * capped or the process is terminated.
738
+ *
739
+ * @param {string} runDir
740
+ * @param {NodeSnapshot} state
741
+ * @param {Invocation} invocation
742
+ * @param {LockHandle} lock
743
+ */
744
+ function persistInvocationUpdate(runDir, state, invocation, lock) {
745
+ try {
746
+ state.invocations = (state.invocations ?? []).map((item) => item.id === invocation.id ? invocation : item);
747
+ state.updatedAt = invocation.updatedAt;
748
+ writeNode(runDir, state, lock);
749
+ } catch (error) {
750
+ if (!(error instanceof LockLostError)) throw error;
751
+ }
752
+ }