@boardwalk-labs/runner 0.1.18 → 0.1.19

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.
@@ -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.fetchImpl(url, { ...init, signal: AbortSignal.timeout(this.controlTimeoutMs) });
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.fetchImpl(url, { ...init, signal: AbortSignal.timeout(this.bulkTimeoutMs) });
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.18",
3
+ "version": "0.1.19",
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": {
@@ -54,7 +54,7 @@
54
54
  },
55
55
  "dependencies": {
56
56
  "@boardwalk-labs/engine": "^0.1.34",
57
- "@boardwalk-labs/workflow": "^0.1.25",
57
+ "@boardwalk-labs/workflow": "^0.1.28",
58
58
  "@modelcontextprotocol/sdk": "^1.29.0",
59
59
  "node-gyp-build": "^4.8.4",
60
60
  "tar": "^7.5.16",