2026-06-20 16:24:56 +08:00
# Session Persistence
Add generated persistence log event catalog with freshness + completeness gates
docs/persistence-catalog/log-events.md enumerates every SessionEventMap
member — the owning dsh-session vocabulary plus the dsh-compact and
dsh-hook-protocol declaration merges — with payload, surface/log-only badge,
JSDoc prose, and declaration site. scripts/gen-persistence-catalog.ts is a
pure AST pass in the gen-cordis-catalog mold: verify-persistence-catalog
(--check) joins doc-sync, so a stale committed catalog fails pre-push and CI.
The walk enforces JSDoc completeness (every member needs description prose;
@mode is rejected as a category error — log events do not dispatch on the
cordis bus), derives the surface badge from the SurfaceEventType union with a
stale-member cross-check, and hard-errors on duplicate declarations. Payloads
render through the TypeScript printer so newline-separated multi-line type
literals still emit valid one-line fragments.
Documented the five previously JSDoc-less core events (turn/step boundaries,
tool/call), removed the two stray @mode tags on the hook/* merges, and
replaced the hand-restated event enumerations (session.md hook/* table,
compact README table, hook-protocol README bullets, session README name-list
— whose merge note had already drifted) with links to the catalog. RFC:
docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md.
2026-07-04 22:58:28 +08:00
The **durability seam** for the event log. [session.md ](session.md ) describes the in-memory `Session` — the append-only `SessionEvent` log that is the source of truth. This page describes how that log is made durable: the abstract `SessionPersistence` service, its backends, the flush checkpoint, crash recovery, and the metadata header that travels alongside the log. The event vocabulary the log carries is enumerated, member by member, in the generated [persistence log event catalog ](../persistence-catalog/log-events.md ).
2026-06-20 16:24:56 +08:00
2026-06-21 02:45:21 +08:00
The seam is a textbook [capability seam ](../rfc/implemented/architecture/2026-06-13-capability-seams.md ): one abstract service ([dsh-session-persistence ](../../packages/session-persistence/session-persistence ), `ctx.sessionPersistence` ) defining create/append/load/list over the existing `SessionEvent` — **no parallel persisted type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence RFC ](../rfc/implemented/architecture/2026-06-14-session-persistence.md ).
2026-06-20 16:24:56 +08:00
## The flush checkpoint
`session/event` is a *synchronous* notification; persistence plugins buffer it (write-behind) and drain at the awaited `session/flush` checkpoint the loop fires at every turn end. Flush is `ctx.parallel` (awaited): a turn's events are durably committed before the next turn starts, and the turn boundary is the commit boundary. A rejecting flush is reported via `agent/error` and the logger — never as a session event (it would land past the commit boundary), so the backend keeps its buffered events for the next flush.
## Crash recovery preserves an interrupted turn
A backend that reloads a log crashed mid-turn finds an open `turn/start` with no `turn/end` . It does **not** truncate — a single turn can be huge in a long-horizon task (many steps, large tool output), and those events were durably appended before the crash. Instead it closes the orphaned turn with a synthetic `turn/end { reason: { kind: 'interrupted' } }` , keeping the log balanced and the turn-enclosure invariant intact. `interrupted` is the one `TurnEndReason` no loop emits (see [session.md ](session.md#why-a-turn-ended-turnendreasonmap )).
## `SessionHeader` — metadata beside the log
2026-07-01 12:56:13 +08:00
Per-session metadata travels **separately** from the event log: format version, cwd, lineage, and the seed boundary are storage concerns, not conversation events, so they stay out of `SessionEventMap` and never reach `deriveMessages()` . The header is attached to a `Session` via `session.header` .
2026-06-20 16:24:56 +08:00
2026-06-20 23:12:14 +08:00
Source: [`packages/core/session/src/types.ts` ](../../packages/core/session/src/types.ts )
2026-06-20 16:24:56 +08:00
```ts type-equiv
interface SessionHeader {
2026-06-21 11:08:10 +08:00
/**
* On-disk format version, stamped from {@link SESSION_FORMAT_VERSION} when the
* session is created. A persistence backend rejects any other version on load
* (no migration — see the constant).
*/
2026-06-20 16:24:56 +08:00
version: number
/** The session's id (mirrors the {@link Session}'s id). */
id: SessionId
/** Unix epoch milliseconds when the session was created. */
createdAt: number
/** Absolute working directory the session was created in (if any). */
cwd?: string
/** The session this one was forked from (seed lineage), if any. */
parentSession?: SessionId
Persist the seed boundary so fork-child replay routes correctly
A fork subagent seeds its child session with a prefix of the parent's log, and
that seed becomes the child's persisted log — so a fork child's .jsonl begins
with the PARENT's events, including the parent's assistant/chunk events. The
snapshot replay harness derived a child's script from its whole log, which would
replay the parent's recorded responses as the child's model calls. Spawn-only
scenarios never hit it, but a fork snapshot would mis-route silently.
Record the seed boundary and skip the inherited prefix at replay:
- SessionHeader gains an optional `seedLength` (how many leading events were
inherited via a seed), threaded through CreateSessionOptions/CreateAgentOptions
meta and stamped by the fork backend (= seeded-prefix length; absent for spawn).
It is EXPLICIT, never inferred from seed.length: a resume seeds the whole stored
log, so the resume path passes the persisted boundary back.
- Both persistence backends round-trip it: JSONL header line, SQLite seed_length
column. The SQLite table change bumps SCHEMA_VERSION 2->3; per the pre-release
stance the backend rejects an older user_version on open with NO migration.
- llm-replay's parseSessionHeader reads seedLength and loadSessionScripts derives
a child script from events AFTER the boundary. seedLength is 0 for spawn, so
spawn replay is byte-for-byte unchanged.
Closes the routing-correctness gap the per-session snapshot replay RFC under-
stated; a recorded fork scenario remains a future addition but now derives
correctly. RFC: docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md.
Regression coverage: a fork child fixture whose seeded prefix carries a parent
chunk (derived script must exclude it, proven red without the slice); a seedLength
persistence round-trip through the shared coordinator contract (both backends);
the fork backend stamping it; resume preserving it from the persisted header.
2026-06-22 20:55:32 +08:00
/**
* How many leading events were INHERITED via a seed rather than produced by
* this session — the seed boundary. Set when a fork seeds a child with a
* prefix of the parent's log (= the seeded prefix length); absent/0 means the
* session produced all its own events. Persisted so a reload reconstructs the
* boundary instead of re-deriving it from the full stored log, and so a replay
* harness can skip the inherited prefix when deriving the child's OWN script
* (the seeded events are the parent's, not this child's model calls).
*/
seedLength?: number
2026-06-20 16:24:56 +08:00
}
```
## `CreateSessionOptions` — seeding and metadata
Persist the seed boundary so fork-child replay routes correctly
A fork subagent seeds its child session with a prefix of the parent's log, and
that seed becomes the child's persisted log — so a fork child's .jsonl begins
with the PARENT's events, including the parent's assistant/chunk events. The
snapshot replay harness derived a child's script from its whole log, which would
replay the parent's recorded responses as the child's model calls. Spawn-only
scenarios never hit it, but a fork snapshot would mis-route silently.
Record the seed boundary and skip the inherited prefix at replay:
- SessionHeader gains an optional `seedLength` (how many leading events were
inherited via a seed), threaded through CreateSessionOptions/CreateAgentOptions
meta and stamped by the fork backend (= seeded-prefix length; absent for spawn).
It is EXPLICIT, never inferred from seed.length: a resume seeds the whole stored
log, so the resume path passes the persisted boundary back.
- Both persistence backends round-trip it: JSONL header line, SQLite seed_length
column. The SQLite table change bumps SCHEMA_VERSION 2->3; per the pre-release
stance the backend rejects an older user_version on open with NO migration.
- llm-replay's parseSessionHeader reads seedLength and loadSessionScripts derives
a child script from events AFTER the boundary. seedLength is 0 for spawn, so
spawn replay is byte-for-byte unchanged.
Closes the routing-correctness gap the per-session snapshot replay RFC under-
stated; a recorded fork scenario remains a future addition but now derives
correctly. RFC: docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md.
Regression coverage: a fork child fixture whose seeded prefix carries a parent
chunk (derived script must exclude it, proven red without the slice); a seedLength
persistence round-trip through the shared coordinator contract (both backends);
the fork backend stamping it; resume preserving it from the persisted header.
2026-06-22 20:55:32 +08:00
Creating a `Session` through the store takes a `seed` (replay/fork an existing event log) and `meta` (the storage-level fields the store folds into a `SessionHeader` ). The store fills in `version` /`id` and defaults `createdAt` ; the caller supplies the validated absolute `cwd` , the `parentSession` lineage, the `seedLength` seed boundary, and — only when reconstructing a persisted session — the original `createdAt` to preserve it.
2026-06-20 16:24:56 +08:00
```ts type-equiv
interface CreateSessionOptions {
/** Events to seed the new session with (replay/fork). */
seed?: SessionEvent[]
/**
* Creation metadata. The store fills in `version` /`id` and defaults
* `createdAt` to now; the caller supplies the storage-level fields (validated
Persist the seed boundary so fork-child replay routes correctly
A fork subagent seeds its child session with a prefix of the parent's log, and
that seed becomes the child's persisted log — so a fork child's .jsonl begins
with the PARENT's events, including the parent's assistant/chunk events. The
snapshot replay harness derived a child's script from its whole log, which would
replay the parent's recorded responses as the child's model calls. Spawn-only
scenarios never hit it, but a fork snapshot would mis-route silently.
Record the seed boundary and skip the inherited prefix at replay:
- SessionHeader gains an optional `seedLength` (how many leading events were
inherited via a seed), threaded through CreateSessionOptions/CreateAgentOptions
meta and stamped by the fork backend (= seeded-prefix length; absent for spawn).
It is EXPLICIT, never inferred from seed.length: a resume seeds the whole stored
log, so the resume path passes the persisted boundary back.
- Both persistence backends round-trip it: JSONL header line, SQLite seed_length
column. The SQLite table change bumps SCHEMA_VERSION 2->3; per the pre-release
stance the backend rejects an older user_version on open with NO migration.
- llm-replay's parseSessionHeader reads seedLength and loadSessionScripts derives
a child script from events AFTER the boundary. seedLength is 0 for spawn, so
spawn replay is byte-for-byte unchanged.
Closes the routing-correctness gap the per-session snapshot replay RFC under-
stated; a recorded fork scenario remains a future addition but now derives
correctly. RFC: docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md.
Regression coverage: a fork child fixture whose seeded prefix carries a parent
chunk (derived script must exclude it, proven red without the slice); a seedLength
persistence round-trip through the shared coordinator contract (both backends);
the fork backend stamping it; resume preserving it from the persisted header.
2026-06-22 20:55:32 +08:00
* absolute `cwd` , `parentSession` lineage, the seed boundary `seedLength` , and
* — when reconstructing a persisted session — the original `createdAt` to
* preserve it).
*
* `seedLength` is EXPLICIT, not inferred from `seed.length` : a reconstruction
* (resume/load) seeds the WHOLE stored log, so its `seed.length` is the full
* length, not the original boundary — the caller must pass the persisted
* boundary back. A fresh fork passes its actual seeded-prefix length.
2026-06-20 16:24:56 +08:00
*/
Persist the seed boundary so fork-child replay routes correctly
A fork subagent seeds its child session with a prefix of the parent's log, and
that seed becomes the child's persisted log — so a fork child's .jsonl begins
with the PARENT's events, including the parent's assistant/chunk events. The
snapshot replay harness derived a child's script from its whole log, which would
replay the parent's recorded responses as the child's model calls. Spawn-only
scenarios never hit it, but a fork snapshot would mis-route silently.
Record the seed boundary and skip the inherited prefix at replay:
- SessionHeader gains an optional `seedLength` (how many leading events were
inherited via a seed), threaded through CreateSessionOptions/CreateAgentOptions
meta and stamped by the fork backend (= seeded-prefix length; absent for spawn).
It is EXPLICIT, never inferred from seed.length: a resume seeds the whole stored
log, so the resume path passes the persisted boundary back.
- Both persistence backends round-trip it: JSONL header line, SQLite seed_length
column. The SQLite table change bumps SCHEMA_VERSION 2->3; per the pre-release
stance the backend rejects an older user_version on open with NO migration.
- llm-replay's parseSessionHeader reads seedLength and loadSessionScripts derives
a child script from events AFTER the boundary. seedLength is 0 for spawn, so
spawn replay is byte-for-byte unchanged.
Closes the routing-correctness gap the per-session snapshot replay RFC under-
stated; a recorded fork scenario remains a future addition but now derives
correctly. RFC: docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md.
Regression coverage: a fork child fixture whose seeded prefix carries a parent
chunk (derived script must exclude it, proven red without the slice); a seedLength
persistence round-trip through the shared coordinator contract (both backends);
the fork backend stamping it; resume preserving it from the persisted header.
2026-06-22 20:55:32 +08:00
meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number }
2026-06-20 16:24:56 +08:00
}
```
Replay/fork is therefore `ctx.sessions.create(id, { seed: seedEvents })` ; resuming a *persisted* session into a live agent is `ctx.agents.resume({ resumeSessionId })` .
## The backends
2026-06-21 02:45:21 +08:00
Both implement the same abstract `SessionPersistence` (create/append/load/list over `SessionEvent` ) and pass `runPersistenceContract` , proving the seam is genuinely backend-agnostic:
2026-06-20 16:24:56 +08:00
2026-06-20 23:12:14 +08:00
- **[dsh-session-persistence-jsonl ](../../packages/session-persistence/session-persistence-jsonl )** — an append-only JSONL log per session with crash-safe atomic writes, the interrupted-turn crash recovery above, and a read/replay path.
2026-06-30 20:07:52 +08:00
- **[dsh-session-persistence-sqlite ](../../packages/session-persistence/session-persistence-sqlite )** — `node:sqlite` , one row per `SessionEvent` . The row shape `(session_id, seq, type, time, data, source_event_seqs, surface_op)` maps 1:1 onto the event, including optional surface metadata, so there is no parallel persisted schema to keep in sync.
2026-06-20 16:24:56 +08:00
2026-06-20 22:29:45 +08:00
Multiple backends sharing one on-disk session coordinate writes through the [shared persistence write-coordinator ](../rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md ).