Skip to content

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.

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;
}
}
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:

  • callLog and position are what you persist. The log records every action that completed before the interrupt (and any concurrent calls that finished behind it); position is the slot of the call that paused the run.
  • interrupt.data is 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.

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;
}

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 }

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.