M2.6
Persistence and resumption: event log vs projection
1,218 words · about 8 min
Concept
There are two ways to make an agent resumable.
Snapshot: after each turn, serialize the current state — the message array, the token counts, the todo list — and write it. To resume, deserialize.
Event log: append an immutable record of every state-changing occurrence. To resume, replay the log.
Snapshotting is easier and it is what most people build first, including in Module 1 if you added persistence at all. It fails in ways that are not obvious until you are deep in.
Mechanism — why replay beats snapshotting
MechanismFour concrete failures of the snapshot approach:
- A snapshot cannot answer "how did we get here?" State is a lossy function of history. Once you have collapsed twelve steps into one message array, the questions "which retries happened", "what did the user cancel", "which tool call produced this file" are unanswerable. With a log, they are queries.
- A snapshot is a lock-step schema. Adding a field means migrating every stored snapshot. An append-only log of typed events lets a new consumer fold old events with new rules — the events did not change, only the fold did.
- A snapshot's write is not atomic with the work it describes. If the process dies between doing a thing and snapshotting it, the thing is lost. With an append-only log the event is the commit, and a crash truncates cleanly at a record boundary.
- A snapshot collapses derived views into stored state. Once "total tokens" is a stored number, fixing a counting bug means every historical session is permanently wrong. When it is a fold over the log, you fix the fold and the past corrects itself.
The cost is real and should be stated plainly: replaying a long log is slower than deserializing a snapshot, and the log grows without bound. dsh addresses both without abandoning the log as source of truth — which is the design pattern worth learning.
Mechanism — the projection contract
MechanismA projection is a pure synchronous fold from events to a value. docs/subsystems/session-projection.md defines ProjectionDefinition<K, S> with key, stateSchema, init(), apply(state, event), an optional wire view, and a stateVersion.
Four rules make it work:
applyis pure and synchronous. No I/O, no async. A fold that can fail or block is not a fold; it is a pipeline, and it will diverge from the log.applyMUST return the same state reference when uninterested. An unchanged reference (byObject.is) produces zero downstream work. This is what makes it viable to fold every event through every registered projection.stateVersionis bumped when the fold's meaning changes. Stale persisted rows are then discarded rather than being forward-applied into garbage. This is the migration story: you do not migrate the data, you invalidate the cache and re-fold.- The whole-value event rule. A state-carrying log event carries the complete post-change state, never a bare delta.
todo/writeis the canonical example — the whole list, last-write-wins. Deltas require every prior delta to be present and correctly ordered to mean anything; a whole value is self-describing, which means a truncated log still yields a correct (if stale) state.
The architectural phrasing from the docs: the framework drives, the domain computes. The registry subscribes to session/event once and folds every committed event through every unit. Domains hold no subscriptions and clients never fold domain events — they receive finished values. The failure this prevents is N independent subscribers each maintaining their own drifting copy of session state.
Mechanism — the cold-read ladder
MechanismReplay cost is managed with a cache, not by replacing the log:
- Try the cached projection row.
- Miss? Read the persistence tail from the cached position (
readFrom). - Still insufficient? Ask the registry to
restoreby folding from further back. - Write the result back durably for next time.
Two details are worth stealing. First, checkpoints are taken at turn/end and session disposal — turn boundaries are the natural quiescent points. Second, the restoreFloor anchor sits one event below the checkpoint, which is how a crash-truncated log is detected: if the log no longer reaches the floor, the cached row cannot be trusted and is discarded. Every durable write in this path is fail-soft — a cache write failure degrades performance, never correctness, because the log is still authoritative.
How `dsh` does it — resumption in one line
How dsh does itThe most instructive line in agent.ts is in the constructor:
session.events.findLast(e => e.type === 'turn/start')?.data.turn ?? 0That is resumption. There is no separate restore path, no loadState(), no snapshot deserializer. The agent's turn counter is recovered by asking the log what the last turn was. A fresh session finds nothing and starts at 0. A resumed session finds turn 14 and continues at 15. The same code handles both because there is only one source of truth.
Crash recovery is the complement. A process killed mid-turn leaves a turn/start with no turn/end. On reload, the persistence backend closes that orphaned turn with { kind: 'interrupted' } — the one TurnEndReason the loop never emits — and the events recorded before the crash remain intact. The transcript is honest about having been interrupted rather than pretending the turn completed or discarding it.
The invariant tying all of this together is from AGENTS.md: model-visible ⟺ logged. Anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event. This is why the repetition guard's reminder is a logged user/message and why request headers are logged as request/header events. If any model-visible input could bypass the log, replay would produce a different request than the original, and every property that depends on replay — resumption, forking, audit, debugging — would silently break.
One more piece follows from the same invariant: derived history. deriveMessages() folds the log into the Message[] the model sees, and the rules are precise — assistant/chunk events are skipped because the assembled assistant/message is authoritative, and an empty-content assistant/message is also skipped, because a max-tokens step cut off with no content still records the event to hold its usage, provider, and model, but a content-less assistant turn must not enter the provider transcript. deriveEventMessage(event) is public precisely so external reconstructors apply the same rules and cannot disagree with the cache.
Tradeoffs
The log is the honest answer and it is not free. assistant/chunk events dominate volume. Replay of a very long session is measurably slower than a snapshot load, mitigated but not eliminated by checkpoints. And an append-only log with no compatibility promise — SESSION_FORMAT_VERSION is at 0 in dsh, explicitly with no promise — means old sessions can become unreadable, which is a defensible pre-1.0 choice and an indefensible post-1.0 one.
How others differ
Claude Code persists JSONL transcripts per session and resumes from them — the same log-shaped instinct. Codex CLI similarly keeps rollout files. The dsh distinctive is the strictness: the log is the source of truth with a typed, merge-extensible event map, a documented durability contract, and a formal projection registry, rather than a transcript file that happens to be replayable. Harnesses that store a message array as the primary artifact get simpler code and lose the ability to answer anything the message array does not directly encode.
Check yourself
4 questions on M2.6