@boardwalk-labs/runner 0.2.14 → 0.2.16

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/README.md CHANGED
@@ -31,7 +31,9 @@ boardwalk-runner start --url https://api.boardwalk.sh --pool default
31
31
  ```
32
32
 
33
33
  `start` polls for runs targeting `runs_on: { kind: "self-hosted" }` and executes one at a
34
- time; run more daemons (or machines) for concurrency. `Ctrl-C` drains: the current run
34
+ time; run more daemons (or machines) for concurrency. Each run executes in a container by default
35
+ (Docker or Podman required); pass `--host` to run it directly on the machine instead. `Ctrl-C`
36
+ drains: the current run
35
37
  finishes, nothing new is claimed. Useful flags: `--once` (execute one run, then exit),
36
38
  `--verbose` (debug-level daemon logs), `--debug` (also debug-logs inside each run process),
37
39
  `--work-dir`, `--identity-dir`. Behind a corporate proxy, launch with `NODE_USE_ENV_PROXY=1`
@@ -58,7 +60,7 @@ runs every non-browser workflow exactly as before.
58
60
 
59
61
  ## Security model
60
62
 
61
- This part of the contract is settled, even though the client isn't built yet:
63
+ This part of the contract is settled:
62
64
 
63
65
  - The runner **never receives broad credentials**: a single-purpose registration token to join,
64
66
  a standing runner token that can only poll/claim/heartbeat, and a short-lived **run-scoped**
@@ -87,7 +89,7 @@ pnpm lint && pnpm typecheck && pnpm build
87
89
  - [`sdk`](https://github.com/boardwalk-labs/sdk) — `@boardwalk-labs/workflow`, the TypeScript API a workflow program imports
88
90
  - [`cli`](https://github.com/boardwalk-labs/cli) — `boardwalk`: scaffold, validate, run locally, deploy
89
91
  - [`examples`](https://github.com/boardwalk-labs/examples) — copyable workflow templates (`boardwalk init --template`)
90
- - [`plugins`](https://github.com/boardwalk-labs/plugins) — skills + MCP server for Claude Code, Codex, Cursor, OpenClaw, OpenCode
92
+ - [`plugins`](https://github.com/boardwalk-labs/plugins) — coding-agent skills (Claude Code, Codex, Cursor, OpenClaw, OpenCode) + a control-plane MCP server
91
93
  - [`runner-images`](https://github.com/boardwalk-labs/runner-images) — reproducible base images hosted runners execute in
92
94
 
93
95
  Hosted platform and docs: [boardwalk.sh](https://boardwalk.sh).
@@ -83,6 +83,11 @@ export class EngineLeafExecutor {
83
83
  // io (children get their own via `forChild`), and the loop is strictly streamModel→reportUsage per
84
84
  // turn, so a single slot never races. Null ⇒ no upstream cost for the next turn (BYO / unavailable).
85
85
  let pendingCostMicros = null;
86
+ // The turn currently streaming, tracked so `streamModel` can attribute a `turn_reset` to it when
87
+ // the broker restarts a dropped model call mid-stream. Set on `startTurn` and every `emit` (the
88
+ // engine stamps both with the turn's id); within a leaf, turns are strictly sequential, so this is
89
+ // never stale for the turn actually streaming.
90
+ let currentTurnId = undefined;
86
91
  return {
87
92
  identity,
88
93
  redactor,
@@ -113,16 +118,24 @@ export class EngineLeafExecutor {
113
118
  throwIfAborted(signal);
114
119
  return this.streamModel(req, providerIo, signal, redactor, (costMicros) => {
115
120
  pendingCostMicros = (pendingCostMicros ?? 0) + costMicros;
121
+ },
122
+ // The broker restarted a dropped model call after content had already streamed: void the
123
+ // turn's emitted text/reasoning so a viewer re-renders from the restart, not the concatenation.
124
+ () => {
125
+ if (currentTurnId !== undefined)
126
+ sink.emit({ kind: "turn_reset" }, currentTurnId);
116
127
  });
117
128
  },
118
129
  // turn_started rides a NEW stride block (the supervisor opens it); subsequent leaf events ride
119
130
  // the same block. The shared emitter owns the run-global cursor.
120
131
  startTurn: (turnId) => {
132
+ currentTurnId = turnId;
121
133
  sink.beginTurn(turnId, { kind: "turn_started", ...identity });
122
134
  },
123
135
  // Engine LeafEventBody → the platform's v1 RunEventBody. The platform already adopted the SDK
124
136
  // v1 wire format, and the engine emits the same kinds, so this is a near-identity mapping.
125
137
  emit: (turnId, body) => {
138
+ currentTurnId = turnId;
126
139
  sink.emit(toRunEventBody(body, identity), turnId);
127
140
  },
128
141
  // Usage flows to the budget authority after EVERY model call. Feed the run-level meter and, if
@@ -202,7 +215,7 @@ export class EngineLeafExecutor {
202
215
  /** POST one model turn to the broker's `/inference` and adapt its NDJSON stream into the engine's
203
216
  * ModelTurnResult: each `delta` frame drives providerIo.onDelta; the terminal `result` frame is
204
217
  * the turn. An `error` frame throws (the broker already classified it). An abort mid-stream throws. */
205
- async streamModel(req, providerIo, signal, redactor, onCost) {
218
+ async streamModel(req, providerIo, signal, redactor, onCost, onReset) {
206
219
  // Runner-direct BYO (D7): the org's own endpoint + key, called with the same engine adapters
207
220
  // the broker uses. No platform cost (BYO is never metered) — onCost stays untouched.
208
221
  // A direct turn needs an explicit model (an omitted model means the managed auto lane,
@@ -240,6 +253,12 @@ export class EngineLeafExecutor {
240
253
  else if (frame.kind === "reasoning") {
241
254
  providerIo.onReasoningDelta?.(frame.text);
242
255
  }
256
+ else if (frame.kind === "reset") {
257
+ // The broker recovered a transient mid-stream drop and is restarting the turn: everything
258
+ // relayed above is void. Signal the viewer to discard the turn's streamed text/reasoning; the
259
+ // authoritative turn still comes from the single terminal `result` frame below.
260
+ onReset?.();
261
+ }
243
262
  else if (frame.kind === "result") {
244
263
  // contextTokens (when the broker knows the served model's window) lets the engine's loop
245
264
  // size compaction against the real window instead of a conservative default.
@@ -208,8 +208,24 @@ export declare class RunnerControlClient {
208
208
  * Backs {@link InferenceProxyTransport} (inference_transport.ts) — the model swap is invisible to
209
209
  * the engine loop, which keeps the runner provider-agnostic (the broker owns model invocation). A
210
210
  * non-200 (a failure BEFORE the stream began) throws the broker's already-classified message.
211
+ *
212
+ * Resilience (added because a bare drop here surfaced as a run-fatal `PROVIDER_ERROR: terminated`):
213
+ * a transient failure is retried WHILE it is still safe to re-POST — i.e. before any CONTENT frame
214
+ * has been relayed. Three cases:
215
+ * - the POST never returned a response (connection refused/reset during an api-server rollover),
216
+ * - a load-balancer {@link INFERENCE_RETRYABLE_STATUSES} (no healthy / gateway timeout) before the
217
+ * stream began — NOT 502, which the broker uses for a real upstream model error,
218
+ * - the body dropped MID-stream (undici `terminated` / socket close) but only `ping` heartbeats
219
+ * had been relayed so far (the observed failure: a huge-context turn streamed only pings for
220
+ * ~120s during time-to-first-token, then the connection dropped).
221
+ * Once a `delta`/`reasoning`/`result`/`error` frame has been yielded, the model has already
222
+ * produced output (or the turn finished), so a re-POST would duplicate it: the drop surfaces as
223
+ * before. Re-POST is billing-safe — the broker aborts the abandoned turn on our disconnect and
224
+ * returns before it meters (no `result` frame ⇒ no usage). The body is a reusable string.
211
225
  */
212
226
  streamInference(req: InferenceProxyRequest): AsyncGenerator<InferenceFrame, void, undefined>;
227
+ /** Log + wait one backoff step before re-POSTing a transient inference failure (see streamInference). */
228
+ private backoffInferenceRetry;
213
229
  private url;
214
230
  private headers;
215
231
  }
@@ -20,6 +20,14 @@ const log = createLogger("RunnerControlClient");
20
20
  * handler error could duplicate a side effect for no healing value.
21
21
  */
22
22
  const RETRYABLE_STATUSES = new Set([502, 503, 504]);
23
+ /**
24
+ * Transient statuses worth retrying on the STREAMING inference POST — a narrower set than
25
+ * {@link RETRYABLE_STATUSES}. 502 is EXCLUDED: on `/inference` the broker uses 502 for a real
26
+ * upstream model error (a clean, customer-facing message), so retrying it just re-hits a guaranteed
27
+ * failure. 503/504 are only ever the load balancer during a rollover (no healthy / draining target,
28
+ * gateway timeout) — safe to re-POST since the stream hasn't begun. See {@link streamInference}.
29
+ */
30
+ const INFERENCE_RETRYABLE_STATUSES = new Set([503, 504]);
23
31
  /** Default backoff (ms) between attempts — ~17.5s of spread, sized to ride out the target-group
24
32
  * rotation window of an api-server rolling deploy (the observed killer: guests died hard
25
33
  * mid-suspend/finalize during TWO deploys, 2026-07-13, and only crash-reclaim saved the runs). */
@@ -473,20 +481,85 @@ export class RunnerControlClient {
473
481
  * Backs {@link InferenceProxyTransport} (inference_transport.ts) — the model swap is invisible to
474
482
  * the engine loop, which keeps the runner provider-agnostic (the broker owns model invocation). A
475
483
  * non-200 (a failure BEFORE the stream began) throws the broker's already-classified message.
484
+ *
485
+ * Resilience (added because a bare drop here surfaced as a run-fatal `PROVIDER_ERROR: terminated`):
486
+ * a transient failure is retried WHILE it is still safe to re-POST — i.e. before any CONTENT frame
487
+ * has been relayed. Three cases:
488
+ * - the POST never returned a response (connection refused/reset during an api-server rollover),
489
+ * - a load-balancer {@link INFERENCE_RETRYABLE_STATUSES} (no healthy / gateway timeout) before the
490
+ * stream began — NOT 502, which the broker uses for a real upstream model error,
491
+ * - the body dropped MID-stream (undici `terminated` / socket close) but only `ping` heartbeats
492
+ * had been relayed so far (the observed failure: a huge-context turn streamed only pings for
493
+ * ~120s during time-to-first-token, then the connection dropped).
494
+ * Once a `delta`/`reasoning`/`result`/`error` frame has been yielded, the model has already
495
+ * produced output (or the turn finished), so a re-POST would duplicate it: the drop surfaces as
496
+ * before. Re-POST is billing-safe — the broker aborts the abandoned turn on our disconnect and
497
+ * returns before it meters (no `result` frame ⇒ no usage). The body is a reusable string.
476
498
  */
477
499
  async *streamInference(req) {
478
- const res = await this.fetchImpl(this.url("inference"), {
479
- method: "POST",
480
- headers: { ...this.headers(true), accept: INFERENCE_NDJSON_CONTENT_TYPE },
481
- body: serializeInferenceRequest(req),
482
- });
483
- if (res.status !== 200 || res.body === null) {
484
- throw await inferenceHttpError(res);
485
- }
486
- for await (const line of readNdjsonLines(res.body)) {
487
- yield parseInferenceFrame(line);
500
+ const body = serializeInferenceRequest(req);
501
+ for (let attempt = 0;; attempt += 1) {
502
+ const last = attempt >= this.retryDelaysMs.length;
503
+ let res;
504
+ try {
505
+ res = await this.fetchImpl(this.url("inference"), {
506
+ method: "POST",
507
+ headers: { ...this.headers(true), accept: INFERENCE_NDJSON_CONTENT_TYPE },
508
+ body,
509
+ });
510
+ }
511
+ catch (err) {
512
+ // No response at all — nothing streamed, so a re-POST is always safe.
513
+ if (last)
514
+ throw err;
515
+ await this.backoffInferenceRetry(attempt, "connect", err);
516
+ continue;
517
+ }
518
+ if (res.status !== 200 || res.body === null) {
519
+ // A load-balancer transient (target draining / gateway timeout) before the stream began —
520
+ // safe to re-POST. Any other non-200 is the broker's real, already-classified answer
521
+ // (incl. a 502 upstream model error): surface it as the run's customer-facing error.
522
+ if (!last && res.body !== null && INFERENCE_RETRYABLE_STATUSES.has(res.status)) {
523
+ await res.body.cancel().catch(() => { });
524
+ await this.backoffInferenceRetry(attempt, "status", res.status);
525
+ continue;
526
+ }
527
+ throw await inferenceHttpError(res);
528
+ }
529
+ // Relay the stream. A mid-stream transport drop throws out of readNdjsonLines; retry it only
530
+ // while nothing but heartbeats has gone out (see the doc-comment). `ping` frames carry no
531
+ // model output, so a drop during the pre-first-token wait stays safely retryable.
532
+ let sawContent = false;
533
+ try {
534
+ for await (const line of readNdjsonLines(res.body)) {
535
+ const frame = parseInferenceFrame(line);
536
+ if (frame.kind !== "ping")
537
+ sawContent = true;
538
+ yield frame;
539
+ }
540
+ return; // the stream ended cleanly
541
+ }
542
+ catch (err) {
543
+ if (last || sawContent)
544
+ throw err;
545
+ await res.body.cancel().catch(() => { });
546
+ await this.backoffInferenceRetry(attempt, "stream", err);
547
+ continue;
548
+ }
488
549
  }
489
550
  }
551
+ /** Log + wait one backoff step before re-POSTing a transient inference failure (see streamInference). */
552
+ async backoffInferenceRetry(attempt, reason, detail) {
553
+ log.warn("broker_inference_retry", {
554
+ reason,
555
+ ...(reason === "status"
556
+ ? { status: detail }
557
+ : { error: detail instanceof Error ? detail.name : "unknown" }),
558
+ attempt: attempt + 1,
559
+ delayMs: this.retryDelaysMs[attempt],
560
+ });
561
+ await sleep(this.retryDelaysMs[attempt] ?? 0);
562
+ }
490
563
  url(suffix) {
491
564
  return `${this.base}/runner/v1/runs/${encodeURIComponent(this.cfg.runId)}/${suffix}`;
492
565
  }
@@ -32,13 +32,17 @@ export interface ProxyError {
32
32
  }
33
33
  /** A parsed response frame. `ping` is a no-payload heartbeat the broker emits during a long model
34
34
  * turn to keep the connection producing bytes (so idle/body timeouts don't sever it); the worker
35
- * ignores it. */
35
+ * ignores it. `reset` voids every delta/reasoning relayed so far for the in-progress turn — the
36
+ * broker restarts a turn after a transient mid-stream drop; the worker signals the viewer to discard
37
+ * the turn's streamed output (the authoritative turn still arrives in the single `result`). */
36
38
  export type InferenceFrame = {
37
39
  kind: "delta";
38
40
  text: string;
39
41
  } | {
40
42
  kind: "reasoning";
41
43
  text: string;
44
+ } | {
45
+ kind: "reset";
42
46
  } | {
43
47
  kind: "result";
44
48
  turn: ChatTurn;
@@ -70,6 +74,10 @@ export declare function serializeReasoningFrame(text: string): string;
70
74
  export declare function serializeResultFrame(turn: ChatTurn, modelRef: string, costMicros?: number, contextTokens?: number): string;
71
75
  /** Serialize a terminal error as a single NDJSON line. */
72
76
  export declare function serializeErrorFrame(error: ProxyError): string;
77
+ /** Serialize a `reset` frame (no payload): the broker restarted a turn after a transient mid-stream
78
+ * drop, so every delta/reasoning relayed so far is void. The broker is the producer; this mirror
79
+ * exists so runner tests can construct the frame. */
80
+ export declare function serializeResetFrame(): string;
73
81
  /** Serialize a heartbeat (no payload) as a single NDJSON line. The broker emits these on an interval
74
82
  * during a long model turn so the worker↔broker connection keeps producing bytes — neither side's
75
83
  * idle/body timeout fires while the model is generating but not yet streaming text. */
@@ -100,6 +100,12 @@ export function serializeResultFrame(turn, modelRef, costMicros = 0, contextToke
100
100
  export function serializeErrorFrame(error) {
101
101
  return `${JSON.stringify({ t: "error", error })}\n`;
102
102
  }
103
+ /** Serialize a `reset` frame (no payload): the broker restarted a turn after a transient mid-stream
104
+ * drop, so every delta/reasoning relayed so far is void. The broker is the producer; this mirror
105
+ * exists so runner tests can construct the frame. */
106
+ export function serializeResetFrame() {
107
+ return `${JSON.stringify({ t: "reset" })}\n`;
108
+ }
103
109
  /** Serialize a heartbeat (no payload) as a single NDJSON line. The broker emits these on an interval
104
110
  * during a long model turn so the worker↔broker connection keeps producing bytes — neither side's
105
111
  * idle/body timeout fires while the model is generating but not yet streaming text. */
@@ -133,6 +139,8 @@ export function parseInferenceFrame(line) {
133
139
  };
134
140
  case "error":
135
141
  return { kind: "error", error: toProxyError(rec.error) };
142
+ case "reset":
143
+ return { kind: "reset" };
136
144
  case "ping":
137
145
  return { kind: "ping" };
138
146
  default:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boardwalk-labs/runner",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
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.2.12",
57
- "@boardwalk-labs/workflow": "0.2.5",
57
+ "@boardwalk-labs/workflow": "0.2.6",
58
58
  "@modelcontextprotocol/sdk": "^1.29.0",
59
59
  "node-gyp-build": "^4.8.4",
60
60
  "tar": "^7.5.16",