Status: Accepted + Implemented (2026-06-13) — Phase 1 and Phase 2 (2a/2b/2c/2d)
are production (see docs/feature-map.md § Time-Travel / Rewind); concurrent-live-fork
is owner-rejected out-of-scope. Authored design-first for issue
#1533; flow-trace verified by docs-maintainer (5/5 seams); ADR + plan reviewed and
signed off by lead-coder.
Track: Core state-model — successor seam to ADR-0001 (WAL+snapshot),
ADR-0002 (forward-replay), ADR-0023 (PlanSnapshot).
Owner status: design judgments co-designed + confirmed with owner (issue
#1533). This ADR records the decision + grounds it in the live code seams.
Reyn already has two of the three "time-travel" capabilities; this ADR adds the third (user-facing rewind/resume). Keeping them distinct is load-bearing:
| Layer | What | Status | Mechanism |
|---|---|---|---|
| time-travel debugging | read-only walk / seek / compare a past run | done | ReplayEngine (src/reyn/replay/engine.py) + dogfood-trace |
| crash recovery | re-execute from the crash point (= head) | done | WAL forward-replay (ADR-0002) |
| user-facing rewind/resume | jump back to an arbitrary checkpoint and continue / branch | NEW (#1533) | this ADR |
The crash-recovery path already reconstructs "head" from snapshot + WAL delta.
User-facing rewind generalises that to "reconstruct arbitrary seq N", which is a
point-in-time-recovery (PITR) problem, plus an append-only branch model
so undo never destroys audit history.
Grounded in the current implementation (the seams a rewind must hook):
- Global, single-sequence, append-only WAL.
append(kind, **fields) -> seqassigns a monotonic globalseqandfsyncs per append (durability). iter_from(min_seq)yields entriesseq >= min_seqin file order — the forward-replay read seam.truncate_below(min_keep_seq)atomically rewrites the WAL keepingseq >= min_keep_seq(tmp-write → fsync → rename). Caller computesmin_keep_seq = min(all agent applied_seq, all active skill last_phase_applied_seq) + 1— the retention seam._scan_max_seqrebuilds the counter on load;truncate_belowdeliberately never drops the highest present seq (so the counter never reissues a used seq).
AgentSnapshot: per-agent materialized state —applied_seq(highest WAL seq baked in),inbox,pending_chains,active_skill_run_ids,outstanding_interventions,buffered_intervention_answers,active_plan_ids.load/saveare atomic (tmp → fsync → rename), with aschema_versionrefuse.SnapshotJournalholds the single current in-memory snapshot and, on each state change, appends to the WAL and advancessnapshot.applied_seq.- Key gap for PITR: today there is exactly one snapshot per agent (the
latest). There are no generations. You can reconstruct
head(snapshot + iter_from(applied_seq+1)) but not an arbitrary past seq N.
SkillResumeAnalyzer.analyze()reads WAL events pastsnapshot.applied_seqand produces aResumePlanofCommittedStep(memoizable — 0-token Ghost Replay) andAmbiguousStep(operator decision). This is the existing "replay forward without re-invoking the LLM" engine — the seam Phase-2 act-turn granularity reuses.
restore_allrebuilds every agent from its snapshot + WAL on startup and is where truncation is driven. The rewind entry point lives here (system-wide reconstruct-as-of-N).
- Cooperative cancellation of an in-flight chain (background task → act-turn →
in-flight op; subprocess via
cancel_inflight). Rewind reuses this cascade.
Reconstructing arbitrary seq N = **nearest snapshot generation (applied_seq ≤ N)
iter_fromWAL delta replayed forward to N**. Crash-recovery (N = head) becomes the special case. Snapshots are full (owner: snapshots are small → no delta chain; reconstruction stays a simple "load + replay"). Snapshot generations are cut at turn / plan-step / phase boundaries — i.e. exactly the user-facing checkpoint granularity.PlanSnapshot(ADR-0023) folds in as one generation kind.
A rewind to seq N is a global consistent cut: every agent, every plan, the router, and the single shared workspace move to as-of-N together. This is architecture-enforced, not a feature we bolt on:
- WAL is a global single-seq log (ADR-0001) — one N orders everything.
- The workspace is one shared SSoT (P5) — one revert covers all.
- cross-agent ordering is already coupled through that single seq.
Mental model = VM snapshot /
git checkoutof the whole repo. All in-flight tasks are cancelled on rewind.
Rewind is not backward deletion. It appends a rewind/reset record to the
WAL that moves the active pointer to seq N; entries > N are retained as an
inactive branch (P6 audit + WAL stay append-only). Therefore every undo is
internally a fork. Core wiring (Phase 1): WAL replay and snapshot
derivation must honor the reset record — after a rewind, reconstruction (incl.
a crash mid-rewind) yields the as-of-N state and must not resurrect the
"discarded future".
Cancel targets only chains executing forward at rewind time (idle ⇒ no cancel,
just state restore). Cascade: background task → current act-turn → in-flight op
(LLM / tool / subprocess via #1470 cancel_inflight). A global rewind cancels all
in-flight.
(Implementation note — addendum to D4 above, not a revision of it.)
Before #2242, the turn body ran inline on the driver task itself (the same
task that owns run_one_iteration's loop), and could only be interrupted by
a COOPERATIVE flag checked at the top of each router-loop iteration — never
mid-generation. A user hitting Ctrl-C while the agent was mid-LLM-call had
to wait for that call to return before the flag was even checked.
#2242 changed the turn body to run as its OWN per-turn sub-task
(self._turn_owner_task = asyncio.create_task(self._run_turn_body(kind, payload))). This is what makes HARD cancel possible:
cancel_inflight() can call _turn_owner_task.cancel() directly, which
injects CancelledError at whatever await point the sub-task is currently
suspended on — including mid-generation, inside the litellm.acompletion
await in RouterLoop — aborting the in-flight HTTP request immediately.
Self vs. external cancel. The driver task distinguishes a cancel IT
issued (via cancel_inflight(), tracked through
self._turn_cancel_self_initiated) from an EXTERNAL cancellation of the
driver task itself (e.g. an anyio scope teardown for an MCP/A2A
request-handler task pumping run_one_iteration directly, FP-0013 §ADR-A).
Confusing the two directions is exactly the failure this mechanism guards
against: treating a self-issued hard-cancel as if it were external (or vice
versa) makes the WAL and the runtime diverge — either a turn the operator
explicitly cancelled gets propagated as if the whole session died, or a
genuine external teardown gets silently swallowed as if it were an ordinary
user cancel, leaving the driver task running when its owning scope believes
it has exited. This is the swallow/re-raise split D4's cancel-cascade
depends on for correctness.
Two retention windows: WAL (fine) + snapshot generations (coarse).
Default = current behaviour (live floor = min(applied_seq)+1); opt-in deeper
for a usable rewind horizon. The knobs (truncation floor / size threshold (current
1 MB) / semantic-boundary gate) are consolidated into a single retention
policy. Rewind is bounded by the retention window — you cannot rewind into
truncated history. The resume window (WAL, truncatable) and inspect window
(EventStore rotation) stay separate; the latter is longer.
Abandoned-branch retention (clarified in review): after a rewind, the inactive
branch (N+1 .. reset-1) is not needed for recovery (recovery follows the
active pointer, D3), so it could be WAL-truncated immediately — a direct benefit of
WAL/audit separation. But Phase-2 fork needs it to reconstruct a forked branch.
Resolution: within the retention window the abandoned branch is retained
(fork-capable); outside the window it is GC'd. Audit history of the abandoned
branch survives in the EventStore regardless (independent rotation). The blob store
(D9) GCs unreferenced workspace blobs on the same window boundary.
- User-facing = chat-turn / plan-step / phase boundaries (snapshot generations).
- act-turn is not a durable checkpoint (ADR-0002 rejected mid-act-turn state on
volume grounds) but is reachable via
snapshot(step-start) + CommittedStep memo0-token Ghost Replay. - Phase 1 = boundaries only. Phase 2 = act-turn via memo.
Unified snapshot-generation timeline (turn / phase) for users; WAL step layer for debug. Web (Chainlit) follows. dogfood-trace CLI stays for dev.
The append-only inactive-branch model (D3) is a branch tree — every branch physically retained. Two operations sit on it:
- undo = move HEAD (the active pointer) back to seq N — a reset. (Phase 1)
- fork = branch at N + checkout — continue on a new branch from a past point. (Phase 2 UX; foundation built in Phase 1)
Exactly one branch is live (checked out) at a time, enforced by the single shared workspace + global rewind (D2). The reconstruct/reset foundation is fork-capable from Phase 1 (Phase 1 must not design fork out); only the fork surface/UX lands in Phase 2. Mental model = git: a tree of branches, one checked out; undo and fork coexist on the same substrate.
D2's "workspace moves to as-of-N" requires rewinding workspace file content,
not just runtime state. Distinction surfaced in lead review: AgentSnapshot
(D1) carries runtime state only (inbox / chains / skill-run ids /
interventions / plan ids) — it holds no workspace artifact/code blob, and there
is no workspace versioning in src today. So a global cut has two halves:
"restore agent/conversation state" (runtime AgentSnapshot) and "restore code"
(workspace files). The second is missing and must be added.
Owner's "snapshots are small → full snapshot" applies to the runtime AgentSnapshot; workspace files are a separate axis and can be large, so full-copy generations would be heavy. Recommended (for owner confirm): a content-addressed / git-like blob store — each generation references workspace files by content hash, unchanged files shared (de-duplicated) across generations; reconstruct-as-of-N restores the tree from the generation's blob manifest + WAL file-op delta. This matches the git mental model (D8) and the shadow-git approach Claude Code uses for code restore. Storage strategy (owner-confirmed): content-addressed shadow-git (Claude Code / Cline style); full-copy and OS-level CoW/reflink are rejected — content-addressing makes a generation at every turn / plan-step / phase boundary cheap (unchanged blobs shared). (Like Claude Code, external/irreversible side effects — sent messages, real subprocess writes outside the workspace — are not rewound; only workspace files + runtime state.)
Two-substrate consistency (owner addition). rewind/fork moves two
substrates: runtime (WAL/snapshot + reset-record, D1/D3) and workspace
(shadow-git, D9). A generation ties them at the boundary seq:
generation(N) = (AgentSnapshot + WAL @ seq N) ⊗ (shadow-git commit).
reconstruct(N) restores both as-of-N atomically — runtime from snapshot + WAL
delta, workspace from the tied shadow-git commit + file-op WAL delta. undo =
reset both substrates; fork = branch both. The boundary-seq tie is the
consistency anchor: a generation is cut only when both substrates are captured
together, so a rewind never leaves runtime/workspace skew.
Reset-record (WAL entry, fsync'd like any append):
{kind: "rewind", seq: R, target_n: N, supersedes: <prior active-pointer seq | null>}.
- Active pointer = the reset-record with the highest
seq(latest wins); if none, the active pointer ishead. - Active path = the WAL minus the abandoned segments
[target_n, R)defined by the reset-record chain, resolved latest-first (a later rewind can abandon an earlier rewind's branch).reconstruct(N)= nearest snapshot generation≤ Non the active path + replay of active entries≤ N. - Nested rewind (rewind of a rewind) is just another reset-record at a higher
seq; it supersedes the prior active pointer and may abandon an earlier rewind's branch. No special case — the "latest reset-record wins + abandoned-segment union" rule composes. - Crash-mid-rewind idempotence (keystone): because the reset-record is fsync'd before reconstruction begins, a restart re-derives the same active pointer and thus the same as-of-N state — the discarded future is never resurrected. The correctness test must cover nested rewind + crash mid-rewind.
- Alternative considered: tag every WAL entry with a
branch_idand resolve by branch lineage (direct git model). Rejected for Phase 1 as a heavier per-entry schema change; the reset-record-on-single-WAL model fits the global-single-seq substrate (D2) with no per-entry change. Revisit if nested-rewind path resolution proves expensive at scale.
- Per-agent scoped rewind — rewinding one agent independently. Rejected: requires splitting the workspace + WAL per agent, which collides head-on with the single-SSoT / global-single-seq architecture (D2). The global cut is both simpler and what the architecture already enforces. (For the common single-agent / single-chat case the global cut feels local anyway.)
- Backward deletion of post-N entries — physically truncating the future on undo. Rejected: violates WAL/P6 append-only + destroys audit history. Use the compensating-record + inactive-branch model (D3).
- Delta-chain snapshots — incremental snapshots to save space. Rejected (owner): snapshots are small; full snapshots keep reconstruction a trivial "load + replay" and avoid chain-rebuild complexity.
- Concurrent live forks — running two branches live at the same time. Rejected (owner, explicit): a second live branch needs a second workspace, which destroys the single-SSoT invariant (P5 / D2). The branch tree is fully retained, but only one branch is checked out at a time — switching is a checkout, not a parallel run. (Distinct from D8's undo/fork, which both live on one checked-out branch.)
- Treating the EventStore (P6 audit log) as the rewind source — conflating
audit with recovery. Rejected: recovery derives from the WAL/StateLog
(fsync-per-append durability), audit from the EventStore (rotation /
observability). They are separate logs (see
project_wal_vs_audit_event_separation).
- Snapshot generations (runtime): extend
SnapshotJournal/AgentSnapshotpersistence to retain generations keyed by boundary seq (turn / plan-step / phase), not just the latest. FoldPlanSnapshotin as a generation kind. (Atomic-write pattern reused.) 1b. Workspace blob store (D9): content-addressed store for workspace file content; each generation carries a blob manifest (file → content hash); blobs shared across generations.reconstruct(N)restores the tree from the generation manifest + WAL file-op delta. (Owner picks storage strategy; the seam — capture workspace tree at each generation boundary, restore on rewind — is the same regardless.) - PITR reconstruct(N): add a reconstruct-as-of-N path = nearest generation
≤ N+StateLog.iter_fromreplayed to N. Crash-recoveryrestore_allbecomesreconstruct(head). - Reset-record honor (core wiring): define a
rewind/resetWAL record; makeiter_from-driven replay + snapshot derivation honor the active pointer (entries> Nfrom before the latest reset are inactive). This is the correctness keystone — verify with a "rewind then crash mid-rewind" replay test. - Global cut + cancel: rewind entry point in
AgentRegistrythat (a) cancels all in-flight viacancel_inflightcascade, (b) appends the reset record, (c) reconstructs every agent + workspace as-of-N. - Retention policy (2-window, config): consolidate truncation floor / size threshold / semantic-boundary gate; add the coarse snapshot-generation window; default current + opt-in deeper. Rewind bounded by window.
- TUI
/rewind(+ Esc-Esc): timeline of snapshot generations; select → global rewind. Multi-agent concurrency = just-do-it (owner): global rewind is always allowed, no warn / no quiescent-gate; D4 cancels all in-flight. - Doc-fix: stale
planner.py:32"in-memory, no workspace persistence MVP" comment (ADR-0023 already persists).
- act-turn checkpoints via
snapshot(step-start) + CommittedStep0-token Ghost Replay (reuseSkillResumeAnalyzer). - edit / fork UX on the inactive-branch model.
- web (Chainlit) surface + multi-plan rewind-time cancel hardening.
- Positive: user-facing rewind/resume; crash-recovery unified as PITR N=head; audit history preserved (append-only branches); architecture-enforced global consistency (no per-agent split needed).
- Cost: snapshot generations increase storage in the coarse window (bounded by retention config); the reset-record-honor wiring touches the replay/derivation hot path (must be correctness-tested, incl. crash-mid-rewind).
- Owner questions — RESOLVED (2026-06-13): (a) workspace blob store = content-addressed shadow-git (full-copy / CoW rejected); generations at turn/plan-step/phase boundaries (cheap via content-addressing). (b) nested-rewind reset semantics signed off (latest-reset-wins + abandoned-segment union, crash-mid-rewind idempotent); nested+crash test required in Phase 1. (c) multi-agent rewind = just-do-it — always allowed, no warn / no quiescent-gate; D4 cancels all in-flight.
- ADR-0001 (WAL + snapshot) / ADR-0002 (forward-replay) / ADR-0023 (PlanSnapshot)
- #1470 (
cancel_inflight) /ReplayEngine(src/reyn/replay/engine.py) - Live seams:
events/state_log.py,events/agent_snapshot.py,chat/services/snapshot_journal.py,skill/skill_resume_analyzer.py,chat/registry.py - Competitor grounding: LangGraph (
get_state_history+update_state+checkpoint_idfork, super-step granularity); Agent VCR (Record/Rewind/Edit/ Resume, frame granularity, memo Ghost Replay); Claude Code (/rewind, Esc-Esc, message granularity, external/bash not reverted)