faberun 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/bin/faberun.mjs +25 -0
  4. package/integrations/claude-code/statusline-bench.sh +42 -0
  5. package/integrations/claude-code/statusline.sh +80 -0
  6. package/package.json +33 -0
  7. package/skills/faberun/SKILL.md +24 -0
  8. package/skills/faberun/references/contract.md +380 -0
  9. package/skills/faberun/references/engineering.md +29 -0
  10. package/skills/faberun/references/handoffs.md +26 -0
  11. package/skills/faberun/references/operations.md +184 -0
  12. package/skills/faberun/references/rules.md +35 -0
  13. package/skills/faberun/references/workflow.md +23 -0
  14. package/skills/init-agentkit/SKILL.md +108 -0
  15. package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
  16. package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
  17. package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
  18. package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
  19. package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
  20. package/skills/init-agentkit/templates/AGENTS.md +110 -0
  21. package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
  22. package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
  23. package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
  24. package/skills/init-agentkit/templates/docs/VISION.md +33 -0
  25. package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
  26. package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
  27. package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
  28. package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
  29. package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
  30. package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
  31. package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
  32. package/src/campaign/brief.mjs +394 -0
  33. package/src/campaign/chain.mjs +555 -0
  34. package/src/campaign/handoff.mjs +516 -0
  35. package/src/campaign/index.mjs +300 -0
  36. package/src/campaign/journal.mjs +347 -0
  37. package/src/campaign/layout.mjs +51 -0
  38. package/src/campaign/metrics-evals.mjs +25 -0
  39. package/src/campaign/metrics.mjs +517 -0
  40. package/src/campaign/projection.mjs +250 -0
  41. package/src/campaign/record.mjs +102 -0
  42. package/src/campaign/unpark.mjs +56 -0
  43. package/src/cli/brand.mjs +205 -0
  44. package/src/cli/campaign.mjs +730 -0
  45. package/src/cli/contract.mjs +67 -0
  46. package/src/cli/init.mjs +170 -0
  47. package/src/cli/launch.mjs +239 -0
  48. package/src/cli/seat.mjs +139 -0
  49. package/src/cli/setup.mjs +294 -0
  50. package/src/cli/skills.mjs +105 -0
  51. package/src/cli/update.mjs +216 -0
  52. package/src/cli.mjs +525 -0
  53. package/src/contract/articles.mjs +12 -0
  54. package/src/contract/assert.mjs +162 -0
  55. package/src/contract/definition-of-done.mjs +97 -0
  56. package/src/contract/final-verification.mjs +96 -0
  57. package/src/contract/index.mjs +641 -0
  58. package/src/contract/judge-envelope.mjs +25 -0
  59. package/src/contract/review-modes.mjs +151 -0
  60. package/src/contract/runtime.mjs +204 -0
  61. package/src/contract/schema-version.mjs +25 -0
  62. package/src/contract/scope-findings.mjs +77 -0
  63. package/src/contract/snapshot.mjs +639 -0
  64. package/src/contract/task-packet.mjs +495 -0
  65. package/src/contract/untrusted.mjs +75 -0
  66. package/src/contract/verification.mjs +185 -0
  67. package/src/contract/worker-result.mjs +138 -0
  68. package/src/engine/assignment.mjs +63 -0
  69. package/src/engine/backoff.mjs +492 -0
  70. package/src/engine/bulk-read.mjs +361 -0
  71. package/src/engine/cancel.mjs +177 -0
  72. package/src/engine/detach.mjs +101 -0
  73. package/src/engine/dispatch.mjs +752 -0
  74. package/src/engine/failover.mjs +192 -0
  75. package/src/engine/gate.mjs +183 -0
  76. package/src/engine/judge-gate.mjs +517 -0
  77. package/src/engine/lifecycle.mjs +772 -0
  78. package/src/engine/live-preflight.mjs +299 -0
  79. package/src/engine/mutation.mjs +146 -0
  80. package/src/engine/notify-queue.mjs +327 -0
  81. package/src/engine/process-identity.mjs +72 -0
  82. package/src/engine/process.mjs +774 -0
  83. package/src/engine/prompts.mjs +289 -0
  84. package/src/engine/recover.mjs +300 -0
  85. package/src/engine/result-file.mjs +222 -0
  86. package/src/engine/resume.mjs +635 -0
  87. package/src/engine/retry.mjs +334 -0
  88. package/src/engine/review.mjs +228 -0
  89. package/src/engine/run-command.mjs +287 -0
  90. package/src/engine/run-identity.mjs +411 -0
  91. package/src/engine/runtime-discovery.mjs +235 -0
  92. package/src/engine/scheduler.mjs +526 -0
  93. package/src/engine/scope.mjs +378 -0
  94. package/src/engine/settle.mjs +207 -0
  95. package/src/engine/state.mjs +148 -0
  96. package/src/engine/supervise.mjs +713 -0
  97. package/src/engine/verify.mjs +167 -0
  98. package/src/harnesses/agy/index.mjs +62 -0
  99. package/src/harnesses/catalogue.mjs +509 -0
  100. package/src/harnesses/claude/index.mjs +90 -0
  101. package/src/harnesses/codex/index.mjs +87 -0
  102. package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
  103. package/src/harnesses/dsh/index.mjs +210 -0
  104. package/src/harnesses/dsh/runner.mjs +259 -0
  105. package/src/harnesses/exec-jsonl/index.mjs +788 -0
  106. package/src/harnesses/index.mjs +508 -0
  107. package/src/harnesses/protocol.mjs +531 -0
  108. package/src/harnesses/replay/bin.mjs +386 -0
  109. package/src/harnesses/replay/index.mjs +238 -0
  110. package/src/harnesses/zcode/index.mjs +276 -0
  111. package/src/host/config.mjs +87 -0
  112. package/src/host/home.mjs +149 -0
  113. package/src/host/package.mjs +23 -0
  114. package/src/host/preflight.mjs +520 -0
  115. package/src/host/tool-policy-decisions.mjs +341 -0
  116. package/src/host/tool-policy-hook.mjs +270 -0
  117. package/src/notify/index.mjs +359 -0
  118. package/src/notify/os-macos.mjs +81 -0
  119. package/src/repo/declared-paths.mjs +220 -0
  120. package/src/repo/integrate.mjs +546 -0
  121. package/src/repo/scope-closure.mjs +665 -0
  122. package/src/repo/signal-block.mjs +16 -0
  123. package/src/repo/signal.mjs +222 -0
  124. package/src/repo/source-identity.mjs +295 -0
  125. package/src/repo/workspace.mjs +557 -0
  126. package/src/repo/worktree.mjs +352 -0
  127. package/src/report/final.mjs +200 -0
  128. package/src/report/metrics-report.mjs +99 -0
  129. package/src/report/next.mjs +383 -0
  130. package/src/report/render.mjs +716 -0
  131. package/src/run/disk-gc.mjs +251 -0
  132. package/src/run/lock.mjs +329 -0
  133. package/src/run/node-store.mjs +62 -0
  134. package/src/run/operations.mjs +286 -0
  135. package/src/run/store.mjs +187 -0
  136. package/src/run/usage.mjs +337 -0
  137. package/src/seat/harnesses.mjs +83 -0
  138. package/src/seat/index.mjs +239 -0
  139. package/src/seat/tmux.mjs +208 -0
  140. package/src/util.mjs +0 -0
  141. package/src/web/api.mjs +371 -0
  142. package/src/web/boundary.mjs +88 -0
  143. package/src/web/index.html +299 -0
  144. package/src/web/server.mjs +552 -0
@@ -0,0 +1,492 @@
1
+ /**
2
+ * How a transient failure becomes a route.
3
+ *
4
+ * Two failures look alike from the controller's side — the invocation came
5
+ * back without a usable result — and they want opposite treatments. A dropped
6
+ * socket is the network's problem, and the runtime the node already warmed is
7
+ * still the cheapest place to finish: wait, then ask it again. A provider that
8
+ * cannot hold the result protocol is the provider's problem, and asking it a
9
+ * third time buys nothing: change provider, or stop and say so.
10
+ *
11
+ * Everything here is a pure decision. Persisting it — the routing history,
12
+ * the override, the node transition — stays in runner.mjs, so this module can
13
+ * be tested without a run directory, a lease, or a provider.
14
+ */
15
+ import { nextHop, nextSynthesizedRuntime, synthesizedChain } from "./failover.mjs";
16
+ import { nextSameTierRuntime } from "./runtime-discovery.mjs";
17
+
18
+ /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
19
+ /** @typedef {import("../contract/index.mjs").ValidatedNode} ValidatedNode */
20
+ /** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
21
+ /** @typedef {{code: string, message: string}} RouteError */
22
+ /** @typedef {"quota_reset"|"network_backoff"|"protocol_failure"|"provider"} TransitionReason */
23
+ /** @typedef {{kind: "reset", at: string, reason: TransitionReason}|{kind: "failover", reason: TransitionReason}} Transition */
24
+ /** @typedef {{runtimeId: string, exhaustedUntil: string|null}} TierExhaustionCandidate */
25
+ /** @typedef {{role: "worker"|"judge", candidates: TierExhaustionCandidate[]}} TierExhaustion */
26
+
27
+ /**
28
+ * A node whose failure is the run's own doing never fails over: it already
29
+ * spent the budget the edge would be charged against. The two attempt deadlines
30
+ * are deliberately absent — an attempt that timed out is worth the same
31
+ * failover hop any other transient failure is, once phase 2's single
32
+ * `auto_retry` on the runtime it already warmed is spent.
33
+ */
34
+ export const NON_FAILOVER_CODES = new Set([
35
+ "revision_cap", "verification_failed", "budget_exceeded",
36
+ "cancellation", "unexpected_write", "scope_violation",
37
+ "permission_denied", "permission_required", "authority_denied", "authority_required",
38
+ "authorization_required", "authentication_failed", "budget_attention", "cost_budget_exceeded",
39
+ "token_budget_exceeded", "rollout_budget_exhausted",
40
+ ]);
41
+
42
+ /** Network attempts on one runtime before the node gives up on it and hops. */
43
+ export const NETWORK_MAX_ATTEMPTS = 3;
44
+
45
+ /** First network wait, doubled per attempt. */
46
+ const NETWORK_BACKOFF_BASE_MS = 1_000;
47
+
48
+ /** No single network wait exceeds two minutes, however many attempts remain. */
49
+ export const NETWORK_BACKOFF_CAP_MS = 120_000;
50
+
51
+ /**
52
+ * Error codes a node owns rather than the network: its own wall-clock budget,
53
+ * its own progress rule, its own operator. Their messages routinely contain
54
+ * "timeout" or "reset", so they are excluded by code before any message is
55
+ * read — otherwise a node that died on its own deadline would be handed three
56
+ * more waits and die on it again.
57
+ *
58
+ * `progress_stalled` is the legacy spelling of `stall_timeout` — recovery now
59
+ * emits `stall_timeout`, so the two name one condition. It is kept recognized
60
+ * so a snapshot persisted before the unification is still classified as the
61
+ * node's own deadline rather than a dropped socket.
62
+ */
63
+ const NODE_DEADLINE_CODES = new Set([
64
+ "wall_clock_timeout", "progress_stalled", "stall_timeout", "progress_snapshot_invalid",
65
+ "token_budget_exceeded", "budget_exceeded", "cancellation",
66
+ ]);
67
+
68
+ /** Error codes providers and Node itself report for a broken connection. */
69
+ const NETWORK_CODES = new Set([
70
+ "ECONNRESET", "ECONNREFUSED", "ECONNABORTED", "EPIPE", "ETIMEDOUT", "ENOTFOUND",
71
+ "EAI_AGAIN", "ENETDOWN", "ENETUNREACH", "EHOSTUNREACH", "EPROTO", "UND_ERR_SOCKET",
72
+ "network_error", "provider_unreachable", "service_unavailable", "gateway_timeout",
73
+ // A streaming provider whose response ends without its terminator reports
74
+ // this and nothing else: dsh emitted `STREAM_CLOSED` / "SSE stream ended
75
+ // without [DONE]" mid-node, and without it here the node failed outright
76
+ // instead of buying the bounded same-runtime wait every other cut transport
77
+ // already gets.
78
+ "STREAM_CLOSED",
79
+ ]);
80
+
81
+ /**
82
+ * Message fragments that identify a transport failure when the provider
83
+ * reported no machine-readable code — the common case for a CLI that prints
84
+ * whatever its HTTP client said and exits.
85
+ */
86
+ const NETWORK_PATTERNS = [
87
+ /\becon(nreset|nrefused|naborted)\b/i,
88
+ /\be(timedout|notfound|ai_again|netunreach|hostunreach|pipe)\b/i,
89
+ /socket hang ?up/i,
90
+ /connection (reset|refused|closed|aborted|error)/i,
91
+ /network (error|is unreachable|unreachable|timeout)/i,
92
+ /(fetch|request) failed/i,
93
+ /temporary failure in name resolution/i,
94
+ /\b(502|503|504)\b|bad gateway|service unavailable|gateway time-?out/i,
95
+ /tls (handshake|connect) (timeout|error)/i,
96
+ ];
97
+
98
+ /**
99
+ * Exit codes that mean the transport failed, not the task.
100
+ *
101
+ * Only an explicit set counts. A provider CLI exits 1 for everything from a
102
+ * dropped socket to a refused prompt, so treating a generic failure code as
103
+ * transient would put every real failure through three pointless waits before
104
+ * it could be reported. curl's connect (7), timeout (28), TLS (35), empty
105
+ * reply (52) and receive (56) codes, sysexits EX_TEMPFAIL (75), and the
106
+ * timeout(1) kill code (124) are unambiguous.
107
+ */
108
+ const TRANSIENT_EXIT_CODES = new Set([7, 28, 35, 52, 56, 75, 124]);
109
+
110
+ /**
111
+ * Is this failure one the node imposed on itself rather than one the network
112
+ * imposed on the node?
113
+ *
114
+ * @param {RouteError|null|undefined} error
115
+ * @returns {boolean}
116
+ */
117
+ export function isTimeoutOrStall(error) {
118
+ return NODE_DEADLINE_CODES.has(String(error?.code ?? ""));
119
+ }
120
+
121
+ /**
122
+ * @param {RouteError|null|undefined} error
123
+ * @param {number|null|undefined} exitCode
124
+ * @returns {boolean}
125
+ */
126
+ function isNetworkFailure(error, exitCode) {
127
+ if (isTimeoutOrStall(error)) return false;
128
+ const code = String(error?.code ?? "");
129
+ // A failure the run imposed on itself keeps its own settlement even when its
130
+ // message happens to quote a socket error out of some tool's log.
131
+ if (NON_FAILOVER_CODES.has(code)) return false;
132
+ if (NETWORK_CODES.has(code)) return true;
133
+ const message = String(error?.message ?? "");
134
+ if (message && NETWORK_PATTERNS.some((pattern) => pattern.test(message))) return true;
135
+ return typeof exitCode === "number" && TRANSIENT_EXIT_CODES.has(exitCode);
136
+ }
137
+
138
+ /**
139
+ * The wait before network attempt `attempt` (0-based): exponential, capped,
140
+ * and jittered over the top half of the window.
141
+ *
142
+ * The jitter is equal rather than full because a wait must never round to
143
+ * zero. A zero-length backoff parks the phase and re-invokes the same failing
144
+ * runtime in the same tick — the hot loop quotaResetSchedule already refuses
145
+ * to open for a stale reset instant.
146
+ *
147
+ * @param {number} attempt
148
+ * @param {() => number} [random]
149
+ * @returns {number} milliseconds
150
+ */
151
+ export function backoffDelayMs(attempt, random = Math.random) {
152
+ const window = Math.min(NETWORK_BACKOFF_CAP_MS, NETWORK_BACKOFF_BASE_MS * 2 ** Math.max(0, attempt));
153
+ return Math.round(window / 2 + random() * (window / 2));
154
+ }
155
+
156
+ /**
157
+ * Marker prefix a network wait writes into its routing-history errorCode, in
158
+ * front of whatever the provider actually reported.
159
+ *
160
+ * The routing history schema has no field for "why we stayed", and the
161
+ * provider's own code is not enough to reconstruct one: a CLI that reports
162
+ * `provider_error` with a dropped-socket message is classified off the
163
+ * message, and the message is not persisted. Tagging the code keeps the
164
+ * provider's answer visible and makes the attempt countable.
165
+ */
166
+ const NETWORK_BACKOFF_CODE = "network_backoff";
167
+
168
+ /**
169
+ * @param {string} code provider error code for a wait being recorded
170
+ * @returns {string} the errorCode to persist in the routing history
171
+ */
172
+ function networkBackoffErrorCode(code) {
173
+ return `${NETWORK_BACKOFF_CODE}:${code}`;
174
+ }
175
+
176
+ /**
177
+ * How many network waits this role already spent in this revision.
178
+ *
179
+ * The count is read back off the durable routing history — the node snapshot
180
+ * is the only thing that survives a controller crash, so the attempt budget
181
+ * has to live there too rather than in controller memory.
182
+ *
183
+ * @param {NodeSnapshot|{routing?: {history?: unknown[]}|null}} state
184
+ * @param {"worker"|"judge"} role
185
+ * @param {number} revision
186
+ * @returns {number}
187
+ */
188
+ export function networkBackoffAttempts(state, role, revision) {
189
+ const history = /** @type {Record<string, unknown>[]} */ (state.routing?.history ?? []);
190
+ return history.filter((entry) => entry.role === role
191
+ && (entry.revision ?? 0) === revision
192
+ && String(entry.errorCode ?? "").startsWith(`${NETWORK_BACKOFF_CODE}:`)).length;
193
+ }
194
+
195
+ /**
196
+ * Classify one unusable invocation into the transition it earns.
197
+ *
198
+ * Order matters. An announced quota reset wins outright: the provider named
199
+ * the instant it will work again, which is cheaper and more certain than any
200
+ * guess. Otherwise a transport failure with attempts left buys a wait on the
201
+ * runtime already warmed. Everything else — including a transport failure that
202
+ * has spent its attempts, or whose wait would outlive the node's own deadline
203
+ * — takes the failover edge.
204
+ *
205
+ * @param {unknown} envelope provider envelope for the unusable invocation
206
+ * @param {{deadline?: string|number|Date|null, exitCode?: number|null, attempt?: number, now?: number, random?: () => number}} [options]
207
+ * @returns {Transition}
208
+ */
209
+ export function classifyTransition(envelope, options = {}) {
210
+ const { deadline = null, exitCode = null, attempt = 0, now = Date.now(), random = Math.random } = options;
211
+ const reset = quotaResetSchedule(envelope, deadline, now);
212
+ if (reset.kind === "reset") return { ...reset, reason: "quota_reset" };
213
+ const record = /** @type {Record<string, unknown>} */ (envelope ?? {});
214
+ const error = /** @type {RouteError|null} */ (record.error ?? null);
215
+ if (!isNetworkFailure(error, exitCode)) return { kind: "failover", reason: "provider" };
216
+ if (attempt >= NETWORK_MAX_ATTEMPTS) return { kind: "failover", reason: "network_backoff" };
217
+ const at = now + backoffDelayMs(attempt, random);
218
+ // A wait the node cannot outlive is not a recovery: hop instead of dying parked.
219
+ const deadlineMs = deadline instanceof Date ? deadline.getTime() : typeof deadline === "number" ? deadline : Date.parse(String(deadline));
220
+ if (Number.isFinite(deadlineMs) && at >= deadlineMs) return { kind: "failover", reason: "network_backoff" };
221
+ return { kind: "reset", at: new Date(at).toISOString(), reason: "network_backoff" };
222
+ }
223
+
224
+ /**
225
+ * @param {Record<string, unknown>|undefined} state
226
+ * @param {number} fallback
227
+ * @returns {number}
228
+ */
229
+ export function latestTimeoutSec(state, fallback) {
230
+ const overrides = /** @type {unknown[]} */ (state?.executionOverrides ?? []);
231
+ const override = [...overrides].reverse().find((item) =>
232
+ item && typeof item === "object" &&
233
+ /** @type {Record<string, unknown>} */ (item).kind === "timeout" &&
234
+ typeof /** @type {Record<string, unknown>} */ (item).timeoutSec === "number" &&
235
+ Number.isFinite(/** @type {Record<string, unknown>} */ (item).timeoutSec),
236
+ );
237
+ return override ? /** @type {number} */ (/** @type {Record<string, unknown>} */ (override).timeoutSec) : fallback;
238
+ }
239
+
240
+ /**
241
+ * Decide what a worker exhaustion envelope buys us: a wait or a failover.
242
+ *
243
+ * A provider that announces when its quota resets is telling us the cheapest
244
+ * possible recovery — keep the runtime the node already warmed and retry at
245
+ * that instant, spending nothing in between. A reset is only worth waiting on
246
+ * inside a window bounded at both ends. It must land strictly after now: a
247
+ * reset already in the past buys no wait at all, and honouring one would park
248
+ * the phase on a zero-length backoff and re-invoke the same exhausted runtime
249
+ * immediately, so a provider that keeps echoing a stale instant would hot-loop
250
+ * on it. It must also land strictly before the node's own deadline; a reset at
251
+ * or after it would have the node sit out its whole budget and die waiting.
252
+ * Outside that window — and for an envelope with no announced reset, or an
253
+ * unparseable one — the caller takes its declared or synthesized failover edge.
254
+ *
255
+ * @param {unknown} envelope provider envelope for the exhausted invocation
256
+ * @param {string|number|Date|null|undefined} deadline node wall-clock deadline
257
+ * @param {number} [now] epoch ms the reset is judged against
258
+ * @returns {{kind: "reset", at: string}|{kind: "failover"}}
259
+ */
260
+ export function quotaResetSchedule(envelope, deadline, now = Date.now()) {
261
+ const record = /** @type {Record<string, unknown>} */ (envelope ?? {});
262
+ const error = /** @type {Record<string, unknown>} */ (record.error ?? {});
263
+ const resetAt = epochMs(record.resetAt ?? error.resetAt);
264
+ if (resetAt === null || resetAt <= now) return { kind: "failover" };
265
+ const deadlineMs = epochMs(deadline);
266
+ if (deadlineMs !== null && resetAt >= deadlineMs) return { kind: "failover" };
267
+ return { kind: "reset", at: new Date(resetAt).toISOString() };
268
+ }
269
+
270
+ /** @param {unknown} value @returns {number|null} */
271
+ function epochMs(value) {
272
+ if (value instanceof Date) return Number.isFinite(value.getTime()) ? value.getTime() : null;
273
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
274
+ if (typeof value !== "string") return null;
275
+ const parsed = Date.parse(value);
276
+ return Number.isFinite(parsed) ? parsed : null;
277
+ }
278
+
279
+ /** The node's wall-clock deadline: when its own timeout budget runs out. @param {ValidatedContract} contract @param {ValidatedNode} node @param {NodeSnapshot} state @returns {string|null} */
280
+ export function nodeDeadlineAt(contract, node, state) {
281
+ const startedAt = state.startedAt ? Date.parse(state.startedAt) : Number.NaN;
282
+ if (!Number.isFinite(startedAt)) return null;
283
+ return new Date(startedAt + latestTimeoutSec(state, node.timeoutSec ?? contract.timeoutSec) * 1_000).toISOString();
284
+ }
285
+
286
+ /**
287
+ * The transition a transient network failure earns, or null when the failure
288
+ * is not the network's doing and belongs to its caller's own settlement.
289
+ *
290
+ * Every unusable invocation asks this first, whatever role produced it. A
291
+ * judge that lost its socket is no more unavailable than a worker that lost
292
+ * one: it gets the same bounded waits on the runtime it already warmed, and
293
+ * only what survives them is the judge's own failure to report.
294
+ *
295
+ * @param {ValidatedContract} contract
296
+ * @param {ValidatedNode} node
297
+ * @param {NodeSnapshot} state
298
+ * @param {"worker"|"judge"} role
299
+ * @param {unknown} envelope
300
+ * @param {number|null} exitCode
301
+ * @returns {Transition|null}
302
+ */
303
+ export function networkTransition(contract, node, state, role, envelope, exitCode) {
304
+ const transition = classifyTransition(envelope, {
305
+ deadline: nodeDeadlineAt(contract, node, state),
306
+ exitCode,
307
+ attempt: networkBackoffAttempts(state, role, state.revisions ?? 0),
308
+ });
309
+ return transition.reason === "network_backoff" ? transition : null;
310
+ }
311
+
312
+ /**
313
+ * Does a worker result that failed to parse still have its repair attempt?
314
+ *
315
+ * The first unparseable result is worth one bounded re-ask on the same
316
+ * provider: the task packet is intact and the model only has to re-emit it.
317
+ * The second is evidence about the provider, not the packet.
318
+ *
319
+ * @param {ValidatedNode|{gate: {enabled?: boolean, maxRevisions?: number}}} node
320
+ * @param {{revisions?: number}} state
321
+ * @returns {boolean}
322
+ */
323
+ export function isRepairable(node, state) {
324
+ return Boolean(node.gate.enabled) && (state.revisions ?? 0) < (node.gate.maxRevisions ?? 1);
325
+ }
326
+
327
+ /**
328
+ * Record one exhausted candidate in the generation's evidence, replacing any
329
+ * earlier entry for the same runtime so a repeated wait on the same candidate
330
+ * never duplicates the list. A different role starts a fresh list: the
331
+ * evidence names one phase's generation at a time.
332
+ *
333
+ * @param {TierExhaustion|null|undefined} existing
334
+ * @param {"worker"|"judge"} role
335
+ * @param {string} runtimeId
336
+ * @param {string|null} exhaustedUntil
337
+ * @returns {TierExhaustion}
338
+ */
339
+ export function upsertTierExhaustionCandidate(existing, role, runtimeId, exhaustedUntil) {
340
+ const candidates = existing?.role === role ? [...existing.candidates] : [];
341
+ const index = candidates.findIndex((candidate) => candidate.runtimeId === runtimeId);
342
+ const entry = { runtimeId, exhaustedUntil: exhaustedUntil ?? null };
343
+ if (index === -1) candidates.push(entry);
344
+ else candidates[index] = entry;
345
+ return { role, candidates };
346
+ }
347
+
348
+ /**
349
+ * Resolve one transition into a concrete route: which runtime runs next, what
350
+ * hop it costs, how long the node waits first, and whether any edge remains.
351
+ *
352
+ * A reset — quota or network — stays on the current runtime and therefore
353
+ * costs no hop; only an actual edge spends from the failover budget.
354
+ *
355
+ * @param {ValidatedContract} contract
356
+ * @param {ValidatedNode} node
357
+ * @param {NodeSnapshot} state
358
+ * @param {"worker"|"judge"} role
359
+ * @param {RouteError} error
360
+ * @param {string} current
361
+ * @param {Transition} schedule
362
+ * @param {number} [now] epoch ms the backoff window is measured from
363
+ * @param {string|null} [exhaustedUntil] the reset instant the exhausted candidate announced, if any
364
+ * @returns {{blocked: RouteError|null, nextRuntime: string, ruleIndex: number|undefined, revision: number, hop: number, backoffSec: number, backoffUntil: string, composed: boolean, tierExhaustion: TierExhaustion|null}}
365
+ */
366
+ export function planRoute(contract, node, state, role, error, current, schedule, now = Date.now(), exhaustedUntil = null) {
367
+ const revision = state.revisions ?? 0;
368
+ // Tier routing is scoped by generation, not revision: an invocation counts
369
+ // as attempted here only when it ran in the current tier-exhaustion
370
+ // generation. Revision keeps its own, unrelated round-bounding meaning.
371
+ const cycle = state.routing?.tierExhaustionCycle ?? 0;
372
+ const attempted = new Set((state.invocations ?? [])
373
+ .filter((invocation) => invocation.phase === role
374
+ && (invocation.revision === undefined || invocation.revision === revision)
375
+ && (/** @type {{cycle?: number}} */ (invocation).cycle ?? 0) === cycle)
376
+ .map((invocation) => invocation.runtimeId)
377
+ .filter((id) => typeof id === "string"));
378
+ // The runtime's own declared fallback is the only edge that exists: one
379
+ // hop, never a chain over every other runtime in the contract.
380
+ const actualWorker = [...(state.invocations ?? [])].reverse().find((invocation) => invocation.phase === "worker")?.runtimeId;
381
+ const routing = state.routing?.assignments
382
+ ? { ...state.routing, assignments: { ...state.routing.assignments, ...(actualWorker ? { worker: actualWorker } : {}) } }
383
+ : state.routing ?? {};
384
+ const declaredFallback = contract.runtimes[current]?.fallback !== undefined;
385
+ const explicitFallbackUsed = (state.routing?.history ?? []).some((entry) =>
386
+ entry.role === role
387
+ && entry.nextRuntime
388
+ && contract.runtimes[entry.runtime]?.fallback === entry.nextRuntime);
389
+ const composedAssignment = role === "worker" ? state.routing?.assignments?.composedWorker === true : state.routing?.assignments?.composedJudge === true;
390
+ const dynamicComposed = composedAssignment && !declaredFallback && !explicitFallbackUsed;
391
+ // The declared candidate is resolved unfiltered so an already-attempted
392
+ // edge is reported as a cycle rather than silently read back as "no edge
393
+ // declared" — nextSynthesizedRuntime's own attempted-filter would otherwise
394
+ // make that distinction unreachable.
395
+ const declaredCandidate = synthesizedChain(contract, role, current)[0] ?? null;
396
+ const fallback = nextSynthesizedRuntime(contract, role, current, attempted)
397
+ ?? (dynamicComposed ? nextSameTierRuntime(contract, routing, role, current, attempted) : null);
398
+ const hop = nextHop(state, role, revision, schedule);
399
+ const nextRuntime = schedule.kind === "reset" ? current : fallback ?? current;
400
+ const blocked = schedule.kind === "reset"
401
+ ? null
402
+ : fallback === null
403
+ ? declaredCandidate !== null && attempted.has(declaredCandidate)
404
+ ? { code: "provider_failover_cycle", message: `runtime ${declaredCandidate} was already attempted in ${role} revision ${revision}` }
405
+ : { code: dynamicComposed ? "runtime_tier_exhausted" : error.code, message: dynamicComposed ? `no available runtime remains in tier ${String(contract.runtimes[current]?.tier ?? "unknown")} for ${role}` : error.message }
406
+ : hop > 1 && !dynamicComposed
407
+ ? { code: "provider_failover_hop_cap", message: "provider failover exceeded the one-hop cap" }
408
+ : null;
409
+ const backoffSec = schedule.kind === "reset"
410
+ ? Math.max(0, (Date.parse(schedule.at) - now) / 1_000)
411
+ : 0;
412
+ const backoffUntil = schedule.kind === "reset" ? schedule.at : new Date(now + backoffSec * 1_000).toISOString();
413
+ // Only the tier-routed path carries this generation's evidence; any other
414
+ // plan returns null so `buildRouting` removes a stale list rather than
415
+ // carrying it across an outcome this feature does not drive.
416
+ const tierExhaustion = dynamicComposed
417
+ ? upsertTierExhaustionCandidate(state.routing?.tierExhaustion, role, current, exhaustedUntil)
418
+ : null;
419
+ return { blocked, nextRuntime, ruleIndex: undefined, revision, hop, backoffSec, backoffUntil, composed: composedAssignment, tierExhaustion };
420
+ }
421
+
422
+ /**
423
+ * Build the durable routing records for one planned route: the history entry
424
+ * that says what happened, and the override that says where the phase goes.
425
+ *
426
+ * A network wait tags its history errorCode so the node carries its own
427
+ * attempt count across a controller crash; see NETWORK_BACKOFF_CODE.
428
+ *
429
+ * @param {NodeSnapshot} state
430
+ * @param {{role: "worker"|"judge", error: RouteError, current: string, plan: ReturnType<typeof planRoute>, schedule: Transition, usage?: unknown, costUsd?: number|null, costProvenance?: "priced", status: string, now: number}} options
431
+ * @returns {{routing: Record<string, unknown>, override: unknown, errorCode: string}}
432
+ */
433
+ export function buildRouting(state, { role, error, current, plan, schedule, usage, costUsd, costProvenance, status, now }) {
434
+ const errorCode = schedule.kind === "reset" && schedule.reason === "network_backoff"
435
+ ? networkBackoffErrorCode(error.code)
436
+ : error.code;
437
+ const shared = {
438
+ at: new Date(now).toISOString(),
439
+ role,
440
+ nextRuntime: plan.nextRuntime,
441
+ rule: plan.ruleIndex,
442
+ ruleIndex: plan.ruleIndex,
443
+ revision: plan.revision,
444
+ hop: plan.hop,
445
+ backoffSec: plan.backoffSec,
446
+ backoffUntil: plan.backoffUntil,
447
+ usage,
448
+ costUsd,
449
+ // Present only for a runtime the controller itself priced; a
450
+ // provider-reported cost leaves the field absent, and the ledger carries
451
+ // its own separate `"provider"` value.
452
+ ...(costProvenance ? { costProvenance } : {}),
453
+ };
454
+ const override = { ...shared, runtime: plan.nextRuntime, reason: routeReason(schedule, role, current, error) };
455
+ // Spread the whole routing state first so assignments, availability, and the
456
+ // generation counter survive every hop. The evidence is rebuilt per plan: a
457
+ // non-tier plan returns null, which removes any stale list.
458
+ const routingBase = { ...(state.routing ?? {}) };
459
+ delete routingBase.tierExhaustion;
460
+ return {
461
+ routing: {
462
+ ...routingBase,
463
+ history: [...(state.routing?.history ?? []), { ...shared, runtime: current, status, errorCode }].slice(-MAX_ROUTING_HISTORY),
464
+ currentOverride: override,
465
+ ...(plan.tierExhaustion ? { tierExhaustion: plan.tierExhaustion } : {}),
466
+ },
467
+ override,
468
+ errorCode,
469
+ };
470
+ }
471
+
472
+ /** Routing history is bounded so a long-lived node cannot grow its snapshot without limit. */
473
+ const MAX_ROUTING_HISTORY = 64;
474
+
475
+ /**
476
+ * The override reason recorded on the node, in the operator's words.
477
+ *
478
+ * @param {Transition} schedule
479
+ * @param {"worker"|"judge"} role
480
+ * @param {string} current
481
+ * @param {RouteError} error
482
+ * @returns {string}
483
+ */
484
+ function routeReason(schedule, role, current, error) {
485
+ if (schedule.kind === "reset" && schedule.reason === "quota_reset") {
486
+ return `${role} provider ${current} quota resets at ${schedule.at}: ${error.message}`;
487
+ }
488
+ if (schedule.kind === "reset") return `${role} provider ${current} hit a transient network failure, retrying at ${schedule.at}: ${error.message}`;
489
+ if (schedule.reason === "network_backoff") return `${role} provider ${current} kept failing on the network: ${error.message}`;
490
+ if (schedule.reason === "protocol_failure") return `${role} provider ${current} could not hold the result protocol: ${error.message}`;
491
+ return `${role} provider ${current} exhausted: ${error.message}`;
492
+ }