Resume an interrupted script
An action inside a script can pause the whole run — to ask a human for approval, say — by
throwing an ActionInterrupt. When that happens the sandbox stops the run and eval throws
a SandboxInterrupt carrying everything you need to persist the run and continue it later.
This guide walks the round trip. For the mechanism underneath it, see
Determinism and resumption; for interrupts in
general, Interrupts.
When a run interrupts
Section titled “When a run interrupts”Consider an action that gates on human approval the first time it is called:
import { defineAction, ActionInterrupt, z } from '@grundlag/core';
const gate = defineAction({ id: 'gate', name: 'Await approval', input: z.object({}), output: z.object({ approved: z.boolean() }), execute: async ({ resumption }) => { if (!resumption) { throw new ActionInterrupt('awaiting approval', undefined, { token: 'checkpoint' }); } return { approved: resumption.data === 'yes' }; },});Running a script that calls it interrupts:
import { SandboxInterrupt } from '@grundlag/sandbox';
const script = 'data.sum = (await add({ a: 1, b: 1 })).sum; data.ok = (await gate({})).approved;';
try { await sandbox.eval({ script, data: {} });} catch (error) { if (error instanceof SandboxInterrupt) { // ...persist and surface, below } else { throw error; }}What SandboxInterrupt carries
Section titled “What SandboxInterrupt carries”class SandboxInterrupt extends Error { callLog: (CallLogEntry | null)[]; // the log accumulated up to the interrupt data: Record<string, unknown>; // the `data` snapshot at the interrupt console: SandboxConsoleEntry[]; // console output captured so far position: number; // log slot of the interrupting call interrupt: { data?: unknown; state?: unknown }; // the action's payload and checkpoint}Two parts matter for resuming:
callLogandpositionare what you persist. The log records every action that completed before the interrupt (and any concurrent calls that finished behind it);positionis the slot of the call that paused the run.interrupt.datais the interrupting action’s caller-facing payload — the question to put to a human ({ reason: 'needs-human' }, a diff to approve, and so on). Surface this to whoever must answer.
interrupt.state is the action’s private checkpoint. You do not interpret it; you store it
and hand it back on resume.
Persist and surface
Section titled “Persist and surface”if (error instanceof SandboxInterrupt) { await store.save(runId, { script, // the SAME script must be re-run to resume callLog: error.callLog, position: error.position, interruptState: error.interrupt.state, }); await askHuman(error.interrupt.data); // e.g. render the approval prompt return;}Resume with the answer
Section titled “Resume with the answer”Once you have the answer, call eval again with the same script, the saved callLog,
and a resumption targeting the interrupted position. Put the answer in resumption.data;
pass the saved checkpoint back as resumption.state:
const saved = await store.load(runId);
const resumed = await sandbox.eval({ script: saved.script, data: {}, callLog: saved.callLog, resumption: { position: saved.position, data: 'yes', // the human's answer state: saved.interruptState, // the action's checkpoint, handed back verbatim },});// resumed.data => { sum: 2, ok: true }What happens on resume: replay
Section titled “What happens on resume: replay”The sandbox does not continue from where it paused. It re-runs the script from the top,
but instead of re-executing the actions recorded in callLog, it replays their recorded
responses. Only when execution reaches the interrupted position does the action run for
real again — and this time its execute receives resumption (your { data, state }), so
the gate action above returns { approved: true } instead of interrupting.
A consequence worth knowing: because the script re-runs from the top, the resumed run’s
console contains the run’s complete output, not just what came after the interrupt.
The determinism guard: CallLogMismatchError
Section titled “The determinism guard: CallLogMismatchError”Replay only works if the script takes the same path it took the first time. The sandbox
checks this at every recorded slot: if the operation at a position is a different kind than
what was recorded (an action call where a clock read was logged, or a call to a different
action), it throws CallLogMismatchError:
import { CallLogMismatchError } from '@grundlag/sandbox';
try { await sandbox.eval({ script, data: {}, callLog, resumption });} catch (error) { if (error instanceof CallLogMismatchError) { // The script diverged from its log at error.position — it is not // deterministic and cannot be safely resumed. }}This is a guard, not a feature you invoke. It means the script’s control flow depended on
something the log did not capture. Keep scripts deterministic — branch on data, action
results, and the sandbox-provided clock and RNG (which are recorded), never on ambient
state — and replay will always line up. See
Determinism and resumption for why this holds.