@boardwalk-labs/runner 0.1.17 → 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.
@@ -162,7 +162,12 @@ export function makeGuestBrowserBackend(cfg) {
162
162
  mcp.once("error", (err) => {
163
163
  log.error("browser_mcp_spawn_error", { error: err.message });
164
164
  });
165
- await waitForHttp(`http://127.0.0.1:${String(mcpPort)}/mcp`, cfg.readyTimeoutMs);
165
+ // Probe the EXACT url the program's client will connect to (the `localhost` form), not a
166
+ // `127.0.0.1` stand-in: if `localhost` doesn't resolve in the guest, connectSessionMcp would
167
+ // otherwise throw a bare `TypeError: fetch failed` while this readiness check passed on a
168
+ // different address (masking the fault). Probing mcpUrl turns that into a diagnosable
169
+ // `browser_backend_timeout`. (The guest ships /etc/hosts mapping localhost → 127.0.0.1.)
170
+ await waitForHttp(mcpUrl, cfg.readyTimeoutMs);
166
171
  let killed = false;
167
172
  return {
168
173
  cdpUrl,
@@ -49,6 +49,7 @@ import { RunnerControlClient } from "./runner_control_client.js";
49
49
  import { BrokerChildDispatcher } from "./broker_child_dispatcher.js";
50
50
  import { BrokerEventPublisher } from "./broker_event_publisher.js";
51
51
  import { createProgramLogSink } from "./program_log_capture.js";
52
+ import { makeRunLogFileSink } from "./run_log_file_sink.js";
52
53
  import { BrokerArtifactStore } from "./broker_artifact_store.js";
53
54
  import { buildBrokerToolHost } from "./broker_tool_host.js";
54
55
  import { CreditWatcher } from "./credit_watcher.js";
@@ -130,7 +131,18 @@ export function assembleWorkerDeps(runtime) {
130
131
  // declared output, and every agent turn stamp through this emitter, so cursors are run-globally
131
132
  // monotonic with no separate program band. Frames publish as `{cursor, event}` rows via the
132
133
  // broker telemetry path. The claim wrapper below bumps it past a previous session's frames.
133
- const runEvents = new WorkerRunEventEmitter({ runId: runtime.runId, publisher: eventPublisher });
134
+ // Optional on-screen mirror: append the (already-redacted) event stream to a local file an xterm in
135
+ // the ambient desktop tails, so the live-view/recording shows the run working. Opt-in via the env the
136
+ // desktop guest image sets (BOARDWALK_RUN_LOG_FILE); absent everywhere else, so no cost off-desktop.
137
+ const runLogFilePath = process.env.BOARDWALK_RUN_LOG_FILE?.trim();
138
+ const runLogLocalSink = runLogFilePath !== undefined && runLogFilePath !== ""
139
+ ? makeRunLogFileSink(runLogFilePath)
140
+ : undefined;
141
+ const runEvents = new WorkerRunEventEmitter({
142
+ runId: runtime.runId,
143
+ publisher: eventPublisher,
144
+ ...(runLogLocalSink !== undefined ? { localSink: runLogLocalSink } : {}),
145
+ });
134
146
  const phaseTracker = new PhaseTracker({ sink: runEvents });
135
147
  // The run's public-API credential (was BOARDWALK_API_KEY). It is NO LONGER in process.env — the
136
148
  // bootstrap captured + scrubbed it (capturePlatformContext) so the agent's bash / subprocesses
@@ -314,6 +326,15 @@ export function assembleWorkerDeps(runtime) {
314
326
  }
315
327
  return Promise.resolve(runApiKey);
316
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
+ },
317
338
  };
318
339
  const host = new WorkerWorkflowHost({
319
340
  leaf,
@@ -6,11 +6,16 @@ export interface RunEventEmitterOptions {
6
6
  resumeAfterCursor?: number;
7
7
  /** Injected clock for deterministic tests. Defaults to Date.now. */
8
8
  now?: () => number;
9
+ /** Optional local mirror of every (already-redacted) event — used to surface the run's output in an
10
+ * on-screen terminal in the ambient desktop (docs/SCREEN_CAPTURE.md). Best-effort: a sink throw never
11
+ * affects the run or the broker publish. Only fed events that already passed the run's redactor. */
12
+ localSink?: (event: RunEvent) => void;
9
13
  }
10
14
  export declare class WorkerRunEventEmitter {
11
15
  private readonly runId;
12
16
  private readonly publisher;
13
17
  private readonly now;
18
+ private readonly localSink;
14
19
  private turn;
15
20
  private seq;
16
21
  constructor(opts: RunEventEmitterOptions);
@@ -20,12 +20,14 @@ export class WorkerRunEventEmitter {
20
20
  runId;
21
21
  publisher;
22
22
  now;
23
+ localSink;
23
24
  turn;
24
25
  seq;
25
26
  constructor(opts) {
26
27
  this.runId = opts.runId;
27
28
  this.publisher = opts.publisher;
28
29
  this.now = opts.now ?? Date.now;
30
+ this.localSink = opts.localSink;
29
31
  const max = opts.resumeAfterCursor ?? 0;
30
32
  this.turn = max > 0 ? Math.floor(max / TURN_CURSOR_STRIDE) + 1 : 0;
31
33
  this.seq = 0;
@@ -44,6 +46,14 @@ export class WorkerRunEventEmitter {
44
46
  this.publisher
45
47
  .publish(`run:${this.runId}`, JSON.stringify({ cursor, event }))
46
48
  .catch(() => undefined); // best-effort; the publisher logs its own failures
49
+ if (this.localSink !== undefined) {
50
+ try {
51
+ this.localSink(event); // best-effort on-screen mirror; must never affect the run
52
+ }
53
+ catch {
54
+ /* ignore */
55
+ }
56
+ }
47
57
  return event;
48
58
  }
49
59
  /** Restart resume: jump past the previous session's frames (claim carries the store max). */
@@ -0,0 +1,9 @@
1
+ import type { RunEvent } from "./agent/events.js";
2
+ /** One readable, ANSI-colored line for a run event, or null to skip it (streaming text/reasoning
3
+ * deltas are buffered by the sink, not formatted here). Reads only a few fields off the SDK's typed
4
+ * event and degrades gracefully on anything unexpected. */
5
+ export declare function formatRunEventLine(event: RunEvent): string | null;
6
+ /** Build the {@link WorkerRunEventEmitter} local sink: append formatted lines to `path`, buffering
7
+ * streaming agent text/reasoning into whole lines. Best-effort — a broken stream (full disk, closed
8
+ * pipe) is swallowed and disables the sink; it never throws into the run. */
9
+ export declare function makeRunLogFileSink(path: string): (event: RunEvent) => void;
@@ -0,0 +1,106 @@
1
+ // On-screen run-output mirror. Formats the run's (already-redacted) event stream into readable,
2
+ // ANSI-colored lines appended to a local file, which an xterm in the ambient desktop tails so the
3
+ // live-view / recording shows the run working (docs/SCREEN_CAPTURE.md). Gated on
4
+ // BOARDWALK_RUN_LOG_FILE — the desktop guest image sets it. This ONLY ever sees events that already
5
+ // passed the run's redactor (the same stream the web run-detail renders), so the file is safe to
6
+ // capture in a durable recording.
7
+ import { createWriteStream } from "node:fs";
8
+ function oneLine(s, n) {
9
+ const t = s.replace(/\s+/g, " ").trim();
10
+ return t.length > n ? t.slice(0, n - 1) + "…" : t;
11
+ }
12
+ function safeJson(v) {
13
+ try {
14
+ return typeof v === "string" ? v : JSON.stringify(v);
15
+ }
16
+ catch {
17
+ return String(v);
18
+ }
19
+ }
20
+ /** One readable, ANSI-colored line for a run event, or null to skip it (streaming text/reasoning
21
+ * deltas are buffered by the sink, not formatted here). Reads only a few fields off the SDK's typed
22
+ * event and degrades gracefully on anything unexpected. */
23
+ export function formatRunEventLine(event) {
24
+ const e = event;
25
+ const k = typeof e.kind === "string" ? e.kind : "";
26
+ const s = (v) => (typeof v === "string" ? v : "");
27
+ switch (k) {
28
+ case "run_status":
29
+ return `\x1b[1;32m● ${s(e.status)}\x1b[0m`;
30
+ case "phase":
31
+ return `\x1b[1;36m▸ ${s(e.name)}\x1b[0m`;
32
+ case "program_output": {
33
+ const t = s(e.text).replace(/\s+$/, "");
34
+ return t ? ` ${t}` : null;
35
+ }
36
+ case "turn_started":
37
+ return `\x1b[35m· agent ${s(e.agentName) || s(e.agentId)}\x1b[0m`;
38
+ case "tool_call_start":
39
+ return ` \x1b[33m⚙ ${s(e.toolName)}\x1b[0m`;
40
+ case "tool_output_delta": {
41
+ const t = s(e.text).replace(/\s+$/, "");
42
+ return t ? ` \x1b[90m${oneLine(t, 200)}\x1b[0m` : null;
43
+ }
44
+ case "tool_call_result":
45
+ return ` \x1b[32m✓ done\x1b[0m`;
46
+ case "tool_call_error":
47
+ return ` \x1b[31m✗ tool error\x1b[0m`;
48
+ case "output":
49
+ return `\x1b[1;32m✔ output ${oneLine(safeJson(e.value), 300)}\x1b[0m`;
50
+ case "human_input_requested":
51
+ return `\x1b[1;33m⏸ awaiting human input\x1b[0m`;
52
+ case "human_input_resolved":
53
+ return `\x1b[33m▶ input received\x1b[0m`;
54
+ case "suspended":
55
+ return `\x1b[90m⏸ suspended\x1b[0m`;
56
+ case "resumed":
57
+ return `\x1b[90m▶ resumed\x1b[0m`;
58
+ case "egress_denied":
59
+ return ` \x1b[31m⛔ egress denied\x1b[0m`;
60
+ default:
61
+ return null; // turn_ended, text_*, tool_call_input_*, tool_call_executing — noise for the terminal
62
+ }
63
+ }
64
+ /** Build the {@link WorkerRunEventEmitter} local sink: append formatted lines to `path`, buffering
65
+ * streaming agent text/reasoning into whole lines. Best-effort — a broken stream (full disk, closed
66
+ * pipe) is swallowed and disables the sink; it never throws into the run. */
67
+ export function makeRunLogFileSink(path) {
68
+ let stream = null;
69
+ try {
70
+ stream = createWriteStream(path, { flags: "a" });
71
+ stream.on("error", () => {
72
+ stream = null;
73
+ });
74
+ stream.write("\n\x1b[1;36m── boardwalk run ──\x1b[0m\n");
75
+ }
76
+ catch {
77
+ stream = null;
78
+ }
79
+ let textBuf = "";
80
+ const flushText = () => {
81
+ if (textBuf.trim() !== "" && stream !== null) {
82
+ stream.write(` \x1b[37m${oneLine(textBuf, 2000)}\x1b[0m\n`);
83
+ }
84
+ textBuf = "";
85
+ };
86
+ return (event) => {
87
+ if (stream === null)
88
+ return;
89
+ const e = event;
90
+ const k = typeof e.kind === "string" ? e.kind : "";
91
+ if (k === "text_delta" || k === "reasoning_delta") {
92
+ textBuf += typeof e.text === "string" ? e.text : "";
93
+ return;
94
+ }
95
+ if (k === "text_start")
96
+ return;
97
+ if (k === "text_end") {
98
+ flushText();
99
+ return;
100
+ }
101
+ flushText(); // a non-text event ends any in-flight text line
102
+ const line = formatRunEventLine(event);
103
+ if (line !== null)
104
+ stream.write(line + "\n");
105
+ };
106
+ }
@@ -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.17",
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",