The shared coordinator serializes operations within one backend instance; it does not coordinate multiple instances writing the same on-disk session. Remove prose that implied unsupported shared-writer semantics. Also update the older seam-simplification note to describe the surviving loadStored existence probe instead of the removed loadLive hook, so implemented documentation matches the current contract.
100 lines
6.6 KiB
Markdown
100 lines
6.6 KiB
Markdown
# Session Persistence
|
|
|
|
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.md).
|
|
|
|
The seam is a textbook [capability seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence/session-persistence), `ctx.sessionPersistence`) defining locate/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 Agent Note](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md).
|
|
|
|
## 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)).
|
|
|
|
## `SessionLocation` — optional per-session artifact target
|
|
|
|
`SessionPersistence.locate(meta)` synchronously resolves a backend-owned independent artifact without reading, creating, or flushing it. JSONL returns its absolute target path; SQLite returns `undefined` because sessions share one database. A returned path can therefore name a file that does not yet exist or lacks the current unflushed turn; it is a location hint, not authorization or a freshness guarantee.
|
|
|
|
```ts type-equiv
|
|
/**
|
|
* A backend-resolved, per-session local artifact location. The path is an
|
|
* absolute target path and can name an artifact that has not materialized yet.
|
|
* Consumers must treat it as a location hint, never as an authorization token.
|
|
*/
|
|
interface SessionLocation {
|
|
/** Backend-specific artifact kind, for example `jsonl`. */
|
|
readonly kind: string
|
|
/** Absolute path to this session's backend-owned artifact. */
|
|
readonly path: string
|
|
}
|
|
```
|
|
|
|
## `SessionHeader` — metadata beside the log
|
|
|
|
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`.
|
|
|
|
Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts)
|
|
|
|
```ts type-equiv
|
|
/**
|
|
* Immutable validated storage metadata, kept outside the conversation event log.
|
|
*/
|
|
interface SessionHeader {
|
|
/**
|
|
* 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).
|
|
*/
|
|
readonly version: number
|
|
/** The session's id (mirrors the {@link Session}'s id). */
|
|
readonly id: SessionId
|
|
/** Unix epoch milliseconds when the session was created. */
|
|
readonly createdAt: number
|
|
/** Absolute working directory the session was created in (if any). */
|
|
readonly cwd?: string
|
|
/** The session this one was forked from (seed lineage), if any. */
|
|
readonly parentSession?: SessionId
|
|
/**
|
|
* How many leading events were inherited through a seed. Persisting this
|
|
* boundary lets resume and replay distinguish parent history from child work.
|
|
*/
|
|
readonly seedLength?: number
|
|
}
|
|
```
|
|
|
|
## `CreateSessionOptions` — seeding and metadata
|
|
|
|
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.
|
|
|
|
```ts type-equiv
|
|
/**
|
|
* Options for creating a {@link Session} via the store. `seed` replays/forks
|
|
* an existing event log; `meta` carries the caller-supplied storage fields the
|
|
* store folds into a {@link SessionHeader}.
|
|
*/
|
|
interface CreateSessionOptions {
|
|
/** Events to seed the new session with (replay/fork). */
|
|
readonly seed?: readonly SessionEvent[]
|
|
/**
|
|
* Storage metadata read once before publication. `seedLength` is explicit
|
|
* because a resumed seed contains the full stored log, not only its inherited prefix.
|
|
*/
|
|
readonly meta?: {
|
|
readonly cwd?: string
|
|
readonly parentSession?: SessionId
|
|
readonly createdAt?: number
|
|
readonly seedLength?: number
|
|
}
|
|
}
|
|
```
|
|
|
|
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
|
|
|
|
Both implement the same abstract `SessionPersistence` (locate/create/append/load/list over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic:
|
|
|
|
- **[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.
|
|
- **[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.
|