Skip to main content
Hour four of an overnight run: the plan node produced a subtly wrong plan, six downstream agents faithfully built on it, and the review node finally caught it. Without time travel your options are bad — start the whole run over and re-pay every token, or hand-edit the workspace and hope the orchestrator’s idea of state still matches the filesystem’s. Neither works, because agent runs have two kinds of state that drift apart the moment you touch one: the orchestrator’s database (which tasks ran, what they output) and the worktree (what the agents actually wrote to disk). Rolling back one without the other produces a run that is lying to itself. Time travel in Smithers means rolling both back together, to a point that was actually recorded — and, when you’d rather not destroy history, branching a new run from that point instead. Every task attempt records a jj commit pointer for its worktree; every render frame gets a snapshot of graph state. Those two ledgers are what make “go back to before the plan node and try again with a different prompt” a one-line command instead of an archaeology project. One distinction up front, because the industry uses “replay” for two different things. Deterministic replay (Temporal, rr) re-executes code against a recorded log and guarantees you reconstruct exactly the same state — which requires the code to be deterministic. State restoration + re-execution (Smithers, LangGraph) restores a persisted checkpoint and runs forward from it, fresh — no determinism contract, so LLM calls are legal, but the re-executed portion may (often deliberately) diverge from what happened before. Smithers is squarely in the second family: you go back to a real recorded state, and everything after it is a new future, not a reconstruction of the old one.

How the industry does it

The jj operation log is the closest spiritual relative to what Smithers does for the filesystem — and not coincidentally, jj is the machinery Smithers uses under the hood. What no system above provides out of the box is the join: orchestration state and filesystem state rolled back as one unit, keyed by task attempt. That join is the whole point.

How Smithers does it

Two ledgers get written during every run, without workflow-author effort:
  • Graph snapshots per frame. Each render frame captures nodes, outputs, loop state, and input, content-hashed (sha256) and stored deduplicated across _smithers_snapshots, _smithers_snapshot_contents, and _smithers_snapshot_payload_refs (packages/time-travel/src/snapshot/captureSnapshotEffect.js). This is the source of truth for fork, replay, and timeline.
  • VCS pointers per attempt. Every task attempt records the worktree’s jj commit ID in _smithers_attempts.jj_pointer (plus jj_cwd), and the durability layer captures intermediate Tier 1/Tier 2 checkpoints in _smithers_workspace_checkpoints while the agent works.
Five operations act on those ledgers. They differ on two axes — destructive vs. branching and DB vs. filesystem — and picking the wrong one is the most common mistake, so here is the whole map: Separately, smithers restore <runId> --node <id> [--seq N] restores a worktree to a durability checkpoint — the finer-grained per-tool-call ledger — rather than a per-attempt pointer. Attempt pointers tell you where each task ended; durability checkpoints tell you every step of how it got there.

Forks are real child runs

forkRunEffect copies the parent’s snapshot at the chosen frame into a new run (fresh UUID, frame 0) and records the edge in _smithers_branches, all in one transaction. The child carries the parent’s outputs as already-satisfied state; only the nodes you --reset-node (plus, via expandResetSet, what depends on them) re-execute. Input overrides overlay the snapshot’s input, so a fork can ask “what if the input had been different.” Replay is fork + auto-resume — and if you pass an edited workflow file, replay re-blesses the child’s workflow hash so the resume guard accepts the very edit you’re carrying forward (replayFromCheckpointEffect.js). smithers timeline <runId> --tree renders the whole family: parent frames, fork points, and every child branch, derived purely from snapshot + branch rows.

The side-effect boundary

Rolling back a database does not un-send an email. Every destructive operation (rewind, timetravel, revert) runs guardEffectBoundary before touching anything: it finds external effects recorded after the target point, executes registered revert compensation handlers for them, and blocks (TIME_TRAVEL_SIDE_EFFECT_BLOCKED) on any succeeded/unknown effect it cannot compensate — unless you pass --force, which records SideEffectBoundaryCrossed and marks the run needs-attention. Compensation is something you opt tools into:
Branch operations (fork, replay) never compensate the parent’s effects — the parent’s history still happened. They stop before an effect-bearing boundary only when they would re-execute across it (replay, fork --run); a plain fork just warns. Git commits, branch moves, worktree writes, and git push are exempt from the boundary; external API mutations (issues, comments, PR merges) are not.

Safety machinery

Destructive operations are serialized and audited: a per-run rewind lock (lease-renewed at every step, checked again inside the DB transaction) prevents two concurrent time-travel operations; a rate limiter caps rewinds per window; every operation writes a row to a rewind audit table, with in-progress rows recovered at startup. A run that is running with a live owner or fresh heartbeat refuses time travel without force: true. All mutations run through the dialect-agnostic storage seam, so the same code path works on bun:sqlite, PGlite, and Postgres.

Guarantees and limits

Guaranteed: filesystem and DB state roll back atomically to a recorded point — the jj pointer restore happens before the DB transaction, and the transaction truncates frames, snapshots, and vcs-tags together so no derived view (fork, replay, timeline) can read discarded state. Forks never mutate the parent. Uncompensated external effects block by default rather than being silently crossed. Not guaranteed:
  • No deterministic re-execution. Re-run nodes call real agents with real nondeterminism. If you need “the replayed run provably does what the original did,” that is Temporal’s contract, not ours — see Smithers vs Temporal.
  • You can only travel to recorded points — attempts with a jj_pointer, frames with a snapshot. An attempt without a recorded pointer returns success: false.
  • --force means you own the consequences. Crossed effects are archived and the run is flagged, but the email is still sent.
  • Filesystem restore targets the worktree where the attempt ran (jj_cwd). If you deleted that worktree, there is nothing to restore into.
  • revert alone desynchronizes deliberately — worktree moves, DB does not. It exists for inspection; follow with timetravel if you want the run to agree.

Operating it

Day-to-day, from the CLI: timeline, snapshots, fork, replay, rewind RUN_ID FRAME_NO, timetravel, revert, restore, plus retry-task for the common “just redo this one task and resume” case. Every one has an MCP twin so an agent operating a run has the same surface: get_timeline, list_snapshots, fork_run, replay_run, rewind_run, time_travel, revert_attempt, restore_checkpoint — the destructive ones require confirm: true, and rewind/timetravel/revert accept --no-revert to skip compensation handlers (skipped effects become blockers).

See also