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,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
|
+
}
|