Files
deepseek-harness/docs/rfc/009-session-persistence-and-resumability.md
T
Tianyi Cui a44f7f3486 docs: add RFCs 009-011 (session persistence + ACP support)
Three proposal documents, numbered in dependency order:

- RFC 009: an abstract, append-only, event-based SessionPersistence
  service over the existing SessionEvent log (no parallel persisted
  type), a JSONL impl, a SessionMeta header seam, and an async
  AgentLoop.resume path. Design informed by Codex/Claude Code/
  opencode/pi. Core design point; unblocks resume + ACP session/load.
- RFC 010: ACP (Agent Client Protocol) support as a dsh-acp
  client-driver plugin on @agentclientprotocol/sdk, mapping ACP onto
  the agent/* events and the tools/execute permission seam. Builds on
  009 for session/load; single active session.
- RFC 011: multiplex concurrent ACP sessions over one connection
  (bridge-layer change; downstream of 010).
2026-06-14 21:14:51 +08:00

15 KiB

RFC 009: Durable session persistence — an abstract, append-only, event-based store

Status: proposed

Problem

Sessions live only in memory. The example session-jsonl.ts plugin (duplicated byte-for-byte in both examples/coding-agent and examples/echo-agent) is write-only telemetry: it buffers session/event and appends JSON lines, but has no read/replay path, no crash-safety (no fsync, no atomic write, and a fire-and-forget dispose drain), no listing, and no format versioning. ADR 0003 and docs/architecture.md both park "real persistence backends (JSONL session dirs, sqlite)" and the session-event-vocabulary review as deferred TODOs "once the loop and the first persistence plugin coexist" — that time is now.

Because nothing can rehydrate a past session from disk into a live agent, durable resume ("continue yesterday's task"), durable forking, and the ACP session/load method (RFC 010) are all impossible. (In-memory replay/fork via ctx.sessions.create(id, seed) already exists and is tested; what is missing is the durable store behind it and a first-class agent-loop resume path.)

The event-sourced model (ADR 0003) makes the log the single source of truth and derives LLM history from it. Persistence must stay faithful to that: it should persist the existing SessionEvent directly — there must be no parallel "persisted message" type that the log has to be converted to and from. We also want the backend to be swappable: a file store now, a database store later, behind one interface.

Proposal

Mirror the codebase's capability-seam pattern (ADR 0009, the bash template: an abstract Service interface, a concrete implementation, and consumers) for persistence.

1. Abstract service SessionPersistence — a new interface package @deepseek-ai/dsh-session-persistence owning ctx.sessionPersistence, depending only on cordis and dsh-session. Its persisted unit IS SessionEvent ({ type, seq, time, data }), reused verbatim — no conversion type. The method surface:

  • create(meta: SessionMeta): Promise<void> — register a new session's header. The backend MAY defer the physical write until the first append (lazy materialization); has/list semantics for a zero-event session are specified, not left implicit.
  • append(id, events: readonly SessionEvent[]): Promise<void> — durably persist a batch (called from the flush drain). Append-only; never rewrites. Contract: the first event's seq MUST equal the backend's stored next-seq (a DB impl asserts this inside a transaction; the file impl appends at EOF). All persisted event.data MUST be JSON-serializable.
  • load(id): Promise<{ meta: SessionMeta; events: SessionEvent[] }> — replay header plus the full event log. Returns meta AND events so the live session is reconstructed with its cwd/lineage, not just its log. Validation: events[i].seq === i (contiguous, zero-based) or the load rejects.
  • list(): Promise<SessionMeta[]> — lightweight listing from headers, no full-log parse.
  • has(id) / delete(id) — existence and removal.
  • update(id, summary: Partial<SessionSummary>): Promise<void> — update mutable header fields without touching the append-only event log.

The new SessionMeta splits into an immutable SessionHeader ({ id, version, createdAt, cwd?, parentSession? }) and a mutable SessionSummary ({ updatedAt, title?, firstPrompt? }); SessionMeta = SessionHeader & SessionSummary. Every reference system writes such a header (pi's version: 3 header line, Codex's SessionMeta, Claude Code's tail metadata). It is kept separate from the event log deliberately: format-version, cwd, and lineage are storage concerns, not conversation events, so they stay out of SessionEventMap and never reach deriveMessages(). The alternative — a merge-extensible session/meta event as log line 0 — was considered: an in-log event would ride along with a seeded/forked session for free, whereas an out-of-log header must be threaded through a seam (item 3a). It was rejected because metadata is not replayable conversation state; the explicit metadata seam is the cleaner cost.

2. Concrete impl SessionPersistenceJsonl — a new package @deepseek-ai/dsh-session-persistence-jsonl. One file per session: a header line ({ type: 'session', version, id, cwd, createdAt, parentSession? }) followed by one SessionEvent JSON per line. On disk: a configured root with per-cwd subdirectories (pi-style --encoded-cwd--/<timestamp>_<id>.jsonl) so sessions group by project. list() reads only each file's header line. Resilience over the example: append plus explicit flush; on load, drop only a corrupt/incomplete trailing line but reject a parse error or seq gap in the middle (a hole would desync seq = log.length appends); lazy materialization (no file until the first real event, so abandoned sessions leave nothing behind).

2a. assistant/chunk persistence policy (decided here, not deferred). The loop appends one assistant/chunk per raw stream chunk, but deriveMessages() skips chunks entirely — the assembled assistant/message is authoritative for history. It is tempting to drop chunks from the durable log (Codex's policy.rs filters deltas from its rollout). But seq = log.length and the load-validation events[i].seq === i require a contiguous log: filtering chunks out would leave holes ([0,1,4,6,8]) and break both the contract and resume. Decision: the canonical durable log persists every SessionEvent verbatim, including assistant/chunk — this keeps seq contiguous, keeps "persist SessionEvent directly" literally true, and lets RFC 010 replay streamed turns on session/load. A chunk-filtered projection (for export or a compacted listing) is possible later as a derived view with its own renumbering, but it is NOT the canonical log and NOT the default. The round-trip test asserts byte-identical events.

3. Write path lives in the impl plugin, generalizing the example: subscribe to session/event (buffer write-behind, keyed by session), drain to append() at the awaited session/flush checkpoint and on dispose — the seam the loop already fires at every turn end. The loop's write path needs no change.

3a. Metadata seam (the one dsh-session change). Today Session has only id plus the log, and session/event carries (session, event) — there is nowhere for cwd/lineage to live, so a plugin listening to events alone cannot know a session's cwd. Add a minimal seam: SessionStore.create(id, { seed?, meta? }) attaches a SessionHeader to the Session (a new readonly session.header), and the persistence plugin captures it on session/created. This is additive; deriveMessages() and the log are untouched.

4. Resume path — an async helper, NOT a change to the synchronous create. AgentLoop.create(agentId, options) is synchronous (the AgentLoop constructor calls it for configured agents), so it cannot await persistence. Add a separate async resume(agentId, resumeSessionId, options?): Promise<LoopAgent> that awaits ctx.sessionPersistence.load(resumeSessionId), then calls ctx.sessions.create(resumeSessionId, { seed: events, meta }), then constructs/registers/starts the LoopAgent on that session. Three distinct identities are kept separate: the agentId (the handle), the live sessionId (here the resumed one, NOT ${agentId}-session), and the resumeSessionId being loaded. Downstream already works: Session's constructor shallow-copies the seed, lastTurnNumber() in loop.ts continues turn numbering, and deriveMessages() rebuilds history.

Seed handling has two cases that the plugin must distinguish, and neither is the naive "re-append on flush" hazard. Seed events are copied into Session by the constructor without emitting session/event (the store installs onAppend only after construction), so the write-behind buffer never sees them — there is no double-write on a plain resume. (1) Resume / adopt an existing on-disk session: the events are already persisted, so the plugin initializes its per-session write cursor to the loaded length and appends only events with seq >= loadedLength. (2) Fork a brand-new session whose seed came from another session: that seed is NOT yet on disk under the new id, so the plugin must persist the full seed once (on session/created, via create(meta) + an initial append) and then set the cursor to the seed length. The append seq-contract makes both safe — a re-append of a stored seq is rejected, never silently duplicated.

5. DB-backend feasibility (proven by the design, implemented later). SessionEvent maps 1:1 onto a row (session_id TEXT, seq INTEGER, type TEXT, time INTEGER, data JSON, PRIMARY KEY(session_id, seq)) — seq already exists and is monotonic. append is INSERT (in a transaction asserting the contiguous-seq contract), load is SELECT … ORDER BY seq, list is SELECT from a sessions header table. A future @deepseek-ai/dsh-session-persistence-sqlite is a drop-in SessionPersistence subclass with no interface change (opencode runs exactly this session_message(session_id, seq, type, data) shape on SQLite/WAL). Because SessionEventMap is merge-extensible and data is typed only as SessionEventMap[K], the interface requires all persisted event.data to be JSON-serializable; append rejects non-serializable data with an error naming the offending event type, and the plugin snapshots (serializes/clones) each event when it buffers on session/event, since session.events hands out the live mutable object. A canonical SQLite backend is one such drop-in; a Codex-style derived search/listing index over the JSONL files would instead be a separate projector service, NOT a SessionPersistence replacement. SessionId is an unvalidated branded string, so the file impl MUST sanitize/encode it before using it in a path (no traversal, no collision).

Plan

  1. Interface package packages/session-persistence/ per the cookbook: abstract SessionPersistence extends Service (super(ctx, 'sessionPersistence')), the declare module 'cordis' ctx key, the SessionHeader/SessionSummary/SessionMeta types, and method contracts documented in JSDoc (durability, append-only, contiguous-seq, JSON-serializable, error semantics).
  2. dsh-session changes: add the three meta types beside SessionId; add the metadata seam. SessionStore.create(id?, seed?) becomes create(id?, options?: { seed?; meta? }) — a breaking signature change (callers pass seed positionally today: AgentLoop.create, and ~20+ call sites across session/invariants/agent-loop tests), so either migrate every caller or keep a deprecated overload during transition. Add a readonly session.header. Persistence captures the header on session/created (a synchronous event), so the impl must hold a per-session init promise that every session/flush awaits before append, and must seed existing live sessions via ctx.sessions.list() on plugin apply (HMR does not replay session/created, mirroring dsh-invariants). Do NOT add meta to SessionEventMap.
  3. JSONL impl packages/session-persistence-jsonl/: header plus event lines (all events, verbatim — see 2a), sanitized per-cwd dirs and filenames, lazy materialize (header + first batch written atomically), append plus flush, trailing-line-tolerant and mid-gap-rejecting load that truncates back to the last complete turn/end rather than resuming a half-written turn, header-only list, the per-session write cursor, and snapshot-on-buffer. static Config for root dir and flush policy.
  4. Generalize the write-path plugin: the impl subscribes to session/created (capture header, persist any seed for forks), session/event (snapshot + buffer), and session/flush/dispose (drain), replacing the per-example session-jsonl.ts; both examples load the shared plugin.
  5. Resume seam: the async AgentLoop.resume(agentId, resumeSessionId, options?); initialize the write cursor to the loaded length; verify lastTurnNumber/deriveMessages continuity. AgentLoop does NOT hard-inject sessionPersistence (that would break non-persistent examples) — resume checks for the service and throws a typed "persistence not configured" error; consumers that need resume (ACP) load the persistence plugin.
  6. Tests (event-sourcing makes these strong): a round-trip property (persist an arbitrary log → reload → byte-identical events and identical deriveMessages() output — the replay equivalence ADR 0003 promises); resume vs fork (resume appends no duplicate seqs; a fork persists its seed once); contiguous-seq enforcement (mid-log gap rejected, re-append of a stored seq rejected); crash tolerance (a truncated final turn truncates back to the last turn/end); JSON-serializability rejection for a plugin-added event carrying non-serializable data; mutation-after-session/event does not corrupt the persisted snapshot; SessionId path-traversal is neutralized; lazy materialization (no file until the first event); has/list semantics for a zero-event session; HMR-safety (dispose drains buffers and closes file handles; apply seeds existing live sessions); concurrent sessions do not cross buffers.
  7. Docs: update the "Event-sourced sessions" durability-seam paragraph and the "Deferred work" list in docs/architecture.md (persistence is no longer deferred); sync the affected package READMEs/JSDoc (dsh-session for the create/session.header change); add a cookbook note on writing a persistence backend; resolve the TODO(review) on the event vocabulary now that a real persistence plugin coexists with the loop. On implementation this likely graduates to an ADR — "persistence is an abstract service over the existing SessionEvent; verbatim append-only log; file canonical, DB drop-in" is durable, contested, and surprising enough to record.

Risks

Format versioning: the header carries a version; load must reject or migrate unknown versions (pi rejects, Codex relies on serde forward-compat). Fix the policy before shipping so v1 files stay loadable.

Crash-safety bounds: append-only plus flush is robust to partial trailing writes (tolerated on load) but not to fsync-less power loss mid-line. State the guarantee honestly; a DB/WAL backend is the stronger option later.

SessionMeta placement is a small public-surface decision (the immutability concern from ADR 0012); pick its owning package deliberately and freeze the shape.

Event-vocabulary churn: persisting the log freezes its shape more firmly, so this is the moment to complete ADR 0003's TODO(review) — especially the assistant/chunk fidelity question — before committing to an on-disk format.