@boardwalk-labs/runner 0.1.18 → 0.1.20
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/dist/runtime/index.js
CHANGED
|
@@ -326,6 +326,15 @@ export function assembleWorkerDeps(runtime) {
|
|
|
326
326
|
}
|
|
327
327
|
return Promise.resolve(runApiKey);
|
|
328
328
|
},
|
|
329
|
+
// OIDC id-token for cloud federation: minted per call by the broker with the CURRENT run
|
|
330
|
+
// token (so post-resume calls just work — no captured value to swap on wake). Recorded in
|
|
331
|
+
// the redactor like every run credential; the broker 403s (naming permissions.id_token)
|
|
332
|
+
// when the pinned manifest doesn't grant it.
|
|
333
|
+
idToken: async (audience) => {
|
|
334
|
+
const { token } = await broker.requestOidcToken(audience);
|
|
335
|
+
redactor.record(token);
|
|
336
|
+
return token;
|
|
337
|
+
},
|
|
329
338
|
};
|
|
330
339
|
const host = new WorkerWorkflowHost({
|
|
331
340
|
leaf,
|
|
@@ -17,6 +17,9 @@ export interface RunnerControlClientConfig {
|
|
|
17
17
|
controlTimeoutMs?: number;
|
|
18
18
|
/** Per-call ceiling for bulk artifact/workspace transfers (default 5 min). */
|
|
19
19
|
bulkTimeoutMs?: number;
|
|
20
|
+
/** Backoff schedule for transient-failure retries (length = extra attempts after the first;
|
|
21
|
+
* see {@link RETRYABLE_STATUSES}). Injectable for tests; [] disables retries. */
|
|
22
|
+
retryDelaysMs?: number[];
|
|
20
23
|
}
|
|
21
24
|
/** The pinned program's download reference (the worker fetches + verifies + extracts it). */
|
|
22
25
|
export interface BrokerProgram {
|
|
@@ -53,6 +56,7 @@ export declare class RunnerControlClient {
|
|
|
53
56
|
private runToken;
|
|
54
57
|
private readonly controlTimeoutMs;
|
|
55
58
|
private readonly bulkTimeoutMs;
|
|
59
|
+
private readonly retryDelaysMs;
|
|
56
60
|
constructor(cfg: RunnerControlClientConfig);
|
|
57
61
|
/** Swap the bearer for a fresh run token (the wake path). Every subsequent call uses it. */
|
|
58
62
|
swapRunToken(token: string): void;
|
|
@@ -69,6 +73,16 @@ export declare class RunnerControlClient {
|
|
|
69
73
|
/** Bulk transfers (artifact + workspace up/download over presigned S3) — a much larger ceiling
|
|
70
74
|
* than a control call, but still bounded so a dead socket can't hang the run. */
|
|
71
75
|
private bulkFetch;
|
|
76
|
+
/**
|
|
77
|
+
* One attempt per entry in the backoff schedule (+1): retry thrown network failures (connection
|
|
78
|
+
* reset/refused mid-rollover, our own per-attempt timeout on a dead socket) and the
|
|
79
|
+
* load-balancer's {@link RETRYABLE_STATUSES}. Safe to re-send because every caller's body is a
|
|
80
|
+
* reusable string/byte-array (the streaming inference call bypasses this entirely), and the
|
|
81
|
+
* broker's mutating endpoints are idempotent per worker/identifier (journal seq, usage
|
|
82
|
+
* identifier, lease per workerId). Before this, ONE blip during an api-server deploy rollover
|
|
83
|
+
* crashed the worker hard mid-suspend/finalize and only crash-reclaim recovered the run.
|
|
84
|
+
*/
|
|
85
|
+
private retryingFetch;
|
|
72
86
|
/** Claim the run's lease. Returns the run on success, or null when it isn't claimable (409 —
|
|
73
87
|
* another worker has it, or it isn't pending), which the worker treats as "claim lost". */
|
|
74
88
|
claim(workerId: string, leaseSeconds: number): Promise<{
|
|
@@ -7,11 +7,24 @@
|
|
|
7
7
|
//
|
|
8
8
|
// Thin + injectable (fetch is overridable) so it's unit-tested without a live server. Status mapping
|
|
9
9
|
// mirrors the broker handlers: claim 409 ⇒ "claim lost" (null), version 404 ⇒ missing (null); any
|
|
10
|
-
// other non-success status throws so the worker fails loud (→ restart, lease reclaimed).
|
|
10
|
+
// other non-success status throws so the worker fails loud (→ restart, lease reclaimed). TRANSIENT
|
|
11
|
+
// failures (thrown network errors, LB 502/503/504 — a control-plane deploy rollover) are retried
|
|
12
|
+
// with backoff first, so a blip heals in place instead of crashing the run.
|
|
11
13
|
import { createLogger } from "./support/index.js";
|
|
12
14
|
import { journalLookupSchema, } from "./suspension.js";
|
|
13
15
|
import { INFERENCE_NDJSON_CONTENT_TYPE, parseInferenceFrame, serializeInferenceRequest, } from "./wire/inference_proxy.js";
|
|
14
16
|
const log = createLogger("RunnerControlClient");
|
|
17
|
+
/**
|
|
18
|
+
* Transient statuses worth retrying: the load-balancer answers during a control-plane deploy
|
|
19
|
+
* rollover (no healthy target / draining target / gateway timeout). Anything else — including a
|
|
20
|
+
* 500 — is treated as a real answer from a live handler and surfaces immediately: retrying a
|
|
21
|
+
* handler error could duplicate a side effect for no healing value.
|
|
22
|
+
*/
|
|
23
|
+
const RETRYABLE_STATUSES = new Set([502, 503, 504]);
|
|
24
|
+
/** Default backoff (ms) between attempts — ~17.5s of spread, sized to ride out the target-group
|
|
25
|
+
* rotation window of an api-server rolling deploy (the observed killer: guests died hard
|
|
26
|
+
* mid-suspend/finalize during TWO deploys, 2026-07-13, and only crash-reclaim saved the runs). */
|
|
27
|
+
const DEFAULT_RETRY_DELAYS_MS = [500, 2_000, 5_000, 10_000];
|
|
15
28
|
export class RunnerControlClient {
|
|
16
29
|
cfg;
|
|
17
30
|
base;
|
|
@@ -21,6 +34,7 @@ export class RunnerControlClient {
|
|
|
21
34
|
runToken;
|
|
22
35
|
controlTimeoutMs;
|
|
23
36
|
bulkTimeoutMs;
|
|
37
|
+
retryDelaysMs;
|
|
24
38
|
constructor(cfg) {
|
|
25
39
|
this.cfg = cfg;
|
|
26
40
|
this.base = cfg.baseUrl.replace(/\/+$/, "");
|
|
@@ -28,6 +42,7 @@ export class RunnerControlClient {
|
|
|
28
42
|
this.runToken = cfg.runToken;
|
|
29
43
|
this.controlTimeoutMs = cfg.controlTimeoutMs ?? 30_000;
|
|
30
44
|
this.bulkTimeoutMs = cfg.bulkTimeoutMs ?? 300_000;
|
|
45
|
+
this.retryDelaysMs = cfg.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS;
|
|
31
46
|
}
|
|
32
47
|
/** Swap the bearer for a fresh run token (the wake path). Every subsequent call uses it. */
|
|
33
48
|
swapRunToken(token) {
|
|
@@ -43,12 +58,51 @@ export class RunnerControlClient {
|
|
|
43
58
|
* streaming inference call is the ONE exception (long-lived NDJSON) and bypasses this.
|
|
44
59
|
*/
|
|
45
60
|
controlFetch(url, init) {
|
|
46
|
-
return this.
|
|
61
|
+
return this.retryingFetch(url, init, this.controlTimeoutMs);
|
|
47
62
|
}
|
|
48
63
|
/** Bulk transfers (artifact + workspace up/download over presigned S3) — a much larger ceiling
|
|
49
64
|
* than a control call, but still bounded so a dead socket can't hang the run. */
|
|
50
65
|
bulkFetch(url, init) {
|
|
51
|
-
return this.
|
|
66
|
+
return this.retryingFetch(url, init, this.bulkTimeoutMs);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* One attempt per entry in the backoff schedule (+1): retry thrown network failures (connection
|
|
70
|
+
* reset/refused mid-rollover, our own per-attempt timeout on a dead socket) and the
|
|
71
|
+
* load-balancer's {@link RETRYABLE_STATUSES}. Safe to re-send because every caller's body is a
|
|
72
|
+
* reusable string/byte-array (the streaming inference call bypasses this entirely), and the
|
|
73
|
+
* broker's mutating endpoints are idempotent per worker/identifier (journal seq, usage
|
|
74
|
+
* identifier, lease per workerId). Before this, ONE blip during an api-server deploy rollover
|
|
75
|
+
* crashed the worker hard mid-suspend/finalize and only crash-reclaim recovered the run.
|
|
76
|
+
*/
|
|
77
|
+
async retryingFetch(url, init, timeoutMs) {
|
|
78
|
+
for (let attempt = 0;; attempt += 1) {
|
|
79
|
+
const last = attempt >= this.retryDelaysMs.length;
|
|
80
|
+
try {
|
|
81
|
+
const res = await this.fetchImpl(url, {
|
|
82
|
+
...init,
|
|
83
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
84
|
+
});
|
|
85
|
+
if (last || !RETRYABLE_STATUSES.has(res.status))
|
|
86
|
+
return res;
|
|
87
|
+
// Drop the unused body so the socket is released before the retry.
|
|
88
|
+
await res.body?.cancel().catch(() => { });
|
|
89
|
+
log.warn("broker_call_retry", {
|
|
90
|
+
status: res.status,
|
|
91
|
+
attempt: attempt + 1,
|
|
92
|
+
delayMs: this.retryDelaysMs[attempt],
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
if (last)
|
|
97
|
+
throw err;
|
|
98
|
+
log.warn("broker_call_retry", {
|
|
99
|
+
error: err instanceof Error ? err.name : "unknown",
|
|
100
|
+
attempt: attempt + 1,
|
|
101
|
+
delayMs: this.retryDelaysMs[attempt],
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
await sleep(this.retryDelaysMs[attempt] ?? 0);
|
|
105
|
+
}
|
|
52
106
|
}
|
|
53
107
|
/** Claim the run's lease. Returns the run on success, or null when it isn't claimable (409 —
|
|
54
108
|
* another worker has it, or it isn't pending), which the worker treats as "claim lost". */
|
|
@@ -493,6 +547,9 @@ export class RunnerControlClient {
|
|
|
493
547
|
return h;
|
|
494
548
|
}
|
|
495
549
|
}
|
|
550
|
+
function sleep(ms) {
|
|
551
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
552
|
+
}
|
|
496
553
|
/** Yield complete newline-delimited lines from a streaming response body (NDJSON inference frames),
|
|
497
554
|
* buffering partial chunks and flushing any trailing line. Blank lines are skipped. */
|
|
498
555
|
async function* readNdjsonLines(body) {
|
|
@@ -93,6 +93,11 @@ export interface RuntimeContext {
|
|
|
93
93
|
apiUrl: string;
|
|
94
94
|
/** A short-lived, manifest-scoped bearer for the public API / MCP / CLI. */
|
|
95
95
|
apiToken(): Promise<string>;
|
|
96
|
+
/** A short-lived OIDC id-token asserting this run's identity for `audience`, for federation into
|
|
97
|
+
* the org's OWN cloud (AWS `AssumeRoleWithWebIdentity` / GCP / Azure). Minted per call by the
|
|
98
|
+
* broker (gated server-side on `permissions.id_token: "write"`) with the CURRENT run token, so it
|
|
99
|
+
* needs no swap handling across suspend/resume — unlike the captured `apiToken` bearer. */
|
|
100
|
+
idToken(audience: string): Promise<string>;
|
|
96
101
|
}
|
|
97
102
|
export interface WorkerWorkflowHostDeps {
|
|
98
103
|
leaf: LeafExecutor;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boardwalk-labs/runner",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.20",
|
|
4
4
|
"description": "Boardwalk self-hosted runner: execute cloud-scheduled runs on your own machines. Currently: the canonical runner contract types.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -53,8 +53,8 @@
|
|
|
53
53
|
"prebuild": "prebuildify --napi --strip -t 24.0.0"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@boardwalk-labs/engine": "^0.1.
|
|
57
|
-
"@boardwalk-labs/workflow": "^0.1.
|
|
56
|
+
"@boardwalk-labs/engine": "^0.1.35",
|
|
57
|
+
"@boardwalk-labs/workflow": "^0.1.29",
|
|
58
58
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
59
59
|
"node-gyp-build": "^4.8.4",
|
|
60
60
|
"tar": "^7.5.16",
|