Skip to content

Snapshots and resume

A snapshot contains finished steps' results and cache keys, as JSON. A new run can reuse those finished steps instead of executing them again.

This is completed-step reuse, not durable execution. June doesn't checkpoint a running model or tool call. Anything that hadn't finished starts again, including its side effects.

What resume isn't

  • Durable execution. A run lives in one process. Nothing restarts it, and nothing checkpoints work in flight.
  • Exactly-once. A step that didn't finish runs again, side effects included.
  • Available from the CLI. june run has no snapshot option; resume from code.

For work that genuinely lives outside the process, such as a training job, use a Job: a backend owns it, and a later process can find it again. <FineTune> can wait on a job it started earlier; see Training in workflows.

Resuming a run

import { openai } from "june/sdk/openai";
import { Agent, Step, run, start, type RunSnapshot } from "june";

declare function save(snapshot: RunSnapshot): Promise<void>;
declare function load(): Promise<RunSnapshot>;

const workflow = (
  <Agent model={openai("gpt-5-mini")}>
    <Step id="plan" prompt="Plan the deploy">
      {(plan) => <Step id="checklist" prompt={`Write a checklist for:\n${plan}`} />}
    </Step>
  </Agent>
);

const handle = start(workflow);
try {
  await handle.done;
} finally {
  await save(handle.snapshot());
}

// later, or in another process
await run(workflow, { snapshot: await load() });
{
  "version": 1,
  "steps": {
    "plan": {
      "cacheKey": "step:1b0e5c3f9a27d4",
      "result": "Deploy web, then api"
    }
  }
}

If checklist failed the first time, the second run reuses plan and runs only checklist.

The resume boundary

The boundary is the step. A step is either finished, and in the snapshot, or it isn't, and it runs again from the start.

Reused: a step whose id and cache key match a snapshot entry. It doesn't run and takes no concurrency slot; its children render right away with the saved result. Its trace node is done with resumed: true and its result, and has no timing, model or usage, since it didn't run. Its result is in steps, but it adds nothing to the run's usage.

Runs again:

  • a step that was running, queued, or waiting on an approval when the snapshot was taken
  • a step that failed
  • a step whose prompt, settings, tools or output changed since, so its cache key no longer matches
  • a step the snapshot has no entry for

June doesn't checkpoint inside a step. A model call or tool loop that was in flight starts over, so a tool can run twice across a crash. Make tools with side effects safe to repeat, or put them behind an approval.

snapshot()

handle.snapshot() works at any time: while the run is going, after it settles, and after it fails or aborts. It returns:

  • every step that has finished in this run, with its result and cache key
  • entries from the run's own snapshot option that it hasn't rendered, so steps behind a branch the run didn't take aren't lost

An entry whose step renders with a different cache key is dropped, and replaced if the step finishes.

run() and start() check a snapshot before rendering anything, and throw for one without version: 1, or with a step that lacks a cacheKey or a result.

Saving snapshots

June has no storage: the snapshot is JSON, and saving it is up to you. Results go in as they are, so return JSON-safe values from useStep if you serialize the snapshot. undefined is lost in JSON, and a step entry without a result is rejected.

To keep finished steps across a crash, save as steps finish. runResumable writes the snapshot to a file whenever a step finishes, resumes from that file when it exists, and removes it once the run succeeds:

// resumable.ts
import type { ReactNode } from "react";
import { start, type RunSnapshot } from "june";

export async function runResumable(workflow: ReactNode, path: string) {
  const file = Bun.file(path);
  const snapshot: RunSnapshot | undefined = (await file.exists())
    ? await file.json()
    : undefined;

  let saved = Object.keys(snapshot?.steps ?? {}).length;
  let saving = Promise.resolve();

  const handle = start(workflow, {
    snapshot,
    onUpdate: () => {
      const latest = handle.snapshot();
      const finished = Object.keys(latest.steps).length;
      if (finished === saved) return;
      saved = finished;
      saving = saving.then(async () => {
        await Bun.write(path, JSON.stringify(latest));
      });
    },
  });

  try {
    const result = await handle.done;
    await saving;
    await file.delete();
    return result;
  } catch (error) {
    await saving;
    await Bun.write(path, JSON.stringify(handle.snapshot()));
    throw error;
  }
}

A snapshot's results are used as they are, without running anything. Load snapshots only from storage you trust, like any other input to your workflow.

Next