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.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- 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
|
+
}
|