6.7 KiB
RFC: Session persistence as an abstract service over the existing SessionEvent
Status: implemented
Merges the original proposal and the decision record for one topic. The proposal's full method-surface and write-path detail lives in git history; this records the decision and the durable, contested choices.
Problem
Sessions lived only in memory. The example session-jsonl.ts plugin (duplicated byte-for-byte in both examples) was write-only telemetry: it buffered session/event and appended JSON lines, with no read/replay path, no crash-safety (no fsync, no atomic write, a fire-and-forget dispose drain), no listing, and no format versioning. Nothing could rehydrate a past session from disk into a live agent, so durable resume ("continue yesterday's task"), durable forking, and the ACP session/load method (ACP support) were all impossible.
The event-sourced model makes the append-only log the single source of truth and derives LLM history from it. Persistence had to stay faithful to that: persist the existing SessionEvent directly, with no parallel "persisted message" type that the log is converted to and from. The backend also had to be swappable — a file store now, a database store later — behind one interface.
Decision
Persistence is an abstract capability seam (capability seams, the dsh-bash template), not loop or core logic:
- Interface (
dsh-session-persistence,ctx.sessionPersistence) — an abstractSessionPersistenceservice:create/append/load/list. Its persisted unit IS the existingSessionEvent({ type, seq, time, data }), reused verbatim — no conversion type. - Implementation (
dsh-session-persistence-jsonl) — an append-only JSONL log per session (aSessionHeaderline then oneSessionEventper line, verbatim includingassistant/chunk).
Key choices recorded here because they are durable, contested, and surprising:
- The canonical durable log persists every
SessionEventverbatim, includingassistant/chunk.deriveMessages()skips chunks, and a chunk-filtered rollout (Codex'spolicy.rs) is tempting — butseq = log.lengthand the load-validationevents[i].seq === irequire a contiguous log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log. - Append-only; a crashed turn is closed, never truncated.
loadpreserves the contiguous, parseable events of an interrupted final turn and appends error results for unanswered tool calls, a missingstep/end, andturn/endwith{ kind: 'interrupted' }. The synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded; a parse error or sequence gap at or before the last realturn/endis corruption and makes the session unloadable. - File backend canonical, DB backend a proven drop-in.
SessionEventmaps 1:1 onto a row(session_id, seq, type, time, data)—appendis INSERT (in a transaction asserting the contiguous-seq contract),loadis SELECT … ORDER BY seq.dsh-session-persistence-sqliteis exactly this: aSessionPersistencesubclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the samerunPersistenceContractsuite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, interrupted-turn close on load, contiguous-seq), expressed once over file bytes and once over rows. - Metadata is out-of-log. Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a
SessionHeaderowned bydsh-sessionand attached to aSessionvia a new readonlysession.header— never inSessionEventMap, never reachingderiveMessages(). The alternative (a merge-extensiblesession/metaevent as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutableSessionHeaderplus a mutableSessionSummarywhose union wasSessionMeta; the mutable summary was later removed as dead state — see Drop the mutable session summary.) ctx.agents.create()andctx.agents.resume()are async factories; resume additionally crosses the persistence boundary.ctx.agents.resume({ resumeSessionId })awaitsctx.sessionPersistence.load, recreates the live session with the loaded events (solastTurnNumber/deriveMessagescontinue), and starts a fresh agent on the resumed id (NOT${agentId}-session). The agent-loop does NOT hard-injectsessionPersistence(that would pend non-persistent demos forever);resumerejects with a clear error when it is absent.
Alternatives considered
Each key choice above records its rejected alternative where the choice is stated: a chunk-filtered canonical log (Codex's policy.rs shape) — breaks the contiguous-seq contract; truncating a crashed turn — silently destroys a long autonomous run's real work; an in-log session/meta event as line 0 — metadata is not replayable state; hard-injecting sessionPersistence into the loop — would pend non-persistent demos forever.
Format versioning: the header carries a version; load rejects any non-current version (no migration — the pre-release session format is pinned at SESSION_FORMAT_VERSION = 0 and absorbs shape churn, per the AGENTS.md pre-release stance). Stated honestly: append-only + flush is robust to partial trailing writes (tolerated on load) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option later.
Consequences
Two new packages and the metadata seam in dsh-session (session.header, the create(id?, options?) signature). Bought: durable resume/fork, a read/replay path, crash tolerance, and the foundation the ACP session/load (ACP support) needs — all over the existing event-sourced log, with the backend swappable behind one interface. The reusable runPersistenceContract suite holds every backend to the same append-only / contiguous-seq / lazy-materialization / serializability semantics. This completes event-sourced sessions's deferred "real persistence backend" and resolves its TODO(review) on the event vocabulary: persisting the log freezes its shape, and the assistant/chunk fidelity question is answered above (persist verbatim).