@boardwalk-labs/runner 0.1.21 → 0.2.0
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/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +8 -40
- package/dist/runtime/program_runner.d.ts +4 -15
- package/dist/runtime/program_runner.js +6 -21
- package/dist/runtime/program_worker.d.ts +0 -18
- package/dist/runtime/program_worker.js +1 -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 +12 -107
- 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 +42 -60
- package/dist/runtime/workflow_host.js +89 -199
- package/package.json +3 -3
|
@@ -12,7 +12,7 @@
|
|
|
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 { SuspensionCounter, SUSPEND_THRESHOLD_MS } from "./suspension.js";
|
|
16
16
|
import { throwIfAborted } from "./run_abort.js";
|
|
17
17
|
/** Parse a `humanInput({ timeout })` string (`"48h"`, `"30m"`, `"90s"`, `"7d"`) to milliseconds, or
|
|
18
18
|
* null when absent/unparseable (the gate then waits indefinitely). */
|
|
@@ -68,7 +68,7 @@ export class WorkerWorkflowHost {
|
|
|
68
68
|
now;
|
|
69
69
|
maxSleepMs;
|
|
70
70
|
heldPollIntervalMs;
|
|
71
|
-
/**
|
|
71
|
+
/** Monotonic per-run counter keying suspensions + their HITL gate rows. */
|
|
72
72
|
seq;
|
|
73
73
|
/** Run context + on-demand public-API bearer the SDK `runtime` accessor reads off the host. */
|
|
74
74
|
runtime;
|
|
@@ -78,25 +78,9 @@ export class WorkerWorkflowHost {
|
|
|
78
78
|
this.now = deps.now ?? Date.now;
|
|
79
79
|
this.maxSleepMs = deps.maxSleepMs ?? MAX_SLEEP_MS;
|
|
80
80
|
this.heldPollIntervalMs = deps.heldPollIntervalMs ?? 3_000;
|
|
81
|
-
this.seq = new
|
|
81
|
+
this.seq = new SuspensionCounter();
|
|
82
82
|
this.runtime = deps.runtime;
|
|
83
83
|
}
|
|
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
84
|
/** Run `fn` only if the run isn't aborted; otherwise REJECT (never throw synchronously) with the
|
|
101
85
|
* signal's RunAbortedError — every Promise-returning hook funnels through this so callers always
|
|
102
86
|
* get a rejected promise on abort, not a sync throw. On the snapshot substrate the body also runs
|
|
@@ -185,6 +169,31 @@ export class WorkerWorkflowHost {
|
|
|
185
169
|
});
|
|
186
170
|
}
|
|
187
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* The no-freeze HOLD for a human-input gate: register it with the broker (it surfaces in the
|
|
174
|
+
* inbox/API at once), then poll until the answer arrives — the process stays alive and pays
|
|
175
|
+
* for the wait. Rejects promptly when the run's abort signal fires (cancel / credit stop),
|
|
176
|
+
* which is also how a server-side gate expiry that fails the run unwinds this loop.
|
|
177
|
+
*/
|
|
178
|
+
async holdForAnswer(seq, gate) {
|
|
179
|
+
const held = this.deps.heldInput;
|
|
180
|
+
if (held === undefined || gate === undefined) {
|
|
181
|
+
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" });
|
|
182
|
+
}
|
|
183
|
+
await held.register(seq, gate);
|
|
184
|
+
for (;;) {
|
|
185
|
+
throwIfAborted(this.deps.signal);
|
|
186
|
+
try {
|
|
187
|
+
const answers = await held.poll(seq);
|
|
188
|
+
if (gate.key in answers)
|
|
189
|
+
return answers[gate.key];
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
/* transient — retry next tick */
|
|
193
|
+
}
|
|
194
|
+
await this.sleeper.hold(this.heldPollIntervalMs, this.deps.signal);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
188
197
|
/** Map a froze/aborted freeze outcome to the gate's answer (or throw on an unexpected abort). */
|
|
189
198
|
resolveFreezeAnswer(outcome, key) {
|
|
190
199
|
if (outcome.kind !== "wake") {
|
|
@@ -204,64 +213,40 @@ export class WorkerWorkflowHost {
|
|
|
204
213
|
}
|
|
205
214
|
setPhase(name, opts) {
|
|
206
215
|
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
216
|
this.deps.phases?.set(name, opts);
|
|
211
217
|
}
|
|
212
218
|
agent(prompt, opts) {
|
|
213
219
|
return this.guarded(() => this.agentSeam(prompt, opts));
|
|
214
220
|
}
|
|
215
|
-
/** The `agent()`
|
|
216
|
-
* `
|
|
217
|
-
*
|
|
221
|
+
/** The `agent()` seam: run the leaf to completion. A leaf that PARKS (the model called the
|
|
222
|
+
* `human_input` tool) waits for the answer — a freeze on the snapshot substrate, a held poll
|
|
223
|
+
* otherwise — and re-enters from its in-memory checkpoint. Nothing is memoized: a crash-restart
|
|
224
|
+
* re-runs the leaf (restart-from-top semantics). */
|
|
218
225
|
async agentSeam(prompt, opts) {
|
|
226
|
+
// One suspension key for the whole leaf: every gate this leaf raises registers under it, and
|
|
227
|
+
// a wake joins ALL of its answered rows, so answers accumulate server-side across parks.
|
|
219
228
|
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
229
|
// 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
230
|
const effectiveOpts = this.bindBrowserSession(opts);
|
|
231
|
+
// Held-path answers accumulate locally too: a turn with several human_input calls parks once
|
|
232
|
+
// per unanswered gate, and each re-entry must still see every earlier answer.
|
|
233
|
+
const heldAnswers = {};
|
|
234
|
+
let resume;
|
|
244
235
|
for (;;) {
|
|
245
236
|
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;
|
|
237
|
+
return await this.deps.leaf.run(prompt, effectiveOpts, this.deps.signal, resume);
|
|
255
238
|
}
|
|
256
239
|
catch (err) {
|
|
257
240
|
if (!(err instanceof LeafParked))
|
|
258
241
|
throw err;
|
|
259
242
|
// The model paused for a person (the in-leaf `human_input` tool).
|
|
243
|
+
if (err.checkpoint === undefined) {
|
|
244
|
+
throw new AppError(ErrorCode.INTERNAL_ERROR, "Parked agent leaf has no checkpoint to resume from", { kind: "leaf_parked_no_checkpoint", seq });
|
|
245
|
+
}
|
|
260
246
|
const signal = {
|
|
261
247
|
reason: "human_input",
|
|
262
248
|
seq,
|
|
263
|
-
|
|
264
|
-
...(err.checkpoint !== undefined ? { leafCheckpoint: err.checkpoint } : {}),
|
|
249
|
+
leafCheckpoint: err.checkpoint,
|
|
265
250
|
humanInput: {
|
|
266
251
|
key: err.request.toolCallId,
|
|
267
252
|
prompt: err.request.prompt,
|
|
@@ -269,14 +254,15 @@ export class WorkerWorkflowHost {
|
|
|
269
254
|
},
|
|
270
255
|
};
|
|
271
256
|
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 });
|
|
257
|
+
if (freeze === undefined) {
|
|
258
|
+
// Hold path: register the gate and poll until answered; the transcript stays in memory.
|
|
259
|
+
heldAnswers[err.request.toolCallId] = await this.holdForAnswer(seq, signal.humanInput);
|
|
260
|
+
resume = { checkpoint: err.checkpoint, answers: { ...heldAnswers } };
|
|
261
|
+
continue;
|
|
279
262
|
}
|
|
263
|
+
// Snapshot substrate: freeze in place; the wake carries the answers and the leaf
|
|
264
|
+
// re-enters from its checkpoint — heap intact. A leaf may park again on a later turn,
|
|
265
|
+
// so this loops.
|
|
280
266
|
const outcome = await this.freezeWait(freeze, signal);
|
|
281
267
|
if (outcome.kind !== "wake") {
|
|
282
268
|
throw new AppError(ErrorCode.INTERNAL_ERROR, "Suspend aborted surfaced to a human-input seam (the coordinator retries these)", { kind: "unexpected_abort", seq });
|
|
@@ -288,32 +274,19 @@ export class WorkerWorkflowHost {
|
|
|
288
274
|
}
|
|
289
275
|
}
|
|
290
276
|
}
|
|
291
|
-
/** Program-level `humanInput()`:
|
|
292
|
-
* SDK marks this optional; the hosted
|
|
277
|
+
/** Program-level `humanInput()`: wait on a gate — a freeze on the snapshot substrate, a held
|
|
278
|
+
* poll otherwise — and return the validated answer. The SDK marks this optional; the hosted
|
|
279
|
+
* host always implements it. */
|
|
293
280
|
humanInput(opts) {
|
|
294
281
|
return this.guarded(() => this.humanInputSeam(opts));
|
|
295
282
|
}
|
|
296
283
|
async humanInputSeam(opts) {
|
|
297
284
|
const seq = this.seq.next();
|
|
298
285
|
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
286
|
const expiresAt = this.timeoutExpiry(opts.timeout);
|
|
313
287
|
const signal = {
|
|
314
288
|
reason: "human_input",
|
|
315
289
|
seq,
|
|
316
|
-
fingerprint,
|
|
317
290
|
humanInput: {
|
|
318
291
|
key,
|
|
319
292
|
prompt: opts.prompt,
|
|
@@ -327,111 +300,51 @@ export class WorkerWorkflowHost {
|
|
|
327
300
|
if (freeze !== undefined) {
|
|
328
301
|
return await this.freezeHumanInput(freeze, signal, key);
|
|
329
302
|
}
|
|
330
|
-
|
|
303
|
+
// Hold path: register the gate and poll until a person answers.
|
|
304
|
+
return normalizeHumanInputResult(await this.holdForAnswer(seq, signal.humanInput));
|
|
331
305
|
}
|
|
332
306
|
/** Absolute wake time for a `humanInput({ timeout })`, or null when there is none / it's unparseable. */
|
|
333
307
|
timeoutExpiry(timeout) {
|
|
334
308
|
const ms = parseTimeoutMs(timeout);
|
|
335
309
|
return ms === null ? null : this.now() + ms;
|
|
336
310
|
}
|
|
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
311
|
callWorkflow(slug, input, opts) {
|
|
359
312
|
return this.guarded(() => this.callWorkflowSeam(slug, input, opts));
|
|
360
313
|
}
|
|
361
|
-
/** The `workflows.call`
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
* local/test path) it falls back to the in-process hold-and-poll. */
|
|
314
|
+
/** The `workflows.call` seam: start the child once (idempotently — the child's run row is the
|
|
315
|
+
* durable memo, so a crash-restarted parent re-attaches instead of re-spawning). A non-terminal
|
|
316
|
+
* child suspends the parent `waiting_for_child` on the snapshot substrate (the wake carries the
|
|
317
|
+
* finalized child, heap intact); without one the parent HOLDS in-process and polls. */
|
|
366
318
|
async callWorkflowSeam(slug, input, opts) {
|
|
367
|
-
|
|
319
|
+
const freeze = this.deps.freeze;
|
|
320
|
+
if (freeze === undefined) {
|
|
321
|
+
// Hold path: the dispatcher's in-process hold-and-poll until the child is terminal.
|
|
368
322
|
return this.deps.children.call(slug, input, opts, this.deps.signal);
|
|
369
323
|
}
|
|
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
|
-
});
|
|
324
|
+
const child = await this.deps.children.start(slug, input, opts, this.deps.signal);
|
|
325
|
+
if (child.status === "completed")
|
|
406
326
|
return child.output;
|
|
407
|
-
}
|
|
408
327
|
if (child.status === "failed" || child.status === "cancelled") {
|
|
409
328
|
throw new AppError(ErrorCode.VALIDATION_FAILED, `Called workflow "${slug}" ${child.status} (run ${child.childRunId})`, { childRunId: child.childRunId, status: child.status });
|
|
410
329
|
}
|
|
411
|
-
// Still running →
|
|
412
|
-
|
|
330
|
+
// Still running → park in place; the wake carries the finalized child directly (the heap
|
|
331
|
+
// holds this whole frame across the freeze).
|
|
332
|
+
const seq = this.seq.next();
|
|
333
|
+
const outcome = await this.freezeWait(freeze, {
|
|
413
334
|
reason: "workflow_call",
|
|
414
335
|
seq,
|
|
415
|
-
fingerprint,
|
|
416
336
|
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 });
|
|
337
|
+
});
|
|
338
|
+
if (outcome.kind !== "wake") {
|
|
339
|
+
throw new AppError(ErrorCode.INTERNAL_ERROR, "Suspend aborted surfaced to a child-wait seam (the coordinator retries these)", { kind: "unexpected_abort", seq });
|
|
340
|
+
}
|
|
341
|
+
const woken = outcome.wake.child;
|
|
342
|
+
if (outcome.wake.kind !== "workflow_call" || woken === undefined) {
|
|
343
|
+
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
344
|
}
|
|
434
|
-
|
|
345
|
+
if (woken.status === "completed")
|
|
346
|
+
return woken.output;
|
|
347
|
+
throw new AppError(ErrorCode.VALIDATION_FAILED, `Called workflow "${slug}" ${woken.status} (run ${woken.run_id})`, { childRunId: woken.run_id, status: woken.status });
|
|
435
348
|
}
|
|
436
349
|
/** Fire-and-forget trigger of another workflow; resolves to the new run's id (no hold/poll). */
|
|
437
350
|
runWorkflow(slug, input, opts) {
|
|
@@ -465,7 +378,7 @@ export class WorkerWorkflowHost {
|
|
|
465
378
|
}
|
|
466
379
|
/** `computer.openBrowser()`: open a program-owned, in-VM browser session (the browser tier of
|
|
467
380
|
* computer use). Not a durable seam — a session is a live resource, reaped at run end, never
|
|
468
|
-
*
|
|
381
|
+
* persisted. Absent backend ⇒ a clear "not available" error. */
|
|
469
382
|
openBrowserSession(opts) {
|
|
470
383
|
return this.guarded(async () => {
|
|
471
384
|
if (this.deps.browserSessions === undefined) {
|
|
@@ -492,42 +405,26 @@ export class WorkerWorkflowHost {
|
|
|
492
405
|
sleep(arg) {
|
|
493
406
|
return this.guarded(() => this.sleepSeam(arg));
|
|
494
407
|
}
|
|
495
|
-
/** A short sleep HOLDS the task in-process (cheaper than a
|
|
496
|
-
* (≥ {@link SUSPEND_THRESHOLD_MS}) SUSPENDS
|
|
497
|
-
*
|
|
408
|
+
/** A short sleep HOLDS the task in-process (cheaper than a snapshot round-trip); a long one
|
|
409
|
+
* (≥ {@link SUSPEND_THRESHOLD_MS}) SUSPENDS on the snapshot substrate — the VM freezes and the
|
|
410
|
+
* wake resolves this very await, heap intact. Without a freeze substrate EVERY sleep holds,
|
|
411
|
+
* whatever its length (the no-substrate rule: snapshot or hold, never replay). */
|
|
498
412
|
async sleepSeam(arg) {
|
|
499
413
|
const ms = this.resolveSleepMs(arg);
|
|
500
414
|
if (ms > this.maxSleepMs) {
|
|
501
415
|
throw new AppError(ErrorCode.VALIDATION_FAILED, `Sleep exceeds the ${String(this.maxSleepMs)}ms (7-day) maximum`, { kind: "sleep_cap" });
|
|
502
416
|
}
|
|
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
417
|
// A non-positive duration (incl. an `until` already in the past) is a no-op hold, not an error.
|
|
516
418
|
const holdMs = Math.max(0, ms);
|
|
517
419
|
if (holdMs === 0)
|
|
518
420
|
return;
|
|
519
421
|
const freeze = this.deps.freeze;
|
|
520
422
|
if (holdMs >= SUSPEND_THRESHOLD_MS && freeze !== undefined) {
|
|
521
|
-
// Snapshot substrate: freeze in place
|
|
522
|
-
//
|
|
523
|
-
// two-ways rule: snapshot or hold, never replay.
|
|
423
|
+
// Snapshot substrate: freeze in place. An abort falls back to holding the REMAINDER
|
|
424
|
+
// in-process.
|
|
524
425
|
const requestedAt = this.now();
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
seq,
|
|
528
|
-
fingerprint,
|
|
529
|
-
durationMs: holdMs,
|
|
530
|
-
});
|
|
426
|
+
const seq = this.seq.next();
|
|
427
|
+
const outcome = await this.freezeWait(freeze, { reason: "sleep", seq, durationMs: holdMs });
|
|
531
428
|
if (outcome.kind === "wake")
|
|
532
429
|
return;
|
|
533
430
|
const remaining = holdMs - (this.now() - requestedAt);
|
|
@@ -538,14 +435,7 @@ export class WorkerWorkflowHost {
|
|
|
538
435
|
await this.sleeper.hold(remaining, this.deps.signal);
|
|
539
436
|
return;
|
|
540
437
|
}
|
|
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
|
|
438
|
+
// Hold: the process sleeps in place (a crash-restart simply re-holds). Snapshot the
|
|
549
439
|
// persistent workspace first so a crash during the hold can restore it. An abort mid-hold rejects.
|
|
550
440
|
if (this.deps.onBeforeSleep !== undefined)
|
|
551
441
|
await this.deps.onBeforeSleep();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boardwalk-labs/runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|