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,635 @@
1
+ /**
2
+ * Picking a run back up: what the previous controller left, and what may be
3
+ * trusted from it.
4
+ *
5
+ * Resume is the hard half of the loop. Nodes marked `running` may have a live
6
+ * process, a dead one, or a finished turn nobody recorded; integration may be
7
+ * half-applied; the source the run was pinned to may have moved. Nothing here
8
+ * re-dispatches hopefully -- every node is adopted, re-judged, retried in place
9
+ * or refused on evidence that is on disk.
10
+ *
11
+ * `runtimeAssignments` re-composes the worker/judge pair only for roles the
12
+ * contract left open, and only from runtimes that are available now: a resume
13
+ * after an exhausted provider is exactly when that matters.
14
+ */
15
+ import { JUDGE_SCHEMA, SETTLED } from "./prompts.mjs";
16
+ import { Buffer } from "node:buffer";
17
+ import { acquire as acquireLock } from "../run/lock.mjs";
18
+ import { applyInvalidWorkerResult, assertRunMutable, handleProviderExhaustion } from "./lifecycle.mjs";
19
+ import { applyJudgeResult } from "./review.mjs";
20
+ import { assertSourceUnchanged, captureRunIdentity, statesFingerprint } from "./run-identity.mjs";
21
+ import { attemptWorkspace, attemptWorktreePath, gitHead, removeWorktree, runRefName } from "../repo/worktree.mjs";
22
+ import { canonicalWorkerResultText, isResultMaterializationInvocation, materializeAttemptResult, recoverWorkerResult } from "./result-file.mjs";
23
+ import { checkPersistedWorkerScope, persistedScopeBoundary, reconcileAmbiguousWorkerRestart, resolveUnknownEffect } from "./scope.mjs";
24
+ import { closePersistedInvocation, recoverOrphan, recoveryFromOverride } from "./recover.mjs";
25
+ import { driveRun, readRunNodes } from "./scheduler.mjs";
26
+ import { emptyUsage, invocationCost, invocationUsage, persistRecoveryUsage } from "../run/usage.mjs";
27
+ import { ensureTerminalEvent, hasDoneEvent, recordExecutionOverride, transition, writeNode } from "./state.mjs";
28
+ import { errorMessage, excerpt } from "../util.mjs";
29
+ import { executeControllerVerification, recoverVerificationAttempts, verifyCandidateWorkspace } from "./verify.mjs";
30
+ import { existsSync, readFileSync, unlinkSync } from "node:fs";
31
+ import { extractJson } from "../harnesses/protocol.mjs";
32
+ import { hasOperationIntent, hasOperationSettlement, operationNeedsRecovery, providerReceipts, providerReceiptsFromInvocationTail, readOperationSettlement, settleInvocation } from "../run/operations.mjs";
33
+ import { isUnknownEffectStop, planResumeRetry, renderPreviousAttemptSection } from "./retry.mjs";
34
+ import { join, resolve } from "node:path";
35
+ import { parseDiscoveryResult, parseWorkerResult } from "../contract/worker-result.mjs";
36
+ import { readJson, writeJsonAtomic } from "../run/store.mjs";
37
+ import { recoverIntegrations } from "../repo/integrate.mjs";
38
+ import { registerRun, resolveCampaign } from "../campaign/index.mjs";
39
+ import { syncAgentSignal } from "../repo/signal.mjs";
40
+ import { validateContract } from "../contract/index.mjs";
41
+ import { validateRunMetadata, OPERATOR_ANSWER_MAX_BYTES } from "../contract/snapshot.mjs";
42
+ import { verificationFailureVerdict } from "./judge-gate.mjs";
43
+ import { verificationFailureWithScope } from "../contract/scope-findings.mjs";
44
+ import { applyRejection, applyVerificationFailure, raiseNodeAttention, settleDone } from "./settle.mjs";
45
+
46
+ /** @typedef {import("../repo/integrate.mjs").IntegrationResult} IntegrationResult */
47
+ /** @typedef {import("./lifecycle.mjs").Invocation} Invocation */
48
+ /** @typedef {import("../cli.mjs").LockHandle} LockHandle */
49
+ /** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
50
+ /** @typedef {import("./scheduler.mjs").RunOutcome} RunOutcome */
51
+ /** @typedef {import("./runtime-discovery.mjs").RuntimeAvailability} RuntimeAvailability */
52
+ /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
53
+ /** @typedef {import("../contract/worker-result.mjs").WorkerResult} WorkerResult */
54
+ /** @typedef {import("../run/usage.mjs").RecoveryOutcome} RecoveryOutcome */
55
+ /** @typedef {import("../harnesses/index.mjs").ProviderEnvelope & {costProvenance?: "priced"}} RecoveryEnvelope */
56
+
57
+ /**
58
+ * The exhaustion envelope a recovered invocation hands to
59
+ * `handleProviderExhaustion`. It carries forward whatever deadline and
60
+ * provenance the underlying recovered result actually recorded — from the
61
+ * recovery outcome or the invocation's own fields — and records
62
+ * `exhaustedUntil: null` when neither had one, never a synthesized instant.
63
+ *
64
+ * @param {RecoveryOutcome} recovery
65
+ * @param {Invocation|undefined} invocation
66
+ * @returns {RecoveryEnvelope}
67
+ */
68
+ export function recoveryExhaustionEnvelope(recovery, invocation) {
69
+ const costProvenance = recovery.costProvenance ?? invocation?.costProvenance;
70
+ return {
71
+ status: "exhausted",
72
+ result: null,
73
+ continuationId: null,
74
+ usage: recovery.usage ?? invocation?.usage ?? emptyUsage(),
75
+ costUsd: recovery.costUsd ?? invocation?.costUsd ?? null,
76
+ ...(costProvenance ? { costProvenance } : {}),
77
+ exhaustedUntil: recovery.exhaustedUntil ?? null,
78
+ error: recovery.error ?? { code: "provider_exhausted", message: recovery.reason ?? "provider exhausted" },
79
+ };
80
+ }
81
+
82
+ /**
83
+ * @param {string} runDirPath
84
+ * @param {{node?: string, reconcile?: string, answer?: {node: string, path: string}, detachedBootstrap?: boolean}} [options]
85
+ * `node` limits the retry in place to one node and its dependants,
86
+ * `reconcile` acknowledges a node stopped as `unknown_effect_reconciled`,
87
+ * `answer` records an operator's answer for a node blocked on
88
+ * `context_missing` and re-dispatches it, and
89
+ * `detachedBootstrap` is set only by the CLI entry when this process is its
90
+ * own detached child
91
+ * @returns {Promise<RunOutcome>}
92
+ */
93
+ export async function resumeRun(runDirPath, options = {}) {
94
+ // `--node` and `--answer` each name the retry closure; two different targets
95
+ // would retry the answered node while narrowing to an unrelated one and
96
+ // leave the answered node's dependants blocked, so it is refused outright.
97
+ if (options.node && options.answer && options.node !== options.answer.node) {
98
+ throw new Error(`--answer ${options.answer.node} conflicts with --node ${options.node}`);
99
+ }
100
+ const runDir = resolve(runDirPath);
101
+ assertRunMutable(runDir);
102
+ const contractPath = join(runDir, "contract.json");
103
+ const contract = validateContract(JSON.parse(readFileSync(contractPath, "utf8")), contractPath, { persisted: true });
104
+ const lock = acquireLock(runDir);
105
+ try {
106
+ const storedMetadata = validateRunMetadata(readJson(join(runDir, "run.json")), { requireSourceIdentity: true });
107
+ if (!gitHead(contract.cwd, runRefName(contract.id))) throw new Error(`integration ref is unavailable for ${contract.id}`);
108
+ const states = new Map(readRunNodes(runDir, contract).map((state) => [state.id, state]));
109
+ for (const state of states.values()) {
110
+ state.usage = invocationUsage(state);
111
+ state.costUsd = invocationCost(state);
112
+ }
113
+ const scopeBoundaries = new Map(contract.nodes.map((node) => [
114
+ node.id,
115
+ persistedScopeBoundary(contract, node, states.get(node.id), attemptWorkspace(states.get(node.id)) ?? contract.cwd),
116
+ ]));
117
+ const sourceIdentity = await captureRunIdentity(contract, scopeBoundaries);
118
+ const identity = assertSourceUnchanged(storedMetadata.sourceIdentity, sourceIdentity);
119
+ // A resume is an explicit instruction to continue the run: it consumes a
120
+ // stale cancel request instead of letting it re-cancel the retried nodes.
121
+ if (existsSync(join(runDir, "cancel.request.json"))) {
122
+ unlinkSync(join(runDir, "cancel.request.json"));
123
+ process.stdout.write(`[resume] ${contract.id} · consumed cancel request\n`);
124
+ }
125
+ // The judge schema is controller code, not run evidence: it is written
126
+ // once at run start, and a resume used to keep reading that copy. When a
127
+ // schema defect is what blocked the run -- `required` missing a property,
128
+ // which OpenAI rejects with 400 before the model runs -- the fix could
129
+ // not reach the run it was written for, and the only way forward was a
130
+ // new run paying for the worker a second time. Refresh it here.
131
+ writeJsonAtomic(join(runDir, "judge.schema.json"), JUDGE_SCHEMA);
132
+ const runsDir = join(runDir, "..");
133
+ const campaign = resolveCampaign(runsDir, contract.campaignId);
134
+ registerRun(campaign.path, contract.id);
135
+ await recoverIntegrationTransactions(contract, runDir, states, lock, campaign.path);
136
+ if (options.answer) {
137
+ const answerNode = contract.nodes.find((node) => node.id === options.answer?.node);
138
+ if (!answerNode) throw new Error(`unknown node id: ${options.answer.node}`);
139
+ const answerState = states.get(options.answer.node);
140
+ if (!answerState || !isBlockedContextTerminal(answerState)) {
141
+ throw new Error(`node ${options.answer.node} is not blocked on missing context`);
142
+ }
143
+ const answerText = readOperatorAnswer(options.answer.path);
144
+ const answerOverride = /** @type {import("../contract/index.mjs").ExecutionOverride} */ (/** @type {unknown} */ ({
145
+ kind: "operator-answer",
146
+ reason: `operator answered missing context for node ${options.answer.node}`,
147
+ text: answerText,
148
+ }));
149
+ recordExecutionOverride(runDir, answerState, answerOverride, lock);
150
+ }
151
+ const plan = planResumeRetry(contract, states, { node: options.node, reconcile: options.reconcile, answer: options.answer?.node });
152
+ for (const item of plan.attention) {
153
+ process.stdout.write(`[run] ${contract.id} attention · ${item.id} · ${item.reason}\n`);
154
+ }
155
+ const stateFingerprintBefore = statesFingerprint(states);
156
+ /** @type {{identityWarnings?: string[], attention?: null}} */
157
+ const resumeMetadata = {
158
+ ...(identity.warnings.length ? { identityWarnings: identity.warnings } : {}),
159
+ };
160
+ for (const node of contract.nodes) {
161
+ const state = states.get(node.id);
162
+ if (!state) continue;
163
+ if (state.status === "done") continue;
164
+ if (isBlockedContextTerminal(state) && node.id !== options.answer?.node) continue;
165
+ const action = plan.actions.get(node.id) ?? "recover";
166
+ // Adoption before retry: an unresolved blocking review is re-judged from
167
+ // the preserved worker result, never reset to a fresh worker attempt.
168
+ if (action === "rejudge") {
169
+ const routing = tierExhaustionRestart(state);
170
+ transition(runDir, state, "pending", { phase: "judge", error: null, blockedBy: [], ...(routing ? { routing } : {}) }, lock);
171
+ continue;
172
+ }
173
+ if (action === "hold") continue;
174
+ if (action === "retry") {
175
+ if (isUnknownEffectStop(state)) {
176
+ recordExecutionOverride(runDir, state, {
177
+ kind: "recovery",
178
+ decision: "reconcile_acknowledged",
179
+ reason: `unknown_effect_reconciled acknowledged by --reconcile; node ${node.id} is re-dispatched`,
180
+ }, lock);
181
+ }
182
+ state.previousAttempt = renderPreviousAttemptSection(state) ?? state.previousAttempt;
183
+ const routing = tierExhaustionRestart(state);
184
+ transition(runDir, state, "pending", { phase: "worker", error: null, blockedBy: [], ...(routing ? { routing } : {}) }, lock);
185
+ continue;
186
+ }
187
+ const lastInvocation = state.invocations?.at(-1);
188
+ await recoverVerificationAttempts(runDir, state, lock);
189
+ const pendingStart = state.status === "pending" && (state.phase === "worker" || state.phase === "judge") && lastInvocation?.status === "active";
190
+ if (!pendingStart && state.status === "pending" && (state.phase === "worker" || state.phase === "judge")) continue;
191
+ const lastInvocationId = lastInvocation?.id;
192
+ const persistedRecovery = lastInvocationId
193
+ ? [...(state.executionOverrides ?? [])].reverse().find((item) => {
194
+ const record = /** @type {Record<string, unknown>} */ (item);
195
+ return record.kind === "recovery" && record.invocationId === lastInvocationId;
196
+ })
197
+ : undefined;
198
+ const recoveryState = pendingStart ? /** @type {NodeSnapshot} */ ({ ...state, status: "running" }) : state;
199
+ const recovery = (state.status === "running" || pendingStart) && persistedRecovery
200
+ ? recoveryFromOverride(persistedRecovery, lastInvocationId)
201
+ : await recoverOrphan(runDir, contract, node, recoveryState, lock);
202
+ await persistRecoveryUsage(runDir, state, recovery, lock);
203
+ if (recovery?.kind === "reconciled") {
204
+ transition(runDir, state, "blocked", {
205
+ phase: recovery.phase ?? "worker",
206
+ error: {
207
+ code: "unknown_effect_reconciled",
208
+ message: excerpt(recovery.reason ?? "unknown effect requires manual reconciliation"),
209
+ },
210
+ }, lock);
211
+ continue;
212
+ }
213
+ if (recovery?.kind === "exhausted") {
214
+ const invocation = state.invocations?.find((item) => item.id === recovery.invocationId);
215
+ const hadUsage = Boolean(invocation?.usage);
216
+ const hadCost = invocation ? Object.hasOwn(invocation, "costUsd") : false;
217
+ state.invocations = closePersistedInvocation(
218
+ state.invocations,
219
+ recovery.invocationId,
220
+ hadUsage ? undefined : recovery.usage,
221
+ hadCost ? undefined : recovery.costUsd,
222
+ );
223
+ state.costUsd = invocationCost(state);
224
+ writeNode(runDir, state, lock);
225
+ settleInvocation(runDir, /** @type {string | Invocation} */ (invocation ?? recovery.invocationId), {
226
+ status: "exhausted",
227
+ usage: invocation?.usage ?? recovery.usage ?? null,
228
+ costUsd: typeof invocation?.costUsd === "number" ? invocation.costUsd : recovery.costUsd ?? null,
229
+ receipts: providerReceiptsFromInvocationTail(contract, invocation),
230
+ error: recovery.error ?? null,
231
+ });
232
+ handleProviderExhaustion(
233
+ contract,
234
+ runDir,
235
+ node,
236
+ state,
237
+ recovery.phase ?? (invocation?.phase === "judge" ? "judge" : "worker"),
238
+ recoveryExhaustionEnvelope(recovery, invocation),
239
+ invocation?.runtimeId ?? null,
240
+ lock,
241
+ states,
242
+ campaign.path,
243
+ );
244
+ continue;
245
+ }
246
+ if (recovery?.kind === "stalled") {
247
+ const invocation = state.invocations?.find((item) => item.id === recovery.invocationId);
248
+ const hadUsage = Boolean(invocation?.usage);
249
+ const hadCost = invocation ? Object.hasOwn(invocation, "costUsd") : false;
250
+ state.invocations = closePersistedInvocation(
251
+ state.invocations,
252
+ recovery.invocationId,
253
+ hadUsage ? undefined : recovery.usage,
254
+ hadCost ? undefined : recovery.costUsd,
255
+ );
256
+ state.costUsd = invocationCost(state);
257
+ settleInvocation(runDir, /** @type {string | Invocation} */ (invocation ?? recovery.invocationId), {
258
+ status: "stalled",
259
+ usage: invocation?.usage ?? recovery.usage ?? null,
260
+ costUsd: typeof invocation?.costUsd === "number" ? invocation.costUsd : recovery.costUsd ?? null,
261
+ receipts: providerReceiptsFromInvocationTail(contract, invocation),
262
+ error: recovery.error ?? null,
263
+ });
264
+ transition(runDir, state, "stalled", {
265
+ phase: recovery.phase ?? (invocation?.phase === "judge" ? "judge" : "worker"),
266
+ // One condition, one code: the live stall detector emits the same
267
+ // `stall_timeout`, so a recovery and a live kill park identically.
268
+ error: recovery.error ?? { code: "stall_timeout", message: recovery.reason ?? "worker made no progress" },
269
+ usage: state.usage,
270
+ }, lock);
271
+ continue;
272
+ }
273
+ if (recovery?.kind === "adopted" || recovery?.kind === "rejudge") {
274
+ // The run-owned canonical file outranks every provider-derived source:
275
+ // the message, the settlement, and the adopted stream result. A
276
+ // present-but-invalid file surfaces as an invalid result on every
277
+ // recovery path, never as a reason to adopt provider evidence.
278
+ let workerResult;
279
+ try {
280
+ materializeAttemptResult(runDir, state, node);
281
+ workerResult = recovery.phase === "judge"
282
+ ? recoverWorkerResult(runDir, state, contract, node)
283
+ : canonicalWorkerResultText(runDir, node.id) ?? recovery.result;
284
+ } catch (error) {
285
+ await applyInvalidWorkerResult(contract, node, state, runDir, null, lock, errorMessage(error), states, campaign.path);
286
+ continue;
287
+ }
288
+ if (recovery.phase === "worker" && workerResult !== null && workerResult !== undefined) {
289
+ const invocation = recovery.kind === "rejudge"
290
+ ? [...(state.invocations ?? [])].reverse().find((item) => item.phase === "worker")
291
+ : state.invocations?.find((item) => item.id === recovery.invocationId);
292
+ // A recovered result-materialization turn keeps its live-path rule:
293
+ // the turn had no workspace authority, so any change is a violation.
294
+ if (!checkPersistedWorkerScope(contract, runDir, state, node, invocation, lock, {
295
+ materialization: isResultMaterializationInvocation(invocation),
296
+ })) continue;
297
+ /** @type {WorkerResult|undefined} */
298
+ let parsedWorkerResult;
299
+ try {
300
+ parsedWorkerResult = parseWorkerResult(String(extractJson(workerResult) ?? workerResult));
301
+ } catch (error) {
302
+ await applyInvalidWorkerResult(contract, node, state, runDir, null, lock, errorMessage(error), states, campaign.path);
303
+ continue;
304
+ }
305
+ if (node.taskPacket.mode === "discovery" && parsedWorkerResult.status === "done") {
306
+ try {
307
+ parseDiscoveryResult(parsedWorkerResult, attemptWorkspace(state) ?? contract.cwd);
308
+ } catch (error) {
309
+ await applyInvalidWorkerResult(contract, node, state, runDir, null, lock, errorMessage(error), states, campaign.path);
310
+ continue;
311
+ }
312
+ }
313
+ state.result = parsedWorkerResult;
314
+ if (parsedWorkerResult.status === "blocked_context") {
315
+ transition(runDir, state, "blocked", {
316
+ phase: "complete",
317
+ result: parsedWorkerResult,
318
+ error: { code: "context_missing", message: parsedWorkerResult.missingContext.join("; ") },
319
+ }, lock);
320
+ continue;
321
+ }
322
+ await executeControllerVerification(contract, runDir, node, state, lock);
323
+ if (!state.verification?.passed) {
324
+ applyVerificationFailure(contract, node, state, runDir, null, lock, states, campaign.path);
325
+ continue;
326
+ }
327
+ } else {
328
+ state.result = workerResult ?? state.result;
329
+ }
330
+ const recoveredInvocation = state.invocations?.find((invocation) => invocation.id === recovery.invocationId);
331
+ const hadUsage = Boolean(recoveredInvocation?.usage);
332
+ const hadCost = recoveredInvocation ? Object.hasOwn(recoveredInvocation, "costUsd") : false;
333
+ state.invocations = closePersistedInvocation(
334
+ state.invocations,
335
+ recovery.invocationId,
336
+ hadUsage ? undefined : recovery.usage,
337
+ hadCost ? undefined : recovery.costUsd,
338
+ );
339
+ state.costUsd = invocationCost(state);
340
+ settleInvocation(runDir, /** @type {string | Invocation} */ (recoveredInvocation ?? recovery.invocationId), {
341
+ status: recovery.kind,
342
+ usage: recoveredInvocation?.usage ?? recovery.usage ?? null,
343
+ costUsd: typeof recoveredInvocation?.costUsd === "number" ? recoveredInvocation.costUsd : recovery.costUsd ?? null,
344
+ structuredResult: recovery.result !== null && recovery.result !== undefined,
345
+ result: recovery.result ?? null,
346
+ receipts: providerReceipts(/** @type {{continuationId?: string|null}|null|undefined} */ (/** @type {unknown} */ (recovery))),
347
+ });
348
+ if (!persistedRecovery) recordExecutionOverride(runDir, state, {
349
+ kind: "recovery",
350
+ decision: recovery.kind === "rejudge" ? "rejudge" : "adopted",
351
+ invocationId: recovery.invocationId,
352
+ phase: recovery.phase,
353
+ result: recovery.result,
354
+ usage: recovery.usage,
355
+ costUsd: recovery.costUsd,
356
+ reason: recovery.kind === "rejudge"
357
+ ? `judge invocation ${recovery.invocationId} was not adopted; completed worker stream was re-judged`
358
+ : `${recovery.phase} invocation ${recovery.invocationId} completed after controller loss`,
359
+ }, lock);
360
+ if (recovery.phase === "worker" && node.gate.enabled) {
361
+ transition(runDir, state, "pending", { phase: "judge", error: null, blockedBy: [] }, lock);
362
+ } else if (recovery.phase === "judge") {
363
+ await applyJudgeResult(contract, node, state, recovery.result, runDir, lock, null, states, campaign.path);
364
+ } else {
365
+ await settleDone(contract, node, state, runDir, lock, states, campaign.path, { phase: "complete", error: null, blockedBy: [] });
366
+ }
367
+ continue;
368
+ }
369
+ if (recovery?.kind === "restart") {
370
+ const restartInvocation = state.invocations?.find((item) => item.id === recovery.invocationId)
371
+ ?? [...(state.invocations ?? [])].reverse()[0];
372
+ const unknownEffectId = recovery.invocationId ?? lastInvocationId;
373
+ const recoveryPhase = recovery.phase ?? restartInvocation?.phase ?? "worker";
374
+ // A restart of a result-materialization turn means the one permitted
375
+ // result-only turn already ran and left no canonical result. It must
376
+ // fail terminally instead of becoming fresh implementation work.
377
+ if (restartInvocation?.phase === "worker" && isResultMaterializationInvocation(restartInvocation)) {
378
+ if (recovery.invocationId) {
379
+ state.invocations = closePersistedInvocation(state.invocations, recovery.invocationId, recovery.usage, recovery.costUsd);
380
+ if (!hasOperationSettlement(runDir, recovery.invocationId)) {
381
+ settleInvocation(runDir, restartInvocation, {
382
+ status: "failed",
383
+ error: { code: "missing_worker_result", message: "result-only materialization produced no canonical worker result" },
384
+ reason: recovery.reason ?? undefined,
385
+ nextState: "failed",
386
+ });
387
+ }
388
+ }
389
+ state.costUsd = invocationCost(state);
390
+ transition(runDir, state, "failed", {
391
+ phase: "worker",
392
+ error: { code: "missing_worker_result", message: "result-only materialization produced no canonical worker result before the controller was interrupted" },
393
+ }, lock);
394
+ continue;
395
+ }
396
+ if (recoveryPhase === "worker" || recoveryPhase === "judge") {
397
+ const unknownInvocation = restartInvocation?.id === unknownEffectId
398
+ ? restartInvocation
399
+ : state.invocations?.find((item) => item.id === unknownEffectId);
400
+ const workerInvocation = unknownInvocation?.phase === "worker"
401
+ ? unknownInvocation
402
+ : [...(state.invocations ?? [])].reverse().find((item) => item.phase === "worker");
403
+ const hasUnknownEffect = Boolean(
404
+ unknownEffectId
405
+ && hasOperationIntent(runDir, unknownEffectId)
406
+ && operationNeedsRecovery(runDir, unknownEffectId),
407
+ );
408
+ if (hasUnknownEffect) {
409
+ // The controller died inside the spawn→settlement window: this
410
+ // attempt's workspace effects are unknown until proven otherwise.
411
+ const resolution = await resolveUnknownEffect(contract, runDir, node, state, workerInvocation, lock);
412
+ const targetInvocation = unknownInvocation ?? workerInvocation ?? unknownEffectId;
413
+ const targetUsage = unknownInvocation?.usage ?? recovery.usage ?? null;
414
+ const targetCost = typeof unknownInvocation?.costUsd === "number" ? unknownInvocation.costUsd : recovery.costUsd ?? null;
415
+ settleInvocation(runDir, /** @type {string | Invocation} */ (targetInvocation ?? unknownEffectId), {
416
+ status: resolution.action === "replay" ? "safe_replay" : "reconciled",
417
+ usage: targetUsage,
418
+ costUsd: targetCost,
419
+ receipts: providerReceiptsFromInvocationTail(contract, unknownInvocation ?? workerInvocation),
420
+ unknownEffect: true,
421
+ classification: "unknown_effect",
422
+ reason: resolution.action === "replay"
423
+ ? "unknown_effect resolved as safe replay"
424
+ : resolution.reason,
425
+ });
426
+ if (!persistedRecovery) recordExecutionOverride(runDir, state, {
427
+ kind: "recovery",
428
+ decision: resolution.action === "replay" ? "safe_replay" : "reconciled",
429
+ invocationId: unknownEffectId,
430
+ phase: recoveryPhase,
431
+ reason: resolution.action === "replay"
432
+ ? "unknown_effect resolved as safe replay; scope clean and deterministic verification passed"
433
+ : resolution.reason,
434
+ }, lock);
435
+ if (resolution.action === "replay") {
436
+ if (recoveryPhase === "judge") {
437
+ // A judge has no workspace effect of its own. Replaying it
438
+ // must keep the accepted worker result and schedule a fresh
439
+ // judge invocation rather than rerunning the worker.
440
+ transition(runDir, state, "pending", { phase: "judge", error: null, blockedBy: [] }, lock);
441
+ continue;
442
+ }
443
+ }
444
+ if (resolution.action === "reconcile") {
445
+ transition(runDir, state, "blocked", {
446
+ phase: recoveryPhase,
447
+ error: { code: "unknown_effect_reconciled", message: excerpt(resolution.reason) },
448
+ }, lock);
449
+ continue;
450
+ }
451
+ }
452
+ }
453
+ if (recovery.phase === "worker" || restartInvocation?.phase === "worker") {
454
+ const invocation = restartInvocation
455
+ ?? [...(state.invocations ?? [])].reverse().find((item) => item.phase === "worker");
456
+ if (reconcileAmbiguousWorkerRestart(contract, runDir, node, state, invocation, recovery, persistedRecovery, lock)) continue;
457
+ }
458
+ const recoveredInvocation = state.invocations?.find((invocation) => invocation.id === recovery.invocationId);
459
+ const hadUsage = Boolean(recoveredInvocation?.usage);
460
+ const hadCost = recoveredInvocation ? Object.hasOwn(recoveredInvocation, "costUsd") : false;
461
+ if (recovery.invocationId) {
462
+ state.invocations = closePersistedInvocation(
463
+ state.invocations,
464
+ recovery.invocationId,
465
+ hadUsage ? undefined : recovery.usage,
466
+ hadCost ? undefined : recovery.costUsd,
467
+ );
468
+ if (!hasOperationSettlement(runDir, recovery.invocationId)) {
469
+ settleInvocation(runDir, recoveredInvocation ?? recovery.invocationId, {
470
+ status: "restarted",
471
+ usage: recoveredInvocation?.usage ?? recovery.usage ?? null,
472
+ costUsd: typeof recoveredInvocation?.costUsd === "number" ? recoveredInvocation.costUsd : recovery.costUsd ?? null,
473
+ receipts: providerReceiptsFromInvocationTail(contract, recoveredInvocation ?? restartInvocation),
474
+ error: recovery.error ?? null,
475
+ reason: recovery.reason,
476
+ });
477
+ }
478
+ }
479
+ const replayingJudge = recoveryPhase === "judge"
480
+ && (persistedRecovery?.decision === "safe_replay"
481
+ || readOperationSettlement(runDir, recovery.invocationId ?? "")?.status === "safe_replay");
482
+ if (replayingJudge) {
483
+ state.costUsd = invocationCost(state);
484
+ transition(runDir, state, "pending", { phase: "judge", error: null, blockedBy: [] }, lock);
485
+ continue;
486
+ }
487
+ state.costUsd = invocationCost(state);
488
+ if (!persistedRecovery) recordExecutionOverride(runDir, state, {
489
+ kind: "recovery",
490
+ decision: "restart",
491
+ invocationId: recovery.invocationId,
492
+ phase: recovery.phase,
493
+ result: recovery.result,
494
+ usage: recovery.usage,
495
+ costUsd: recovery.costUsd,
496
+ reason: recovery.reason,
497
+ }, lock);
498
+ }
499
+ transition(runDir, state, "pending", { phase: "waiting", error: null, blockedBy: [] }, lock);
500
+ }
501
+ // A resume that actually changed node state clears the durable attention
502
+ // anchor, so a later park starts a fresh re-nag interval. A resume that
503
+ // threw before changing anything never reaches this point and leaves the
504
+ // anchor intact, which is what keeps the attention live.
505
+ if (statesFingerprint(states) !== stateFingerprintBefore) resumeMetadata.attention = null;
506
+ const outcome = await driveRun(contract, runDir, states, campaign, lock, sourceIdentity, resumeMetadata, options);
507
+ syncAgentSignal(runsDir);
508
+ return outcome;
509
+ } catch (error) {
510
+ lock.release();
511
+ throw error;
512
+ }
513
+ }
514
+ /**
515
+ * @param {NodeSnapshot} state
516
+ * @returns {boolean}
517
+ */
518
+ export function isBlockedContextTerminal(state) {
519
+ return state.status === "blocked" && state.error?.code === "context_missing";
520
+ }
521
+ /**
522
+ * The routing write that opens the next tier-exhaustion generation, or null
523
+ * when this re-dispatch is not one: the generation counter bumps by exactly
524
+ * one and the evidence starts over empty, both in the same transition that
525
+ * re-dispatches the node. The counter is never reset and the evidence is never
526
+ * carried forward; any other retry or rejudge leaves routing untouched.
527
+ *
528
+ * @param {NodeSnapshot} state
529
+ * @returns {Record<string, unknown>|null}
530
+ */
531
+ function tierExhaustionRestart(state) {
532
+ if (state.status !== "blocked" || state.error?.code !== "runtime_tier_exhausted") return null;
533
+ const role = state.phase === "judge" ? "judge" : "worker";
534
+ return {
535
+ ...(state.routing ?? {}),
536
+ tierExhaustionCycle: (state.routing?.tierExhaustionCycle ?? 0) + 1,
537
+ tierExhaustion: { role, candidates: [] },
538
+ };
539
+ }
540
+ /**
541
+ * Read the operator's answer file relative to the current working directory —
542
+ * the operator's own shell, never `contract.cwd` — enforcing the same hard
543
+ * byte ceiling the other bounded prompt sections use.
544
+ *
545
+ * @param {string} path
546
+ * @returns {string}
547
+ */
548
+ function readOperatorAnswer(path) {
549
+ let text;
550
+ try {
551
+ text = readFileSync(resolve(path), "utf8");
552
+ } catch (error) {
553
+ throw new Error(`cannot read answer file ${path}: ${errorMessage(error)}`);
554
+ }
555
+ if (Buffer.byteLength(text, "utf8") > OPERATOR_ANSWER_MAX_BYTES) {
556
+ throw new Error(`answer file exceeds ${OPERATOR_ANSWER_MAX_BYTES} bytes`);
557
+ }
558
+ return text;
559
+ }
560
+ /**
561
+ * @param {ValidatedContract} contract
562
+ * @param {string} runDir
563
+ * @param {Map<string, NodeSnapshot>} states
564
+ * @param {LockHandle} lock
565
+ * @param {string} campaignPath
566
+ * @returns {Promise<import("../repo/integrate.mjs").IntegrationResult|null>}
567
+ */
568
+ export async function recoverIntegrationTransactions(contract, runDir, states, lock, campaignPath) {
569
+ return recoverIntegrations({
570
+ repo: contract.cwd,
571
+ runDir,
572
+ runId: contract.id,
573
+ verifyCandidate: async (workspace, transaction) => {
574
+ const node = contract.nodes.find((candidate) => candidate.id === transaction.node);
575
+ const state = states.get(transaction.node);
576
+ if (!node || !state) return { passed: false, error: "journal references an unknown node" };
577
+ return verifyCandidateWorkspace(contract, node, state, runDir, workspace);
578
+ },
579
+ onAccepted: async (transaction) => {
580
+ const state = states.get(transaction.node);
581
+ if (!state) return;
582
+ // Once a `done` transition for this attempt was already durably
583
+ // recorded, every effect this transaction owns (ref move, done-write,
584
+ // cleanup, terminal event) was already fully applied — on some earlier
585
+ // resume, or in the same process that accepted it. A later reset of
586
+ // node status (recovery exercising a different concern for the same
587
+ // attempt) is not evidence that the crash this callback recovers from
588
+ // ever happened; forcing "done" again here would stomp that unrelated
589
+ // recovery outcome.
590
+ if (state.attempt === transaction.attempt && hasDoneEvent(runDir, state.id, state.attempt)) return;
591
+ const path = state.attempt === transaction.attempt && state.worktree?.path
592
+ ? state.worktree.path
593
+ : attemptWorktreePath(runDir, contract.id, transaction.node, transaction.attempt);
594
+ if (state.attempt === transaction.attempt && state.status !== "done") {
595
+ transition(runDir, state, "done", {
596
+ phase: "complete",
597
+ integratedHead: transaction.candidateSha,
598
+ worktree: { ...(state.worktree ?? {}), status: "removed", commit: transaction.attemptSha, baseSha: transaction.previousRunRefTip },
599
+ }, lock);
600
+ }
601
+ if (state.attempt === transaction.attempt && state.status === "done") ensureTerminalEvent(runDir, state, lock);
602
+ removeWorktree(contract.cwd, path);
603
+ },
604
+ onVerificationFailure: async (transaction) => {
605
+ const node = contract.nodes.find((candidate) => candidate.id === transaction.node);
606
+ const state = states.get(transaction.node);
607
+ if (!node || !state || SETTLED.has(state.status)) return;
608
+ const verdict = verificationFailureWithScope(verificationFailureVerdict(state), state.scope);
609
+ verdict.summary = "integrated candidate verification failed during recovery";
610
+ applyRejection(contract, node, state, runDir, null, lock, states, campaignPath, verdict, {
611
+ code: "verification_failed",
612
+ label: "candidate-verification",
613
+ });
614
+ },
615
+ onConflict: async (transaction) => {
616
+ const state = states.get(transaction.node);
617
+ if (!state || SETTLED.has(state.status)) return;
618
+ const paths = transaction.conflictingPaths?.length ? transaction.conflictingPaths.join(", ") : "unknown paths";
619
+ transition(runDir, state, "blocked", {
620
+ phase: "complete",
621
+ error: { code: "integration_conflict", message: `integration conflict in: ${paths}` },
622
+ }, lock);
623
+ await raiseNodeAttention(campaignPath, runDir, state, "integration_conflict");
624
+ },
625
+ onConcurrentMove: async (transaction) => {
626
+ const state = states.get(transaction.node);
627
+ if (!state || SETTLED.has(state.status)) return;
628
+ transition(runDir, state, "blocked", {
629
+ phase: "complete",
630
+ error: { code: "integration_concurrent_move", message: `run ref moved from ${transaction.previousRunRefTip} to ${transaction.currentRunRefTip ?? "unknown"}` },
631
+ }, lock);
632
+ await raiseNodeAttention(campaignPath, runDir, state, "integration_concurrent_move");
633
+ },
634
+ });
635
+ }