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(plusjj_cwd), and the durability layer captures intermediate Tier 1/Tier 2 checkpoints in_smithers_workspace_checkpointswhile the agent works.
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:
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 isrunning 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 returnssuccess: false. --forcemeans 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. revertalone desynchronizes deliberately — worktree moves, DB does not. It exists for inspection; follow withtimetravelif 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
- Recipes: time travel, fork, replay, diff — the copy-paste quickstart
- Time-travel commands compared — the five commands side by side
- Time-travel API —
timeTravel/revertToAttemptprogrammatic surface - Revert — compensation handler contract in full
- Durability — the checkpoint ledgers time travel reads from