@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.
- package/README.md +11 -0
- package/dist/contract.d.ts +2 -0
- package/dist/contract.js +14 -0
- package/dist/daemon/container.d.ts +3 -2
- package/dist/daemon/container.js +15 -2
- package/dist/daemon/daemon.d.ts +1 -0
- package/dist/daemon/daemon.js +16 -1
- package/dist/runtime/agent/budget.d.ts +14 -1
- package/dist/runtime/agent/budget.js +23 -0
- package/dist/runtime/broker_child_dispatcher.d.ts +0 -2
- package/dist/runtime/broker_child_dispatcher.js +0 -7
- package/dist/runtime/budget_gate.d.ts +51 -0
- package/dist/runtime/budget_gate.js +114 -0
- package/dist/runtime/freeze_coordinator.js +8 -0
- package/dist/runtime/index.d.ts +15 -2
- package/dist/runtime/index.js +80 -57
- package/dist/runtime/leaf_executor.d.ts +20 -0
- package/dist/runtime/leaf_executor.js +13 -4
- package/dist/runtime/local_workspace_store.d.ts +22 -0
- package/dist/runtime/local_workspace_store.js +103 -0
- package/dist/runtime/program_runner.d.ts +32 -28
- package/dist/runtime/program_runner.js +75 -44
- package/dist/runtime/program_worker.d.ts +5 -18
- package/dist/runtime/program_worker.js +3 -20
- package/dist/runtime/runner_control_client.d.ts +2 -22
- package/dist/runtime/runner_control_client.js +2 -49
- package/dist/runtime/suspension.d.ts +20 -109
- package/dist/runtime/suspension.js +18 -111
- package/dist/runtime/wire/human_input.d.ts +1 -1
- package/dist/runtime/wire/human_input.js +2 -2
- package/dist/runtime/workflow_host.d.ts +65 -62
- package/dist/runtime/workflow_host.js +124 -199
- package/dist/runtime/workspace_store.d.ts +31 -3
- package/dist/runtime/workspace_store.js +43 -4
- 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 {
|
|
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
|
-
/**
|
|
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
|
|
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()`
|
|
216
|
-
* `
|
|
217
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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()`:
|
|
292
|
-
* SDK marks this optional; the hosted
|
|
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
|
-
|
|
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`
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
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
|
-
|
|
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
|
|
371
|
-
|
|
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 →
|
|
412
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
496
|
-
* (≥ {@link SUSPEND_THRESHOLD_MS}) SUSPENDS
|
|
497
|
-
*
|
|
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
|
|
522
|
-
//
|
|
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
|
|
526
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
106
|
-
|
|
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
|
|
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.
|
|
57
|
-
"@boardwalk-labs/workflow": "^0.
|
|
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",
|