Persistence and Compaction: Crash-Safe by Construction (Part 7)
Series: Inside the DeepSeek Harness — Part 7 of 16
Part 6 established the log as the model’s only source of truth. This post asks the question that follows naturally: what happens when the process holding that log dies mid-write? And separately — a conversation can’t grow forever inside a finite context window, so what happens when the log needs to get shorter without breaking the invariant that everything the model sees must still be reconstructable from it?
Both answers turn out to be more carefully engineered than “just write to a file” suggests.
Two backends, one contract, very different failure shapes
dsh ships two persistence backends behind one interface, and they fail in genuinely different ways.
JSONL (packages/session/session-persistence-jsonl/src/) writes a header line followed by event rows, by default as checksummed Zstandard frames (.jsonl.zstd; plaintext is configurable). The detail worth remembering: a fresh file is published via link() followed by unlink() on the temp file — not rename(). The real comment in source explains exactly why: “link fails with EEXIST if the final path already exists, so two processes materializing the same id concurrently cannot clobber each other. rename() would silently overwrite.” Most engineers reach for rename() by default for atomic publish; this is a case where that default is quietly wrong under concurrent writers, and the code picks the POSIX primitive that fails loudly instead of the one that succeeds silently onto someone else’s data.
Figure: the JSONL publish path — one POSIX detail that closes a real concurrent-writer race.
SQLite (packages/session/session-persistence-sqlite/src/) is the opposite shape: one row per SessionEvent, columns mapped 1:1, explicitly “no parallel persisted schema to keep in sync” with the durable event types. SCHEMA_VERSION = 15 (schema.ts:20) is stored in SQLite’s own PRAGMA user_version and checked on open (schema.ts:108-111) — a nonzero on-disk version that doesn’t match throws immediately, same zero-tolerance policy as the log’s own SESSION_FORMAT_VERSION from Part 6.
Crash repair on the SQLite side is a genuinely clean piece of engineering: commitRepair deletes the torn tail and inserts the synthetic closer described below, inside one transaction. DELETE FROM events WHERE session_id = ? AND seq >= ? followed by the INSERT, both committed together — so a second crash during repair can never leave the database half-repaired. Repair is either fully applied or not applied at all.
interrupted: the one turn outcome no loop ever emits on purpose
Here’s the actual crash-recovery contract, and it’s stricter than “just close the open turn.” On load, if the log has an open turn/start with no matching turn/end, the loader does not truncate it — a turn can genuinely be enormous, many steps deep, with large tool output, all of it durably appended before the crash. Truncating would throw away real, valid history. Instead it closes the turn with a synthetic event: turn/end { reason: { kind: 'interrupted' } }.
Figure: recovery preserves everything durably appended before the crash — it only closes the wound.
interrupted is explicitly documented as “the one TurnEndReason no loop emits” — every other reason (completed, blocked, aborted, error, max-tokens) comes from the live driver you read about in Part 9; only crash recovery ever produces interrupted, and only on load. One more careful distinction: this repair path applies only to cold sessions — a session with an open turn discovered while it’s live, not being loaded fresh, is rejected instead of patched. Repair must never race an actual live writer; patching a session someone else is actively appending to would be a much worse bug than leaving it alone.
Write-behind: batched, but never indefinitely postponed
session/event is dispatched synchronously — the producer isn’t blocked while persistence happens. A per-session write-behind controller batches events into a bounded window before flushing to disk, and the real number — DEFAULT_WRITE_BATCH_MAX_DELAY_MS at coordinator.ts:30, plumbed into the write-behind controller at :1342 — is 200 milliseconds. The first pending event in an empty batch starts the deadline; every event that arrives after that joins the same batch without resetting the clock — so a sustained stream of events can’t postpone the flush indefinitely. session/flush is the explicit checkpoint that cancels the wait and drains to quiescence, and it’s what the loop awaits before doing anything that needs the write to have actually landed — dispatching a model request, executing a tool, entering a pre-step decision.
Compaction: a lock bracket, not an in-place rewrite
The log never gets smaller by deleting anything. Compaction works entirely through the replace surface operation from Part 6 — it shrinks what the surface shows, while the underlying events stay physically present in the log forever. The operation itself is bracketed by three log-only events: compaction/start {turn}, then summarization work, then compaction/summary {...} plus a user/message carrying the actual replace op, then compaction/end {turn, error?}.
Figure: the ordering is the safety property — an orphaned start is a detectable bug signal; a false end would be a silent lie.
The design rationale is stated directly and is worth quoting in full, because it’s exactly the kind of “why the ordering matters” reasoning this series keeps coming back to: “Releasing the lock last turns a crash mid-operation into a detectable orphaned lock (a compaction/start with no matching compaction/end) rather than a compaction/end that falsely claims compaction finished.” If the write order were reversed, a crash between “write summary” and “write end” would leave a log claiming compaction succeeded when it might not have. Ordering the events so the failure mode is “obviously incomplete” rather than “silently wrong” is the entire design decision.
Two trigger kinds exist — pressure and context-overflow — and pressure-triggered compaction runs at the serial agent/pre-step waterfall, before the request is even derived (a direct tie to Part 8’s event system). Failed-request recovery, through agent/request-error, has a genuinely careful rule: it only returns a retry action when the surface replacement generation actually advances — even if later summary work throws after pruning already ran. Partial progress that didn’t shrink the surface enough still counts as “no progress” for retry purposes, which avoids an infinite thrash loop where a partial fix keeps re-triggering the same overflow.
Tool-result pruning — a separate, optional step that can run before compaction’s own summarization — truncates by Unicode code point, deliberately not UTF-16 code unit, specifically to avoid splitting a surrogate pair mid-character. A small detail, but exactly the kind of correctness nit that’s easy to get wrong and expensive to debug once garbled text starts showing up in truncated tool output.
Coming up in this series
(All 16 parts are live today — no daily drip.)
| Part | Title | What it covers |
|---|---|---|
| 1 | DeepSeek Harness: Inside the Open-Source Claude Code Rival | The launch, the comparison, no privileged core, the four Cordis primitives |
| 2 | Composing an App From YAML, Not Code | Profiles, bundles, five patch layers, boot, live HMR, --dump-config |
| 3 | Scope: Why a Live Agent Is the Key of Its Own Registration | Shadowing, restriction, lineage vs. scope |
| 4 | Tool Execution in DeepSeek Harness: Guards and Approval | Pre-execute, monotonic guards, post-execute, approval |
| 16 | DeepSeek Harness: Agent Presets as Data, Not Code | Agent presets, PTC/Code Mode, the plugin ecosystem, compared to Claude Code’s agent types |
| 5 | Capability Seams: Making Bash Swappable for Sandboxed Bash | The 3-role pattern end-to-end, across ~85 real seams |
| 6 | The Session Log: DeepSeek Harness’s Enforced Invariant | The real invariant-checking code, surface projection |
| 7 | Persistence and Compaction: Crash-Safe by Construction (this post) | JSONL/SQLite, torn-tail repair, the compaction lock bracket |
| 8 | Waterfalls: The One Event Pattern That Runs Everything | Five dispatch modes, durable vs. live events, retry |
| 9 | The Agent Loop: Turns, Steps, and a Real Cancellation Bug | The phase state machine, the inbox, a dated bug fix |
| 10 | The LLM Layer: One Message Format, Every Surface | Message vocabulary, streaming, retry-via-log-replay |
| 11 | Subagents and Workflows: Composing Agents From Agents | Provider kinds, continuable children, Ralph rounds |
| 12 | Defense in Depth: Sandboxing and Four Real Incidents | bwrap/Landlock/Seatbelt/ACL, real production postmortems |
| 13 | Three Surfaces, One Spine: Web, Typert RPC, and SDK/ACP | Typert codegen, the four-quadrant RPC envelope |
| 14 | Engineering Rigor: DeepSeek Harness’s Verification Gate | The 100% coverage gate, real engineering-culture quotes |
| 15 | What a Second Production Harness Teaches | dsh vs. the Agent Harness series, side by side |
Part 16 shipped after this series’ initial 15 posts, once Agent Presets and Code Mode landed — it reads best right after Part 4, which is why its row sits there instead of at the end.
Next: Part 8 is the event system itself — the five Cordis dispatch modes, and the real waterfall() implementation that every policy decision in this series has been quietly running through.
References
- Source citations pulled directly from the repo as of commit
47f9438(master, Aug 13, 2026 — same commit cited throughout this series):packages/session/session-persistence-jsonl/src/index.ts(link()-not-rename()publish),packages/session/session-persistence-sqlite/src/schema.ts(SCHEMA_VERSION),packages/session/session-persistence-sqlite/src/index.ts(commitRepair),packages/session/session-persistence/src/coordinator.ts(write-behind, 200ms batching) docs/subsystems/persistence.mdanddocs/persistence-catalog.mddocs/subsystems/compaction.md— the lock-bracket rationale quoted above- Part 1 through Part 6 of this series
Have thoughts on this?
I read every email. If something resonated, felt wrong, or made you think — I'd love to hear from you.
Comments