# Conflicts: # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/event-producer-consumer.md # docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md # docs/rfc/implemented/architecture/2026-06-20-branded-ids.md # docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md # docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md # docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md # packages/bash/tool-bash/README.md # packages/bash/tool-bash/src/index.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent/README.md # packages/core/agent/src/index.ts # packages/guard/repeat-tool-guard/src/index.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/subagent/subagent-acp/tests/mock-acp-server.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/stdio-agent/src/index.ts # packages/ui/stdio-agent/src/stdio-chat.ts # packages/ui/stdio-agent/tests/stdio-chat.spec.ts # packages/util/brand/src/index.ts
6.5 KiB
RFC: Session persistence as an abstract service over the existing SessionEvent
Status: implemented
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. Events through a flushed
turn/endare never rewritten, and the loop flushes only at turn end. Because one interrupted turn may contain substantial valid work,loadpreserves its contiguous, parseable events 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 registers the fresh agent under the exact resumed id. 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, and serializability semantics. Persisting the full log also settles event fidelity: assistant/chunk remains verbatim.