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,772 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The control loop's view of one node: absorb what a closed invocation
|
|
3
|
+
* produced, route it (settle, re-judge, retry, fail over), and decide what an
|
|
4
|
+
* unusable worker result or a spent provider costs.
|
|
5
|
+
*
|
|
6
|
+
* Dispatch left for `engine/dispatch.mjs` and settlement for
|
|
7
|
+
* `engine/settle.mjs`; what stays is the policy that chooses between them.
|
|
8
|
+
* `finalizeClosedJobs` is the whole of it in one function -- every terminal
|
|
9
|
+
* decision a node can reach passes through there.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
existsSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import {
|
|
16
|
+
PARKED,
|
|
17
|
+
SETTLED,
|
|
18
|
+
} from "./prompts.mjs";
|
|
19
|
+
import {
|
|
20
|
+
judgeReaskOutstanding,
|
|
21
|
+
} from "./judge-gate.mjs";
|
|
22
|
+
import {
|
|
23
|
+
judgeVerdictEvidence,
|
|
24
|
+
} from "../contract/review-modes.mjs";
|
|
25
|
+
import {
|
|
26
|
+
JUDGE_MAX_FAILURES,
|
|
27
|
+
applyJudgeProtocolFailure,
|
|
28
|
+
applyJudgeResult,
|
|
29
|
+
applyJudgeRound,
|
|
30
|
+
settleUnavailableJudge,
|
|
31
|
+
} from "./review.mjs";
|
|
32
|
+
import { routeRuntimeForState, routingBackoffActive, runtimeSnapshot } from "./failover.mjs";
|
|
33
|
+
import {
|
|
34
|
+
NON_FAILOVER_CODES,
|
|
35
|
+
buildRouting,
|
|
36
|
+
classifyTransition,
|
|
37
|
+
isRepairable,
|
|
38
|
+
networkBackoffAttempts,
|
|
39
|
+
networkTransition,
|
|
40
|
+
nodeDeadlineAt,
|
|
41
|
+
planRoute,
|
|
42
|
+
upsertTierExhaustionCandidate,
|
|
43
|
+
} from "./backoff.mjs";
|
|
44
|
+
import { exhaustedUntilOf } from "./runtime-discovery.mjs";
|
|
45
|
+
|
|
46
|
+
import { acquire as acquireLock } from "../run/lock.mjs";
|
|
47
|
+
import { parseDiscoveryResult } from "../contract/worker-result.mjs";
|
|
48
|
+
import { boundedUtf8, errorCode, errorMessage, excerpt } from "../util.mjs";
|
|
49
|
+
import { readJson, writeJsonAtomic } from "../run/store.mjs";
|
|
50
|
+
import { invocationAlive } from "./process.mjs";
|
|
51
|
+
import { operationNextState, providerReceipts, settleInvocation } from "../run/operations.mjs";
|
|
52
|
+
import { appendTransitionEvent, transition, writeNode } from "./state.mjs";
|
|
53
|
+
import { appendUsageRecord, invocationCost, invocationUsage, recordInvocationUsage } from "../run/usage.mjs";
|
|
54
|
+
import { attemptWorkspace } from "../repo/worktree.mjs";
|
|
55
|
+
import { executeControllerVerification } from "./verify.mjs";
|
|
56
|
+
import {
|
|
57
|
+
materializeAttemptResult,
|
|
58
|
+
readWorkerResultFile,
|
|
59
|
+
resolveWorkerResult,
|
|
60
|
+
workerResultPath,
|
|
61
|
+
} from "./result-file.mjs";
|
|
62
|
+
import { canReuseResultEvidence, checkResultMaterializationScope, checkWorkerScope, recordScopeFinding, sourceWorkerRuntime } from "./scope.mjs";
|
|
63
|
+
import { startJudge, startResultMaterialization } from "./dispatch.mjs";
|
|
64
|
+
import { raiseNodeAttention, settleDone } from "./settle.mjs";
|
|
65
|
+
import { applyRejection, applyVerificationFailure } from "./settle.mjs";
|
|
66
|
+
import { emitNodeAdvisories } from "./notify-queue.mjs";
|
|
67
|
+
|
|
68
|
+
/** @typedef {import("../repo/integrate.mjs").IntegrationResult} IntegrationResult */
|
|
69
|
+
/** @typedef {import("./backoff.mjs").Transition} Transition */
|
|
70
|
+
|
|
71
|
+
/** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
72
|
+
/** @typedef {import("../contract/index.mjs").ValidatedNode} ValidatedNode */
|
|
73
|
+
/** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
|
|
74
|
+
/** @typedef {import("../contract/index.mjs").RuntimeSnapshot} RuntimeSnapshot */
|
|
75
|
+
/** @typedef {import("../contract/index.mjs").RunMetadata} RunMetadata */
|
|
76
|
+
/** @typedef {import("../contract/index.mjs").SourceIdentity} SourceIdentity */
|
|
77
|
+
/** @typedef {import("../contract/index.mjs").EventRecord} EventRecord */
|
|
78
|
+
/** @typedef {import("../contract/index.mjs").Usage} Usage */
|
|
79
|
+
/** @typedef {import("../contract/index.mjs").GateResult} GateResult */
|
|
80
|
+
/** @typedef {import("../contract/index.mjs").SnapshotError} SnapshotError */
|
|
81
|
+
/** @typedef {import("../contract/index.mjs").BoundedScope} BoundedScope */
|
|
82
|
+
/** @typedef {import("../repo/workspace.mjs").WorkspaceSnapshot} WorkspaceSnapshot */
|
|
83
|
+
/** @typedef {import("../run/lock.mjs").LockRecord} LockRecord */
|
|
84
|
+
/** @typedef {ReturnType<typeof acquireLock>} LockHandle */
|
|
85
|
+
/** @typedef {import("../harnesses/index.mjs").HarnessRuntime} HarnessRuntime */
|
|
86
|
+
/** @typedef {import("../harnesses/index.mjs").ProbeResult} ProbeResult */
|
|
87
|
+
/** @typedef {import("../harnesses/index.mjs").ProviderEnvelope} ProviderEnvelope */
|
|
88
|
+
/** @typedef {ProviderEnvelope & {costProvenance?: "priced"}} PricedEnvelope */
|
|
89
|
+
/** @typedef {import("../contract/verification.mjs").VerificationAttempt} VerificationAttempt */
|
|
90
|
+
/** @typedef {import("../contract/verification.mjs").VerificationAttemptResult} VerificationAttemptResult */
|
|
91
|
+
/** @typedef {import("../contract/verification.mjs").VerificationResult} VerificationResult */
|
|
92
|
+
/** @typedef {import("../repo/workspace.mjs").ScopeComparison} ScopeComparison */
|
|
93
|
+
/** @typedef {import("../contract/worker-result.mjs").WorkerResult} WorkerResult */
|
|
94
|
+
/** @typedef {import("../campaign/index.mjs").Campaign} Campaign */
|
|
95
|
+
/** @typedef {{path: string, campaign: Campaign}} CampaignRef */
|
|
96
|
+
/** @typedef {import("./prompts.mjs").JudgeVerdict} JudgeVerdict */
|
|
97
|
+
/** @typedef {import("./process.mjs").PathSet} PathSet */
|
|
98
|
+
/** @typedef {import("./process.mjs").Invocation} Invocation */
|
|
99
|
+
/** @typedef {import("./process.mjs").InvocationProbe} InvocationProbe */
|
|
100
|
+
/** @typedef {import("./process.mjs").Job} Job */
|
|
101
|
+
/** @typedef {{kind: "adopted"|"rejudge"|"restart"|"reconciled"|"exhausted"|"stalled", phase?: "worker"|"judge", result?: unknown, usage?: Usage, costUsd?: number|null, costProvenance?: "priced", exhaustedUntil?: string|null, error?: {code: string, message: string}|null, invocationId?: string, reason?: string}} RecoveryOutcome */
|
|
102
|
+
/** @typedef {import("node:child_process").ChildProcess & {bootstrapNonce?: string, bootstrapProcessStartToken?: string|null}} DetachedChild */
|
|
103
|
+
/** @typedef {{status?: string, nonce?: string, pid?: number, processStartToken?: string|null, holderId?: string, generation?: number, error?: unknown, runDir?: string}} BootstrapRecord */
|
|
104
|
+
|
|
105
|
+
/** @typedef {import("node:child_process").ChildProcess} ChildProcess */
|
|
106
|
+
|
|
107
|
+
/** @param {string} runDir */
|
|
108
|
+
export function assertRunMutable(runDir) {
|
|
109
|
+
if (!existsSync(join(runDir, "incident-freeze.json"))) return;
|
|
110
|
+
const error = /** @type {Error & {code: string}} */ (new Error("incident_frozen: run is frozen as immutable incident evidence"));
|
|
111
|
+
error.code = "incident_frozen";
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Derive the run-level liveness state from the node snapshots alone.
|
|
117
|
+
* A run awaiting a provider backoff is persisted as a pending node whose
|
|
118
|
+
* routing override carries a future backoffUntil, so that shape - and only
|
|
119
|
+
* that shape - derives paused_quota. A snapshot where every node is already
|
|
120
|
+
* terminal is never paused_quota: terminal exhaustion with no remaining
|
|
121
|
+
* failover route reports failed instead.
|
|
122
|
+
*
|
|
123
|
+
* @param {Map<string, NodeSnapshot>} states
|
|
124
|
+
* @returns {string}
|
|
125
|
+
*/
|
|
126
|
+
export function livenessState(states) {
|
|
127
|
+
const all = [...states.values()];
|
|
128
|
+
const running = all.filter((state) => state.status === "running");
|
|
129
|
+
if (running.some((state) => state.phase !== "judge")) return "running";
|
|
130
|
+
if (running.length > 0) return "waiting_gate";
|
|
131
|
+
if (all.some((state) => state.status === "blocked")) return "blocked";
|
|
132
|
+
if (all.length > 0 && all.every((state) => state.status === "done")) return "done";
|
|
133
|
+
if (all.length > 0 && all.every((state) => SETTLED.has(state.status))) return "failed";
|
|
134
|
+
if (all.some((state) => state.status === "pending" && routingBackoffActive(state, state.phase))) return "paused_quota";
|
|
135
|
+
return "running";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The projector error code of a terminal node event. A `blocked_context`
|
|
140
|
+
* worker result is persisted by the runner as error code `context_missing`;
|
|
141
|
+
* it is classified as the blocking question it is, so the notify template
|
|
142
|
+
* carries the worker's own terminal status code.
|
|
143
|
+
*
|
|
144
|
+
* @param {NodeSnapshot} state
|
|
145
|
+
* @returns {string|null}
|
|
146
|
+
*/
|
|
147
|
+
export function terminalErrorCode(state) {
|
|
148
|
+
const result = state.result && typeof state.result === "object" ? /** @type {{status?: unknown}} */ (state.result) : {};
|
|
149
|
+
if (result.status === "blocked_context") return "blocked_context";
|
|
150
|
+
const error = state.error && typeof state.error === "object" ? /** @type {{code?: unknown}} */ (state.error) : {};
|
|
151
|
+
return typeof error.code === "string" && error.code ? error.code : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Error codes that earn exactly one automatic retry before parking. */
|
|
155
|
+
export const AUTO_RETRY_CODES = new Set(["judge_unavailable", "provider_error", "stall_timeout", "wall_clock_timeout"]);
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Timeout codes earn their automatic retry only when phase 5b sealed work
|
|
159
|
+
* before the kill: the seal is what the next attempt is cut from, so an empty
|
|
160
|
+
* one means there is nothing worth re-dispatching and the node parks.
|
|
161
|
+
*/
|
|
162
|
+
const AUTO_RETRY_TIMEOUT_CODES = new Set(["stall_timeout", "wall_clock_timeout"]);
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* @param {string} runDir
|
|
166
|
+
* @returns {Record<string, unknown>}
|
|
167
|
+
*/
|
|
168
|
+
function autoRetryLedgerMetadata(runDir) {
|
|
169
|
+
/** @type {Record<string, unknown>} */
|
|
170
|
+
let metadata;
|
|
171
|
+
try {
|
|
172
|
+
metadata = readJson(join(runDir, "run.json"));
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (errorCode(error) !== "ENOENT") throw error;
|
|
175
|
+
metadata = {};
|
|
176
|
+
}
|
|
177
|
+
return metadata;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Whether this node already spent its one automatic retry. The record lives in
|
|
182
|
+
* run.json, not in controller memory, so a restarted controller cannot grant a
|
|
183
|
+
* fresh one.
|
|
184
|
+
*
|
|
185
|
+
* @param {string} runDir @param {string} nodeId @returns {boolean}
|
|
186
|
+
*/
|
|
187
|
+
export function autoRetryConsumed(runDir, nodeId) {
|
|
188
|
+
const metadata = autoRetryLedgerMetadata(runDir);
|
|
189
|
+
const autoRetries = metadata.autoRetries;
|
|
190
|
+
return Boolean(autoRetries && typeof autoRetries === "object" && Object.hasOwn(autoRetries, nodeId));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* @param {string} runDir @param {string} nodeId @param {string} code
|
|
195
|
+
*/
|
|
196
|
+
function consumeAutoRetry(runDir, nodeId, code) {
|
|
197
|
+
const metadata = autoRetryLedgerMetadata(runDir);
|
|
198
|
+
const existing = metadata.autoRetries && typeof metadata.autoRetries === "object" ? metadata.autoRetries : {};
|
|
199
|
+
const autoRetries = { ...existing, [nodeId]: { code, at: new Date().toISOString() } };
|
|
200
|
+
writeJsonAtomic(join(runDir, "run.json"), { ...metadata, autoRetries });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* An error that announces a reset takes no automatic retry: waiting for the
|
|
205
|
+
* named instant is cheaper than spending the one retry immediately.
|
|
206
|
+
*
|
|
207
|
+
* @param {NodeSnapshot} state @returns {boolean}
|
|
208
|
+
*/
|
|
209
|
+
function carriesQuotaReset(state) {
|
|
210
|
+
if (state.error?.exhaustedUntil !== undefined && state.error.exhaustedUntil !== null) return true;
|
|
211
|
+
const candidates = state.routing?.tierExhaustion?.candidates ?? [];
|
|
212
|
+
return candidates.some((candidate) => candidate.exhaustedUntil !== null && candidate.exhaustedUntil !== undefined);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* A phase 5b attempt seal persisted on the worktree, or false while no seal
|
|
217
|
+
* exists. `sealBeforeTerminate` writes `sealedSha` only for a non-empty seal,
|
|
218
|
+
* so an attempt that timed out with nothing to preserve parks exactly as it
|
|
219
|
+
* did before the hook existed.
|
|
220
|
+
*
|
|
221
|
+
* @param {NodeSnapshot} state @returns {boolean}
|
|
222
|
+
*/
|
|
223
|
+
function attemptSealIsNonEmpty(state) {
|
|
224
|
+
const worktree = /** @type {Record<string, unknown>} */ (state.worktree ?? {});
|
|
225
|
+
return typeof worktree.sealedSha === "string" && worktree.sealedSha.length > 0;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Give a parked node its one automatic retry on the runtime it already ran on:
|
|
230
|
+
* transition it back to pending in the same role, record the `auto_retry`
|
|
231
|
+
* event, and consume the durable flag. It never touches the gate revision
|
|
232
|
+
* counter or the failover hop counter, and it leaves routing untouched so the
|
|
233
|
+
* next dispatch lands on the same runtime.
|
|
234
|
+
*
|
|
235
|
+
* @param {string} runDir @param {NodeSnapshot} state @param {string|undefined} code @param {LockHandle|null} lock
|
|
236
|
+
* @returns {boolean} whether the node was re-opened
|
|
237
|
+
*/
|
|
238
|
+
export function autoRetryNode(runDir, state, code, lock) {
|
|
239
|
+
const failureCode = code ?? state.error?.code;
|
|
240
|
+
if (typeof failureCode !== "string" || !AUTO_RETRY_CODES.has(failureCode)) return false;
|
|
241
|
+
if (autoRetryConsumed(runDir, state.id)) return false;
|
|
242
|
+
if (carriesQuotaReset(state)) return false;
|
|
243
|
+
if (AUTO_RETRY_TIMEOUT_CODES.has(failureCode) && !attemptSealIsNonEmpty(state)) return false;
|
|
244
|
+
consumeAutoRetry(runDir, state.id, failureCode);
|
|
245
|
+
const role = state.phase === "judge" ? "judge" : "worker";
|
|
246
|
+
const from = state.status;
|
|
247
|
+
transition(runDir, state, "pending", { phase: role, error: null, blockedBy: [] }, lock);
|
|
248
|
+
appendTransitionEvent(runDir, state, from, "pending", { type: "auto_retry", errorCode: failureCode, role }, lock);
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Re-open every node that just parked and still has its automatic retry. A
|
|
254
|
+
* node already parked when this controller started is left for the operator's
|
|
255
|
+
* resume to route — an explicit `--node` hold must not be overridden by an
|
|
256
|
+
* automatic retry. This is also the path that reaches `judge_unavailable`,
|
|
257
|
+
* which review settles directly rather than by a continuing failover decision.
|
|
258
|
+
*
|
|
259
|
+
* @param {ValidatedContract} contract @param {string} runDir @param {Map<string, NodeSnapshot>} states @param {LockHandle|null} lock @param {Set<string>} [previouslyParked]
|
|
260
|
+
*/
|
|
261
|
+
export function autoRetryParkedNodes(contract, runDir, states, lock, previouslyParked = new Set()) {
|
|
262
|
+
for (const node of contract.nodes) {
|
|
263
|
+
const state = states.get(node.id);
|
|
264
|
+
if (!state || !PARKED.has(state.status) || previouslyParked.has(node.id)) continue;
|
|
265
|
+
autoRetryNode(runDir, state, undefined, lock);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Remove this generation's tier-exhaustion evidence from a node that reached
|
|
271
|
+
* an outcome the tier-routing feature does not drive. The generation counter
|
|
272
|
+
* is deliberately untouched: it is append-only for the node's life, so a later
|
|
273
|
+
* exhaustion can never silently fall back to an older generation's invocation
|
|
274
|
+
* set. The field is removed entirely, never left as a tombstone; a later tier
|
|
275
|
+
* hop rebuilds it through the same upsert rule.
|
|
276
|
+
*
|
|
277
|
+
* @param {NodeSnapshot} state
|
|
278
|
+
*/
|
|
279
|
+
function clearTierExhaustion(state) {
|
|
280
|
+
if (!state.routing || state.routing.tierExhaustion === undefined) return;
|
|
281
|
+
const routing = { ...state.routing };
|
|
282
|
+
delete routing.tierExhaustion;
|
|
283
|
+
state.routing = /** @type {NodeSnapshot["routing"]} */ (routing);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* @param {ValidatedContract} contract
|
|
288
|
+
* @param {string} runDir
|
|
289
|
+
* @param {Map<string, NodeSnapshot>} states
|
|
290
|
+
* @param {Map<string, Job>} running
|
|
291
|
+
* @param {LockHandle} lock
|
|
292
|
+
* @param {string} campaignPath
|
|
293
|
+
* @returns {Promise<void>}
|
|
294
|
+
*/
|
|
295
|
+
export async function finalizeClosedJobs(contract, runDir, states, running, lock, campaignPath) {
|
|
296
|
+
// Advisory spend lines are checked every tick, before outcome handling: a
|
|
297
|
+
// crossing must be visible while the spend is happening, not only when the
|
|
298
|
+
// run is already over. The check never stops or transitions a node.
|
|
299
|
+
await emitNodeAdvisories(contract, runDir, states);
|
|
300
|
+
for (const [nodeId, job] of running) {
|
|
301
|
+
if (!job.closed || invocationAlive(job.invocation)) continue;
|
|
302
|
+
running.delete(nodeId);
|
|
303
|
+
const state = states.get(nodeId);
|
|
304
|
+
if (!state) continue;
|
|
305
|
+
// Usage is extracted and persisted BEFORE any outcome-specific handling:
|
|
306
|
+
// a scope-gate failure, a killed process, or an invalid stream must never
|
|
307
|
+
// lose the tokens its invocation already spent (the 2026-08 incident
|
|
308
|
+
// persisted zero usage for 1.2M+ token workers on exactly this path).
|
|
309
|
+
if (SETTLED.has(state.status)) {
|
|
310
|
+
recordInvocationUsage(job, { accumulate: false });
|
|
311
|
+
writeNode(runDir, state, lock);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
// The branch's onClose already persisted this invocation's usage and
|
|
315
|
+
// recomputed state.usage; this call only extracts the provider envelope
|
|
316
|
+
// (with transcript backfill) without accumulating a second time.
|
|
317
|
+
let envelope = recordInvocationUsage(job, { accumulate: false });
|
|
318
|
+
if (job.spawnError) {
|
|
319
|
+
settleInvocation(runDir, job.invocation, {
|
|
320
|
+
status: "failed",
|
|
321
|
+
error: { code: "spawn_error", message: job.spawnError.message },
|
|
322
|
+
reason: "provider did not start",
|
|
323
|
+
nextState: operationNextState(state),
|
|
324
|
+
});
|
|
325
|
+
clearTierExhaustion(state);
|
|
326
|
+
transition(runDir, state, "failed", { phase: job.phase, error: { code: "spawn_error", message: job.spawnError.message } }, lock);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
// The provider close evidence was already read by recordInvocationUsage
|
|
330
|
+
// above; run the scope gate before any settlement so a scope failure
|
|
331
|
+
// still persists the provider receipts and usage.
|
|
332
|
+
// Whether this is a completed attempt is decided before the scope gate:
|
|
333
|
+
// only a completed attempt may defer its scope verdict to after controller
|
|
334
|
+
// verification (TECH-SPEC lean, rule 1). Completed is exactly what the
|
|
335
|
+
// canonical result file says: a done envelope without that file, with a
|
|
336
|
+
// non-done result in it, or with an unparseable file is not accepted work,
|
|
337
|
+
// so it keeps the terminal unexpected_write it always had instead of
|
|
338
|
+
// deferring a verdict nothing will settle. The envelope's own result is
|
|
339
|
+
// materialized into the canonical file only after this gate.
|
|
340
|
+
/** @type {import("../contract/worker-result.mjs").WorkerResult|null} */
|
|
341
|
+
let adoptedWorkerResult = null;
|
|
342
|
+
/** @type {Error|undefined} */
|
|
343
|
+
let workerResultError;
|
|
344
|
+
if (job.phase === "worker" && !job.resultMaterialization) {
|
|
345
|
+
try {
|
|
346
|
+
materializeAttemptResult(runDir, state, job.node);
|
|
347
|
+
adoptedWorkerResult = readWorkerResultFile(runDir, job.node.id);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
// A present-but-invalid canonical file is not completed work: the
|
|
350
|
+
// invalid-result branch below decides, exactly as it did before the
|
|
351
|
+
// advisory scope verdict existed.
|
|
352
|
+
workerResultError = /** @type {Error} */ (error);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const completedAttempt = job.phase === "worker" && !job.resultMaterialization
|
|
356
|
+
&& adoptedWorkerResult?.status === "done";
|
|
357
|
+
if (job.phase === "worker") {
|
|
358
|
+
const scopeOk = job.resultMaterialization
|
|
359
|
+
? checkResultMaterializationScope(contract, runDir, job, lock)
|
|
360
|
+
: checkWorkerScope(contract, runDir, job, lock, { deferViolation: completedAttempt });
|
|
361
|
+
if (!scopeOk) {
|
|
362
|
+
settleInvocation(runDir, job.invocation, {
|
|
363
|
+
status: "failed",
|
|
364
|
+
usage: job.invocation.usage ?? null,
|
|
365
|
+
costUsd: typeof job.invocation.costUsd === "number" ? job.invocation.costUsd : null,
|
|
366
|
+
receipts: providerReceipts(envelope),
|
|
367
|
+
error: state.error ?? { code: "scope_check_failed", message: "worker scope check failed" },
|
|
368
|
+
nextState: operationNextState(state),
|
|
369
|
+
});
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
state.invocations = (state.invocations ?? []).map((invocation) => invocation.id === job.invocation.id
|
|
374
|
+
? {
|
|
375
|
+
...invocation,
|
|
376
|
+
continuationId: envelope.continuationId ?? invocation.continuationId ?? null,
|
|
377
|
+
usage: envelope.usage,
|
|
378
|
+
costUsd: envelope.costUsd,
|
|
379
|
+
costProvenance: envelope.costProvenance,
|
|
380
|
+
}
|
|
381
|
+
: invocation);
|
|
382
|
+
settleInvocation(runDir, job.invocation, {
|
|
383
|
+
status: envelope.status,
|
|
384
|
+
usage: envelope.usage ?? null,
|
|
385
|
+
costUsd: typeof envelope.costUsd === "number" ? envelope.costUsd : null,
|
|
386
|
+
structuredResult: Boolean(envelope.result),
|
|
387
|
+
result: envelope.result ?? null,
|
|
388
|
+
receipts: providerReceipts(envelope),
|
|
389
|
+
error: envelope.error ?? null,
|
|
390
|
+
nextState: operationNextState(state),
|
|
391
|
+
});
|
|
392
|
+
appendUsageRecord(runDir, state.invocations.find((invocation) => invocation.id === job.invocation.id));
|
|
393
|
+
state.usage = invocationUsage(state);
|
|
394
|
+
state.costUsd = invocationCost(state);
|
|
395
|
+
// A closed worker whose canonical result file is valid and whose scope
|
|
396
|
+
// passed is completed work, no matter what the provider envelope or the
|
|
397
|
+
// exit code said. The durable file was read above, before the scope gate,
|
|
398
|
+
// so the gate knows whether this attempt may defer its verdict; the file
|
|
399
|
+
// is adopted before the exhaustion and failure branches and enters the
|
|
400
|
+
// normal verification/gate flow with
|
|
401
|
+
// that result. A present-but-invalid file still fails exactly as the done
|
|
402
|
+
// path fails it today.
|
|
403
|
+
if (workerResultError) {
|
|
404
|
+
if (envelope.status === "done") {
|
|
405
|
+
await applyInvalidWorkerResult(contract, job.node, state, runDir, running, lock, errorMessage(workerResultError), states, campaignPath);
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
adoptedWorkerResult = null;
|
|
409
|
+
}
|
|
410
|
+
if (adoptedWorkerResult) {
|
|
411
|
+
settleInvocation(runDir, job.invocation, {
|
|
412
|
+
status: "done",
|
|
413
|
+
usage: envelope.usage ?? null,
|
|
414
|
+
costUsd: typeof envelope.costUsd === "number" ? envelope.costUsd : null,
|
|
415
|
+
structuredResult: true,
|
|
416
|
+
result: adoptedWorkerResult,
|
|
417
|
+
receipts: providerReceipts(envelope),
|
|
418
|
+
error: null,
|
|
419
|
+
nextState: operationNextState(state),
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
if (!adoptedWorkerResult && envelope.status === "exhausted") {
|
|
423
|
+
handleProviderExhaustion(contract, runDir, job.node, state, /** @type {"worker"|"judge"} */ (job.phase), envelope, job.runtime.id, lock, states, campaignPath);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
// A judge provider that failed outright (its turn died, its tool host was
|
|
427
|
+
// gone) is a provider failure, never a verdict: the gate cannot adopt a
|
|
428
|
+
// result the judge could not ground in inspection. Re-dispatch the judge
|
|
429
|
+
// once on the same routing, then settle by review mode so a judge failure
|
|
430
|
+
// is surfaced, never silently settled. A stream that never reached its
|
|
431
|
+
// terminal envelope is a protocol defect instead and takes the bounded
|
|
432
|
+
// re-ask below.
|
|
433
|
+
if (job.phase === "judge" && envelope.status === "failed" && envelope.error?.code !== "incomplete_stream") {
|
|
434
|
+
// A judge that lost its socket is not an unavailable judge. It buys the
|
|
435
|
+
// same bounded network waits a worker does, on the runtime it already
|
|
436
|
+
// warmed, and spends none of the one re-dispatch counted below.
|
|
437
|
+
const network = networkTransition(contract, job.node, state, "judge", envelope, job.exitCode);
|
|
438
|
+
if (network && handleProviderExhaustion(contract, runDir, job.node, state, "judge", envelope, job.runtime.id, lock, states, campaignPath, network)) continue;
|
|
439
|
+
clearTierExhaustion(state);
|
|
440
|
+
// The provider died on the bounded re-ask itself, so the one permitted
|
|
441
|
+
// re-ask is spent: settle by review mode here rather than dispatch a
|
|
442
|
+
// third judge invocation behind a fresh failure count.
|
|
443
|
+
// The provider died on the bounded re-ask itself, so the one permitted
|
|
444
|
+
// re-ask is spent: settle by review mode here rather than dispatch a
|
|
445
|
+
// third judge invocation behind a fresh failure count.
|
|
446
|
+
if (judgeReaskOutstanding(state)) {
|
|
447
|
+
await applyJudgeProtocolFailure(contract, job.node, state, runDir, running, lock, states, campaignPath, envelope.error?.message ?? "judge provider failed");
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
state.judgeFailures = (state.judgeFailures ?? 0) + 1;
|
|
451
|
+
if (state.judgeFailures < JUDGE_MAX_FAILURES) {
|
|
452
|
+
writeNode(runDir, state, lock);
|
|
453
|
+
await applyJudgeRound(await startJudge(contract, job.node, state, runDir, running, state.result, lock, states, campaignPath),
|
|
454
|
+
contract, job.node, state, runDir, running, lock, states, campaignPath, state.result);
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
await settleUnavailableJudge(contract, job.node, state, runDir, lock, states, campaignPath, envelope.error?.message ?? "judge provider failed");
|
|
458
|
+
continue;
|
|
459
|
+
} // Whatever else this invocation produced, it is not exactly one usable
|
|
460
|
+
// verdict: no verdict at all, several of them in separate agent messages,
|
|
461
|
+
// an unparseable one, a stream cut off before its terminal envelope, or a
|
|
462
|
+
// phase killed on its wall clock. One bounded re-ask, then the review mode
|
|
463
|
+
// decides — advisory completes, blocking enters attention with the work
|
|
464
|
+
// preserved so a retry in place can re-judge it.
|
|
465
|
+
if (job.phase === "judge") {
|
|
466
|
+
const evidence = judgeVerdictEvidence(envelope);
|
|
467
|
+
if (!evidence.ok) {
|
|
468
|
+
const network = networkTransition(contract, job.node, state, "judge", envelope, job.exitCode);
|
|
469
|
+
if (network && handleProviderExhaustion(contract, runDir, job.node, state, "judge", envelope, job.runtime.id, lock, states, campaignPath, network)) continue;
|
|
470
|
+
clearTierExhaustion(state);
|
|
471
|
+
await applyJudgeProtocolFailure(contract, job.node, state, runDir, running, lock, states, campaignPath, evidence.reason);
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
clearTierExhaustion(state);
|
|
475
|
+
await applyJudgeResult(contract, job.node, state, evidence.result, runDir, lock, running, states, campaignPath);
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
// An empty final message is a missing worker result, not a no-op worker:
|
|
479
|
+
// when the canonical file exists it is authoritative (the message is
|
|
480
|
+
// redundant), and a worker that completed without it gets exactly one
|
|
481
|
+
// result-only continuation before the node fails.
|
|
482
|
+
if (job.phase === "worker") materializeAttemptResult(runDir, state, job.node);
|
|
483
|
+
const fileBackedNoOp = job.phase === "worker" && envelope.status === "no-op" && existsSync(workerResultPath(runDir, job.node.id));
|
|
484
|
+
if (!adoptedWorkerResult && job.phase === "worker" && envelope.status === "no-op" && !fileBackedNoOp) {
|
|
485
|
+
if (job.resultMaterialization) {
|
|
486
|
+
clearTierExhaustion(state);
|
|
487
|
+
transition(runDir, state, "failed", {
|
|
488
|
+
phase: "worker",
|
|
489
|
+
error: { code: "missing_worker_result", message: "result-only materialization produced no canonical worker result" },
|
|
490
|
+
}, lock);
|
|
491
|
+
} else {
|
|
492
|
+
startResultMaterialization(
|
|
493
|
+
contract,
|
|
494
|
+
job.node,
|
|
495
|
+
state,
|
|
496
|
+
runDir,
|
|
497
|
+
running,
|
|
498
|
+
job.invocation,
|
|
499
|
+
job.runtime,
|
|
500
|
+
envelope.continuationId ?? job.invocation.continuationId ?? null,
|
|
501
|
+
lock,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
if (!adoptedWorkerResult && envelope.status !== "done" && !fileBackedNoOp) {
|
|
507
|
+
// Auto-retry comes before any failover decision, on the runtime the node
|
|
508
|
+
// already ran on; only the second failure spends the failover edge.
|
|
509
|
+
if (autoRetryNode(runDir, state, envelope.error?.code ?? state.error?.code, lock)) continue;
|
|
510
|
+
// A dropped connection is not a failed task: let the node wait on the
|
|
511
|
+
// runtime it already warmed before it spends a failover hop on it. When
|
|
512
|
+
// the waits and the edges are both spent, the failure is reported here.
|
|
513
|
+
const role = /** @type {"worker"|"judge"} */ (job.phase);
|
|
514
|
+
const network = networkTransition(contract, job.node, state, role, envelope, job.exitCode);
|
|
515
|
+
if (network && handleProviderExhaustion(contract, runDir, job.node, state, role, envelope, job.runtime.id, lock, states, campaignPath, network)) continue;
|
|
516
|
+
clearTierExhaustion(state);
|
|
517
|
+
transition(runDir, state, envelope.status, {
|
|
518
|
+
phase: job.phase,
|
|
519
|
+
result: state.result,
|
|
520
|
+
error: envelope.error,
|
|
521
|
+
usage: state.usage,
|
|
522
|
+
}, lock);
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
if (job.phase === "worker") {
|
|
526
|
+
/** @type {WorkerResult} */
|
|
527
|
+
let workerResult;
|
|
528
|
+
try {
|
|
529
|
+
workerResult = resolveWorkerResult(runDir, job.node, envelope.result);
|
|
530
|
+
} catch (error) {
|
|
531
|
+
if (job.resultMaterialization) {
|
|
532
|
+
clearTierExhaustion(state);
|
|
533
|
+
transition(runDir, state, "failed", {
|
|
534
|
+
phase: "worker",
|
|
535
|
+
error: { code: "missing_worker_result", message: `result-only materialization did not produce a valid canonical worker result: ${errorMessage(error)}` },
|
|
536
|
+
}, lock);
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
await applyInvalidWorkerResult(contract, job.node, state, runDir, running, lock, errorMessage(error), states, campaignPath);
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (job.node.taskPacket.mode === "discovery" && workerResult.status === "done") {
|
|
543
|
+
try {
|
|
544
|
+
parseDiscoveryResult(workerResult, attemptWorkspace(state) ?? contract.cwd);
|
|
545
|
+
} catch (error) {
|
|
546
|
+
await applyInvalidWorkerResult(contract, job.node, state, runDir, running, lock, errorMessage(error), states, campaignPath);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
state.result = workerResult;
|
|
551
|
+
if (workerResult.status === "blocked_context") {
|
|
552
|
+
clearTierExhaustion(state);
|
|
553
|
+
transition(runDir, state, "blocked", {
|
|
554
|
+
phase: "complete",
|
|
555
|
+
result: workerResult,
|
|
556
|
+
error: { code: "context_missing", message: workerResult.missingContext.join("; ") },
|
|
557
|
+
}, lock);
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
if (job.resultMaterialization && canReuseResultEvidence(state, job.node)) {
|
|
561
|
+
clearTierExhaustion(state);
|
|
562
|
+
if (job.node.gate.enabled) await applyJudgeResult(contract, job.node, state, state.gate, runDir, lock, running, states, campaignPath);
|
|
563
|
+
else await settleDone(contract, job.node, state, runDir, lock, states, campaignPath, { phase: "complete", result: workerResult, error: null });
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
clearTierExhaustion(state);
|
|
567
|
+
await executeControllerVerification(contract, runDir, job.node, state, lock);
|
|
568
|
+
if (!state.verification?.passed) {
|
|
569
|
+
applyVerificationFailure(contract, job.node, state, runDir, running, lock, states, campaignPath);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if (job.scopeViolation) recordScopeFinding(runDir, state, lock);
|
|
573
|
+
if (job.node.gate.enabled) {
|
|
574
|
+
await applyJudgeRound(await startJudge(contract, job.node, state, runDir, running, workerResult, lock, states, campaignPath),
|
|
575
|
+
contract, job.node, state, runDir, running, lock, states, campaignPath, workerResult);
|
|
576
|
+
}
|
|
577
|
+
else await settleDone(contract, job.node, state, runDir, lock, states, campaignPath, { phase: "complete", result: workerResult });
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Settle one unusable invocation onto the route it earned: a wait on the
|
|
585
|
+
* runtime the node already warmed, or a hop to the next one.
|
|
586
|
+
*
|
|
587
|
+
* Exhaustion is the common caller and classifies the transition itself; a
|
|
588
|
+
* caller that already knows why it is rerouting — a provider that could not
|
|
589
|
+
* hold the result protocol, say — passes its own.
|
|
590
|
+
*
|
|
591
|
+
* @param {ValidatedContract} contract
|
|
592
|
+
* @param {string} runDir
|
|
593
|
+
* @param {ValidatedNode} node
|
|
594
|
+
* @param {NodeSnapshot} state
|
|
595
|
+
* @param {"worker"|"judge"} role
|
|
596
|
+
* @param {PricedEnvelope} envelope
|
|
597
|
+
* @param {string|null} currentRuntime
|
|
598
|
+
* @param {LockHandle} lock
|
|
599
|
+
* @param {Map<string, NodeSnapshot>} states
|
|
600
|
+
* @param {string} campaignPath
|
|
601
|
+
* @param {import("./backoff.mjs").Transition} [precomputed]
|
|
602
|
+
* @returns {boolean} false when no edge remained and the node was left blocked by the caller
|
|
603
|
+
*/
|
|
604
|
+
export function handleProviderExhaustion(contract, runDir, node, state, role, envelope, currentRuntime, lock, states, campaignPath, precomputed) {
|
|
605
|
+
const error = envelope.error ?? { code: "provider_exhausted", message: "provider exhausted" };
|
|
606
|
+
if (NON_FAILOVER_CODES.has(error.code)) {
|
|
607
|
+
clearTierExhaustion(state);
|
|
608
|
+
transition(runDir, state, "exhausted", { phase: role, result: state.result, usage: state.usage, error }, lock);
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
const current = currentRuntime ?? state.runtime?.id ?? routeRuntimeForState(contract, node, state, role).id;
|
|
612
|
+
const now = Date.now();
|
|
613
|
+
// A quota reset inside the node's remaining window is cheaper than any hop,
|
|
614
|
+
// and so is one more wait on a dropped connection: neither spends a runtime,
|
|
615
|
+
// so planRoute charges neither a hop.
|
|
616
|
+
const schedule = precomputed ?? classifyTransition(envelope, {
|
|
617
|
+
deadline: nodeDeadlineAt(contract, node, state),
|
|
618
|
+
attempt: networkBackoffAttempts(state, role, state.revisions ?? 0),
|
|
619
|
+
now,
|
|
620
|
+
});
|
|
621
|
+
const exhaustedUntil = exhaustedUntilOf(envelope);
|
|
622
|
+
const plan = planRoute(contract, node, state, role, error, current, schedule, now, exhaustedUntil);
|
|
623
|
+
const status = envelope.status === "failed" ? "failed" : "exhausted";
|
|
624
|
+
if (plan.blocked) {
|
|
625
|
+
// A caller that classified the failure itself also owns what happens when
|
|
626
|
+
// there is nowhere left to route it: it has a better answer than another
|
|
627
|
+
// silent exhaustion — attention, or the provider's own error.
|
|
628
|
+
if (precomputed) return false;
|
|
629
|
+
const attention = plan.blocked.code === "runtime_tier_exhausted";
|
|
630
|
+
if (attention) {
|
|
631
|
+
// The blocking transition does not call buildRouting, so the evidence is
|
|
632
|
+
// written explicitly here, appending the final candidate before the
|
|
633
|
+
// block — with the whole routing state spread so the cycle counter and
|
|
634
|
+
// assignments survive.
|
|
635
|
+
const tierExhaustion = upsertTierExhaustionCandidate(state.routing?.tierExhaustion, role, current, exhaustedUntil);
|
|
636
|
+
transition(runDir, state, "blocked", {
|
|
637
|
+
phase: role,
|
|
638
|
+
result: state.result,
|
|
639
|
+
usage: state.usage,
|
|
640
|
+
routing: { ...(state.routing ?? {}), tierExhaustion },
|
|
641
|
+
error: { ...plan.blocked, ...(exhaustedUntil ? { exhaustedUntil } : {}) },
|
|
642
|
+
}, lock);
|
|
643
|
+
void raiseNodeAttention(campaignPath, runDir, state, plan.blocked.code).catch(() => {});
|
|
644
|
+
} else {
|
|
645
|
+
// Any other block reason is not this feature's evidence to keep.
|
|
646
|
+
clearTierExhaustion(state);
|
|
647
|
+
transition(runDir, state, "exhausted", {
|
|
648
|
+
phase: role,
|
|
649
|
+
result: state.result,
|
|
650
|
+
usage: state.usage,
|
|
651
|
+
error: { ...plan.blocked, ...(exhaustedUntil ? { exhaustedUntil } : {}) },
|
|
652
|
+
}, lock);
|
|
653
|
+
}
|
|
654
|
+
return true;
|
|
655
|
+
}
|
|
656
|
+
// A judge fallback that would land on the vendor of the worker it is
|
|
657
|
+
// reviewing is not caught at validation time — reachability depends on
|
|
658
|
+
// which worker runtime actually ran, which a static contract cannot know —
|
|
659
|
+
// so it is refused here, and the node is parked for a human rather than
|
|
660
|
+
// quietly arbitrated by the vendor it is supposed to check.
|
|
661
|
+
if (role === "judge" && plan.nextRuntime !== current) {
|
|
662
|
+
const workerRuntimeId = sourceWorkerRuntime(state);
|
|
663
|
+
const workerVendor = workerRuntimeId ? contract.runtimes[workerRuntimeId]?.vendor : undefined;
|
|
664
|
+
const judgeFallbackVendor = contract.runtimes[plan.nextRuntime]?.vendor;
|
|
665
|
+
if (workerVendor && judgeFallbackVendor && workerVendor === judgeFallbackVendor) {
|
|
666
|
+
if (precomputed) return false;
|
|
667
|
+
clearTierExhaustion(state);
|
|
668
|
+
transition(runDir, state, "blocked", {
|
|
669
|
+
phase: role,
|
|
670
|
+
result: state.result,
|
|
671
|
+
usage: state.usage,
|
|
672
|
+
error: {
|
|
673
|
+
code: "judge_fallback_vendor_conflict",
|
|
674
|
+
message: `judge fallback runtime ${plan.nextRuntime} shares vendor ${judgeFallbackVendor} with worker runtime ${workerRuntimeId}`,
|
|
675
|
+
},
|
|
676
|
+
}, lock);
|
|
677
|
+
return true;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
applyRoute(contract, runDir, state, lock, { role, error, current, plan, schedule, envelope, status, now });
|
|
681
|
+
return true;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Persist one planned route: the history entry that records what happened, the
|
|
686
|
+
* override that tells the scheduler where the phase goes next, and the pending
|
|
687
|
+
* transition that makes it schedulable once the backoff window closes.
|
|
688
|
+
*
|
|
689
|
+
* @param {ValidatedContract} contract
|
|
690
|
+
* @param {string} runDir
|
|
691
|
+
* @param {NodeSnapshot} state
|
|
692
|
+
* @param {LockHandle} lock
|
|
693
|
+
* @param {{role: "worker"|"judge", error: {code: string, message: string}, current: string, plan: ReturnType<typeof planRoute>, schedule: import("./backoff.mjs").Transition, envelope: PricedEnvelope, status: string, now: number}} options
|
|
694
|
+
*/
|
|
695
|
+
function applyRoute(contract, runDir, state, lock, { role, error, current, plan, schedule, envelope, status, now }) {
|
|
696
|
+
const { routing, override, errorCode } = buildRouting(state, {
|
|
697
|
+
role, error, current, plan, schedule, status, now, usage: envelope.usage, costUsd: envelope.costUsd, costProvenance: envelope.costProvenance,
|
|
698
|
+
});
|
|
699
|
+
transition(runDir, state, "pending", {
|
|
700
|
+
phase: role,
|
|
701
|
+
runtime: runtimeSnapshot(contract, plan.nextRuntime),
|
|
702
|
+
result: state.result,
|
|
703
|
+
error: null,
|
|
704
|
+
routing,
|
|
705
|
+
}, lock);
|
|
706
|
+
appendTransitionEvent(runDir, state, "pending", "pending", { role, status, currentRuntime: current, errorCode, override }, lock);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* A worker result that does not match the structured protocol gets one bounded
|
|
711
|
+
* repair on the same provider, then stops asking it.
|
|
712
|
+
*
|
|
713
|
+
* The first unparseable result is worth re-asking for: the task packet is
|
|
714
|
+
* intact and the model only has to re-emit its answer as the object it was
|
|
715
|
+
* told to return. A second one is evidence about the provider rather than the
|
|
716
|
+
* packet, so the node records a protocol_failure and takes its failover edge —
|
|
717
|
+
* and when no edge remains, it blocks and raises attention rather than filing
|
|
718
|
+
* a quiet exhaustion nobody reads.
|
|
719
|
+
*
|
|
720
|
+
* @param {ValidatedContract} contract @param {ValidatedNode} node @param {NodeSnapshot} state @param {string} runDir @param {Map<string, Job>|null} running @param {LockHandle} lock @param {string} message @param {Map<string, NodeSnapshot>} states @param {string} campaignPath
|
|
721
|
+
*/
|
|
722
|
+
export async function applyInvalidWorkerResult(contract, node, state, runDir, running, lock, message, states, campaignPath) {
|
|
723
|
+
clearTierExhaustion(state);
|
|
724
|
+
const verdict = /** @type {JudgeVerdict} */ ({
|
|
725
|
+
verdict: "fail",
|
|
726
|
+
maxSeverity: "critical",
|
|
727
|
+
summary: "worker result did not match the structured result protocol",
|
|
728
|
+
findings: [{
|
|
729
|
+
severity: "critical",
|
|
730
|
+
description: "the entire final message must be exactly the required JSON object: no markdown fences, no prose before or after it. Return it as the only content of the final message.",
|
|
731
|
+
evidence: boundedUtf8(message, 4 * 1024),
|
|
732
|
+
}],
|
|
733
|
+
});
|
|
734
|
+
if (isRepairable(node, state)) {
|
|
735
|
+
applyRejection(contract, node, state, runDir, running, lock, states, campaignPath, verdict, {
|
|
736
|
+
code: "invalid_worker_result",
|
|
737
|
+
label: "worker-result",
|
|
738
|
+
message,
|
|
739
|
+
});
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
const error = { code: "protocol_failure", message: excerpt(message) ?? "worker result did not match the structured result protocol" };
|
|
743
|
+
/** @type {ProviderEnvelope} */
|
|
744
|
+
const envelope = {
|
|
745
|
+
status: "failed",
|
|
746
|
+
result: null,
|
|
747
|
+
continuationId: null,
|
|
748
|
+
usage: { inputTokens: null, outputTokens: null, cacheReadInputTokens: null },
|
|
749
|
+
costUsd: null,
|
|
750
|
+
error,
|
|
751
|
+
};
|
|
752
|
+
// A failover hop is a fresh chance on a different provider, not a rejection
|
|
753
|
+
// of the work to fix: `state.gate` must not carry this synthetic verdict
|
|
754
|
+
// into the next dispatch, or the generic retry prompt would frame it as a
|
|
755
|
+
// quality gate rejection instead of a plain new attempt.
|
|
756
|
+
const routed = handleProviderExhaustion(contract, runDir, node, state, "worker", envelope, state.runtime?.id ?? null, lock, states, campaignPath, { kind: "failover", reason: "protocol_failure" });
|
|
757
|
+
if (routed) {
|
|
758
|
+
process.stdout.write(`[worker-result] ${node.id} protocol failure · failing over\n`);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
transition(runDir, state, "blocked", { phase: "worker", gate: verdict, result: state.result, usage: state.usage, error }, lock);
|
|
762
|
+
await raiseNodeAttention(campaignPath, runDir, state, "protocol_failure");
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Run-owned worker-result sidecar. It lives in its own directory because every
|
|
767
|
+
* `.json` directly under `nodes/` is a validated node snapshot — status,
|
|
768
|
+
* report, campaign summary, and signal readers reject or miscount anything
|
|
769
|
+
* else there.
|
|
770
|
+
*
|
|
771
|
+
* @param {string} runDir @param {string} nodeId @returns {string} */
|
|
772
|
+
|