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.
dsh-session
Event-sourced session log and in-memory store. A Session is the append-only source of truth for an agent's whole interaction history — the LLM message history is derived from it.
Service: SessionStore (ctx key: sessions)
Creates and holds event-sourced Session instances. Persistence is intentionally not implemented here — plugins subscribe to session/event and flush on session/flush.
Public API
ctx.sessions.create(id?: string, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number } }): Session— Create a session.options.seedreplays/forks an existing event log;options.metaattaches creation metadata (validated absolutecwd,parentSessionlineage) as the immutableSessionHeader. The store fillsversion/idand defaultscreatedAtto now; a caller reconstructing a persisted session passes the originalcreatedAtto preserve it. Disposed with the calling fiber.ctx.sessions.get(id: string): Session | undefinedctx.sessions.list(): Session[]
Events
| Event | Mode | Purpose |
|---|---|---|
session/created |
emit | A session was created |
session/event |
emit | An event was appended (sync, fire-and-forget) |
session/flush |
parallel | Awaited durability checkpoint (persistence plugins drain buffers here) |
Class: Session
Plain class (not a Cordis Service). Create via ctx.sessions.create().
session.append(type, data): SessionEvent— synchronous, never blocks on I/O. Throws ifdatais not losslessly JSON-serializable (BigInt, function, symbol, undefined, non-finite number, circular ref, or an exotic object like Map/Set/Date) — the event log is the durable source of truth, so this invariant is enforced at the source (exported asisJsonValuefor backends to reuse on their replay/fork entry points).session.deriveMessages(): Message[]— derive the LLM message history from the event log. Rawassistant/chunkevents are skipped;context/messageandsteering/messagerender as tagged synthetic user messages.session.events,session.seq,session.idsession.header: SessionHeader— immutable creation metadata (version,id,createdAt, optionalcwd/parentSession). Kept out of the event log (a storage concern, not replayable state); a minimal v1 header is synthesized for bareSessionconstruction.
Metadata types (types.ts)
SessionHeader— immutable, written once:{ version, id, createdAt, cwd?, parentSession? }.SessionSummary— mutable, updateable without touching the log:{ updatedAt, title?, firstPrompt? }.SessionMeta = SessionHeader & SessionSummary— owned here (besideSessionId) becauseSession.headeris typed by it; persistence backends re-export these rather than own them (which would force a package cycle).
Session event vocabulary (types.ts)
The append-only log: turn/start, turn/end, step/start, step/end, user/message, assistant/message, assistant/chunk, tool/call, tool/result, steering/message, context/message, usage, error.
Merge-extensible via SessionEventMap — a compaction plugin adds compaction/marker, etc.
Also defines TurnTriggerMap and TurnEndReasonMap (merge-extensible sum types for typed turn boundaries — kind-tagged instead of strings).
Extension points
- Persistence plugins: subscribe to
session/event(write-behind) and drain onsession/flush(awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (SessionHeader/SessionSummary/SessionMeta,session.header) is what such a backend stores beside the log. - Replay/fork:
ctx.sessions.create(id, { seed })seeds a new session with an existing event log.
What is NOT here (TODO)
- Session branching/tree (pi-style entry tree) — defered unless needed beyond seed-based forking.