@boardwalk-labs/runner 0.1.21 → 0.2.1

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.
Files changed (35) hide show
  1. package/README.md +11 -0
  2. package/dist/contract.d.ts +2 -0
  3. package/dist/contract.js +14 -0
  4. package/dist/daemon/container.d.ts +3 -2
  5. package/dist/daemon/container.js +15 -2
  6. package/dist/daemon/daemon.d.ts +1 -0
  7. package/dist/daemon/daemon.js +16 -1
  8. package/dist/runtime/agent/budget.d.ts +14 -1
  9. package/dist/runtime/agent/budget.js +23 -0
  10. package/dist/runtime/broker_child_dispatcher.d.ts +0 -2
  11. package/dist/runtime/broker_child_dispatcher.js +0 -7
  12. package/dist/runtime/budget_gate.d.ts +51 -0
  13. package/dist/runtime/budget_gate.js +114 -0
  14. package/dist/runtime/freeze_coordinator.js +8 -0
  15. package/dist/runtime/index.d.ts +15 -2
  16. package/dist/runtime/index.js +80 -57
  17. package/dist/runtime/leaf_executor.d.ts +20 -0
  18. package/dist/runtime/leaf_executor.js +13 -4
  19. package/dist/runtime/local_workspace_store.d.ts +22 -0
  20. package/dist/runtime/local_workspace_store.js +103 -0
  21. package/dist/runtime/program_runner.d.ts +32 -28
  22. package/dist/runtime/program_runner.js +75 -44
  23. package/dist/runtime/program_worker.d.ts +5 -18
  24. package/dist/runtime/program_worker.js +3 -20
  25. package/dist/runtime/runner_control_client.d.ts +2 -22
  26. package/dist/runtime/runner_control_client.js +2 -49
  27. package/dist/runtime/suspension.d.ts +20 -109
  28. package/dist/runtime/suspension.js +18 -111
  29. package/dist/runtime/wire/human_input.d.ts +1 -1
  30. package/dist/runtime/wire/human_input.js +2 -2
  31. package/dist/runtime/workflow_host.d.ts +65 -62
  32. package/dist/runtime/workflow_host.js +124 -199
  33. package/dist/runtime/workspace_store.d.ts +31 -3
  34. package/dist/runtime/workspace_store.js +43 -4
  35. package/package.json +3 -3
@@ -12,7 +12,8 @@
12
12
  import { AppError, ErrorCode } from "./support/index.js";
13
13
  import { LeafParked } from "@boardwalk-labs/engine/core";
14
14
  import { normalizeHumanInputResult } from "./wire/human_input.js";
15
- import { SeamSequencer, SuspendError, childRunIdSchema, determinismError, leafResumeSchema, seamFingerprint, SUSPEND_THRESHOLD_MS, } from "./suspension.js";
15
+ import { BUDGET_GATE_KEY } from "./budget_gate.js";
16
+ import { SuspensionCounter, SUSPEND_THRESHOLD_MS } from "./suspension.js";
16
17
  import { throwIfAborted } from "./run_abort.js";
17
18
  /** Parse a `humanInput({ timeout })` string (`"48h"`, `"30m"`, `"90s"`, `"7d"`) to milliseconds, or
18
19
  * null when absent/unparseable (the gate then waits indefinitely). */
@@ -68,7 +69,7 @@ export class WorkerWorkflowHost {
68
69
  now;
69
70
  maxSleepMs;
70
71
  heldPollIntervalMs;
71
- /** Synchronous durable-seam counter + silent-replay live flag (the durable-suspension design). */
72
+ /** Monotonic per-run counter keying suspensions + their HITL gate rows. */
72
73
  seq;
73
74
  /** Run context + on-demand public-API bearer the SDK `runtime` accessor reads off the host. */
74
75
  runtime;
@@ -78,25 +79,9 @@ export class WorkerWorkflowHost {
78
79
  this.now = deps.now ?? Date.now;
79
80
  this.maxSleepMs = deps.maxSleepMs ?? MAX_SLEEP_MS;
80
81
  this.heldPollIntervalMs = deps.heldPollIntervalMs ?? 3_000;
81
- this.seq = new SeamSequencer(deps.replayFrontier ?? 0);
82
+ this.seq = new SuspensionCounter();
82
83
  this.runtime = deps.runtime;
83
84
  }
84
- /** True while re-running already-journaled seams on a resume (the worker suppresses program-log +
85
- * phase observability during this window — those lines were emitted in the prior segment). */
86
- isReplaying() {
87
- return this.seq.isReplaying();
88
- }
89
- /** Raise a durable suspension. With `onSuspend` wired (the real worker) the host signals out-of-band
90
- * and returns a never-resolving promise, so the program's own try/catch can't swallow it; without
91
- * it (the local/test path) it rejects with {@link SuspendError}. */
92
- suspend(signal) {
93
- if (this.deps.onSuspend === undefined)
94
- return Promise.reject(new SuspendError(signal));
95
- this.deps.onSuspend(signal);
96
- return new Promise(() => {
97
- /* never settles: the worker races this against the body and tears the task down */
98
- });
99
- }
100
85
  /** Run `fn` only if the run isn't aborted; otherwise REJECT (never throw synchronously) with the
101
86
  * signal's RunAbortedError — every Promise-returning hook funnels through this so callers always
102
87
  * get a rejected promise on abort, not a sync throw. On the snapshot substrate the body also runs
@@ -185,6 +170,31 @@ export class WorkerWorkflowHost {
185
170
  });
186
171
  }
187
172
  }
173
+ /**
174
+ * The no-freeze HOLD for a human-input gate: register it with the broker (it surfaces in the
175
+ * inbox/API at once), then poll until the answer arrives — the process stays alive and pays
176
+ * for the wait. Rejects promptly when the run's abort signal fires (cancel / credit stop),
177
+ * which is also how a server-side gate expiry that fails the run unwinds this loop.
178
+ */
179
+ async holdForAnswer(seq, gate) {
180
+ const held = this.deps.heldInput;
181
+ if (held === undefined || gate === undefined) {
182
+ throw new AppError(ErrorCode.VALIDATION_FAILED, "humanInput is not available in this runtime (no control-plane connection to register the gate)", { kind: "human_input_unavailable" });
183
+ }
184
+ await held.register(seq, gate);
185
+ for (;;) {
186
+ throwIfAborted(this.deps.signal);
187
+ try {
188
+ const answers = await held.poll(seq);
189
+ if (gate.key in answers)
190
+ return answers[gate.key];
191
+ }
192
+ catch {
193
+ /* transient — retry next tick */
194
+ }
195
+ await this.sleeper.hold(this.heldPollIntervalMs, this.deps.signal);
196
+ }
197
+ }
188
198
  /** Map a froze/aborted freeze outcome to the gate's answer (or throw on an unexpected abort). */
189
199
  resolveFreezeAnswer(outcome, key) {
190
200
  if (outcome.kind !== "wake") {
@@ -204,64 +214,40 @@ export class WorkerWorkflowHost {
204
214
  }
205
215
  setPhase(name, opts) {
206
216
  throwIfAborted(this.deps.signal);
207
- // Suppressed during replay: the marker was already emitted in the prior segment.
208
- if (this.seq.isReplaying())
209
- return;
210
217
  this.deps.phases?.set(name, opts);
211
218
  }
212
219
  agent(prompt, opts) {
213
220
  return this.guarded(() => this.agentSeam(prompt, opts));
214
221
  }
215
- /** The `agent()` durable seam: a journal hit returns the memoized result (never re-runs the LLM); a
216
- * `suspended` hit resumes a parked leaf from its checkpoint + answers; a miss runs the leaf and
217
- * journals the result. A leaf that PARKS (the model called `human_input`) suspends the run. */
222
+ /** The `agent()` seam: run the leaf to completion. A leaf that PARKS (the model called the
223
+ * `human_input` tool) waits for the answer a freeze on the snapshot substrate, a held poll
224
+ * otherwise and re-enters from its in-memory checkpoint. Nothing is memoized: a crash-restart
225
+ * re-runs the leaf (restart-from-top semantics). */
218
226
  async agentSeam(prompt, opts) {
227
+ // One suspension key for the whole leaf: every gate this leaf raises registers under it, and
228
+ // a wake joins ALL of its answered rows, so answers accumulate server-side across parks.
219
229
  const seq = this.seq.next();
220
- const fingerprint = seamFingerprint([
221
- "agent",
222
- opts?.provider ?? null,
223
- opts?.model ?? null,
224
- prompt,
225
- opts?.schema ?? null,
226
- ]);
227
- let resume;
228
- if (this.deps.journal !== undefined) {
229
- const existing = await this.deps.journal.get(seq);
230
- if (existing !== null) {
231
- if (existing.fingerprint !== fingerprint)
232
- throw determinismError(seq, "agent", existing.kind);
233
- if (existing.state === "resolved")
234
- return existing.result;
235
- // A `suspended` entry is a parked leaf (tool-level human_input): re-enter it from the stored
236
- // checkpoint + the answers the broker joined in.
237
- resume = leafResumeSchema.parse(existing.result);
238
- }
239
- }
240
230
  // Bind a computer-use session's tools (browser tier ⇒ its in-VM Playwright MCP) if one was passed.
241
- // Done AFTER the fingerprint so the injected MCP never perturbs determinism (the fingerprint keys
242
- // on provider/model/prompt/schema only — an mcp/session change must not invalidate a journal hit).
243
231
  const effectiveOpts = this.bindBrowserSession(opts);
232
+ // Held-path answers accumulate locally too: a turn with several human_input calls parks once
233
+ // per unanswered gate, and each re-entry must still see every earlier answer.
234
+ const heldAnswers = {};
235
+ let resume;
244
236
  for (;;) {
245
237
  try {
246
- const result = await this.deps.leaf.run(prompt, effectiveOpts, this.deps.signal, resume);
247
- await this.deps.journal?.put({
248
- seq,
249
- kind: "agent",
250
- fingerprint,
251
- label: prompt.slice(0, 120),
252
- result,
253
- });
254
- return result;
238
+ return await this.deps.leaf.run(prompt, effectiveOpts, this.deps.signal, resume);
255
239
  }
256
240
  catch (err) {
257
241
  if (!(err instanceof LeafParked))
258
242
  throw err;
259
243
  // The model paused for a person (the in-leaf `human_input` tool).
244
+ if (err.checkpoint === undefined) {
245
+ throw new AppError(ErrorCode.INTERNAL_ERROR, "Parked agent leaf has no checkpoint to resume from", { kind: "leaf_parked_no_checkpoint", seq });
246
+ }
260
247
  const signal = {
261
248
  reason: "human_input",
262
249
  seq,
263
- fingerprint,
264
- ...(err.checkpoint !== undefined ? { leafCheckpoint: err.checkpoint } : {}),
250
+ leafCheckpoint: err.checkpoint,
265
251
  humanInput: {
266
252
  key: err.request.toolCallId,
267
253
  prompt: err.request.prompt,
@@ -269,14 +255,15 @@ export class WorkerWorkflowHost {
269
255
  },
270
256
  };
271
257
  const freeze = this.deps.freeze;
272
- if (freeze === undefined)
273
- return this.suspend(signal);
274
- // Snapshot substrate: freeze in place; the wake carries the answers and the leaf
275
- // re-enters from its checkpoint — heap intact, no journal round-trip. A leaf may park
276
- // again on a later turn, so this loops.
277
- if (err.checkpoint === undefined) {
278
- throw new AppError(ErrorCode.INTERNAL_ERROR, "Parked agent leaf has no checkpoint to resume from", { kind: "leaf_parked_no_checkpoint", seq });
258
+ if (freeze === undefined) {
259
+ // Hold path: register the gate and poll until answered; the transcript stays in memory.
260
+ heldAnswers[err.request.toolCallId] = await this.holdForAnswer(seq, signal.humanInput);
261
+ resume = { checkpoint: err.checkpoint, answers: { ...heldAnswers } };
262
+ continue;
279
263
  }
264
+ // Snapshot substrate: freeze in place; the wake carries the answers and the leaf
265
+ // re-enters from its checkpoint — heap intact. A leaf may park again on a later turn,
266
+ // so this loops.
280
267
  const outcome = await this.freezeWait(freeze, signal);
281
268
  if (outcome.kind !== "wake") {
282
269
  throw new AppError(ErrorCode.INTERNAL_ERROR, "Suspend aborted surfaced to a human-input seam (the coordinator retries these)", { kind: "unexpected_abort", seq });
@@ -288,32 +275,19 @@ export class WorkerWorkflowHost {
288
275
  }
289
276
  }
290
277
  }
291
- /** Program-level `humanInput()`: suspend the run on a gate, resume with the validated answer. The
292
- * SDK marks this optional; the hosted host always implements it. */
278
+ /** Program-level `humanInput()`: wait on a gate a freeze on the snapshot substrate, a held
279
+ * poll otherwise — and return the validated answer. The SDK marks this optional; the hosted
280
+ * host always implements it. */
293
281
  humanInput(opts) {
294
282
  return this.guarded(() => this.humanInputSeam(opts));
295
283
  }
296
284
  async humanInputSeam(opts) {
297
285
  const seq = this.seq.next();
298
286
  const key = opts.key ?? `seam-${String(seq)}`;
299
- const fingerprint = seamFingerprint(["human_input", key, opts.prompt, opts.input]);
300
- if (this.deps.journal !== undefined) {
301
- const existing = await this.deps.journal.get(seq);
302
- if (existing !== null) {
303
- if (existing.fingerprint !== fingerprint) {
304
- throw determinismError(seq, "human_input", existing.kind);
305
- }
306
- // A resolved entry is the human's validated response; a still-pending entry is a spurious
307
- // wake without an answer, so fall through and re-suspend.
308
- if (existing.state === "resolved")
309
- return normalizeHumanInputResult(existing.result);
310
- }
311
- }
312
287
  const expiresAt = this.timeoutExpiry(opts.timeout);
313
288
  const signal = {
314
289
  reason: "human_input",
315
290
  seq,
316
- fingerprint,
317
291
  humanInput: {
318
292
  key,
319
293
  prompt: opts.prompt,
@@ -327,111 +301,85 @@ export class WorkerWorkflowHost {
327
301
  if (freeze !== undefined) {
328
302
  return await this.freezeHumanInput(freeze, signal, key);
329
303
  }
330
- return this.suspend(signal);
304
+ // Hold path: register the gate and poll until a person answers.
305
+ return normalizeHumanInputResult(await this.holdForAnswer(seq, signal.humanInput));
306
+ }
307
+ /**
308
+ * Park the run at a BUDGET gate and resolve with the responder's answer (docs/SUSPEND_POLICY.md
309
+ * Decision 3). Called by the leaf executor's `streamModel` seam when the run's `max_usd` cap is
310
+ * breached, i.e. from INSIDE an in-flight `agent()` — which drives two deliberate differences from
311
+ * {@link humanInputSeam}:
312
+ *
313
+ * - **No `guarded()` wrapper.** The enclosing `agent()` seam already counted this leaf as work via
314
+ * `trackWork`. Wrapping again would double-count it, and the extra count would never be released
315
+ * — quiescence would never be reached and the freeze would hang forever. `freezeHumanInput` →
316
+ * `freezeWait` does the right thing here: it `endWork`s for the duration of the park (this leaf
317
+ * is waiting, not working, which is exactly what lets the run reach quiescence and freeze) and
318
+ * rejoins on resume.
319
+ * - **Abort is not re-checked up front.** `streamModel` has just done it; a park is not a new
320
+ * entry point.
321
+ *
322
+ * The gate itself is an ordinary {@link HumanInputGate} keyed `budget`, so it persists, surfaces in
323
+ * the inbox, and is answered by the same machinery as any other gate. No timeout: an unanswered
324
+ * budget gate is aged out by the control plane's inactive-cancel reaper, not by a wake we schedule.
325
+ */
326
+ async budgetClearance(gate) {
327
+ const seq = this.seq.next();
328
+ const key = BUDGET_GATE_KEY;
329
+ const signal = {
330
+ reason: "budget",
331
+ seq,
332
+ humanInput: { key, prompt: gate.prompt, inputSpec: gate.inputSpec },
333
+ };
334
+ const freeze = this.deps.freeze;
335
+ if (freeze !== undefined) {
336
+ return await this.freezeHumanInput(freeze, signal, key);
337
+ }
338
+ // No freeze substrate (self-hosted runner / local dev): hold the live process until answered.
339
+ return normalizeHumanInputResult(await this.holdForAnswer(seq, signal.humanInput));
331
340
  }
332
341
  /** Absolute wake time for a `humanInput({ timeout })`, or null when there is none / it's unparseable. */
333
342
  timeoutExpiry(timeout) {
334
343
  const ms = parseTimeoutMs(timeout);
335
344
  return ms === null ? null : this.now() + ms;
336
345
  }
337
- /** `step.run(name, fn)`: run `fn` exactly once across restarts, memoizing its result in the journal
338
- * (the escape hatch for nondeterministic work on a suspend/resume path). */
339
- step(name, fn) {
340
- return this.guarded(() => this.stepSeam(name, fn));
341
- }
342
- async stepSeam(name, fn) {
343
- const seq = this.seq.next();
344
- const fingerprint = seamFingerprint(["step", name]);
345
- if (this.deps.journal !== undefined) {
346
- const existing = await this.deps.journal.get(seq);
347
- if (existing !== null) {
348
- if (existing.fingerprint !== fingerprint)
349
- throw determinismError(seq, "step", existing.kind);
350
- if (existing.state === "resolved")
351
- return existing.result;
352
- }
353
- }
354
- const result = await fn();
355
- await this.deps.journal?.put({ seq, kind: "step", fingerprint, label: name, result });
356
- return result;
357
- }
358
346
  callWorkflow(slug, input, opts) {
359
347
  return this.guarded(() => this.callWorkflowSeam(slug, input, opts));
360
348
  }
361
- /** The `workflows.call` durable seam (the durable-suspension design): start the child once + memoize its
362
- * output; a non-terminal child SUSPENDS the parent (`waiting_for_child`, the child's id journaled)
363
- * the parent releases its task and is woken when the child finalizes. On resume the seam polls
364
- * the journaled child and returns its output (or throws on a failed child). Without a journal (the
365
- * local/test path) it falls back to the in-process hold-and-poll. */
349
+ /** The `workflows.call` seam: start the child once (idempotently the child's run row is the
350
+ * durable memo, so a crash-restarted parent re-attaches instead of re-spawning). A non-terminal
351
+ * child suspends the parent `waiting_for_child` on the snapshot substrate (the wake carries the
352
+ * finalized child, heap intact); without one the parent HOLDS in-process and polls. */
366
353
  async callWorkflowSeam(slug, input, opts) {
367
- if (this.deps.journal === undefined) {
354
+ const freeze = this.deps.freeze;
355
+ if (freeze === undefined) {
356
+ // Hold path: the dispatcher's in-process hold-and-poll until the child is terminal.
368
357
  return this.deps.children.call(slug, input, opts, this.deps.signal);
369
358
  }
370
- const seq = this.seq.next();
371
- const fingerprint = seamFingerprint([
372
- "workflow_call",
373
- slug,
374
- input ?? null,
375
- opts?.idempotencyKey ?? null,
376
- ]);
377
- let knownChildId;
378
- const existing = await this.deps.journal.get(seq);
379
- if (existing !== null) {
380
- if (existing.fingerprint !== fingerprint) {
381
- throw determinismError(seq, "workflow_call", existing.kind);
382
- }
383
- if (existing.state === "resolved")
384
- return existing.result;
385
- // A pending entry holds the child run id we suspended waiting on (resume polls it).
386
- knownChildId = childRunIdSchema.parse(existing.result);
387
- }
388
- // First execution starts (idempotently) the child; a resume polls the journaled one.
389
- const child = knownChildId === undefined
390
- ? await this.deps.children.start(slug, input, opts, this.deps.signal)
391
- : await this.deps.children.poll(knownChildId);
392
- if (child === null) {
393
- throw new AppError(ErrorCode.INTERNAL_ERROR, `Called workflow's child run vanished`, {
394
- slug,
395
- childRunId: knownChildId,
396
- });
397
- }
398
- if (child.status === "completed") {
399
- await this.deps.journal.put({
400
- seq,
401
- kind: "workflow_call",
402
- fingerprint,
403
- label: slug,
404
- result: child.output,
405
- });
359
+ const child = await this.deps.children.start(slug, input, opts, this.deps.signal);
360
+ if (child.status === "completed")
406
361
  return child.output;
407
- }
408
362
  if (child.status === "failed" || child.status === "cancelled") {
409
363
  throw new AppError(ErrorCode.VALIDATION_FAILED, `Called workflow "${slug}" ${child.status} (run ${child.childRunId})`, { childRunId: child.childRunId, status: child.status });
410
364
  }
411
- // Still running → suspend; the child's finalize wakes us.
412
- const signal = {
365
+ // Still running → park in place; the wake carries the finalized child directly (the heap
366
+ // holds this whole frame across the freeze).
367
+ const seq = this.seq.next();
368
+ const outcome = await this.freezeWait(freeze, {
413
369
  reason: "workflow_call",
414
370
  seq,
415
- fingerprint,
416
371
  childRunId: child.childRunId,
417
- };
418
- const freeze = this.deps.freeze;
419
- if (freeze !== undefined) {
420
- // Snapshot substrate: park in place; the wake carries the finalized child directly (no
421
- // journal write for the wake-derived value — the heap holds it and nothing replays).
422
- const outcome = await this.freezeWait(freeze, signal);
423
- if (outcome.kind !== "wake") {
424
- throw new AppError(ErrorCode.INTERNAL_ERROR, "Suspend aborted surfaced to a child-wait seam (the coordinator retries these)", { kind: "unexpected_abort", seq });
425
- }
426
- const woken = outcome.wake.child;
427
- if (outcome.wake.kind !== "workflow_call" || woken === undefined) {
428
- throw new AppError(ErrorCode.INTERNAL_ERROR, `Wake value does not carry the awaited child run (got kind "${outcome.wake.kind}")`, { kind: "wake_mismatch", childRunId: child.childRunId });
429
- }
430
- if (woken.status === "completed")
431
- return woken.output;
432
- throw new AppError(ErrorCode.VALIDATION_FAILED, `Called workflow "${slug}" ${woken.status} (run ${woken.run_id})`, { childRunId: woken.run_id, status: woken.status });
372
+ });
373
+ if (outcome.kind !== "wake") {
374
+ throw new AppError(ErrorCode.INTERNAL_ERROR, "Suspend aborted surfaced to a child-wait seam (the coordinator retries these)", { kind: "unexpected_abort", seq });
375
+ }
376
+ const woken = outcome.wake.child;
377
+ if (outcome.wake.kind !== "workflow_call" || woken === undefined) {
378
+ throw new AppError(ErrorCode.INTERNAL_ERROR, `Wake value does not carry the awaited child run (got kind "${outcome.wake.kind}")`, { kind: "wake_mismatch", childRunId: child.childRunId });
433
379
  }
434
- return this.suspend(signal);
380
+ if (woken.status === "completed")
381
+ return woken.output;
382
+ throw new AppError(ErrorCode.VALIDATION_FAILED, `Called workflow "${slug}" ${woken.status} (run ${woken.run_id})`, { childRunId: woken.run_id, status: woken.status });
435
383
  }
436
384
  /** Fire-and-forget trigger of another workflow; resolves to the new run's id (no hold/poll). */
437
385
  runWorkflow(slug, input, opts) {
@@ -465,7 +413,7 @@ export class WorkerWorkflowHost {
465
413
  }
466
414
  /** `computer.openBrowser()`: open a program-owned, in-VM browser session (the browser tier of
467
415
  * computer use). Not a durable seam — a session is a live resource, reaped at run end, never
468
- * journaled/replayed. Absent backend ⇒ a clear "not available" error. */
416
+ * persisted. Absent backend ⇒ a clear "not available" error. */
469
417
  openBrowserSession(opts) {
470
418
  return this.guarded(async () => {
471
419
  if (this.deps.browserSessions === undefined) {
@@ -492,42 +440,26 @@ export class WorkerWorkflowHost {
492
440
  sleep(arg) {
493
441
  return this.guarded(() => this.sleepSeam(arg));
494
442
  }
495
- /** A short sleep HOLDS the task in-process (cheaper than a release + replay round-trip); a long one
496
- * (≥ {@link SUSPEND_THRESHOLD_MS}) SUSPENDS releases the task, and a timer re-dispatches the run
497
- * when due. Journaled so a resumed run replays past an already-elapsed sleep instantly. */
443
+ /** A short sleep HOLDS the task in-process (cheaper than a snapshot round-trip); a long one
444
+ * (≥ {@link SUSPEND_THRESHOLD_MS}) SUSPENDS on the snapshot substrate the VM freezes and the
445
+ * wake resolves this very await, heap intact. Without a freeze substrate EVERY sleep holds,
446
+ * whatever its length (the no-substrate rule: snapshot or hold, never replay). */
498
447
  async sleepSeam(arg) {
499
448
  const ms = this.resolveSleepMs(arg);
500
449
  if (ms > this.maxSleepMs) {
501
450
  throw new AppError(ErrorCode.VALIDATION_FAILED, `Sleep exceeds the ${String(this.maxSleepMs)}ms (7-day) maximum`, { kind: "sleep_cap" });
502
451
  }
503
- const seq = this.seq.next();
504
- const fingerprint = seamFingerprint(["sleep"]);
505
- if (this.deps.journal !== undefined) {
506
- const existing = await this.deps.journal.get(seq);
507
- if (existing !== null) {
508
- if (existing.fingerprint !== fingerprint)
509
- throw determinismError(seq, "sleep", existing.kind);
510
- // A journaled sleep already elapsed in a prior segment — a resumed run only progresses past a
511
- // sleep once it is due, so on replay this returns immediately.
512
- return;
513
- }
514
- }
515
452
  // A non-positive duration (incl. an `until` already in the past) is a no-op hold, not an error.
516
453
  const holdMs = Math.max(0, ms);
517
454
  if (holdMs === 0)
518
455
  return;
519
456
  const freeze = this.deps.freeze;
520
457
  if (holdMs >= SUSPEND_THRESHOLD_MS && freeze !== undefined) {
521
- // Snapshot substrate: freeze in place; the wake (when the sleep is due) resolves this very
522
- // await, heap intact. An abort falls back to holding the REMAINDER in-process — the
523
- // two-ways rule: snapshot or hold, never replay.
458
+ // Snapshot substrate: freeze in place. An abort falls back to holding the REMAINDER
459
+ // in-process.
524
460
  const requestedAt = this.now();
525
- const outcome = await this.freezeWait(freeze, {
526
- reason: "sleep",
527
- seq,
528
- fingerprint,
529
- durationMs: holdMs,
530
- });
461
+ const seq = this.seq.next();
462
+ const outcome = await this.freezeWait(freeze, { reason: "sleep", seq, durationMs: holdMs });
531
463
  if (outcome.kind === "wake")
532
464
  return;
533
465
  const remaining = holdMs - (this.now() - requestedAt);
@@ -538,14 +470,7 @@ export class WorkerWorkflowHost {
538
470
  await this.sleeper.hold(remaining, this.deps.signal);
539
471
  return;
540
472
  }
541
- // Long wait (transitional substrate): SUSPEND (release the task). The broker records the
542
- // (resolved) sleep journal entry + the wake time transactionally, so on wake this seam replays
543
- // past it (the journal hit above). Only when a journal is wired — without it there is no
544
- // resume, so fall back to holding.
545
- if (holdMs >= SUSPEND_THRESHOLD_MS && this.deps.journal !== undefined) {
546
- return this.suspend({ reason: "sleep", seq, fingerprint, durationMs: holdMs });
547
- }
548
- // Short wait: HOLD the process (no journal — a crash-restart simply re-holds). Snapshot the
473
+ // Hold: the process sleeps in place (a crash-restart simply re-holds). Snapshot the
549
474
  // persistent workspace first so a crash during the hold can restore it. An abort mid-hold rejects.
550
475
  if (this.deps.onBeforeSleep !== undefined)
551
476
  await this.deps.onBeforeSleep();
@@ -6,10 +6,27 @@
6
6
  * workspace never enters memory. NOT a security boundary (tenant isolation is the per-workflow key);
7
7
  * purely a guardrail, like the run budget's `max_usd`. */
8
8
  export declare const WORKSPACE_SNAPSHOT_MAX_BYTES: number;
9
+ /** What a run persists: the WHOLE workspace, or exactly these workspace-relative dirs (possibly none). */
10
+ export type PersistSelection = true | readonly string[];
11
+ /**
12
+ * Resolve what this run persists (docs/WORKSPACE_PERSISTENCE.md §3): the manifest's declaration
13
+ * UNIONED with every `agent({ memory })` dir the run actually used.
14
+ *
15
+ * The union is why this is resolved at PERSIST time, not construction time: memory dirs are
16
+ * undeclared by design (`sdk/src/types.ts` — "`mcp` servers, `skills`, and `memory` — the manifest
17
+ * declares none of them"), so they are only known once the run has made the agent calls. A workflow
18
+ * whose manifest says nothing at all still persists, iff it used memory.
19
+ *
20
+ * `true` swallows the list: the whole workspace already contains every memory dir. An empty array
21
+ * means persist nothing, which is the common case and must stay cheap.
22
+ */
23
+ export declare function resolvePersistSelection(declared: boolean | readonly string[] | undefined, memoryDirs: ReadonlySet<string>): PersistSelection;
9
24
  /** tar+gzip a directory to a file / extract one. Shells out to `tar` in production; injected in tests. */
10
25
  export interface WorkspaceArchiver {
11
- /** Create a gzipped tar of `dir`'s CONTENTS at `destPath`; resolve to the archive's byte size. */
12
- archive(dir: string, destPath: string): Promise<number>;
26
+ /** Create a gzipped tar of `dir`'s CONTENTS at `destPath`; resolve to the archive's byte size.
27
+ * `paths` (workspace-relative, already filtered to those that exist) narrows the archive to
28
+ * exactly those entries — the `persist: [...]` form. Omitted ⇒ the whole tree (`persist: true`). */
29
+ archive(dir: string, destPath: string, paths?: readonly string[]): Promise<number>;
13
30
  /** Extract a gzipped tar `srcPath` into `dir` (created if absent). */
14
31
  extract(srcPath: string, dir: string): Promise<void>;
15
32
  }
@@ -30,6 +47,10 @@ export interface WorkspaceFs {
30
47
  readFile(path: string): Promise<Uint8Array>;
31
48
  writeFile(path: string, data: Uint8Array): Promise<void>;
32
49
  rm(path: string): Promise<void>;
50
+ /** Does this path exist? Used to narrow a `persist: [...]` selection to the dirs the run actually
51
+ * created — `tar` fails the whole archive on one missing member, and a declared-but-unused dir is
52
+ * normal (a workflow declares `["cache", "index"]` and only writes `cache` on its first run). */
53
+ exists(path: string): Promise<boolean>;
33
54
  }
34
55
  export interface WorkspaceStoreDeps {
35
56
  broker: WorkspaceBrokerTransport;
@@ -37,6 +58,9 @@ export interface WorkspaceStoreDeps {
37
58
  fs: WorkspaceFs;
38
59
  /** The `/workspace` root to snapshot/restore. */
39
60
  workspaceRoot: string;
61
+ /** What to persist, read AT PERSIST TIME (see {@link resolvePersistSelection}) — the run's memory
62
+ * dirs aren't known until its agent calls have run, so this cannot be a construction-time value. */
63
+ selection: () => PersistSelection;
40
64
  /** Scratch path for the in-flight tarball. */
41
65
  tmpPath?: string;
42
66
  /** Snapshot tarballs larger than this are skipped (logged). Defaults to {@link WORKSPACE_SNAPSHOT_MAX_BYTES}. */
@@ -59,10 +83,13 @@ export declare class WorkspaceStore {
59
83
  * manifest opts into persistence, so archiving-first never runs for a non-persist workflow; the
60
84
  * only redundant archive is a self-hosted+persist run, where the broker returns a null URL. */
61
85
  persist(): Promise<number>;
86
+ /** Narrow a selection to the dirs that exist: `tar` fails the whole archive on one missing member,
87
+ * and declaring a dir the run hasn't written yet is ordinary (first run of `persist: ["cache"]`). */
88
+ private presentPaths;
62
89
  }
63
90
  /** Production archiver — shells out to the runner image's `tar` (the runner has full shell tooling). */
64
91
  export declare class TarWorkspaceArchiver implements WorkspaceArchiver {
65
- archive(dir: string, destPath: string): Promise<number>;
92
+ archive(dir: string, destPath: string, paths?: readonly string[]): Promise<number>;
66
93
  extract(srcPath: string, dir: string): Promise<void>;
67
94
  }
68
95
  /** Production fs — node's fs/promises. */
@@ -70,4 +97,5 @@ export declare class NodeWorkspaceFs implements WorkspaceFs {
70
97
  readFile(path: string): Promise<Uint8Array>;
71
98
  writeFile(path: string, data: Uint8Array): Promise<void>;
72
99
  rm(path: string): Promise<void>;
100
+ exists(path: string): Promise<boolean>;
73
101
  }
@@ -27,6 +27,24 @@ const exec = promisify(execFile);
27
27
  * workspace never enters memory. NOT a security boundary (tenant isolation is the per-workflow key);
28
28
  * purely a guardrail, like the run budget's `max_usd`. */
29
29
  export const WORKSPACE_SNAPSHOT_MAX_BYTES = 512 * 1024 * 1024;
30
+ /**
31
+ * Resolve what this run persists (docs/WORKSPACE_PERSISTENCE.md §3): the manifest's declaration
32
+ * UNIONED with every `agent({ memory })` dir the run actually used.
33
+ *
34
+ * The union is why this is resolved at PERSIST time, not construction time: memory dirs are
35
+ * undeclared by design (`sdk/src/types.ts` — "`mcp` servers, `skills`, and `memory` — the manifest
36
+ * declares none of them"), so they are only known once the run has made the agent calls. A workflow
37
+ * whose manifest says nothing at all still persists, iff it used memory.
38
+ *
39
+ * `true` swallows the list: the whole workspace already contains every memory dir. An empty array
40
+ * means persist nothing, which is the common case and must stay cheap.
41
+ */
42
+ export function resolvePersistSelection(declared, memoryDirs) {
43
+ if (declared === true)
44
+ return true;
45
+ const list = declared === undefined || declared === false ? [] : declared;
46
+ return [...new Set([...list, ...memoryDirs])];
47
+ }
30
48
  export class WorkspaceStore {
31
49
  deps;
32
50
  tmpPath;
@@ -71,8 +89,15 @@ export class WorkspaceStore {
71
89
  * only redundant archive is a self-hosted+persist run, where the broker returns a null URL. */
72
90
  async persist() {
73
91
  try {
92
+ // What this run actually compounds: the manifest's declaration ∪ the memory dirs it used.
93
+ // Nothing selected is the common case (a workflow that opted into neither) — return before any
94
+ // fs or broker work, so persistence costs a run that doesn't use it precisely nothing.
95
+ const selection = this.deps.selection();
96
+ const paths = selection === true ? undefined : await this.presentPaths(selection);
97
+ if (paths !== undefined && paths.length === 0)
98
+ return 0;
74
99
  await mkdir(dirname(this.tmpPath), { recursive: true }); // per-run TMPDIR may not exist yet
75
- const size = await this.deps.archiver.archive(this.deps.workspaceRoot, this.tmpPath);
100
+ const size = await this.deps.archiver.archive(this.deps.workspaceRoot, this.tmpPath, paths);
76
101
  // Guardrail: an oversized snapshot is dropped (logged), never read into memory or uploaded — the
77
102
  // workflow re-does filesystem work next run, as it would without persistence. Checked on the
78
103
  // on-disk archive size so the big tarball never hits the worker's heap.
@@ -98,12 +123,23 @@ export class WorkspaceStore {
98
123
  return 0;
99
124
  }
100
125
  }
126
+ /** Narrow a selection to the dirs that exist: `tar` fails the whole archive on one missing member,
127
+ * and declaring a dir the run hasn't written yet is ordinary (first run of `persist: ["cache"]`). */
128
+ async presentPaths(selection) {
129
+ const checked = await Promise.all(selection.map(async (p) => (await this.deps.fs.exists(join(this.deps.workspaceRoot, p))) ? p : null));
130
+ return checked.filter((p) => p !== null);
131
+ }
101
132
  }
102
133
  /** Production archiver — shells out to the runner image's `tar` (the runner has full shell tooling). */
103
134
  export class TarWorkspaceArchiver {
104
- async archive(dir, destPath) {
105
- // `-C dir .` archives the CONTENTS of dir (not the dir itself), so extract restores it in place.
106
- await exec("tar", ["czf", destPath, "-C", dir, "."]);
135
+ async archive(dir, destPath, paths) {
136
+ // `-C dir <members>` archives relative to dir, so extract restores in place either way: `.` for
137
+ // the whole tree (`persist: true`), or exactly the named dirs (`persist: [...]` ∪ memory dirs).
138
+ // Members are workspace-relative and schema-validated (no `..`, no absolute, no backslashes —
139
+ // sdk/src/manifest.ts `persistPath`) and pre-filtered to those that exist; `--` keeps a name
140
+ // that starts with `-` from being read as a flag.
141
+ const members = paths === undefined ? ["."] : [...paths];
142
+ await exec("tar", ["czf", destPath, "-C", dir, "--", ...members]);
107
143
  return (await stat(destPath)).size;
108
144
  }
109
145
  async extract(srcPath, dir) {
@@ -128,6 +164,9 @@ export class NodeWorkspaceFs {
128
164
  rm(path) {
129
165
  return rm(path, { force: true });
130
166
  }
167
+ async exists(path) {
168
+ return (await stat(path).catch(() => null)) !== null;
169
+ }
131
170
  }
132
171
  function errMsg(err) {
133
172
  return err instanceof Error ? err.message : String(err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boardwalk-labs/runner",
3
- "version": "0.1.21",
3
+ "version": "0.2.1",
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.35",
57
- "@boardwalk-labs/workflow": "^0.1.29",
56
+ "@boardwalk-labs/engine": "^0.2.0",
57
+ "@boardwalk-labs/workflow": "^0.2.0",
58
58
  "@modelcontextprotocol/sdk": "^1.29.0",
59
59
  "node-gyp-build": "^4.8.4",
60
60
  "tar": "^7.5.16",