Session lifecycle
How a session is minted, persisted, resumed, aborted, time-traveled, and deleted — the unit of identity that outlives any single turn.
A session is the unit of persistence and identity. It carries the locked provider and model, the message history, the channel state, and the checkpoint chain. The runtime is per-process; the session is per-conversation. Every turn runs against one session id, and every audit row joins back to it.
This page walks the session through its lifecycle. Per-turn execution flow — the stream events, the tool loop, the synthesis stage — lives on Turn lifecycle.
The runtime-lifecycle cluster
Session lifecycle is one of three concepts paired with Turn lifecycle (the per-message arc) and Event log (the append-only stream both arcs write into). Sessions outlive turns, turns outlive their stream frames, and the event log outlives both. The full triplet framing lives at Concept clusters → Runtime-lifecycle; the rest of this page is the deep dive on the session arc itself.
Minting a session
const session = await runtime.createSession({
provider: { type: "anthropic" },
model: { id: "claude-sonnet-4-20250514" },
tools: { enabled: ["search", "calculator"] },
})createSession takes a Partial<SessionConfig> and writes the new
session to the storage adapter before resolving. The returned
Session is your handle — session.id is a UUIDv7 the runtime
generates, and it's what every subsequent call takes.
Three things land on storage during createSession:
- The session row itself (id, owner, createdAt, locked provider+model).
- An initial version-vector entry keyed on the runtime's
clientId. - An empty channel set ready for the first turn's writes.
Provider and model are locked at this point. A session keeps the
same (provider, model) pair for its lifetime — switching models
mid-conversation means a new session, not a mutation. See
SessionRuntime for the
full config table and
POST /api/harness/sessions for the HTTP wire
shape.
Resuming a session
const session = await runtime.resumeSession(sessionId)Resume rebuilds a session into the runtime from three layers:
- The storage row. The adapter reads the session record —
owner, locked provider/model, version vector, last-active
timestamp, and the durable
SessionState(messages, pending tool calls, artifacts, jobs). This is the base state. - The latest checkpoint. When a
checkpointeris wired and holds a snapshot at least as recent as the storage row, the runtime overlays it — the recovery path for a crash that landed a checkpoint but lost the row write. The version guard means an older checkpoint never rolls back a fresher row; the checkpoint snapshot is authoritative when it wins. (Making the event log the canonical message/state reader is a separate forward-looking direction — that projection currently runs as a behavior-free shadow, gated off by default.) - The event log (server only). When the runtime can read the
persisted log (a service-role server adapter),
hydrateFromEventsrebuilds the ephemeral card-lifecycle state theSessionStaterow does not carry — resolved interrupts, subagents, sandbox exports, and user cards. Because that state is derived from the durable log, it rides the returned Session's transienthydratedHarnessStatefield and is not written back to the row. The browser / in-memory path skips this layer. See Event log for the walk.
Resume reconstructs only what storage durably held. The default in-memory adapter keeps everything in this process's heap — restart the process (or land on a fresh serverless instance) and there is nothing to resume. Durability is the host's storage-adapter choice, not an automatic guarantee; wire a durable adapter (Supabase / Postgres / Redis) for resume to survive a restart. See Storage backends below.
Resume is idempotent. Calling it twice with the same id returns
two Session instances pointing at the same persisted state — the
later call wins as the runtime's getActiveSession().
A resumed session keeps its original id, its clientId lineage,
and every prior audit row. The audit ledger doesn't fork on
resume; it appends.
Aborting an in-flight turn
const ctrl = new AbortController()
setTimeout(() => ctrl.abort(), 30_000)
for await (const event of runtime.executeMessage(
session.id,
prompt,
{ abortSignal: ctrl.signal },
)) {
// ...
}The AbortController propagates into the per-call provider stream
and into every active subagent under the turn. The runtime cancels
in-flight network calls, unwinds the tool loop, and flushes a
final audit row with outcome.status: "user-aborted" — recorded
distinctly from provider-error at both the tool-loop decision call
and the synthesis terminal, so cost rollups and compliance can tell
caller cancellation apart from real provider failure.
Aborting does not delete the session. The session row stays; the
partial turn lands an audit row carrying the same
(sessionId, turnId, stageId, seqWithinTurn) identity tuple as a
successful row — so per-turn cost rollups still count the prefill
tokens the provider already consumed. The next executeMessage on
the same sessionId continues from the post-abort state.
See SessionRuntime · Aborting a turn for the code path inside the runtime.
Time-travel and rollback
The rollback API itself and the rollback-vs-fork choice live on Checkpointing. Two session-level invariants matter here:
- Audit rows are never rolled back. The ledger is append-only
by contract —
ProviderDecisionLedgerhas noupdateordeleteprimitive. A rolled-back turn's calls stay in the ledger; the rollback is a new event downstream of them. - Subagent provenance is preserved. Audit rows for subagents
nested under the rolled-back point keep their
parentTurnIdandsubagentDepth, so a rollback can be replayed and diffed against the original branch.
Fork is the non-destructive sibling — it mints a new sessionId
pointing at the same checkpoint, so the original transcript stays
intact. See Checkpointing for the rollback
and fork APIs, and Time travel for the eval
and replay use cases.
Conflict resolution (multi-client)
Two clients writing the same session bump their respective
version-vector entries. When one pushes to the server, the
coordinator compares vectors — a concurrent outcome is settled by
SyncCoordinator.resolveConflicts(local, remote) using
last-writer-wins on updatedAt, rather than clobbering by arrival
order. This is the self-host resolution path in open @pleach/core.
Interactive conflict resolution is enterprise-tier / planned, not shipped in the open package:
// Enterprise-tier / planned — NOT functional in open @pleach/core.
await runtime.resolveConflict(sessionId, conflictId, "local")
// or "remote"In @pleach/core, runtime.resolveConflict is a stub: it logs and
returns a receipt without merging or persisting, and no sync.conflict
stream event is emitted to hand it a conflictId. CRDT merge and the
interactive conflict surface are the enterprise upgrade. For the
working self-host path, call
SyncCoordinator.resolveConflicts(local, remote) on your push
handler. See Sync for the version-vector math, the
durable outbox, and the 3xxx error code range.
Deleting a session
await runtime.deleteSession(sessionId)Delete cascades into the checkpointer when one is wired — every
checkpoint for the session drops. The session row is removed from
storage and getActiveSession() returns null if it pointed at
the deleted id.
The audit ledger is the exception. Audit rows survive delete
because the ledger is append-only by contract; that's what makes
ProviderDecisionLedger replayable and what gives finance and
compliance a row set that can't be silently retroactively edited.
Subject-key-derived deletion of audit data goes through the
GDPRSoftDelete plug-point. It leaves the recordId in place (so
the hash chain holds) and clears the identifying fields under the
subject key — satisfying a deletion request without breaking the
append-only invariant. See
Audit ledger · The three compliance plug-points.
Storage backends
The storage adapter is what createSession writes to and what
resumeSession reads from. Pick by environment.
| Adapter | Environment | Use for |
|---|---|---|
MemoryAdapter | Any | Tests, local dev, ephemeral demos |
IndexedDBAdapter | Browser | Offline-first apps, browser extensions, multi-device drafts |
SupabaseAdapter | Server (Node) | Persistent storage, multi-tenant RLS, production |
MemoryAdapter is the zero-config default — and it is
non-durable: session state (messages, checkpoints, event log)
lives in the process heap, is lost on restart, and is not shared
across instances. On serverless, each cold start or new instance
begins from empty state. The quickstart route
(createPleachRoute) emits a one-time startup warning when it
falls back to this adapter. Production hosts should supply a
durable adapter — durability is the host's storage-adapter choice,
not something the runtime provides automatically.
All three implement the same StorageAdapter interface, so
swapping is a one-line constructor change. See
Storage for the per-adapter config and the RLS
template that SupabaseAdapter parameterizes.
Lifecycle method index
| Method | Signature | When to reach for it |
|---|---|---|
createSession | (config?: Partial<SessionConfig>) => Promise<Session> | Starting a new conversation — locks provider and model |
resumeSession | (sessionId: string) => Promise<Session> | Loading a session from a prior process or another tab |
saveSession | (state: SessionState) => Promise<void> | Flushing an explicit state mutation outside the normal turn write path |
deleteSession | (sessionId: string) => Promise<void> | Removing the session and its checkpoints (audit rows stay) |
getActiveSession | () => Session | null | Reading the last session the runtime loaded |
rollbackToCheckpoint | (sessionId, checkpointId) => Promise<Session> | Rewinding a session in place (deprecated alias for runtime.checkpoints.rollback) |
resolveConflict | (sessionId, conflictId, "local" | "remote") => Promise<…> | Enterprise-tier / planned — a stub in open @pleach/core (logs, no merge). Use SyncCoordinator.resolveConflicts for the working last-writer-wins path |
destroy | () => Promise<void> | Tearing the runtime down — stream manager, interrupt manager, listeners |
The same flat-method-to-facet deprecation contract applies here:
rollbackToCheckpoint redirects to runtime.checkpoints.rollback,
and flat methods stay callable through one minor cycle before the
next major drops them. See
SessionRuntime · Session lifecycle methods
for the canonical signature reference.
Where to go next
Turn lifecycle
What happens inside one executeMessage call — stages, stream events, the tool loop.
SessionRuntime
The constructor config, every accessor method, and the full strategy slot table.
API routes
The HTTP + SSE wire shapes for create / get / put / delete / sync.
Checkpointing
Snapshot writes, the rollback and fork APIs, and the Checkpointer contract.
Sync
Version vectors, the durable outbox, and the 3xxx error code range.