A crash can leave a durable log whose final turn never closed. The old
behavior truncated everything after the last turn/end as a "crash tail".
But a single turn can be HUGE in a long-horizon task (many steps, large
tool output), so truncating it silently destroys real, durably-written
work — truncating a turn is wrong.
New crash recovery (ADR 0018): load() PRESERVES the interrupted turn's
events and CLOSES the orphaned turn by durably appending synthetic
boundary events — a step/end if a step was open, then a turn/end carrying
the new merge-extensible TurnEndReason {kind:'interrupted'}. load()
returns the balanced log, so a resumed session is immediately usable. Only
a never-fully-written TORN tail fragment is discarded; corruption in the
committed region is still unloadable.
- dsh-session: TurnEndReason {kind:'interrupted'} + shared
interruptedTurnClosers() repair helper.
- JSONL backend: scanLog preserves the longest contiguous prefix
(including a partial final turn); loadCore truncates a torn fragment and
durably writes the closers, returning the balanced log.
- runPersistenceContract gains a crash-recovery test (both backends + mock).
- Docs: ADR 0018/0017, architecture.md, package READMEs.
Also (review #33): RFC 013 records the "move event vocabulary to Zod"
question (merge-extensible maps → runtime schema registry) + blast radius;
deferred, not done here.
@deepseek-ai/dsh-session-persistence
The abstract durable session-persistence seam (ctx.sessionPersistence). Defines WHAT a persistence backend does — durably store, reload, list, and update sessions — without saying HOW. Mirrors the dsh-bash capability-seam template (ADR 0009): an abstract service here, a concrete implementation in a sibling package, consumers that inject the interface.
The persisted unit IS the existing SessionEvent (event-sourced model — the log is the single source of truth), so there is no parallel "persisted message" type. Metadata that is NOT replayable conversation state (format version, cwd, lineage) travels separately as SessionMeta, owned by dsh-session and re-exported here.
Service API (ctx.sessionPersistence)
| Method | Contract |
|---|---|
create(meta): Promise<void> |
Register a new session's metadata. MAY defer the physical write until the first append (lazy materialization). |
append(id, events): Promise<void> |
Durably persist a batch (from the session/flush drain). Append-only; first event seq == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. |
load(id): Promise<{ meta; events }> |
Reload meta + log. Preserves an interrupted (unclosed) final turn and closes it with synthetic step/end?+turn/end {interrupted} (a turn can be huge — never truncated); only a torn tail fragment is dropped. Events contiguous (events[i].seq === i); rejects a committed-region gap/parse error or unknown version. |
list(): Promise<SessionMeta[]> |
Lightweight listing from metadata, no full-log parse. |
has(id) / delete(id) |
Existence / removal. A zero-event lazily-materialized session is absent from has/list. |
update(id, summary): Promise<void> |
Update mutable SessionSummary fields without touching the append-only log. |
Invariants every backend must honor
- Append-only; a crashed turn is closed, not truncated. Committed events (at or below a flushed
turn/end) are never rewritten. A crash can leave an unclosed final turn whose events are real and possibly large;loadpreserves them and durably appends synthetic closers (step/end?+turn/end {interrupted}) to balance the log. Only a never-fully-written torn tail fragment is discarded. - Contiguous seq.
loadrejects aseqgap/parse error in the MIDDLE of the log;append's firstseqmust equal the stored next-seq. - JSON-serializable data.
appendrejects non-serializableevent.data; backends snapshot each event when buffering (the livesession.eventsobject is mutable). - Durability.
appendreturns only once the batch is durable.
Testing backends
Import runPersistenceContract from tests/contract.ts and call it with a factory that yields a fresh, empty backend plus a teardown. Every backend is held to the same append-only / contiguous-seq / lazy-materialization / serializability semantics; a backend's own spec adds implementation-specific tests (crash repair, path sanitization) on top.
TODO (validate the abstraction with a second backend):
dsh-session-persistence-jsonlis currently the only implementation, so the interface andrunPersistenceContractare only proven against one storage model. A second backend — a SQLite implementation (dsh-session-persistence-sqlite), where eachSessionEventmaps 1:1 onto a row(session_id, seq, type, time, data)— would run the SAMErunPersistenceContractsuite and so prove the seam is genuinely backend-agnostic (lazy materialization, crash-tail-on-load, contiguous-seq all expressed against a transactional store rather than an append-only file).
Metadata types
Re-exported from dsh-session: SessionHeader (immutable: version, id, createdAt, cwd?, parentSession?), SessionSummary (mutable: updatedAt, title?, firstPrompt?), SessionMeta (their intersection).