diff --git a/docs/research/session-store/RESEARCH_PROMPT.md b/docs/research/session-store/RESEARCH_PROMPT.md new file mode 100644 index 000000000..de2367c91 --- /dev/null +++ b/docs/research/session-store/RESEARCH_PROMPT.md @@ -0,0 +1,214 @@ +# Research Prompt: how {PRODUCT} stores and resumes sessions + +Reusable prompt for the session-store study. Run once per product. Output +goes into `docs/research/session-store/products/{slug}.md`, following the +section skeleton below. Add the dossier to `index.md` when done. + +## Task + +Research how **{PRODUCT}** ({URLS}) persists, resumes, lists, and retires +session transcripts and session state. The goal is the operational storage +model, not marketing copy: what the durable session actually is in their +API, data model, on-disk layout, and runtime, and what is derived from it. +This feeds the platform's event-sourced Session Store design, so pay special +attention to whether the durable session is an append-only log with derived +projections or a mutable record. + +## Research questions + +Answer every question the sources can support. Quote primary sources; mark +gaps as gaps instead of guessing. + +### 1. The storage model + +- What do the docs/API/source literally say the durable session is? Capture + exact quotes. +- What is the source of truth: an append-only event/update log, a mutable + document, a row set, a set of files? What format (JSONL, JSON, SQL rows, + blobs)? +- Which is derived and which is authoritative? Identify caches, summaries, + indexes, and search state that are rebuildable projections versus the + primary record. +- Which conceptual model fits best: session-as-transcript, session-as-log, + session-as-document, session-as-directory, session-as-row, other? + +### 2. Keying and identity + +- How is a session addressed? Map the key components (project/workspace/cwd + encoding, session id, subpath/subagent suffix) and their hierarchy. +- How are session ids minted (UUID, UUIDv7, server-assigned, client-supplied) + and does the scheme encode ordering or location? +- Is listing scoped (per project/cwd) or global? Is there cross-project + enumeration? +- How are relocations (moved working directory, worktree changes) or renames + reconciled to the identity? + +### 3. The store interface + +Always document the store's interface, whether or not it is pluggable. The +goal is a clear operational contract of every way callers read from and write +to the durable session. + +- If the product exposes a store interface/protocol (a pluggable adapter, an + SDK type, an RPC surface), capture the **full contract verbatim**: every + method with its exact signature, which methods are required versus optional, + and when each is invoked. Reproduce the type as-is, not a paraphrase. +- If there is no pluggable interface (the store is an internal on-disk layout, + a private module, or ad-hoc call sites), **reconstruct the effective + interface** from the source and present it the same way: name each operation + (append/write, load/read, list, summarize, delete, rename, fork, etc.), its + inputs and outputs, its ordering and consistency guarantees, and the call + sites that invoke it. Note that this is a reconstruction, not an exported + type, and give a repo `path:line` for each operation. +- Either way, the reader should come away knowing the complete set of + operations against the store and their contracts, without having to infer + them from the append/read narrative that follows. + +### 4. Write and append path + +- How is a new entry/turn committed: append, insert, full rewrite, + compare-and-swap? +- What guarantees ordering (positional line order, sequence number, server + timestamp, monotonic id)? +- What is the durability/atomicity story (locks, temp-file-and-rename, + transactions, torn-write healing, fsync)? +- What is the concurrency model: single-writer-per-session, multi-writer, + optimistic concurrency with an expected position? Is there any + expected-version precondition on append? +- What are the delivery semantics to the store (best-effort, at-least-once, + exactly-once)? Is there client-side idempotence (dedup by entry id)? + +### 5. Read and resume path + +- How is a session reconstructed on resume: full ordered read, incremental + read from a cursor, replay of a log, load of a cached view? +- Does resume read the durable store or a local cache/filesystem first? +- Is there entry-level pagination, offset, or a bound on transcript size? +- What is materialized eagerly on resume versus loaded lazily? + +### 6. Listing, summaries, and search + +- How are sessions enumerated for a picker/list view: index, directory scan, + query? What does it cost at scale (quote any stated numbers)? +- Is there a metadata sidecar/summary maintained at write time (a read model)? + What fields does it denormalize, and how is it kept consistent with the log? +- Is search a separate indexed subsystem (FTS, vector, external)? How is it + bootstrapped, updated, and kept consistent? + +### 7. Entry/message structure and versioning + +- Document the actual structure of what is stored, not just whether it is + opaque. Capture the entry/message type and its fields: any envelope or + wrapper (timestamps, method/kind tags, ids), the payload shape, how message + types are distinguished, and how entries link into a chain or thread + (parent/uuid references, ordering fields). Quote the type definitions. +- Is the entry opaque to the store (persisted and returned verbatim) or does + the store parse and interpret it? What field, if any, does the store rely on + for identity/dedup? +- How does the format evolve across product versions (schema version fields, + additive serde defaults, legacy-format sniffing, migrations)? +- Is there versioning of the session/store format itself, and a one-way vs + reversible migration ratchet? + +### 8. Compaction and history management + +- How does the model-visible view shrink (compaction, summarization, + truncation) while the durable record persists, if at all? +- Is compaction a store concern or an upstream concern? What artifact does it + leave in the durable log (marker, external snapshot, in-place rewrite)? +- What resume/replay behavior crosses a compaction boundary? + +### 9. Rewind, checkpoints, and fork + +- Are there retroactive operations (rewind, undo, branch)? Are they expressed + as appended markers interpreted at replay, or as destructive edits? +- Are there file-state or environment checkpoints tied to turns? How are they + stored (full content, diff, hash, dedup) and what do they cost? +- How does fork work: shared-prefix reference, copy-plus-lineage, identity + rewrite? What lineage metadata is recorded? + +### 10. Subagents and nested sessions + +- How is a subagent/child session stored: nested under the parent, a + first-class sibling session, entries in the parent transcript? +- What is the durable parent-child link, and does the child inherit or isolate + its transcript? +- Is nesting bounded? What happens to child sessions on parent + delete/rewind/crash (cascade, orphan, reconcile)? + +### 11. Retention, deletion, and multi-host + +- Who owns retention (TTL, lifecycle policy, scheduled cleanup) and does the + store or the product enforce it? +- How does delete behave: cascade to subkeys/summaries/search, remote-first, + no-op for append-only backends? +- How does the store behave across hosts/processes (shared filesystem + assumptions, crash detection, network-filesystem handling, remote + writeback)? Is multi-host a first-class path or a workaround? + +### 12. Interop with foreign session stores + +- Does the product read other products' native session stores (discovery, + import, resume)? If so, which, how bounded, and read-only or converting? + (Skip if not applicable.) + +### 13. The implicit model + +- Based on all the above, what makes something "a stored session" in this + product, and how close is the durable design to an append-only log with + derived projections? State it in one or two sentences, in our words, clearly + marked as our inference, and note what it implies for our event-sourced + Session Store. + +## Method + +1. Primary sources first: official docs, API reference, SDK source, config + schemas, on-disk layouts, official repos. Secondary sources only to + triangulate. When the source is a repository, pin the exact commit and cite + `path:line` for each claim. +2. Capture each exact quote as a checked-in source excerpt. Record its direct + URL or repo `path:line`, document section or repository symbol, source + version or commit when available, and retrieval date. Also record an + archive or snapshot URL when one exists and a content digest when the source + publishes or permits one. These evidence records must remain auditable if + the live URL changes. +3. Record the retrieval date with `date +%F`; never guess it. +4. Stay on the operational storage model. Ignore pricing, marketing claims, + and features unrelated to how sessions are persisted, resumed, listed, and + retired. +5. Fill the product skeleton sections in the order the product's model makes + natural; omit a section only when the product genuinely has no such concept, + and say so. Put anything the sources leave unanswered under **Open + questions**. +6. Where a conclusion here would differ from an accepted record in the + [ADR index](../../adr/index.md), the ADR is authoritative; note the + difference rather than overriding it. + +## Output skeleton (per product file) + +```markdown +# {PRODUCT}: how session transcripts are stored and resumed + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT](../RESEARCH_PROMPT.md). +Evidence snapshot retrieved YYYY-MM-DD. Version-sensitive claims were checked +against these authoritative anchors: + +- {primary-source anchor: doc URL, or repo + commit + relevant paths} +- {additional anchors} + +## The storage model +## Keying and identity +## The store interface +## Write and append path (ordering, durability, concurrency, delivery) +## Read and resume path +## Listing, summaries, and search +## Entry/message structure and versioning +## Compaction and history management +## Rewind, checkpoints, and fork +## Subagents and nested sessions +## Retention, deletion, and multi-host +## Interop with foreign session stores +## What this implies for our Session Store (our inference) +## Open questions +``` diff --git a/docs/research/session-store/index.md b/docs/research/session-store/index.md new file mode 100644 index 000000000..4b1ec87bf --- /dev/null +++ b/docs/research/session-store/index.md @@ -0,0 +1,36 @@ +# Session store research corpus + +This corpus is the research input behind the platform's Session Store +design: an industry study of how agent products persist, resume, list, and +retire session transcripts and session state. It follows the same method as +the [agent platform corpus](../agent-platform/index.md). Where a conclusion +here differs from an accepted record in the [ADR index](../../adr/index.md), +the ADR is authoritative. + +## Method + +The [research prompt](./RESEARCH_PROMPT.md) is preserved so the shared scope +and evidence rules behind each product dossier remain reproducible. + +## Status + +Synthesis complete. Nine product dossiers and the cross-product synthesis +exist; the decision record has not run yet. + +## Product dossiers + +- [Claude Agent SDK and Claude Code](./products/claude-agent-sdk.md) +- [Codex CLI (OpenAI)](./products/codex-cli.md) +- [Gemini CLI (Google)](./products/gemini-cli.md) +- [Goose (Block)](./products/goose.md) +- [Grok Build](./products/grok-build.md) +- [Hermes (Nous Research)](./products/hermes-agent.md) +- [LangGraph (LangChain)](./products/langgraph.md) +- [OpenCode](./products/opencode.md) +- [T3 Code](./products/t3code.md) + +## Synthesis + +- [Synthesis: what the industry means by a "stored session"](./synthesis.md), + the cross-product convergence and divergence analysis drawn from the + dossiers above, organized around the append-log-vs-mutable-record spectrum. diff --git a/docs/research/session-store/products/claude-agent-sdk.md b/docs/research/session-store/products/claude-agent-sdk.md new file mode 100644 index 000000000..2d42d395e --- /dev/null +++ b/docs/research/session-store/products/claude-agent-sdk.md @@ -0,0 +1,535 @@ +# Claude Agent SDK and Claude Code: how session transcripts are stored and resumed + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT](../RESEARCH_PROMPT.md). +Evidence snapshot retrieved 2026-07-23. Version-sensitive claims were checked +against these authoritative anchors: + +- Claude Agent SDK documentation, + [Persist sessions to external storage](https://code.claude.com/docs/en/agent-sdk/session-storage) + (the `SessionStore` contract, dual-write behavior, delivery semantics, fork, + compaction, retention). +- Claude Code documentation, + [Manage sessions](https://code.claude.com/docs/en/sessions) + (on-disk transcript location and encoding, `--continue`/`--resume`/`--from-pr`, + the session picker, naming, branching, `/export`). +- Claude Code documentation, + [Checkpointing](https://code.claude.com/docs/en/checkpointing) + (rewind, file snapshots, what is and is not tracked). +- The exported types `SessionStore`, `SessionKey`, `SessionStoreEntry`, and + `SessionSummaryEntry` from `@anthropic-ai/claude-agent-sdk` (TypeScript) and + `claude_agent_sdk` (Python). +- Direct inspection of the local Claude Code store under `~/.claude/` + (transcript JSONL layout, entry envelopes, subagent sidecars, file-history + blobs, the running-session registry), retrieved 2026-07-23. On-disk examples + below use synthetic keys and ids; only structure is reproduced. + +> **Two surfaces, one model.** Claude Code owns the *authoritative* durable +> store: append-only JSONL transcripts on the local filesystem. The Agent SDK's +> `SessionStore` is a *pluggable mirror* of that same stream to an external +> backend. The SDK doc states it plainly: "The store is a mirror, not a +> replacement. The Claude Code subprocess always writes to local disk first; +> the SDK then forwards each batch to `append()`." This dossier documents the +> on-disk store first (the source of truth) and then the mirror contract, since +> the mirror's shape is derived from the on-disk one. + +## The storage model + +**Authoritative store (Claude Code, local):** an append-only JSONL transcript +per session. From the sessions doc: "By default, transcripts are stored as +JSONL at `~/.claude/projects//.jsonl` ... Each line is a +JSON object for a message, tool use, or metadata entry. The entry format is +internal to Claude Code and changes between versions, so scripts that parse +these files directly can break on any release." + +The on-disk tree observed under `~/.claude/`: + +```text +~/.claude/ + projects//.jsonl main transcript (append-only) + projects///subagents/agent-.jsonl subagent transcript + projects///subagents/agent-.meta.json subagent metadata sidecar + projects///subagents/workflows/wf_/agent-.jsonl workflow sub-session + file-history//@v checkpoint file-content blobs + sessions/.json live running-session registry + history.jsonl global prompt-input history +``` + +- **Source of truth is the log, projections are derived.** The `.jsonl` + transcript is authoritative and append-only. The running-session registry + (`sessions/.json`), the AI-generated title, the message-chain view the + model sees, the checkpoint file snapshots, and the session picker's listing + are all derived from or maintained alongside the log, not the primary record. +- **The SDK mirror is not the source of truth during a run.** "By default, the + SDK writes session transcripts to JSONL files under `~/.claude/projects/` on + the local filesystem. A `SessionStore` adapter lets you mirror those + transcripts to your own backend ... so a session created on one host can be + resumed on another." +- **Stated motivations for the mirror:** multi-host deployments (serverless, + autoscaled workers, CI runners with no shared filesystem), durability across + container restarts, and compliance ("Keep transcripts in storage you already + govern, with your own retention rules, encryption, and access controls."). +- **Conceptual model: session-as-log materialized as session-as-directory.** + The durable session is an ordered, append-only log of JSON entries keyed by + working directory + session UUID, with subagent transcripts nested under a + per-session subdirectory and out-of-band file-content blobs stored + separately. There is no server-side session resource; identity is the + `sessionId` UUID that names the file. + +## Keying and identity + +The address of one transcript is `projectKey / sessionId / subpath`: + +- **`projectKey`** is "your working directory path with non-alphanumeric + characters replaced by `-`" (sessions doc). The SDK generalizes this to "a + stable, filesystem-safe encoding of the working directory." So the absolute + cwd is flattened into a single directory-name segment; the key literally + encodes location on disk. +- **`sessionId`** is "the session UUID" (a random v4-shaped UUID observed on + disk as the `.jsonl` filename). It is client-side (Claude Code) assigned, not + server-assigned, and the scheme encodes neither ordering nor location; + ordering comes from position in the log, and location comes from the + `projectKey` directory it lives in. +- **`subpath`** is set "when the entry belongs to a subagent transcript or + sidecar file rather than the main conversation ... it follows the on-disk + layout, for example `subagents/agent-`. When `subpath` is undefined the + key refers to the main transcript." + +Hierarchy is project -> session -> subpath. Every store operation is scoped to +one `projectKey`; there is no cross-project enumeration in the SDK contract. + +**Scoping and relocation (Claude Code specifics):** + +- Listing/resume is scoped to the current project directory. "session ID lookup + is scoped to the current project directory and its git worktrees, so a + session created elsewhere reports `No conversation found with session ID: + `." The picker can widen: `Ctrl+W` to "all worktrees of the + repository," `Ctrl+A` to "every project on this machine." +- **Relocation is handled by moving storage, not by rewriting identity.** "From + v2.1.169, moving a session with `/cd` relocates it to the new directory's + project storage, so it appears in that directory's picker afterward." A moved + session "stays out of the old directory's picker even after a crash or forced + exit" (v2.1.196+). Because `projectKey` is derived from cwd, a directory move + is reconciled by physically re-homing the transcript under the new + `projectKey`. +- Worktrees of the same repo are treated as siblings for lookup: "Selecting a + session from another worktree of the same repository resumes it in place." + +## The store interface + +Two contracts exist. First the **pluggable SDK interface** (verbatim exported +type), then the **reconstructed on-disk contract** that Claude Code itself uses +when no store is attached. + +### SDK `SessionStore` (verbatim, pluggable) + +TypeScript, as exported from `@anthropic-ai/claude-agent-sdk`: + +```typescript +// Exported from @anthropic-ai/claude-agent-sdk as +// SessionStore, SessionKey, SessionStoreEntry, SessionSummaryEntry. + +type SessionKey = { + projectKey: string; + sessionId: string; + subpath?: string; +}; + +type SessionStore = { + // Required + append(key: SessionKey, entries: SessionStoreEntry[]): Promise; + load(key: SessionKey): Promise; + + // Optional + listSessions?( + projectKey: string, + ): Promise>; + listSessionSummaries?(projectKey: string): Promise; + delete?(key: SessionKey): Promise; + listSubkeys?(key: { + projectKey: string; + sessionId: string; + }): Promise; +}; + +type SessionSummaryEntry = { + sessionId: string; + mtime: number; + data: Record; +}; +``` + +Python mirrors the same protocol (`claude_agent_sdk`), with snake_case fields, +`SessionStore` as a `Protocol`, and optional methods that are "omitted or raise +`NotImplementedError`": + +```python +class SessionKey(TypedDict): + project_key: str + session_id: str + subpath: NotRequired[str] + +class SessionStore(Protocol): + # Required + async def append(self, key: SessionKey, entries: list[SessionStoreEntry]) -> None: ... + async def load(self, key: SessionKey) -> list[SessionStoreEntry] | None: ... + + # Optional — omit or raise NotImplementedError + async def list_sessions(self, project_key: str) -> list[SessionStoreListEntry]: ... + async def list_session_summaries(self, project_key: str) -> list[SessionSummaryEntry]: ... + async def delete(self, key: SessionKey) -> None: ... + async def list_subkeys(self, key: SessionListSubkeysKey) -> list[str]: ... + +class SessionSummaryEntry(TypedDict): + session_id: str + mtime: int + data: dict[str, Any] +``` + +Method contract (from the doc's table): + +| Method | Required | Called when | +| --- | --- | --- | +| `append` | Yes | "After each batch of transcript entries is written locally. Entries are JSON-safe objects, one per line in the local JSONL." | +| `load` | Yes | "Before the subprocess spawns when `resume` is set, and once per session when listing falls back from `listSessionSummaries`. Return `null` if the session is unknown." | +| `listSessions` | No | "By `listSessions({ sessionStore })` and by `query()`/`startup()` with `continue: true`. If undefined, `continue: true` throws, and `listSessions({ sessionStore })` throws unless `listSessionSummaries` is implemented." | +| `listSessionSummaries` | No | "By `listSessions({ sessionStore })` to read metadata for all sessions in one call. Maintain the summaries inside `append`. If undefined, listing falls back to `listSessions` plus a per-session `load`." | +| `delete` | No | "By `deleteSession({ sessionStore })`. Deleting the main key (no `subpath`) must cascade to all subkeys for that session and also remove the session's summary entry ... If undefined, deletion is a no-op, which suits append-only backends." | +| `listSubkeys` | No | "During resume, to discover subagent transcripts. If undefined, only the main transcript is restored." | + +`importSessionToStore()` (TypeScript) migrates an existing local session into a +store; `InMemorySessionStore` ships in both SDKs for development and testing. + +### Reconstructed on-disk contract (Claude Code, no store attached) + +This is not an exported type; it is reconstructed from the on-disk layout and +documented behavior. Each operation names its effect against `~/.claude/`: + +| Operation | Effect | Ordering / consistency | +| --- | --- | --- | +| append entry | Append one JSON line to `projects//.jsonl` (or the subagent `.jsonl` for a child) | Positional line order is the total order; no sequence number field is required for ordering | +| read/resume | Read the full `.jsonl`, parse each line, rebuild the message chain via `parentUuid`/`uuid` links | Full ordered read; the model-visible view is the linked chain, not every line | +| list | Enumerate `.jsonl` files under the current `projectKey` directory (and, on widen, other project dirs) | Directory scan; per-project by default | +| checkpoint file state | On each user prompt, snapshot edited files; write a `file-history-snapshot` entry into the log and content blobs under `file-history//` | Keyed to the prompt's `messageId`; keeps the 100 most recent checkpoints | +| rename / title | Set a name (`--name`, `/rename`) or an AI-generated title (`ai-title` entry) | In-log metadata entry; last-writer view | +| branch / fork | Copy the transcript to a new `sessionId`, switch the process to write to it | New identity; original left intact on disk | +| delete / retire | Sweep transcripts older than `cleanupPeriodDays` (default 30) | Time-based cleanup, not per-call delete | + +The reader should come away knowing the complete operation set from either +contract without inferring it from the narrative below. + +## Write and append path (ordering, durability, concurrency, delivery) + +**On-disk (authoritative):** + +- **Append per line.** A new turn/entry is a new JSON line appended to the + session's `.jsonl`. "Sessions are saved continuously to local transcript + files as you work." No full rewrite, no compare-and-swap on the transcript + file itself. +- **Ordering** is positional (line order in the file). Each entry additionally + carries a `timestamp` and a `uuid`, and message entries carry a `parentUuid` + that threads the conversation chain independently of raw line order. +- **Concurrency is multi-writer with no lock.** "If you resume the same session + in two terminals without forking, messages from both interleave into one + transcript." Claude Code offers `/branch` / `--fork-session` as the safe + alternative to divergent concurrent writers. +- **Durability/atomicity** of individual line writes is not documented; the + format is declared internal and version-volatile. Treated as an open + question (temp-file-and-rename vs. plain append, fsync, torn-line healing are + not stated). + +**SDK mirror (best-effort, at-least-once):** + +- **Dual-write, local first.** "The Claude Code subprocess always writes to + local disk first; the SDK then forwards each batch to `append()`." Combining + `sessionStore` with `persistSession: false` throws, and combining it with + file checkpointing (`enableFileCheckpointing`) throws, "since file-history + backup blobs are written directly to local disk and are not mirrored to the + store." +- **Retry and drop.** "If `append()` rejects, the SDK retries the batch up to + two more times with a short backoff, for at most three attempts in total. A + call that times out isn't retried, since the original call may still land. If + the batch still fails, the error is logged, a `{ type: "system", subtype: + "mirror_error" }` message is emitted into the iterator, the batch is dropped, + and the query continues." +- **Idempotence is the adapter's job.** "Because a retried batch can re-deliver + entries that already landed, deduplicate by `entry.uuid` in your `append()` + implementation." There is no expected-position precondition on `append`, so + no optimistic-concurrency surface at the store boundary. + +## Read and resume path + +- **On-disk resume** rebuilds the conversation from the full transcript. + Third-party and doc descriptions agree the entire message history is + deserialized and the linked chain restored; tool results remain in context. + A resumed session also restores model, agent, permission mode (except `plan` + and `bypassPermissions`), active goal, and non-expired scheduled tasks, per + the sessions doc. +- **SDK resume reads the store, not the filesystem.** "`load` runs before the + subprocess spawns when `resume` is set." Resume also calls `listSubkeys` to + restore subagent transcripts; "without it, only the main transcript is + materialized." +- **No entry-level pagination.** `load(key)` returns the entire transcript in + one call; nothing in the contract bounds transcript size or offers a cursor. +- **The model-visible view is materialized lazily via the chain, not the raw + log.** `getSessionMessages({ sessionStore })` "returns the linked message + chain the agent would see on resume," which after compaction is far smaller + than the raw entry count (see Compaction). + +## Listing, summaries, and search + +- **Picker listing is a directory scan** over the current `projectKey`, with + widen-to-worktree (`Ctrl+W`) and widen-to-machine (`Ctrl+A`) modes. Each row + shows name/title, time since last activity, git branch, and file size; + `Ctrl+B` filters by branch, and pasting a PR URL finds the session that + created it (`--from-pr`, backed by `pr-link` entries in the log). +- **AI-generated titles are a derived read model.** "If you don't name a + session, Claude Code generates a session title for it: a short summary of your + first prompt, written by a background request to the small/fast model, + normally a Haiku-class model." On disk this surfaces as an `ai-title` control + entry in the log. +- **Running-session registry** lives at `~/.claude/sessions/.json`, keyed + by process id, holding `{pid, sessionId, cwd, name, nameSource, status, kind, + entrypoint, startedAt, updatedAt, ...}`. This is the liveness/agent-view + projection (`claude agents --json`), separate from the durable transcript. +- **SDK summaries** are a per-project read model. `listSessionSummaries` + "read[s] metadata for all sessions in one call." The SDK exports a pure fold + to maintain it: "Build the entries by calling the exported `foldSessionSummary` + helper ... on each batch inside `append`." "Skip batches whose key has a + `subpath`." "The fold never sets `mtime`: stamp it at persist time." `data` + "is opaque SDK-owned state; persist it verbatim." "Concurrent `append` calls + for the same session can race on the sidecar, so serialize the read-fold-write + with a transaction, a compare-and-swap, or a per-session lock; the fold itself + is pure." +- **Search** is not a separate indexed subsystem. The picker filters the + scanned list in memory (name/title/first-prompt, branch, PR URL); there is no + documented FTS/vector index over transcripts. + +## Entry/message structure and versioning + +Entries are line-delimited JSON. Two broad classes appear in the transcript. + +**Conversation entries** (`type: "user"` / `"assistant"`) share an envelope +observed on disk: + +```text +{ parentUuid, uuid, sessionId, type, timestamp, cwd, gitBranch, + version, isSidechain, userType, entrypoint, message, + // user adds: promptId, permissionMode + // assistant adds: requestId } +``` + +- `message` is the Anthropic API message payload. For assistant entries it + carries `model, id, type, role, content, stop_reason, stop_sequence, + stop_details, usage` plus internal fields (`diagnostics`, occasionally + `container`, `context_management`); for user entries, `role` and `content`. +- The conversation is threaded by `parentUuid` -> `uuid` links (a chain, not + just line order). `isSidechain` distinguishes subagent/sidechain turns from + the main thread. + +**Control / metadata entries** (top-level `type` tags, not part of the message +chain) observed on disk include: `last-prompt` (a `leafUuid` pointer to the +current chain tip), `mode` / `permission-mode`, `queue-operation`, `ai-title`, +`pr-link` (`{prNumber, prRepository, prUrl}`), `attachment`, `system` +(`subtype`, hook info, `toolUseID`), `agent-name`, `file-history-snapshot`, and +occasionally `custom-title` / `frame-link`. + +- **Opaque to the store, interpreted by the SDK/CLI.** To an adapter, + `SessionStoreEntry` is "a `{ type: string; ... }` object": "Treat them as + opaque JSON-safe values: persist them in order and return them from `load` in + the same order." "`load` must return entries that are deep-equal to what was + appended; byte-equal serialization is not required, so backends like Postgres + `jsonb` that reorder object keys are fine." The one field an adapter relies on + is `uuid` (for dedup). +- **Versioning.** Each conversation entry carries a `version` field (the Claude + Code version that wrote it). The store format "is internal to Claude Code and + changes between versions, so scripts that parse these files directly can break + on any release." There is no stable, documented schema-version ratchet for + `SessionStoreEntry` beyond `{ type: string }`; evolution is by additive fields + and version-sniffing, and it is one-way (old readers may not understand newer + entries). + +## Compaction and history management + +- **Compaction shrinks the model-visible view, not the durable log.** "After + auto-compaction, earlier turns are replaced by a summary, so a session whose + store holds 503 raw entries may return 18 messages from `getSessionMessages`. + For the full raw history, including pre-compaction turns and metadata entries, + call `store.load(key)` directly." +- **Compaction is an upstream (SDK/CLI) concern, not a store concern.** The + durable log keeps every raw entry; the summary is another appended entry. On + disk a compaction boundary appears as a `user` entry with + `isCompactSummary: true` and `isVisibleInTranscriptOnly: true`, chained into + the thread via `parentUuid`; the pre-compaction turns remain in the file. +- **Targeted summarize.** `/rewind`'s "Summarize from here" / "Summarize up to + here" and `/compact` compress a span into an AI summary. "In both cases the + original messages are preserved in the session transcript, so Claude can + reference the details if needed." Replay across a compaction boundary + therefore sees the summary in the chain while the raw turns stay on disk. + +## Rewind, checkpoints, and fork + +- **Rewind is a non-destructive view operation over an append-only log.** The + rewind menu offers "Restore code and conversation," "Restore conversation," + "Restore code," and the two summarize options. Restoring conversation moves + the chain tip back; the raw entries remain in the transcript. +- **File checkpoints are content snapshots, not diffs, tied to prompts.** "Every + user prompt creates a new checkpoint." "Claude Code keeps file snapshots for + the 100 most recent checkpoints in a session. Discarding an older checkpoint + deletes the snapshot files that no remaining checkpoint references, except + each file's first snapshot." On disk this is a `file-history-snapshot` log + entry — `{messageId, snapshot: {messageId, timestamp, trackedFileBackups}, + isSnapshotUpdate}` where `trackedFileBackups` maps each tracked path to + `{backupFileName, version, backupTime}` — plus content blobs at + `~/.claude/file-history//@v`. "Checkpoints are + saved with the conversation, so a resumed session can still `/rewind` to + them." These blobs "are written directly to local disk and are not mirrored + to the store." +- **What checkpointing does not track:** bash-command file changes, subagent + edits (except a foreground `context: fork` skill), external/concurrent-session + edits, and symlinked/hard-linked paths. "Checkpoints complement but don't + replace proper version control." +- **Fork rewrites identity, not a byte copy.** CLI `/branch` (or + `--fork-session`) "creates a copy of the conversation so far and switches you + into it, leaving the original intact"; both get their own session IDs and + appear as separate picker rows (grouped when the picker finds duplicates). + The SDK is explicit: "`forkSession({ sessionStore })` reads the source + entries, rewrites every `sessionId` field and remaps message UUIDs, then + appends the transformed entries under a new key. An adapter-level copy or + `CopyObject` shortcut would produce a transcript that still references the old + session ID, so the SDK does not use one." Lineage is the shared prefix content + plus the new/old session IDs printed on branch. + +## Subagents and nested sessions + +- **Nested under the parent, as sibling files in a per-session directory.** + Subagent transcripts are mirrored under `subpath: "subagents/agent-"`; on + disk they live at + `projects///subagents/agent-.jsonl` with a + `agent-.meta.json` sidecar (`{agentType, description, toolUseId}`). Deeper + nesting is observed for workflow orchestration: + `subagents/workflows/wf_/agent-.jsonl`. +- **Durable parent-child link** is the on-disk containment (child files live + inside the parent session's directory) plus the `toolUseId` in the child's + meta sidecar that ties the child to the parent tool call that spawned it. The + child has its own isolated transcript, not entries inlined in the parent. +- **SDK surface:** "`listSubagents({ sessionStore })` requires the adapter to + implement `listSubkeys`; `getSubagentMessages({ sessionStore })` uses it when + available but falls back to the direct subpath when it is undefined." +- **Cascade on delete.** In the SDK, "Deleting the main key (no `subpath`) must + cascade to all subkeys for that session." On disk, the subagent files are + inside the session directory, so removing the session removes its children. + Orphan/crash reconciliation of child sessions is not separately documented. + +## Retention, deletion, and multi-host + +- **Product owns retention; the store does not enforce it.** Locally, "Change + the 30-day retention" via `cleanupPeriodDays`; local transcripts under + `CLAUDE_CONFIG_DIR` "are swept independently by the `cleanupPeriodDays` + setting." Writes can be suppressed with `CLAUDE_CODE_SKIP_PROMPT_HISTORY` or, + per non-interactive run, `--no-session-persistence`. +- **SDK delete never happens on its own.** "The SDK never deletes from your + store on its own. Retention is the adapter's responsibility: implement TTLs, + S3 lifecycle policies, or scheduled cleanup." `delete` is optional; when + undefined "deletion is a no-op, which suits append-only backends." +- **Multi-host is a first-class motivation for the mirror.** Local storage + assumes a single filesystem; "Serverless functions, autoscaled workers, and + CI runners don't share a filesystem. A shared store lets any replica resume + any session." The `sessions/.json` registry is per-process/per-host + liveness state, not a cross-host coordinator. Cross-host correctness of the + mirror hinges on adapter-side ordering (see the S3 clock-skew caveat below). + +## Reference adapters (examples/session-stores) + +"Reference implementations. Not published to npm ... copy the `src/` file you +need into your project." Each "passes the ... conformance suite." + +| Adapter | Backend client | Storage model | +| --- | --- | --- | +| `S3SessionStore` | `@aws-sdk/client-s3` | "One JSONL part file per `append()`; `load()` lists, sorts, and concatenates." | +| `RedisSessionStore` | `ioredis` | "`RPUSH`/`LRANGE` list per transcript, plus a sorted-set session index." | +| `PostgresSessionStore` | `pg` | "One row per entry in a `jsonb` table, ordered by `BIGSERIAL`." | + +Conformance: TypeScript vendors `examples/session-stores/shared/conformance.ts` +and runs `runSessionStoreConformance(factory)` ("the factory must return a +fresh, isolated store on every call"); Python ships +`claude_agent_sdk.testing.run_session_store_conformance(MyStore)`. "Tests for +optional methods skip automatically when those methods are not implemented." +The S3 adapter's documented failure mode is instructive for our design: +part-file ordering uses the client wall clock, so "Multiple writer instances +with clock skew >1s may produce out-of-order `load()` results. Use NTP or a +single writer per session." + +## Supported operations + +TypeScript functions that accept `sessionStore` and operate against the store +instead of the local filesystem: `query()`, `startup()`, `listSessions()`, +`getSessionInfo()`, `getSessionMessages()`, `renameSession()`, `tagSession()`, +`deleteSession()`, `forkSession()`, `listSubagents()`, `getSubagentMessages()`. + +Python: `session_store` in `ClaudeAgentOptions` for `query()`, plus store-backed +standalone functions (`list_sessions_from_store()`, +`get_session_info_from_store()`, `get_session_messages_from_store()`, +`list_subagents_from_store()`, `get_subagent_messages_from_store()`, +`rename_session_via_store()`, `tag_session_via_store()`, +`delete_session_via_store()`, `fork_session_via_store()`). "`startup()` has no +Python equivalent." + +## What this implies for our Session Store (our inference) + +Both surfaces converge on the same shape: **a stored session is an append-only, +per-key log of opaque JSON entries plus derived projections (a summary/read +model, a message-chain view, a file-content checkpoint store, and a liveness +registry) rebuilt from that log.** That is within one small step of an +event-sourced Session Store, and the SDK interface is already stream-shaped: + +- `append(key, entries)` is an ordered append to one logical stream per + `SessionKey`; `load(key)` is a full ordered read. The contract never asks for + random access, mutation, or in-place rewrite. This is our event stream and + full-replay read. +- Entries are opaque to the store by design (the host owns durability, + ordering, listing, retention; the SDK owns message semantics). That matches an + event envelope whose payload the store never inspects, with `uuid` as the + idempotency key. +- Projections are explicit and rebuildable: `listSessionSummaries` maintained by + a pure `foldSessionSummary` at append time is a classic projection, and its + documented sidecar race (fix: transaction, CAS, or per-session lock) is the + classic projection-update concurrency problem. The AI title, the message + chain, and the picker list are likewise derived. +- Compaction, rewind, and fork are all expressed above the log without + destroying it: compaction appends a summary marker (`isCompactSummary`), + rewind moves a view pointer, fork rewrites identity into a new stream. An + event-sourced backing models these as markers/new streams, not destructive + edits — exactly our design goal. +- Delivery is at-least-once with client-generated `uuid`, so our backing needs + idempotent append rather than exactly-once transport. + +**Gaps we must close that this product leaves open.** There is no expected- +position precondition on `append` (no optimistic-concurrency surface), and +Claude Code explicitly tolerates multi-writer interleaving into one transcript. +An event-sourced store that wants single-writer-per-stream or optimistic +concurrency is *adding* a guarantee neither surface provides. `load` has no +cursor or size bound. `SessionStoreEntry` has no versioned schema. And file- +history blobs are deliberately *outside* the mirrored stream, so a faithful +event-sourced session that also wants checkpoint file state must model a second +content-addressed store alongside the event log. + +## Open questions + +- Durability/atomicity of the local JSONL append (temp-file-and-rename, fsync, + torn-line healing) is undocumented; the format is declared internal and + version-volatile. +- `SessionStoreEntry` has no versioned schema; how should a durable store handle + entry-shape evolution across SDK/CLI versions it did not write? +- `mtime` must "share a clock source" between `listSessions` and the summary + sidecar; the S3 adapter shows the failure mode (client wall-clock skew + reorders parts). Who owns the clock in a multi-writer deployment? +- The store never sees which entries survive compaction; raw history grows + unbounded unless the adapter imposes retention the SDK cannot observe. What + listing/resume behavior is expected after adapter-side truncation? +- `load` returns the entire transcript in one call; nothing bounds transcript + size or offers incremental reads for resume. +- Multi-writer interleaving is documented as *tolerated* for local resume in two + terminals; there is no reconciliation or conflict model beyond "use `/branch` + to fork." How should a multi-host mirror behave when two hosts append to the + same key concurrently? diff --git a/docs/research/session-store/products/codex-cli.md b/docs/research/session-store/products/codex-cli.md new file mode 100644 index 000000000..ea29cfffe --- /dev/null +++ b/docs/research/session-store/products/codex-cli.md @@ -0,0 +1,541 @@ +# Codex CLI: how session transcripts are stored and resumed + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT](../RESEARCH_PROMPT.md). +Evidence snapshot: local shallow checkout of `openai/codex` +(`https://github.com/openai/codex.git`) at commit +`8d34c0667215f9ae4f8a11678e27752d8a4a120f` (committed 2026-07-23). Every +citation below was verified against this exact commit on 2026-07-23. Paths are +relative to the `codex-rs/` Rust workspace unless stated otherwise; citations +use `path:line` shorthand. + +Authoritative anchors: + +- `openai/codex` @ `8d34c0667215f9ae4f8a11678e27752d8a4a120f` +- `codex-rs/rollout/**` (rollout persistence + discovery), `codex-rs/state/**` + (SQLite state runtime), `codex-rs/thread-store/**` (thread/lineage reader), + `codex-rs/protocol/src/protocol.rs` (durable record types), + `codex-rs/core/src/session/rollout_reconstruction.rs` (resume replay). + +> Scope note. Codex has **two persistence tiers that both ship in the same +> binary and are kept in sync by design**: +> +> 1. **The rollout JSONL log** — the durable source of truth. One append-only +> `.jsonl` file per thread under `~/.codex/sessions/YYYY/MM/DD/` +> (`codex-rs/rollout/src/recorder.rs:1536-1555`). This is unambiguously +> session-as-log. +> 2. **A derived SQLite `state` database** — a rebuildable index/projection of +> the JSONL logs (thread metadata for listing, plus an optional per-item +> "thread history" projection). It is backfilled and read-repaired from the +> logs; the logs win on any discrepancy +> (`codex-rs/rollout/src/state_db.rs:495-620`). +> +> The design is therefore closer to our event-sourced target than a first +> glance at "`.jsonl` files" suggests: the log is authoritative, and everything +> queryable (listing index, per-turn/per-item tables) is a projection with an +> explicit rebuild path. This dossier centers the log and documents the SQLite +> projection where it clarifies listing, resume, or consistency. + +## The storage model + +The durable session is an **append-only JSONL rollout file**. The recorder's +own doc comment states it plainly (`codex-rs/rollout/src/recorder.rs:75-82`): + +```rust +/// Writes canonical session rollout items to JSONL. +/// +/// Rollouts are recorded as JSONL and can be inspected with tools such as: +/// $ jq -C . ~/.codex/sessions/rollout-2025-05-07T17-24-21-...-....jsonl +``` + +Each line is a `RolloutLine` — a timestamp, an optional monotonic `ordinal`, and +a flattened, tagged `RolloutItem` payload +(`codex-rs/protocol/src/protocol.rs:3386-3393`): + +```rust +pub struct RolloutLine { + pub timestamp: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ordinal: Option, + #[serde(flatten)] + pub item: RolloutItem, +} +``` + +`RolloutItem` is a closed, `snake_case`-tagged union +(`codex-rs/protocol/src/protocol.rs:3191-3206`): + +```rust +#[serde(tag = "type", content = "payload", rename_all = "snake_case")] +pub enum RolloutItem { + SessionMeta(SessionMetaLine), + ResponseItem(ResponseItem), + InterAgentCommunication(InterAgentCommunication), + InterAgentCommunicationMetadata { trigger_turn: bool }, + Compacted(CompactedItem), + TurnContext(TurnContextItem), + WorldState(WorldStateItem), + EventMsg(EventMsg), +} +``` + +The first line of every file is always a `SessionMeta` record; the rest are the +turn-by-turn stream (model response items, turn-context snapshots, compaction +markers, world-state snapshots, and selected protocol events). + +What is authoritative vs. derived: + +- **Authoritative**: the per-thread rollout `.jsonl` file. It is the only thing + written on the live path, appended item-by-item, and it is what resume reads. +- **Derived / rebuildable**: the SQLite `state` DB. `threads` is a listing + index row per thread (`codex-rs/state/migrations/0001_threads.sql:1-25`); the + optional `thread_history` projection (`thread_turns`, `thread_items`, and a + `thread_history_projection_state` cursor) materializes per-turn/per-item rows + out of the log (`codex-rs/state/thread_history_migrations/0001_thread_history.sql`). + Both are backfilled from and read-repaired against the JSONL + (`codex-rs/rollout/src/state_db.rs:495-620`); a divergence triggers a rescan + of the file and an upsert, never the reverse. + +Conceptual model: **session-as-log** at the durable tier (an append-only event +log of `RolloutItem`s per thread), with **session-as-row** (listing metadata) +and a projected **session-as-turns/items** read model layered on top in SQLite. + +## Keying and identity + +- **Store scope / on-disk layout**: rollouts live under + `$CODEX_HOME/sessions///
/` with archived threads under + `$CODEX_HOME/archived_sessions/` (`codex-rs/rollout/src/lib.rs:25-26`, + `recorder.rs:1536-1555`). The path is time-sharded by *creation* date, not by + project/cwd. There is one SQLite `state` DB per `$CODEX_HOME` + (`codex-rs/rollout/src/state_db.rs:44-74`). +- **Filename encodes identity + ordering**: the file is + `rollout--.jsonl` + (`recorder.rs:1553`). Colons are replaced with `-` for filesystem + compatibility (`recorder.rs:1545-1548`). So the filename carries both a + creation timestamp and the thread id, and the listing layer parses both back + out (`parse_timestamp_uuid_from_filename`, used in `recorder.rs:1124`). +- **Id minting**: a `ThreadId` (and its derived `SessionId`) is a **UUIDv7**, + minted client-side with `Uuid::now_v7()` + (`codex-rs/protocol/src/thread_id.rs:21-25`, `session_id.rs:19-24`). The type + doc says so explicitly: "Codex-generated thread IDs are UUIDv7, and some use + cases rely on that" (`thread_id.rs:12-14`). `SessionId` is derived from the + `ThreadId`'s uuid (`session_id.rs:54-57`). Because UUIDv7 is time-ordered, the + id itself encodes creation ordering, and the filename timestamp prefix + reinforces it. +- **Keying inside the record**: `SessionMeta` carries `session_id`, `id` + (thread id), `forked_from_id`, `parent_thread_id`, `cwd`, `originator`, + `source`, `history_mode`, and subagent fields + (`protocol/src/protocol.rs:3063-3121`). So the durable key hierarchy is + effectively `thread_id` (primary) with optional `forked_from_id` / + `parent_thread_id` lineage pointers and a `cwd` for scoping. +- **Listing scope**: global within a `$CODEX_HOME`, then *filtered*. Listing + accepts allowed `SessionSource`s, model-provider filters, and **`cwd_filters`** + (`recorder.rs:295-307`). So a per-project view is a cwd filter over the global + set, not a separate store. The SQLite `threads.cwd` column plus cwd sort + indexes back this (`state/migrations/0027_threads_cwd_sort_indexes.sql`). +- **Relocation / rename**: identity is the immutable `thread_id` in the + filename and `SessionMeta`; the working directory is data (`SessionMeta.cwd`, + `TurnContextItem.cwd`), so moving the working tree does not change identity. A + new `TurnContextItem` with the new `cwd` is appended on the next turn. cwd + values are normalized for comparison at the SQLite boundary + (`normalize_cwd_for_state_db`, `state_db.rs:593`). + +## The store interface + +There is no *pluggable* store trait exposed to third parties; the store is the +`codex-rollout` crate's public surface plus the `codex-state` SQLite runtime. +Reconstructed from the source, the effective contract is: + +**Rollout log (authoritative), `RolloutRecorder` — `codex-rs/rollout/src/recorder.rs`:** + +- `RolloutRecorder::new(params)` — open a recorder. `params` is either + `Create { session_id, conversation_id, forked_from_id, parent_thread_id, + source, thread_source, originator, base_instructions, dynamic_tools, + history_mode, subagent_history_start_ordinal, ... }` or `Resume { path }` + (`recorder.rs:90-112`, constructor at `recorder.rs:785-903`). Create defers + file creation (writes the `SessionMeta` line lazily on first flush); + Resume reopens the existing file for append and recovers the ordinal cursor + (`recorder.rs:856-868`). +- `record_canonical_items(&[RolloutItem]) -> io::Result<()>` — queue items for + the background writer (append). Non-blocking; sends `RolloutCmd::AddItems` + (`recorder.rs:909-921`). +- `persist() -> io::Result<()>` — materialize the file and flush all buffered + items; idempotent, retryable (`recorder.rs:927-...`, `RolloutCmd::Persist`). +- `flush()` — barrier that returns once buffered writes are on disk + (`RolloutCmd::Flush`, `recorder.rs:1616-1621`). +- `shutdown() -> io::Result<()>` — drain then stop the writer task + (`recorder.rs:1050-1072`). +- `get_rollout_history(path) -> InitialHistory` — full ordered read of a file + into a `Resumed` history (`recorder.rs:1030-1045`). +- `list_threads(state_db, config, page_size, cursor, sort_key, sort_direction, + allowed_sources, model_providers, cwd_filters, default_provider, search_term) + -> ThreadsPage` — paginated listing (`recorder.rs:295-322`). +- Free function `append_rollout_item_to_path(path, &RolloutItem)` — append a + single item to an *unloaded* thread's file (metadata updates), recovering the + ordinal first (`recorder.rs:1819-1827`). + +**SQLite state runtime (derived), `StateDbHandle = Arc` — `codex-rs/rollout/src/state_db.rs`:** + +- `init(config) -> Option` / `try_init(config)` — open the + SQLite runtime, run migrations, kick off rollout-metadata backfill + (`state_db.rs:44-74`). +- `list_threads_db(...)` — list thread ids/metadata straight from SQLite for + parity checks and fast listing (`state_db.rs:306`, `363`). +- `reconcile_rollout_items(...)` — "Reconcile rollout items into SQLite, + falling back to scanning the rollout file" (`state_db.rs:495`). +- `read_repair_rollout_path(...)` — recompute a thread's metadata from its file + and upsert if SQLite diverged (fast path = path/cwd/archived fixups; slow path + = full rebuild from rollout contents) (`state_db.rs:574-620`). +- `ctx.upsert_thread(&ThreadMetadata)` — write one listing row + (`state_db.rs:607`). + +The reader/lineage side is the `codex-thread-store` crate +(`thread-store/src/local/**`): `LocalThreadStore` resolves a thread id to its +rollout path, follows fork `history_base` pointers to assemble a +`RolloutLineage` of physical segments, and reads projected turns/items +(`thread-store/src/local/rollout_lineage.rs:31-55`). + +The key contract takeaway: **every write goes to the JSONL log; SQLite is only +ever written as a reconciled projection of that log**, and reads can be served +from either tier with the log as the tiebreaker. + +## Write and append path (ordering, durability, concurrency, delivery) + +- **Append, never rewrite.** The writer opens the file with + `OpenOptions::new().read(true).append(true).create(true)` + (`recorder.rs:1573-1577`) and each item is serialized to one line + `\n` and + written (`JsonlWriter::write_line`, `recorder.rs:1896-1902`). There is no + in-place mutation of prior lines on the normal path. +- **Single-writer background task.** `RolloutRecorder` owns a bounded + `mpsc::channel::(256)` feeding one spawned `rollout_writer` task + that owns the file handle (`recorder.rs:872-896`, `1753-1783`). All appends + for a live session funnel through that one task, so the model is + **single-writer-per-session**. Callers never touch the file directly. +- **Ordering** is positional line order, reinforced by an explicit per-record + `ordinal`. `ThreadHistoryMode::Paginated` sessions carry a monotonic `ordinal` + starting at 0 and incremented per written record + (`codex-rs/rollout/src/ordinal.rs:16-47`); `Legacy` sessions omit it + (`ordinal: None`). On resume the ordinal cursor is recovered by + reverse-scanning to the last record and taking `last.ordinal + 1` + (`ordinal.rs:50-95`). So ordering is both physical (line order) and logical + (dense per-thread ordinal), which is exactly what the SQLite projection cursor + keys on. +- **Durability / atomicity.** Each write does `write_all` then `flush()` on the + async file handle (`recorder.rs:1899-1900`), and on open the file is repaired + to be newline-terminated so a torn final line cannot corrupt the next append + (`ensure_rollout_is_newline_terminated`, `recorder.rs:1848-1861`). There is no + temp-file-and-rename and no explicit `fsync`/`sync_all` on the hot path; + durability rests on append-only writes + flush + newline healing. I/O failure + enters a **recovery mode**: the writer drops the file handle but keeps the + unwritten suffix buffered, reopens, and retries on the next barrier + (`RolloutWriterState` doc + `enter_recovery_mode` + `write_pending_with_recovery`, + `recorder.rs:1582-1674`, `1630-1650`). +- **Concurrency model**: single-writer-per-session via the writer task; there is + no caller-supplied expected-version precondition on append (no optimistic + concurrency token). Cross-process safety relies on one live recorder per + thread; `append_rollout_item_to_path` for unloaded threads recovers the + ordinal from the file head/tail before appending + (`recorder.rs:1819-1846`), but there is no lock protocol beyond append + semantics. +- **What is persisted (policy filter).** Not every in-memory item is durable. + `is_persisted_rollout_item` gates writes (`codex-rs/rollout/src/policy.rs:9-21`): + session-meta, compaction markers, turn-context, world-state, and + inter-agent-communication are always persisted; response items are filtered by + `should_persist_response_item` (messages, reasoning, tool/function calls and + outputs, web-search, image-gen, compaction — yes; `AdditionalTools`, + `CompactionTrigger`, `Other` — no) (`policy.rs:39-59`); protocol `EventMsg`s + are filtered by `should_persist_event_msg` (`policy.rs:87-...`). +- **Delivery semantics** to the store are **best-effort with in-process retry**, + not at-least-once across crashes: items sit in `pending_items` and are drained + on flush/persist/shutdown; if the process dies with items still buffered and + the file unwritten, those items are lost (nothing is journaled outside the file + itself). There is no client-side dedup id on the store — dedup, where it + matters, happens at resume/reconstruction by interpreting the log. + +## Read and resume path + +- **Resume is a full ordered read of the log.** `RolloutRecorder::resume` + reopens the file for append (`recorder.rs:856`), and the transcript is loaded + via `get_rollout_history(path)`, which calls `load_rollout_items` and returns + `InitialHistory::Resumed(ResumedHistory { conversation_id, history: + Arc>, rollout_path })` (`recorder.rs:1030-1045`). The entire + file is parsed into an in-memory `Vec`. +- **`InitialHistory` is the resume contract** + (`protocol/src/protocol.rs:2555-2561`): `New`, `Cleared`, + `Resumed(ResumedHistory)`, or `Forked(Vec)`. Fork/lineage helpers + hang off it (`forked_from_id`, `session_cwd`, `get_rollout_items`, + `protocol.rs:2571-...`). +- **Reconstruction is a replay, not a raw load.** `Session:: + reconstruct_history_from_rollout` scans the loaded items **newest-to-oldest**, + stopping once it finds the newest surviving replacement-history checkpoint + (compaction) and the required resume metadata, then replays only the buffered + surviving tail forward "to preserve exact history semantics" + (`core/src/session/rollout_reconstruction.rs:113-186`). This is where + compaction markers, `ThreadRolledBack` events, `TurnContext` baselines, and + `WorldState` snapshots are folded into the model-visible history. +- **Log vs cache on resume**: resume reads the durable JSONL directly (not a + SQLite cache). The SQLite `thread_history` projection exists for *listing and + UI paging over turns/items*, tracked by + `thread_history_projection_state(next_rollout_byte_offset, + next_rollout_ordinal)` (`state/thread_history_migrations/0001_thread_history.sql`), + not for reconstructing the model context on resume. +- **Bounds / pagination**: the model-visible transcript is bounded by compaction + (below), not by a row cap on read. Turn/item paging for the UI is keyed on + `rollout_ordinal` with unique per-page indexes + (`idx_thread_turns_page`, `idx_thread_items_page`), and turn byte offsets are + recorded so a UI can seek into the file + (`thread_history_migrations/0003_turn_rollout_positions.sql`). + +## Listing, summaries, and search + +- **Listing is DB-first with a filesystem-scan fallback.** + `list_threads_with_db_fallback` (`recorder.rs:424-...`) serves listings from + the SQLite `threads` index when it can, and falls back to + `page_from_filesystem_scan` (`recorder.rs:1139`, scan driver near + `recorder.rs:1268-1312`) — a bounded, reverse-chronological walk of the + `sessions/YYYY/MM/DD` tree — when the DB is unavailable or a filtered/uncached + listing is requested. The scan is explicitly capped: `scan_page_size = + page_size * 8` clamped to `[256, 2048]`, and it reports `num_scanned_files` and + a `reached_scan_cap` flag (`recorder.rs:1268-1312`). This is the stated scale + guard — the scan does bounded work and surfaces when it truncated. +- **Repair modes**: listings run either `ScanAndRepair` (scan the files and + reconcile SQLite) or `StateDbOnly` (trust the DB), chosen per call site + (`recorder.rs:286-290`, `320`, `352`). Relationship-filtered listings "treat + persisted state as authoritative" (`state_db.rs:430`) — i.e. use the DB + without a rescan. +- **Summary sidecar = the `threads` row.** The SQLite `threads` table is the + denormalized read model: `rollout_path`, `created_at`, `updated_at`, `source`, + `model_provider`, `cwd`, `title`, `sandbox_policy`, `approval_mode`, + `tokens_used`, `has_user_event`, `archived`, `archived_at`, `git_sha`, + `git_branch`, `git_origin_url` (`state/migrations/0001_threads.sql:1-19`), later + extended with `first_user_message`, `preview`, `name`, `is_pinned`, + `thread_source`, `history_mode`, agent path/nickname, and recency/visible sort + indexes (migrations `0007`, `0032`, `0041`, `0043`, `0030`, `0040`, `0013`, + `0022`, `0036`). It is kept consistent with the log by backfill + + read-repair: any mismatch recomputes metadata from the file and upserts + (`state_db.rs:574-620`). +- **Search**: there is a `search` submodule (`search_rollout_matches`, + `search_rollout_paths`, `first_rollout_content_match_snippet`, + `rollout/src/lib.rs:78-79`) plus a `search_term` listing parameter + (`recorder.rs:307`). Search is over rollout content/paths (content scan + + metadata columns), not a separate FTS/vector engine — no `CREATE VIRTUAL + TABLE ... fts5` exists in the state migrations. + +## Entry/message structure and versioning + +- **Envelope**: every durable line is `RolloutLine { timestamp, ordinal?, item + }` with the `item` flattened via a `type`/`payload` tag + (`protocol/src/protocol.rs:3386-3393`, `3191-3206`). The serialized form uses a + `RolloutLineRef` writer struct that skips `ordinal` when `None` + (`recorder.rs:1867-1874`). +- **Session header**: `SessionMetaLine { #[serde(flatten)] meta: SessionMeta, + git: Option }` (`protocol.rs:3154-3160`), with a custom + `Deserialize` that backfills `session_id` from `id` for older files + (`protocol.rs:3162-3189`). `SessionMeta` is a large, additive struct + (`protocol.rs:3063-3121`) — most new fields are `#[serde(default, + skip_serializing_if = ...)]`, which is the primary evolution mechanism. +- **Payload shapes**: `ResponseItem` (the model-facing item; message, reasoning, + tool/function call + output, web-search, image-gen, compaction variants — + `policy.rs:39-59`), `TurnContextItem` (per-turn cwd, approval/sandbox/permission + policy, model, effort, collaboration mode — `protocol.rs:3269-3313`), + `CompactedItem` (the compaction marker; see below), `WorldStateItem` + (`full` snapshot vs `patch`, `protocol.rs:3209-3224`), and `EventMsg` + (protocol events like `TurnComplete`, `TurnAborted`, `ThreadRolledBack`). +- **Store interpretation**: the store *parses* items (it must, to filter by + policy, to write session-meta first, to recover ordinals, and to reconstruct + history), but it persists the full payload verbatim. Identity for dedup is not + a store-level entry id; reconstruction interprets the *stream* (compaction + checkpoints, rollback counters) to derive the surviving history. +- **Versioning**: two mechanisms. (1) **Additive serde defaults + legacy + sniffing** — new fields default and are skipped when absent; the deserializer + patches missing `session_id`, strips legacy "ghost snapshot" lines + (`recorder.rs:1090-1111`), and rejects unknown `history_mode` values + (`reject_unknown_thread_history_mode`, `recorder.rs:1075-1088`). (2) **A + `history_mode` ratchet** — `ThreadHistoryMode::{Legacy, Paginated}` changes + whether records carry ordinals and whether the paginated SQLite projection + applies (`ordinal.rs:22-28`). (3) **SQLite schema migrations** are a + forward-only numbered set (`state/migrations/0001..0043`, plus separate + `logs_`, `goals_`, `memory_`, and `thread_history_` migrator sets, + `state/src/migrations.rs:6-10`); an older binary tolerates a DB migrated by a + newer one via `ignore_missing` (`migrations.rs:12-27`). + +## Compaction and history management + +- **Compaction is upstream of the store but leaves a durable marker in the + log.** When context is compacted, Codex appends a `RolloutItem::Compacted` + carrying `CompactedItem { message, replacement_history: + Option>, window_number, first_window_id, previous_window_id, + window_id }` (`protocol.rs:3226-3243`; written at + `core/src/compact_remote.rs:281`, `compact_remote_v2.rs:302`). The comment on + the compaction path notes it ensures "the live and persisted histories remain + identical" (`core/src/compact.rs:77`). +- **The durable log keeps everything; the model-visible view shrinks.** The + `replacement_history` field is the compacted (summarized) history that replaces + the pre-compaction body for the model. On resume, reconstruction scans backward + to the newest `Compacted` with a `replacement_history`, uses it as the base, + and replays only the tail after it (`rollout_reconstruction.rs:156-186`) — so + crossing a compaction boundary means "start from the summary, then apply + everything appended since," while the original pre-compaction lines remain in + the file. +- **Context windows are chained**: `window_number` (monotonic position), + `first_window_id`, `previous_window_id`, `window_id` (UUIDv7s) form a window + chain so reconstruction can identify the active window and detect legacy + compactions lacking a window number (`rollout_reconstruction.rs:123-172`). +- **`WorldState` snapshots** (full baseline vs patch, `protocol.rs:3209-3224`) + are the analogous mechanism for model-visible world-state diffing, replayed on + resume (`rollout_reconstruction.rs:142`, `159`). + +## Rewind, checkpoints, and fork + +- **Rewind is an appended marker interpreted at replay.** A rollback is recorded + as an `EventMsg::ThreadRolledBack { num_turns }` line; reconstruction, scanning + in reverse, turns it into "skip the next N user-turn segments we finalize" + (`rollout_reconstruction.rs:144-146`, `188-191`). Nothing is deleted from the + file — the rolled-back turns remain durable and are simply excluded from the + replayed model history. This is the append-marker-interpreted-at-replay pattern. +- **Fork is copy-plus-lineage with a shared-prefix pointer.** A forked thread + gets its own new `thread_id` and a `SessionMeta.forked_from_id` pointing at the + source (`protocol.rs:3068`), and resume distinguishes `InitialHistory::Forked` + from `Resumed` (`protocol.rs:2560`). Physically, forks are stitched via + `SessionMeta.history_base: Option` — "Exclusive prefix of + another paginated rollout inherited by this thread" (`protocol.rs:3107-3109`). + The `codex-thread-store` `RolloutLineage` follows those `history_base` pointers + to assemble the ordered physical segments of a logical forked history, guarding + against cycles (`thread-store/src/local/rollout_lineage.rs:31-55`). So fork is + a lineage of bounded rollout segments plus an inherited-prefix pointer, not a + full copy in the common (paginated) case. +- **File-state / environment checkpoints**: there is no full working-tree + snapshot store here. What is checkpointed durably per turn is *policy/context* + state — `TurnContextItem` (cwd, approval/sandbox/permission profile, model, + effort) and `WorldStateItem` — plus `GitInfo` (commit hash, branch, remote URL) + captured into `SessionMeta` at session start (`recorder.rs:1791-1803`). Legacy + "ghost snapshot" response items existed but are now stripped on load + (`recorder.rs:1090-1111`). + +## Subagents and nested sessions + +- **A subagent is a first-class sibling thread with its own rollout file.** The + spawn path creates a child thread whose `SessionMeta` carries + `parent_thread_id`, plus `agent_nickname`, `agent_role`, and `agent_path` + identifying the AgentControl-spawned sub-agent (`protocol.rs:3070`, + `3080-3088`; spawn logic in `core/src/agent/control/spawn.rs`). The child gets + its own `thread_id`, its own JSONL file, and its own SQLite row. +- **The durable parent-child link** is `SessionMeta.parent_thread_id` + (`protocol.rs:3070`), surfaced in listing as `parent_thread_ids` on a + `ThreadsPage` (`recorder.rs:1908-1917`) and stored via the + `thread_spawn_edges` table (`state/migrations/0021_thread_spawn_edges.sql`). +- **Inherited vs isolated history**: a subagent can *inherit* a bounded prefix of + the parent's context via `subagent_history_start_ordinal` — "First rollout + ordinal that belongs to this subagent's own projected history. Earlier rollout + records are inherited model context and stay out of child turn/item projection" + (`protocol.rs:3110-3115`). So the child's own transcript is isolated from that + ordinal onward, while earlier records are treated as inherited context. The + ordinal cursor recovery specifically validates that a child's inherited prefix + is complete before appending (`ordinal.rs:81-91`). +- **Cascade**: parent/child are separate files and separate rows; there is no + automatic file cascade on parent delete. Lineage resolution guards against + cycles and missing sources by erroring (`rollout_lineage.rs:42-52`), i.e. a + missing parent produces a "malformed lineage" error rather than silent + cascade. + +## Retention, deletion, and multi-host + +- **Retention**: none enforced by the store. Rollout files accumulate + indefinitely under `sessions/YYYY/MM/DD`; no TTL or scheduled cleanup of JSONL + or SQLite rows was found. +- **Archive vs delete**: the first-class lifecycle operation is **archive**, not + delete. There is an `archived_sessions/` subtree + (`ARCHIVED_SESSIONS_SUBDIR`, `lib.rs:26`), the `threads` row carries + `archived`/`archived_at` (`0001_threads.sql:14-15`), listing supports + `Active`/`Archived` filters (`ThreadListArchiveFilter`, `recorder.rs:280-284`), + and read-repair reconciles the archived flag/timestamp with reality + (`state_db.rs:595-601`). Deletion, where it happens, is a filesystem removal of + the JSONL plus reconciliation of the SQLite row; there is no append-only + tombstone marker for delete in the durable log. +- **Multi-host**: this is a **single-host, local-filesystem** design. The store + assumes `$CODEX_HOME` is a local directory, one live recorder per thread, and + best-effort append. There is no shared-filesystem coordination protocol, no + remote writeback, and no crash-detection/lease mechanism across hosts. The + SQLite runtime tolerates a *newer* binary having migrated the same DB + (`migrations.rs:12-27`) but that is version tolerance, not multi-host + concurrency. Cloud/remote Codex sessions are a separate product surface not in + this local store. + +## Interop with foreign session stores + +`INTERACTIVE_SESSION_SOURCES` lists the session sources the local store treats +as first-class interactive threads: `Cli`, `VSCode`, and custom `"atlas"` / +`"chatgpt"` sources (`rollout/src/lib.rs:27-34`). These are Codex's *own* +frontends writing into the same rollout format, not foreign products' stores. +There is also an `external_agent_config_imports` migration +(`state/migrations/0038_external_agent_config_imports.sql`), which concerns +importing external *agent config*, not foreign session transcripts. No +discovery/import/resume of another product's native session store (Claude Code, +Gemini, etc.) was found in the store layer. + +## What this implies for our Session Store (our inference) + +*(Our inference, clearly marked as such.)* Codex's durable session is **an +append-only per-thread JSONL event log (`RolloutLine` of tagged `RolloutItem`s) +that is the single source of truth, with a SQLite `state` DB acting as a purely +derived, rebuildable projection** (a listing index row per thread plus an +optional per-turn/per-item read model with an explicit +`next_rollout_byte_offset`/`next_rollout_ordinal` cursor). That is +architecturally the same CQRS shape our event-sourced Session Store targets, +implemented on plain files rather than a database log. Salient lessons: + +- **A file log can still be event-sourced.** The pattern — authoritative + append-only log + read-repaired SQL projections + a projection cursor — does + not require a database as the log. But it *does* require the discipline Codex + encodes: a dense per-record `ordinal`, byte-offset checkpoints for the + projector, and a read-repair path that treats the log as the tiebreaker + (`ordinal.rs`, `state_db.rs:574-620`, `thread_history_projection_state`). We + should keep the ordinal/offset cursor idea even if our log lives in a DB. +- **Rewind and compaction as appended markers re-applied at replay** (`ThreadRolledBack` + and `Compacted{replacement_history}`, `rollout_reconstruction.rs:144-186`) + validate our append-only rewind/compaction model: the durable log never + shrinks; the model-visible view is a reverse-scan fold that stops at the newest + surviving checkpoint. Worth adopting the "newest replacement-history checkpoint + wins, replay the tail forward" reconstruction rule. +- **Fork as a shared-prefix pointer (`history_base`) + lineage resolution** + rather than a full copy (`rollout_lineage.rs`) is a cheap, log-friendly fork + primitive we should mirror, together with cycle detection. +- **Subagents as first-class sibling logs with an inherited-prefix ordinal** + (`subagent_history_start_ordinal`, `parent_thread_id`, `thread_spawn_edges`) is + a clean way to isolate a child transcript while sharing inherited context — + better than nesting child entries in the parent log. +- **UUIDv7 client-minted ids** give time-ordered identity without a server + round-trip, and the filename encodes both timestamp and id for zero-DB + discovery. Our design can keep server-authoritative ids but should note the + value of time-ordered ids for listing. +- **Cautions**: (1) **Durability is best-effort** — items buffered in memory are + lost on a hard crash before flush; there is no write-ahead journal outside the + file, and no `fsync` on the hot path. Our store should decide its durability + contract explicitly. (2) **No expected-version/OCC on append** — Codex relies + on single-writer-per-thread, which does not generalize to multi-writer or + multi-host; our design wants an expected-position precondition. (3) **No + retention or log truncation** — logs grow unbounded and projection rebuild is a + full file rescan; we need a snapshot/retention story. (4) **Single-host only** — + there is no cross-host coordination; multi-host is out of scope for this store + and would need to be designed in, not retrofitted. + +## Open questions + +- **Exact `fsync`/crash-consistency guarantees**: writes `flush()` the async + handle but no `sync_all`/`fsync` was observed on the hot path; the precise + durability guarantee under power loss (vs. process crash) was not pinned down. +- **When the `thread_history` projection is populated**: the projector schema and + cursor exist (`thread_history_migrations/**`) and `runtime_thread_history_migrator` + is present but marked `#[allow(dead_code)]` at this commit + (`state/src/migrations.rs:46-49`), so which builds/flows actually populate + `thread_turns`/`thread_items` (vs. keeping it dark) was not fully traced. +- **Delete semantics end-to-end**: archive is first-class, but exactly which UI + path performs a hard JSONL delete, and how the SQLite row and any child threads + are reconciled on that delete, was not exhaustively mapped. +- **Compaction v1 vs v2**: both `compact_remote.rs` and `compact_remote_v2.rs` + write `CompactedItem`s; which is active by default and how `window_number`-less + legacy compactions interact with v2 windows on resume was only partially + traced (`rollout_reconstruction.rs:123-126`). +- **Search backing**: `search_rollout_matches` appears to scan content; whether + any indexed acceleration exists beyond the `threads` metadata columns + (there is no FTS5 virtual table) was not confirmed for large corpora. diff --git a/docs/research/session-store/products/gemini-cli.md b/docs/research/session-store/products/gemini-cli.md new file mode 100644 index 000000000..e44361fa0 --- /dev/null +++ b/docs/research/session-store/products/gemini-cli.md @@ -0,0 +1,428 @@ +# Gemini CLI: how session transcripts are stored and resumed + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT](../RESEARCH_PROMPT.md). +Evidence snapshot: local shallow checkout of `google-gemini/gemini-cli` +(`https://github.com/google-gemini/gemini-cli.git`) at commit +`87f785192c34067e4e8f26bda16cf9ce24014d83` (committed 2026-07-23). Every +citation below was verified against this exact commit on 2026-07-23. Citations +use repo-relative `path:line` shorthand. + +Authoritative anchors: + +- `google-gemini/gemini-cli` @ `87f785192c34067e4e8f26bda16cf9ce24014d83` +- `packages/core/src/services/chatRecordingService.ts` + + `chatRecordingTypes.ts` (the durable transcript log) +- `packages/core/src/config/storage.ts` + `packages/core/src/utils/paths.ts` + (on-disk layout, project keying) +- `packages/cli/src/utils/sessionUtils.ts` + `sessions.ts` (listing) +- `packages/core/src/services/gitService.ts` + + `packages/core/src/utils/checkpointUtils.ts` (file-state checkpoints) +- `packages/core/src/core/logger.ts` (legacy prompt log + `/chat save` checkpoints) + +> Scope note. Gemini CLI keeps **three related on-disk artifacts**, only the +> first of which is the durable session transcript: +> +> 1. **Chat recording JSONL** — one append-only `session-*.jsonl` per session +> under `/chats/`. This is the durable session-as-log and the +> focus of this dossier (`chatRecordingService.ts`). +> 2. **Shadow-git file-state checkpoints** — a hidden git repo mirroring the +> workspace, one commit per restorable tool call, referenced by hash from a +> per-tool-call `checkpoint-*.json` (`gitService.ts`, `checkpointUtils.ts`). +> 3. **Legacy `Logger`** — a global `logs.json` cross-session prompt history and +> `checkpoint-.json` full-history snapshots for `/chat save`/`/restore` +> (`core/logger.ts`). + +## The storage model + +The durable session is an **append-only JSONL log** whose lines are interpreted +by a replay reducer at load time. The type comment is explicit: "Complete +conversation record stored in session files" +(`chatRecordingTypes.ts:90-104`): + +```ts +export interface ConversationRecord { + sessionId: string; + projectHash: string; + startTime: string; + lastUpdated: string; + messages: MessageRecord[]; + summary?: string; + memoryScratchpad?: MemoryScratchpad; + directories?: string[]; + kind?: 'main' | 'subagent'; +} +``` + +But `ConversationRecord` is the *materialized projection*, not what is stored +line-by-line. On disk the file is a sequence of four line kinds, all appended, +never edited in place (`chatRecordingService.ts:550-576`, `168-346`): + +- **An initial metadata line** — a `PartialMetadataRecord { sessionId, + projectHash, startTime, lastUpdated, kind, directories, ... }` + (`chatRecordingTypes.ts:131-140`), written once at session start + (`chatRecordingService.ts:521-530`). +- **Message lines** — full `MessageRecord`s (`id`, `timestamp`, `type`, + `content`, and for `gemini` messages `toolCalls`, `thoughts`, `tokens`, + `model`) (`chatRecordingTypes.ts:44-87`). A message is *re-appended in full* + whenever it changes (tokens arrive, tool results update), and the loader keeps + the **last** occurrence per `id` (`chatRecordingService.ts:572-587`, `234`). +- **Metadata-update lines** — `{ "$set": { ...partial ConversationRecord } }`, + merged into metadata at replay; a `$set` carrying a full `messages` array is a + **checkpoint** that clears and rebuilds the message set + (`chatRecordingService.ts:243-299`, `566-570`). +- **Rewind markers** — `{ "$rewindTo": "" }`, interpreted at replay + as "drop this message and everything after it" + (`chatRecordingService.ts:76-78`, `172-202`, `855-875`). + +So the on-disk format is an **event log with last-write-wins-by-id message +upserts plus `$set`/`$rewindTo`/checkpoint markers**; the in-memory +`ConversationRecord` (`cachedConversation`) and the `messagesMap` built by +`loadConversationRecord` are the derived projections +(`chatRecordingService.ts:157-352`). + +Conceptual model: **session-as-log** (append-only JSONL, replayed) that +projects to a **session-as-document** (`ConversationRecord`). It sits between a +pure event log and a mutable document: writes are append-only, but the message +semantics are "latest full copy per id wins," not immutable events. + +## Keying and identity + +- **On-disk scope**: sessions live under + `/chats/`, where `projectTempDir` is + `~/.gemini/tmp//` (`storage.ts:154-156`, `181-185`). The + global root is `~/.gemini` (`GEMINI_DIR`, `storage.ts:54-59`). +- **Project keying is two-headed.** The *directory* uses a + **`projectShortId`** minted by a `ProjectRegistry` (`projects.json`) mapping + the absolute project root to a short slug (`storage.ts:218-248`). The + *record* stores `projectHash = sha256(projectRoot)` inside every + `ConversationRecord` (`paths.ts:318-320`, `chatRecordingService.ts:415`). The + short id superseded a legacy `sha256(projectRoot)` directory name, and + `performMigration` moves the old hash dirs to the new slug dirs + (`storage.ts:255-273`). +- **Filename encodes time + id**: a main session file is + `session--.jsonl` + (`chatRecordingService.ts:490-509`; `SESSION_FILE_PREFIX = 'session-'`, + `chatRecordingTypes.ts:12`). Colons in the timestamp are replaced with `-`. + The 8-char id slice is what listing/deletion match on ("shortId"). +- **Session id minting**: the session id is the runtime `context.promptId` + (`chatRecordingService.ts:414`, `469`); message ids are `randomUUID()` + (`chatRecordingService.ts:602`). There is no ordinal/sequence number — order + is line order, and identity within a session is the message `id`. +- **Listing scope**: per-project. Listing scans one project's `chats/` dir; + there is no global cross-project session enumeration in the picker + (`sessionUtils.ts:445-447`). +- **Relocation / rename**: identity of a session is its file; the *project* + directory is reconciled through the `ProjectRegistry` short-id + + `performMigration`, so a project whose path scheme changed keeps its sessions + by migrating the temp/history dirs (`storage.ts:255-273`). A moved *workspace + root* produces a new short id (and thus a new chats dir); there is no + automatic re-linking of a session file to a moved root beyond the registry. + +## The store interface + +There is no pluggable store abstraction; the store is the `ChatRecordingService` +class plus module-level load/delete helpers. Reconstructed contract +(`packages/core/src/services/chatRecordingService.ts`): + +- `initialize(resumedSessionData?, kind?) -> Promise` — open or resume. + New session: mint the file path, `mkdir -p` the chats dir, append the initial + metadata line, seed `cachedConversation` (`:418-535`). Resume: adopt the + existing file, load it into `cachedConversation`, migrate a legacy `.json` + document to `.jsonl`, and append a `$set` updating the session id (`:424-463`). +- `recordMessage({ model, type, content, displayContent?, id? }) -> string` — + append a new/updated message line and bump `lastUpdated` via `$set` + (`:610-641`). `recordSyntheticMessage(...)` wraps it (`:647-658`). +- `recordToolCalls(model, toolCalls[])` — attach/merge tool-call records onto the + last `gemini` message and re-append it (`:699-768`). +- `recordThought(thought)` / `recordMessageTokens(usage)` — queue thoughts and + fold token usage into the last `gemini` message (`:660-697`). +- `saveSummary(summary)` / `recordDirectories(dirs)` — `$set` metadata updates + (`:770-786`). +- `rewindTo(messageId) -> ConversationRecord | null` — truncate the in-memory + messages and append a `$rewindTo` marker (`:855-875`). +- `updateMessagesFromHistory(history)` — reconcile the recorded messages against + the live model history (masking sync) and, if changed, append a checkpoint + `$set: { messages }` (`:877-962`). +- `getConversation() / getConversationFilePath()` — read the cached projection + (`:788-795`). +- `deleteSession(idOrBasename)` / `deleteCurrentSessionAsync()` / + `deleteCurrentSessionIfNotResumableAsync()` — delete files (`:804-849`). + +Module-level readers: `loadConversationRecord(filePath, options?)` — the replay +reducer that folds all line kinds into a `ConversationRecord` (with a +`metadataOnly` fast path and a `maxMessages` window) (`:133-400`); and +`parseLegacyRecordFallback` — parse a whole-file single-JSON legacy record +(`:965-1026`). The takeaway: **every mutation is an append; all read/rewind/ +checkpoint semantics are applied by the loader replaying the log**. + +## Write and append path (ordering, durability, concurrency, delivery) + +- **Append, synchronous, one line at a time.** `appendRecord` does + `fs.appendFileSync(file, JSON.stringify(record) + '\n')` after `mkdir -p` of + the parent (`chatRecordingService.ts:550-555`). There is no batching, no + background writer, no temp-file-and-rename, and no explicit `fsync`. Durability + is the OS append plus process-synchronous write. +- **Ordering** is purely positional (line order). There is no sequence number or + ordinal; the loader relies on iteration order to build the `messagesMap` and to + apply `$rewindTo`/checkpoint markers relative to prior lines + (`chatRecordingService.ts:168-346`). +- **Message identity + upsert.** Because a changing message is re-appended in + full and the loader keeps the last occurrence for an `id` + (`messagesMap.set(id, record)`, `:234`; in-memory upsert at `:572-587`), the + effective semantics are **last-write-wins per message id**. That is how a + `gemini` message accumulates tool calls, thoughts, and token counts across + several appends. +- **Concurrency**: single-writer-per-session in practice — one + `ChatRecordingService` owns the file for a live session. There is no file lock, + no optimistic-concurrency token, and no expected-position precondition. Two + processes appending to the same session file would interleave lines with no + coordination. +- **Durability / failure handling**: writes are best-effort. On `ENOSPC` the + service disables recording (`this.conversationFile = null`) and warns that "the + conversation will continue but will not be saved to disk" + (`chatRecordingService.ts:46-49`, `540-563`). A crash mid-write can leave a + torn final line; the loader tolerates it by ignoring per-line parse errors + (`:343-345`). +- **Delivery semantics**: best-effort, no idempotence id beyond the message `id` + (which provides dedup on *read* via last-write-wins, not on write). Nothing is + journaled outside the file. + +## Read and resume path + +- **Resume is a full ordered replay of the file.** `initialize` with + `resumedSessionData` calls `loadConversationRecord(filePath)`, which streams the + JSONL line-by-line via `readline` and folds it into `cachedConversation` + (`chatRecordingService.ts:429-433`, `133-400`). The file is read start-to-end; + `$rewindTo` and checkpoint (`$set.messages`) lines rewrite the accumulating + message set during that pass. +- **Reads the durable store directly.** Resume reads the JSONL file itself; there + is no separate cache or index consulted first. The in-memory + `cachedConversation` is rebuilt from the file each time. +- **Legacy `.json` migration on resume.** If the resumed file ends in `.json` + (an old whole-document format), the service rewrites it as `.jsonl` by + appending the initial metadata line, every message, and any + `memoryScratchpad`, then continues in append mode + (`chatRecordingService.ts:436-460`). `parseLegacyRecordFallback` handles a file + that is a single JSON object (`:965-1026`). +- **Bounds / pagination.** The loader supports `metadataOnly` (count messages and + extract the first user message without materializing bodies) and `maxMessages` + (keep only the last N messages by evicting the oldest map entry) + (`chatRecordingTypes.ts:118-121`, `chatRecordingService.ts:233-241`). + `MAX_HISTORY_MESSAGES = 50` and `MAX_TOOL_OUTPUT_SIZE = 50KB` + (`chatRecordingTypes.ts:13-14`) bound what is surfaced/persisted for tool + output. There is no hard cap on total file size. +- **What is materialized eagerly**: the full projection is built on load (or just + counts/first-message under `metadataOnly`). There is no lazy per-turn loading + from an index. + +## Listing, summaries, and search + +- **Listing is a directory scan.** `SessionSelector.listSessions` reads + `/chats/`, keeps files starting with `session-` and ending in + `.json`/`.jsonl`, and loads each with `metadataOnly` to build `SessionInfo` + (`sessionUtils.ts:409-447`, `234-321`). **Subagent sessions are skipped** in + the picker — "these are implementation details of a tool call" + (`sessionUtils.ts:288-290`). Results are sorted by `startTime` + (`sessions.ts:36-40`). Cost scales linearly with the number of session files; + no stated scale numbers, no index. +- **Summary sidecar = the metadata lines in the file.** There is no separate + index file. The denormalized summary fields (`summary`, `lastUpdated`, + `directories`, `memoryScratchpad`, `kind`) live as `$set`/initial-metadata + lines inside each session's JSONL and are folded by the loader + (`chatRecordingService.ts:296-299`). A `summary` is generated lazily for the + most-recent session before listing (`generateSummary(config)`, + `sessions.ts:21`). +- **`memoryScratchpad`** is workflow metadata (`workflowSummary`, `toolSequence`, + `touchedPaths`, `validationStatus`) written via `$set`; the loader tracks + whether messages appended *after* it make it stale + (`memoryScratchpadIsStale`) (`chatRecordingTypes.ts:33-39`, + `chatRecordingService.ts:164-206`, `243-249`). +- **Search**: there is no FTS/vector search over transcripts. Listing extracts a + `firstUserMessage` for display (`chatRecordingService.ts:215-231`); selection + is by index, UUID, or short id (`sessions.ts:70-90`, `sessionUtils.ts:419-429`), + not full-text query. + +## Entry/message structure and versioning + +- **Envelope**: each line is a bare JSON object discriminated *structurally* by + the loader, not by a `type`/version tag on the line itself + (`chatRecordingService.ts:76-97`): `$rewindTo` ⇒ rewind, `$set` ⇒ metadata + update/checkpoint, `id` present ⇒ message, `sessionId`+`projectHash` ⇒ initial + metadata. +- **Message payload**: `BaseMessageRecord { id, timestamp, content, displayContent? }` + plus a `ConversationRecordExtra` union tagged by `type` + (`'user' | 'info' | 'error' | 'warning'` or `'gemini'` with `toolCalls`, + `thoughts`, `tokens`, `model`) (`chatRecordingTypes.ts:44-87`). `content` is a + `@google/genai` `PartListUnion`. Tool calls are `ToolCallRecord`s with + `status`, `result`, and UI display fields (`chatRecordingTypes.ts:54-67`). +- **Store interpretation**: the store fully parses and interprets lines (it must, + to apply upserts/rewinds/checkpoints), and it enriches tool calls with registry + metadata before writing (`chatRecordingService.ts:702-712`). The field it + relies on for identity/dedup is the message `id`. +- **Versioning**: there is **no explicit schema-version field** on the session + format. Evolution is handled by (a) additive optional fields on the + interfaces; (b) **format sniffing** — legacy whole-file `.json` documents are + detected and migrated to `.jsonl` on resume + (`chatRecordingService.ts:436-460`, `965-1026`); and (c) `MemoryScratchpad` + carrying its own `version: 1` literal (`chatRecordingTypes.ts:34`). The + project-directory scheme has its own migration (`hash → shortId`, + `storage.ts:255-273`, `config/storageMigration.ts`). + +## Compaction and history management + +- **Compaction is an upstream concern reflected as a checkpoint in the log.** + When the model history is compressed/summarized, the live history is + reconciled into the record via `updateMessagesFromHistory`, which appends a + **checkpoint** `$set: { messages: newMessages }` replacing the message set + (`chatRecordingService.ts:877-962`, `945-954`). On replay, a `$set.messages` + line clears `messagesMap` and rebuilds it from the provided array + (`:250-294`), so the post-compaction message set supersedes the earlier lines. +- **The durable file keeps everything; the projection shrinks.** Earlier message + lines remain physically in the JSONL after a checkpoint; the loader simply + rebuilds from the latest checkpoint forward. So crossing a compaction boundary + on resume means "adopt the checkpointed message set, then apply any lines + appended after it." +- A stored `summary` (from `saveSummary`) is the human-facing session summary, + distinct from context compaction (`chatRecordingService.ts:770-777`). + +## Rewind, checkpoints, and fork + +- **Rewind is an appended marker interpreted at replay.** `rewindTo(messageId)` + truncates the in-memory messages and appends `{ "$rewindTo": messageId }` + (`chatRecordingService.ts:855-875`). On load, the reducer finds that id and + deletes it plus everything after (or clears all if not found) + (`:172-202`). The pre-rewind lines are not physically removed — this is the + append-marker pattern, so the transcript file still contains the rewound turns. +- **File-state checkpoints are a shadow git repo, not inline content.** `/restore` + (interactive `checkpointing`) uses `GitService`, which maintains a hidden + "shadow" git repository mirroring the workspace with a dedicated gitconfig and + ignore rules (`gitService.ts:52-176`). Before a restorable tool call runs, + `processRestorableToolCalls` calls `createFileSnapshot(message)` to commit the + current workspace state and records the `commitHash` + (`checkpointUtils.ts:84-116`; snapshot at `gitService.ts:193-208`). Restore + runs `git restore --source .` plus a clean of untracked files + (`gitService.ts:213-217`). The per-tool-call metadata is a + `checkpoint-*.json` (`ToolCallData { history, clientHistory, commitHash, + toolCall, messageId }`, `checkpointUtils.ts:15-24`) named + `--` under `getProjectTempCheckpointsDir()` + (`generateCheckpointFileName`, `checkpointUtils.ts:48-67`; + `storage.ts:313-315`). So file snapshots are git-deduplicated commits, cheap by + content addressing, keyed to a specific tool call and message id. +- **Legacy `/chat save ` checkpoints** are a separate mechanism: the + `Logger` writes a full `checkpoint-.json` snapshot of the conversation + history to the project `.gemini` dir (`core/logger.ts:285-345`), independent of + the shadow-git file checkpoints. +- **Fork**: there is no first-class fork/branch operation on the chat record. The + closest primitive is resume (adopt an existing file and continue appending, + updating the session id via `$set`, `chatRecordingService.ts:462-463`), which + continues the same file rather than branching a new lineage. + +## Subagents and nested sessions + +- **A subagent is a nested session file under the parent.** When `kind === + 'subagent'` and a `parentSessionId` is present, the chats dir becomes + `/chats//`, and the child file is + `.jsonl` (no `session-` prefix, no timestamp) + (`chatRecordingService.ts:475-509`). The child records its own workspace + directories (`:512-519`). +- **Durable parent-child link** is *path-based* (the child lives in a directory + named for the parent's session id) plus the `kind: 'subagent'` field on the + record (`chatRecordingTypes.ts:103`). There is no id pointer field on the + child record beyond the directory nesting. +- **Isolated transcript**: the child has its own JSONL and its own message + stream; it does not write into the parent's file. The child is *excluded* from + the resume picker (`sessionUtils.ts:288-290`). +- **Cascade on delete**: deletion is designed to cascade. `deleteStoredSession` + derives an 8-char short id and "finds and deletes all associated files (parent + and subagents)" (`chatRecordingService.ts:797-806`). Nesting is effectively one + level (subagents under a parent id); deeper nesting bounds were not traced. + +## Retention, deletion, and multi-host + +- **Retention**: the product, not a store layer, owns cleanup. There is a + `sessionCleanup` utility (`packages/cli/src/utils/sessionCleanup.ts`) and + `deleteCurrentSessionIfNotResumableAsync`, which deletes a session on exit if + it has no resumable content (no real user prompt, model response, or tool + activity) (`chatRecordingService.ts:839-849`, `127-131`). No TTL/age-based + expiry of transcripts was found in the recording service itself. +- **Deletion**: `deleteCurrentSessionAsync` unlinks the tracked JSONL and then + delegates tool-output/log cleanup to `deleteSessionArtifactsAsync` + (`chatRecordingService.ts:813-832`). `deleteStoredSession` cascades to parent + + subagent files by short id (`:804-806`). Deletion is a filesystem unlink; there + is no tombstone in an append-only sense. +- **Multi-host**: single-host, local-filesystem only. The store assumes a local + `~/.gemini/tmp//` tree, synchronous appends, and one live + writer per session. There is no shared-filesystem coordination, remote + writeback, lease, or crash-detection protocol. (A separate `a2a-server` + package has its own GCS persistence for the agent-to-agent server surface — + `packages/a2a-server/src/persistence/gcs.ts` — but that is a different product + surface, not the CLI's session store.) + +## Interop with foreign session stores + +Not applicable. Gemini CLI reads only its own session formats: current `.jsonl` +and legacy whole-file `.json` records (`chatRecordingService.ts:436-460`, +`965-1026`). No discovery/import/resume of another product's native session +store was found. + +## What this implies for our Session Store (our inference) + +*(Our inference, clearly marked as such.)* Gemini CLI's durable session is **an +append-only per-session JSONL log whose lines are folded by a replay reducer +into a `ConversationRecord` projection**, with rewind and compaction expressed +as appended markers (`$rewindTo`, checkpoint `$set: {messages}`) rather than +in-place edits. That is directionally the same append-only-log-with-projection +shape our design targets, but implemented loosely — the message semantics are +"latest full copy per id wins," not immutable events. Lessons: + +- **Markers-at-replay for rewind and compaction** (`$rewindTo`, checkpoint + `$set.messages`) confirm the pattern is workable on a plain file: never edit + prior lines; append a marker; let the reducer recompute the view + (`chatRecordingService.ts:172-202`, `250-299`). We should keep this shape but + add the rigor Gemini omits. +- **Structural discrimination + last-write-wins-by-id is fragile.** Lines carry + no explicit type tag or schema version, and ordering has no sequence number, so + correctness depends entirely on physical line order and on the loader's + heuristics (`chatRecordingService.ts:76-97`). Our store should use explicit + entry types, a monotonic sequence/ordinal, and immutable events instead of + re-appended full-message upserts — the Codex/OpenCode ordinal approach is the + contrast to follow. +- **File-state checkpoints via a content-addressed shadow git repo** + (`gitService.ts`, `checkpointUtils.ts`) are a cheap, dedup'd way to snapshot the + workspace per tool call and restore by commit hash — a good model for our + environment-checkpoint story, keyed to a specific turn/message id. +- **Directory-based subagent nesting** (`chats//.jsonl`) is + simple but couples identity to path; a first-class parent pointer (as in + Codex/OpenCode) is more robust to relocation. We should prefer an explicit + lineage field. +- **Project keying drift**: the record's `projectHash = sha256(root)` and the + directory's `shortId` from a registry can diverge, and a moved root yields a new + chats dir with no automatic session re-linking (`storage.ts:181-273`). Our + design needs a stable session key independent of cwd, plus explicit relocation + reconciliation. +- **Cautions**: (1) **Best-effort durability** — synchronous append with no + fsync, silent disable on `ENOSPC`, torn-line tolerance on read + (`chatRecordingService.ts:540-563`, `343-345`); a hard crash can lose the last + write. (2) **No concurrency control** — single-writer assumption with no lock + or expected-version; unsafe for multi-writer/multi-host. (3) **Linear-scan + listing** with no index; scales poorly with many sessions. (4) **No log + compaction/retention** of the JSONL itself — rewound and superseded lines + persist in the file indefinitely, so the file grows even as the projection + shrinks. + +## Open questions + +- **fsync / crash-consistency**: `appendFileSync` provides no durability barrier; + the exact behavior under power loss (vs. clean process exit) was not tested. +- **Concurrent resume of the same session** from two processes: there is no lock; + the resulting interleaving and last-write-wins outcome was not exercised. +- **Subagent nesting depth and cascade correctness**: deletion claims to remove + "parent and subagents" by 8-char short id (`chatRecordingService.ts:797-806`); + multi-level nesting and short-id collision behavior were not verified. +- **Relationship between the three artifacts on delete**: whether deleting a + session also prunes its shadow-git commits and `checkpoint-*.json` files (vs. + only tool outputs and logs) was not fully traced. +- **`logs.json` retention**: the legacy `Logger` prompt history + (`core/logger.ts:15`) accumulates across sessions; its pruning/retention policy + was not examined. diff --git a/docs/research/session-store/products/goose.md b/docs/research/session-store/products/goose.md new file mode 100644 index 000000000..c010db057 --- /dev/null +++ b/docs/research/session-store/products/goose.md @@ -0,0 +1,528 @@ +# Goose: how session transcripts are stored and resumed + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT](../RESEARCH_PROMPT.md). +Evidence snapshot: local checkout of Goose (Block's open-source AI agent, +originally [block/goose](https://github.com/block/goose)) in the +`aaif-goose/goose` fork, at commit +`d17d65f2f30876e391395e1e8b5f2666320588c6` (committed 2026-07-23, working tree +clean). Every citation below was re-verified against this exact commit on +2026-07-23. Citations use repo-relative `path:line` shorthand; unless another +crate is named, paths are under `crates/goose/src/`. + +Goose is a Rust agent that runs the model itself (via pluggable providers) and +persists all session state locally. Since schema v10+ the durable session store +is a **single SQLite database** (`sessions.db`), replacing an earlier +per-session JSONL layout that is now only read for one-time import. The store +lives in `session/session_manager.rs` (4238 lines: the `SessionStorage` type, +its schema, migrations, and every read/write call site), with satellites in +`session/chat_history_search.rs` (keyword search), `session/legacy.rs` (JSONL +import), `session/import_formats/` (foreign-format import), and +`session/extension_data.rs`. + +## The storage model + +The durable session is a **mutable row plus an ordered set of message rows in +SQLite**, not an append-only event log. The database is one file per install: +`{data_dir}/sessions/sessions.db` (`session_manager.rs:27-28`, `859-867`; +`data_dir` on macOS is `~/Library/Application Support/Block/goose`, +`config/paths.rs:19-37`, `52-54`). The pool opens in WAL mode with foreign keys +on and a 30s busy timeout (`session_manager.rs:849-856`). + +The source of truth is three tables (`create_schema`, +`session_manager.rs:924-999`): + +- **`sessions`** — one mutable row per session; the session "header" plus + denormalized rollups (`session_manager.rs:926-956`): + + ```sql + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + user_set_name BOOLEAN DEFAULT FALSE, + session_type TEXT NOT NULL DEFAULT 'user', + working_dir TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + extension_data TEXT DEFAULT '{}', + total_tokens INTEGER, input_tokens INTEGER, output_tokens INTEGER, + cache_read_tokens INTEGER, cache_write_tokens INTEGER, + accumulated_total_tokens INTEGER, accumulated_input_tokens INTEGER, + accumulated_output_tokens INTEGER, accumulated_cache_read_tokens INTEGER, + accumulated_cache_write_tokens INTEGER, accumulated_cost REAL, + schedule_id TEXT, recipe_json TEXT, user_recipe_values_json TEXT, + provider_name TEXT, model_config_json TEXT, + goose_mode TEXT NOT NULL DEFAULT 'auto', + archived_at TIMESTAMP, project_id TEXT, parent_session_id TEXT + ) + ``` + +- **`messages`** — the transcript, one row per message, insertion-ordered by an + autoincrement `id` and a `created_timestamp` (`session_manager.rs:962-978`): + + ```sql + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id TEXT, + session_id TEXT NOT NULL REFERENCES sessions(id), + role TEXT NOT NULL, + content_json TEXT NOT NULL, + created_timestamp INTEGER NOT NULL, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + tokens INTEGER, + metadata_json TEXT + ) + ``` + +- **`usage_ledger`** — an **append-only** per-session ledger of token/cost + deltas, `ON DELETE CASCADE` from `sessions` (`session_manager.rs:980-999`). It + is the one genuinely append-structured table in the design, used to reconcile + cumulative usage (including carried-forward rows for subagents). + +Authoritative vs. derived: + +- **Authoritative**: the `sessions` row and its `messages` rows. Both are + mutated in place — the session row via `UPDATE` (`apply_update`, + `session_manager.rs:1590-1725`); the transcript via `INSERT` for a new turn + (`add_message`, `1765-1798`) but also via bulk `DELETE`+re-`INSERT` + (`replace_conversation_inner`, `1800-1838`) and `DELETE` (`truncate_*`, + `2344-2385`). There is no separate event stream from which these are folded. +- **Semi-derived rollups on the authoritative row**: `total_tokens`, + `accumulated_*`, `accumulated_cost` on `sessions` are running aggregates + maintained by `record_usage_metrics` alongside the append-only `usage_ledger` + (`session_manager.rs:2078-2166`); `get_session_usage_totals` recomputes the + authoritative total from the ledger + row (`2168-2250`). +- **Fully derived / rebuildable**: `message_count` and `last_message_at` are + computed at read time by `COUNT(*)`/`MAX(created_timestamp)` over `messages`, + never stored (`get_session`, `session_manager.rs:1574-1585`; list query + `1906-1908`). `last_message_snippet` is hydrated on demand for list pages + (`1996-1999`). Keyword search is a live SQL scan, not a maintained index (see + Listing). +- **Unrelated side tables**: `provider_inventory_entries` / + `provider_inventory_models` (`providers/inventory/mod.rs:1145-1165`) live in + the same DB but hold provider/model inventory, not session data. Migration 10 + also created `threads`/`thread_messages` tables (`session_manager.rs:1348-1380`) + that are dormant in the current session path. + +Best-fit conceptual model: **session-as-row + session-as-transcript** (a mutable +document with an ordered, in-place-editable message list). It is explicitly +**not** session-as-log: retroactive operations rewrite or delete rows rather +than appending interpreted markers. + +## Keying and identity + +- **Store scope**: one `sessions.db` for the whole install; all sessions of all + working directories share it and are separated by columns + (`session_manager.rs:859-867`). No cwd/project path is encoded into the key. +- **Session id**: a `TEXT PRIMARY KEY` minted server-side (by the store) as + `YYYYMMDD_N` — today's date followed by a per-day monotonic counter computed + inside the `INSERT` (`create_session`, `session_manager.rs:1497-1540`): + + ```sql + INSERT INTO sessions (id, ...) VALUES ( + ? || '_' || CAST(COALESCE(( + SELECT MAX(CAST(SUBSTR(id, 10) AS INTEGER)) + FROM sessions WHERE id LIKE ? || '_%' + ), 0) + 1 AS TEXT), ...) + ``` + + So the id encodes **date + ordinal**, giving coarse chronological ordering but + no location. It is not a UUID and not client-supplied on create. +- **Message id**: `message_id TEXT`, either carried on the `Message` or minted as + `msg_{session_id}_{uuid_v4}` at insert (`add_message`, `session_manager.rs:1771-1774`). + It is indexed but **not UNIQUE** (`idx_messages_message_id`, `1007`), so it is + a convenience handle, not a dedup key. Intra-second ordering falls back to the + autoincrement `id` (`get_conversation` `ORDER BY created_timestamp, id`, + `1727-1733`). +- **Listing scope**: global by default, optionally narrowed by `working_dir` + equality and/or `session_type` and/or keyword (`SessionListFilters`, + `session_manager.rs:330-336`; query build `1861-1892`). Cross-"project" + enumeration is the norm; `project_id` is just a nullable column, not a key + prefix. +- **Relocation / rename**: identity is the `id`, so moving the working directory + is reconciled by an `UPDATE sessions SET working_dir = ?` that leaves the id + and transcript intact (ACP `on_update_working_dir`, + `acp/server/manage_sessions.rs:29-33`). Title renames are an `UPDATE` of + `name`/`user_set_name` (`SessionUpdateBuilder`, `session_manager.rs:212-228`). + +## The store interface + +There is **no pluggable store trait**. `SessionStorage` is a concrete type +wrapping a `sqlx` SQLite `Pool`, and `SessionManager` is a thin public facade +over it (`session_manager.rs:314-646`, `648-652`). The effective interface, +reconstructed from `SessionManager`/`SessionStorage` methods, is: + +Session lifecycle: + +- `create_session(working_dir, name, session_type, goose_mode) -> Session` — + mints the `YYYYMMDD_N` id and inserts the row (`session_manager.rs:1497-1540`). +- `get_session(id, include_messages) -> Session` — load the row; optionally load + and attach the full ordered conversation, else just compute count + last + timestamp (`1542-1588`). +- `update(id) -> SessionUpdateBuilder` … `.apply()` — partial mutable update of + any header field; builds a dynamic `UPDATE ... SET` and always bumps + `updated_at` (`426-432`, `1590-1725`). +- `delete_session(id)` — existence check, then `DELETE messages`, `DELETE + usage_ledger`, `DELETE sessions` in one tx (`2012-2043`). +- `copy_session(id, new_name) -> Session` and `import_session`/`export_session` + (`2252-2342`). + +Transcript I/O: + +- `add_message(id, &Message)` — append one message row; bump `updated_at` + (`1765-1798`). +- `replace_conversation(id, &Conversation)` — destructive full rewrite: + `DELETE` all message rows for the session, then re-`INSERT` each + (`1800-1847`). +- `get_conversation(id) -> Conversation` (internal) — ordered read of all rows + (`1727-1763`). +- `truncate_conversation(id, timestamp)` — `DELETE ... created_timestamp >= ?` + (`2344-2353`). +- `truncate_conversation_from_message(id, message_id)` — resolve the boundary + row, then delete it and everything after (`2355-2385`). +- `update_message_metadata(id, message_id, f)` — read/modify/write a message's + `metadata_json` (`2412-2452`). +- `update_tool_request_meta(id, message_id, tool_call_id, patch)` — in-place + merge into a `ToolRequest.tool_meta` inside a stored message's `content_json` + (`2459-2509`). + +Listing / analytics / search: + +- `list_sessions()` / `list_sessions_by_types(types)` / `list_all_sessions()` + (`442-459`, `1952-2010`). +- `list_sessions_paged(SessionListPageQuery)` — keyset pagination by + `(sort_timestamp, id)` returning a `next_cursor` (`450-455`, `1963-2005`). +- `get_insights()` — count + summed tokens over selected types (`2045-2076`). +- `get_session_usage_totals(id)` — recursive parent→child rollup (`2168-2250`). +- `record_usage_metrics(...)` — reconcile rollups + append a `usage_ledger` row + (`2078-2166`). +- `search_chat_history(query, limit, dates, exclude, types)` — keyword LIKE scan + (`599-618`, delegating to `chat_history_search.rs`). + +Ordering/consistency contract: every mutation runs inside a `BEGIN IMMEDIATE` +transaction (e.g. `1505`, `1715`, `1767`, `1805`, `2014`), which takes SQLite's +write lock immediately so concurrent writers serialize (with the 30s busy +timeout). There is **no expected-version precondition** anywhere; there is no +CAS. The unit of a "read model" is the live SQL query, not a maintained +projection. + +There is also a shared-process singleton: `SESSION_STORAGE` +(`LazyLock>`, `session_manager.rs:56-57`) backing +`SessionManager::instance()` (`400-404`), so within one process a single pool +and single lazy-init are reused. + +## Write and append path (ordering, durability, concurrency, delivery) + +- **New turn commit**: a single `INSERT INTO messages (...)` per message, with a + sibling `UPDATE sessions SET updated_at = datetime('now')`, both in one + `BEGIN IMMEDIATE` tx (`add_message`, `session_manager.rs:1765-1798`). This is + the normal per-turn path; the agent loop calls it repeatedly as it streams + user, assistant, tool-request, and tool-response messages + (`agents/agent.rs:1619-2004`). +- **Ordering** is positional: `messages.id INTEGER PRIMARY KEY AUTOINCREMENT` + gives global insertion order, and `created_timestamp` gives wall-clock order; + reads sort by `created_timestamp, id` so same-second messages keep insertion + order (`get_conversation`, `1730-1733`). There is no per-session sequence + number or stream version. +- **Durability / atomicity**: SQLite WAL + `BEGIN IMMEDIATE` per operation + (`849-854`, transactions throughout). Multi-statement operations (append, + replace, delete, migrations, usage reconcile) are atomic within their tx. The + first-run `create_schema` deliberately uses `BEGIN IMMEDIATE` + `IF NOT + EXISTS` DDL + `INSERT OR IGNORE` on the version row to survive two processes + racing to initialize the same file (`893-923` and its comment). There is no + application-level temp-file-and-rename or torn-write healing beyond SQLite's + own guarantees. +- **Concurrency**: **single-writer-at-a-time per database**, enforced by + SQLite's write lock (immediate transactions), not by a per-session lock or an + in-process command queue. Multiple sessions and multiple processes contend on + the one DB file; writers block up to the 30s busy timeout. +- **Delivery / idempotence**: **best-effort, no store-level idempotence**. + `add_message` unconditionally inserts; `message_id` is not unique, so a + retried append would create a duplicate row. There is no dedup key and no + at-least-once/exactly-once machinery at the store boundary. (The `usage_ledger` + reconcile in `record_usage_metrics`, `2089-2124`, guards against double-counting + *usage totals* via a `MAX(... - ledger_sum, 0)` clause, but that is an + analytics safeguard, not transcript idempotence.) + +## Read and resume path + +- **Resume is a full ordered read of the durable store**, not a cursor replay or + a cached view. `get_session(id, include_messages=true)` runs `SELECT role, + content_json, created_timestamp, metadata_json, message_id FROM messages WHERE + session_id = ? ORDER BY created_timestamp, id` and rebuilds a `Conversation` + from every row (`get_conversation`, `session_manager.rs:1727-1763`). There is + no local filesystem cache consulted first; the SQLite file *is* the store. +- **ACP load** (`acp/server/load_session.rs:203-262`) calls `get_session(id, + true)`, then `replay_conversation_to_client` streams each stored message back + to the client as ACP `SessionUpdate` chunks, filtered to + `user_visible_messages()` (`load_session.rs:103-200`). Message provenance + (`created`, `messageId`, `steer`) is re-emitted under `_meta.goose` + (`load_session.rs:17-45`). +- **Agent context vs. UI view**: the model's context window is the + `agent_visible` subset of the same rows, while the UI shows the `user_visible` + subset (`MessageMetadata.user_visible` / `agent_visible`, + `goose-provider-types/src/conversation/message.rs:661-675`). Both are the same + durable rows read eagerly; the split is a metadata filter, not a second store. +- **Pagination / bounds**: the transcript read is unbounded (whole session). + Only *listing* paginates (keyset cursor, below). The legacy JSONL importer caps + a single legacy file at 50 MiB (`legacy.rs:11`, `42-44`), but the SQLite path + imposes no per-session size cap. +- **Materialization**: on resume everything is eager — the full conversation is + loaded into memory as a `Conversation`. `last_message_snippet` and per-message + usage are the only things hydrated lazily/optionally. + +## Listing, summaries, and search + +- **Enumeration** is a single grouped SQL query over `sessions LEFT JOIN + messages`, grouping by `s.id`, computing `message_count` and + `last_message_timestamp`, and ordering by a `sort_timestamp = COALESCE(MAX(msg + ts), updated_at)` then `id` (`list_sessions_matching`, + `session_manager.rs:1849-1950`). Pagination is **keyset**: the cursor is + `(sort_at, session_id)` and the query fetches `page_size + 1` rows to derive + `next_cursor` (`list_sessions_paged`, `1963-2005`; cursor type `318-322`). + Supporting indexes: `idx_sessions_updated`, `idx_sessions_type`, + `idx_sessions_parent`, `idx_messages_session` (`1001-1020`). No scale numbers + are quoted in-source; cost is a per-page grouped join, so it scales with rows + matched, not total history. +- **Summaries / read model**: there is **no maintained sidecar**. Picker fields + are recomputed each query: counts and last-activity via aggregates, and + `last_message_snippet` hydrated on demand for the returned page + (`last_message_snippet::hydrate_last_message_snippets`, `1996-1999`). The only + denormalized-at-write values are the token rollups on the `sessions` row. +- **Search** is a **live keyword LIKE scan**, not FTS or vectors. Two variants: + the list filter builds `instr(LOWER(json_extract(value,'$.text')), ?) > 0` + clauses over `json_each(content_json)` (`message_keyword_clause`, + `session_manager.rs:361-382`); `ChatHistorySearch` builds `LOWER(json_extract( + content.value,'$.text')) LIKE ?` clauses and additionally respects + `metadata_json.$.agentVisible` and per-content `annotations.audience` so hidden + text is not recalled (`chat_history_search.rs:133-206`). Nothing is + bootstrapped or kept in sync — the scan reads current rows every time. + +## Entry/message structure and versioning + +- **Stored entry** = one `messages` row. The store parses and interprets it + partially: `role` and `created_timestamp` are first-class columns used for + ordering and filtering; `content_json` and `metadata_json` are JSON blobs that + the store nonetheless reaches *into* via `json_extract`/`json_each` for search + and for the `update_tool_request_meta` patch (`session_manager.rs:2459-2509`). + So it is neither fully opaque nor a normalized schema — JSON columns with + targeted introspection. +- **Message type** (`goose-provider-types/src/conversation/message.rs:763-770`): + + ```rust + pub struct Message { + pub id: Option, + pub role: Role, // User | Assistant + pub created: i64, // unix seconds (ms tolerated on read) + pub content: Vec, + pub metadata: MessageMetadata, + } + ``` + + `content` is a tagged union (`#[serde(tag = "type")]`) over `Text`, `Image`, + `ToolRequest`, `ToolResponse`, `ToolConfirmationRequest`, `ActionRequired`, + `FrontendToolRequest`, `Thinking`, `RedactedThinking`, `SystemNotification` + (`message.rs:205-219`). `MessageMetadata` carries the visibility flags plus + `steer`, `inference`, and an optional `usage` box (`message.rs:661-675`). + `ToolRequest` carries `id`, the tool call, provider `metadata`, and a + `tool_meta` bag used for LLM-generated titles/chain summaries that must survive + reload (`message.rs:80-114`). +- **Chaining/threading**: there is no per-message parent pointer. Tool + request/response pair by shared `id` (`ToolRequest.id` == `ToolResponse.id`). + Ordering is entirely positional (`created_timestamp`, autoincrement `id`). + Session-to-session lineage is the `parent_session_id` column, not a + message-level link. +- **Timestamp normalization**: reads tolerate both second and millisecond epochs + via a `MILLISECOND_TIMESTAMP_THRESHOLD` guard (`session_manager.rs:29`, + `661-674`). +- **Format evolution** is handled two ways. (1) **Content-level migration at + deserialization**: `deserialize_sanitized_content` drops pre-14.0 + `conversationCompacted` blocks, rewrites legacy `reasoning` → `thinking`, and + sanitizes Unicode-tag exploits every time a message is read + (`message.rs:22-71`). Most new fields are additive with serde defaults + (`#[serde(default)]` across `Session` and `MessageMetadata`). (2) + **Schema-level migration**: `CURRENT_SCHEMA_VERSION = 15` + (`session_manager.rs:26`), tracked in a `schema_version` table; `run_migrations` + applies each `v(current+1..=15)` in order inside one tx and records it + (`1146-1203`), with `apply_migration` holding the DDL for each step + (`1206-1495`). Migrations are **forward-only** (no down migrations; an unknown + version `bail!`s, `1489-1491`) and defensively idempotent (each checks + `pragma_table_info` before `ALTER`). A brand-new DB is created at v15 directly + via `create_schema` (`893-1032`). + +## Compaction and history management + +- Compaction is an **upstream (agent-loop) concern that rewrites the durable + transcript**, but non-destructively at the message level. When the context + crosses the auto-compact threshold, `compact_messages` produces a new message + list where the **original messages are retained but flipped to + `agent_invisible`** (still `user_visible`), a summary message is appended as + `agent_only`, plus a continuation message and optionally the preserved last + user message (`context_mgmt/mod.rs:76-186`, esp. `139-173`). The agent loop + then calls `session_manager.replace_conversation(...)`, i.e. `DELETE` all rows + and re-`INSERT` the new list (`agents/agent.rs:1812-1826`; + `replace_conversation_inner`, `session_manager.rs:1800-1838`). +- **Net effect on the durable record**: the full pre-compaction transcript + survives on disk as `user_visible`/`agent_invisible` rows, so the human history + is preserved and re-shown on resume, while the model only re-reads the summary + + tail. This is a **soft, view-shrinking compaction implemented by rewriting + the whole message set with new visibility flags** — not an appended marker + interpreted at replay, and not a hard deletion. Usage of the summarization call + is charged and flagged `is_compaction` in the `usage_ledger` + (`message.rs:636-637`; `session_manager.rs:837`). +- **Resume across a compaction boundary**: because compaction has already + rewritten the rows, resume just reads the current rows; the model context is + reconstituted as the `agent_visible` subset (summary + retained tail), the UI + shows the full `user_visible` set. + +## Rewind, checkpoints, and fork + +- **Rewind / undo is a destructive delete, not an appended marker.** ACP fork and + edit paths call `truncate_conversation(session_id, timestamp)` (delete rows at + or after a cutoff, `session_manager.rs:2344-2353`) or + `truncate_conversation_from_message(session_id, message_id)` (resolve the + boundary row, delete it and everything after in one tx, `2355-2385`). The + removed turns are gone from the durable store. +- **No file-state or environment checkpoints.** There is no per-turn snapshot of + the working tree, no git-ref checkpoint, and no diff/hash store. The only + per-turn artifact is the `usage_ledger` row. (Contrast T3 Code's + content-addressed git checkpoints.) +- **Fork is copy-plus-truncate into a brand-new session (physical copy, not a + shared prefix).** ACP `handle_fork_session` calls `copy_session(source, "{name} + (copy)")` then optionally `truncate_conversation(new, conversationBefore)` + (`acp/server/fork_session.rs:25-37`). `copy_session` mints a fresh + `YYYYMMDD_N` id, copies header fields (extension_data, recipe, provider, + model, mode, project_id), and **re-inserts every source message into the new + session** via `replace_conversation` (`session_manager.rs:2299-2342`). Lineage + recorded: the copy is a wholly independent session; the fork does **not** set + `parent_session_id` (that column is reserved for subagents), so the only link + is the `" (copy)"` name convention. Cost is O(history) per fork. + +## Subagents and nested sessions + +- A subagent is a **first-class sibling session**, stored exactly like a + top-level session in the same DB, not nested storage and not entries in the + parent transcript. `create_subagent_session` calls `create_session(..., + SessionType::SubAgent, ...)` with the parent's working dir, then sets + `parent_session_id` to the parent's id (`agents/platform_extensions/summon.rs:503-531`). +- **Durable parent→child link**: the `parent_session_id` column (added in + migration 15, indexed by `idx_sessions_parent`, `session_manager.rs:1445-1461`). + The child has its **own isolated transcript** (its own `messages` rows); it + does not inherit or share the parent's message rows. +- **Rollup traversal**: usage totals walk the tree with a recursive CTE + (`WITH RECURSIVE tree ... JOIN tree ON s.parent_session_id = tree.id`) so a + parent's totals include descendant subagents (`get_session_usage_totals`, + `session_manager.rs:2186-2201`). This is the one place the parent/child link is + read structurally. +- **Bounds / cascade**: nesting is not bounded by the schema (arbitrary depth via + the column). On parent delete there is **no cascade to children**: + `delete_session` deletes only that session's own messages, ledger, and row + (`2012-2043`), leaving a subagent row with a dangling `parent_session_id` + (orphan). Subagent listing is normally filtered out of the default picker, + which lists only `User`/`Scheduled` types (`list_sessions`, `2007-2010`). + +## Retention, deletion, and multi-host + +- **Retention**: none. No TTL, lifecycle sweep, or scheduled cleanup of sessions, + messages, or the usage ledger was found. History is kept indefinitely. + `archived_at` exists as a soft-archive timestamp column + (`session_manager.rs:953`, `298-301`) but there is no retention policy acting + on it. +- **Delete** is a hard, immediate, single-session cascade *within* that session: + `DELETE FROM messages`, `DELETE FROM usage_ledger` (also `ON DELETE CASCADE`, + `984`), `DELETE FROM sessions`, in one `BEGIN IMMEDIATE` tx after an existence + check (`delete_session`, `2012-2043`). It does **not** cascade to subagent + child sessions (see above). Because messages live in the same DB, there is no + remote-first or append-only no-op consideration. +- **Multi-host**: not a first-class path. The design assumes a single local + SQLite file; concurrency safety is SQLite write-locking + a 30s busy timeout + + WAL, and the explicit multi-process concern addressed in-source is only + *first-run schema-init races on a shared file* (`create_schema` comment, + `893-905`). There is no network-filesystem handling, no crash detection, and no + remote writeback. Cross-host *sharing* exists but only as an export mechanism + (see below), not as a shared store. + +## Interop with foreign session stores + +Goose both **imports foreign transcripts** and **exports/shares its own**, but +never treats a foreign store as a live backend. + +- **Foreign import**: `session/import_formats/` converts other agents' on-disk + session files into goose-native `Session` JSON, then feeds them through the + normal `import_session` pipeline (create + `replace_conversation`). Supported: + **Claude Code** (`.jsonl` under `~/.claude/projects/...`), **Codex** (`.jsonl` + rollouts under `~/.codex/sessions/YYYY/MM/DD/...`), and **Pi** (`.jsonl` under + `~/.pi/agent/sessions/...`) (`import_formats/mod.rs:1-24`; + `claude_code.rs`, `codex.rs`, `pi.rs`). Import is a one-way, converting copy + into goose's SQLite — read-only against the foreign file, and the result is a + normal goose session. +- **Legacy self-import**: on first initialization of a fresh DB, goose scans the + old per-session `*.jsonl` layout in the sessions folder and imports each into + SQLite (`import_legacy`, `session_manager.rs:1034-1144`; JSONL reader + `legacy.rs:13-108`, first line = metadata object, subsequent lines = messages). + This is the migration from the pre-v10 JSONL store to the SQLite store. +- **Export / share**: `export_session` serializes a `Session` (with conversation) + to pretty JSON (`session_manager.rs:2252-2255`); the optional `nostr`-gated + `nostr_share` module publishes an encrypted session as a Nostr event (kind + 30278) with a deeplink for out-of-band sharing (`session/nostr_share.rs:1-40`). + These are copies out, not a shared durable store. + +## What this implies for our Session Store (our inference) + +*(Our inference, clearly marked as such.)* Goose is the corpus's clearest +**non-event-sourced** local store: a session is a **mutable SQLite row plus an +in-place-editable ordered message table**, where resume is a full ordered +`SELECT`, listing/summaries/search are **live queries** (nothing maintained), and +every retroactive operation (compaction, rewind, fork) works by **rewriting or +deleting rows** rather than appending interpreted markers. This is close to the +opposite of our event-sourced target, and it is instructive precisely as a +contrast: + +- **Where it validates our direction**: the pain points our design avoids are + visible here. Compaction and fork both go through a `DELETE`-all + + re-`INSERT` (`replace_conversation`, `session_manager.rs:1800-1838`), so a + crash mid-rewrite risks a torn transcript, fork is O(history) copy, and rewind + destroys the pre-cutoff turns (`truncate_*`, `2344-2385`). An append-only log + with derived projections gets crash-safe rewind, cheap shared-prefix forks, and + intact history for free. +- **Ideas worth carrying over**: (1) The **`agent_visible`/`user_visible` + visibility split** (`message.rs:661-675`) is a clean way to shrink the + model-visible view while keeping the human transcript — our log can express the + same with a projection filter instead of a metadata flag on a rewritten row. + (2) The **append-only `usage_ledger` beside the mutable row** shows they already + reach for an append log exactly where correctness of cumulative state matters; + our design generalizes that to the whole session. (3) **Recomputing + count/last-activity/snippet at query time** (`get_session`, list query) is a + reminder that not every list field needs a maintained sidecar — cheap + projections can stay lazy. +- **Cautions our design should heed**: (1) **No store-level idempotence** — + `message_id` is non-unique and `add_message` blind-inserts (`1765-1798`); we + want a dedup/idempotency key on append. (2) **No expected-version / OCC** — + concurrency is only SQLite's write lock; the moment writers aren't one local + process, that guarantee evaporates, so we need an explicit expected-position + precondition. (3) **Fork/subagent lineage is thin** — fork records no + `parent_session_id` at all and delete does not cascade to subagent children, + leaving orphans (`2012-2043`); our lineage metadata and cascade/retention rules + must be deliberate. + +## Open questions + +- **Concurrent transcript writes to one session**: two processes appending to the + same `session_id` serialize on the SQLite write lock, but nothing orders their + interleave or dedups a retried append. The intended single-writer-per-session + discipline (if any) above the store was not pinned down. +- **`threads`/`thread_messages` tables** (migration 10, `session_manager.rs:1348-1380`) + and the `thread_id` column appear to be a dormant or superseded feature; their + current role, if any, in the session path was not traced. +- **`archived_at` lifecycle**: the column and builder exist + (`298-301`, `953`) and list queries can carry it, but no code path that sets it + or filters the default picker by it was located; whether archive is surfaced in + a UI or is vestigial is unresolved. +- **Subagent orphan cleanup**: nothing reconciles a subagent whose parent was + deleted; whether an out-of-band sweep exists (outside the session module) was + not found. +- **Migration-8/ledger interaction on old DBs**: migration 15 (re)creates + `usage_ledger` `IF NOT EXISTS` (`1463-1487`) while `create_schema` also creates + it; the exact state of the ledger for DBs that upgraded through intermediate + versions before v15 was not exercised. diff --git a/docs/research/session-store/products/grok-build.md b/docs/research/session-store/products/grok-build.md new file mode 100644 index 000000000..0d43b49e3 --- /dev/null +++ b/docs/research/session-store/products/grok-build.md @@ -0,0 +1,349 @@ +# Grok Build: how session transcripts are stored and resumed + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT](../RESEARCH_PROMPT.md). +Evidence snapshot: local checkout of +[xai-org/grok-build](https://github.com/xai-org/grok-build) at commit +`a5727c5960452e7527a154b25cb5bf00cda0545e` (committed 2026-07-22), the public +Rust source of SpaceXAI's terminal AI coding agent (`grok` CLI/TUI, headless, +and ACP-embedded), synced periodically from the SpaceXAI monorepo. Every +citation below was re-verified against this exact commit on 2026-07-23 (working +tree clean, `origin/main` unchanged). The session subsystem spans +`xai-grok-shell` (persistence core), `xai-grok-workspace` (file state, foreign +sessions), `xai-grok-config` (paths), `xai-grok-shared` (identity), +`xai-sqlite-journal`, `xai-fast-worktree`, and `xai-grok-tools` (subagent +depth cap). Citations use crate-relative shorthand: every crate named here +resolves under `crates/codegen/`, e.g. `xai-grok-shell/src/...` is +`crates/codegen/xai-grok-shell/src/...` in the repo. + +## The storage model + +A session is a directory of files under the user's grok home (default +`~/.grok`), keyed by working directory and session id: + +```text +{grok_home}/sessions/{encoded_cwd}/{session_id}/ +``` + +Inside, one append-only JSONL file is the source of truth and everything +else is derived or sidecar state. The house comment on the rebuild path +states the philosophy outright: "Rebuild the derived `chat_history.jsonl` +cache from `updates.jsonl`, the durable source of truth, so a session +restores from its update stream alone" +(`xai-grok-shell/src/session/storage/mod.rs:126-127`). This is event +sourcing on the filesystem in all but name: an ordered event log per +session, with caches, summaries, and indexes as rebuildable projections. + +## Keying and identity + +- Session identity is `Info { id: acp::SessionId, cwd: String }` + (`xai-grok-shared/src/session/info.rs:4-8`); every persistence call takes + it, and `session_dir(info)` resolves the directory. +- Session ids are minted as UUIDv7 when the ACP client does not supply one: + `acp::SessionId::new(uuid::Uuid::now_v7().to_string())` + (`xai-grok-shell/src/agent/mvp_agent/acp_agent.rs:994-996`), so directory + names are time-ordered. +- The cwd component is URL-encoded when the encoded form fits 255 bytes; + longer cwds use a compact `{slug}-{blake3_hex16}` form plus a sibling + `.cwd` file (written with `O_CREAT|O_EXCL` against TOCTOU races) that + records the original cwd for reverse mapping + (`xai-grok-config/src/paths.rs:108-187`). +- Sessions whose cwd moves (worktree relocations) are tracked by JSON + relocation journals under `{grok_home}/relocations/{session_id}.json`; a + `RelocationView` snapshot resolves each session id to its authoritative + directory during listing and lookup + (`xai-grok-shell/src/session/storage/relocation/view.rs:14-106`, built from + the per-session journals loaded at `view.rs:188-208`). Note: + this `RelocationJournal` is unrelated to the `xai-sqlite-journal` crate, + which despite the name is only a SQLite journal-mode selection helper. + +## Session directory contents + +| File | Format | Role | +| --- | --- | --- | +| `updates.jsonl` (+ `.jsonl.lock`) | JSONL of `SessionUpdateEnvelope {timestamp, method, params}` | Source-of-truth append log of every ACP and xAI-extension session notification | +| `chat_history.jsonl` | JSONL of `ConversationItem` | Derived conversation cache for prompt building; rebuilt from `updates.jsonl` when empty or corrupt | +| `summary.json` (+ `.json.lock`) | Single JSON `Summary` | Denormalized metadata sidecar: titles, timestamps, model, fork lineage, git info, `session_kind`, `hidden`, `chat_format_version` | +| `rewind_points.jsonl` (+ lock) | JSONL of `RewindPoint` | Per-prompt file-state checkpoints holding full before/after file content | +| `compaction_checkpoints/{id}.json` | JSON `CompactionCheckpointFile` | Full compacted-history snapshot per compaction event | +| `compaction_requests/`, `recap_requests/` | JSON per request | Summarizer request/response artifacts kept for offline prompt iteration | +| `compaction/segment_NNN.md`, `INDEX.md` | Markdown | Verbatim history segments, written only in Segments compaction mode | +| `subagents/{subagent_id}/meta.json` (+ `output.json`) | JSON `SubagentMeta` | Parent-to-child link and status pointer; not the child transcript | +| `plan.json`, `plan_mode.json`, `signals.json`, `goal/state.json`, `announcement_state.json` | JSON per concern | Assorted per-session feature state | +| `events.jsonl` | JSONL, tagged `Event` enum | Append-only per-turn analytics log (`EVENT_SCHEMA_VERSION` "1.0", `xai-file-utils`) | + +Adjacent, outside the per-session directory: + +- `{grok_home}/sessions/session_search.sqlite`: full-text search index. +- `{grok_home}/active_sessions.json` (+ lock + tmp): registry of currently + open TUI processes for crash detection. +- `~/.grok/worktrees.db`: SQLite worktree registry linking worktrees to + session ids (`WorktreeKind::Session|Subagent|Fork|...`). + +## Append path, ordering, and durability + +- Appends to any per-session JSONL take an exclusive `fs2` lock on a + sibling `.jsonl.lock`. Before writing, the code heals a torn + trailing line left by a crashed append: "jsonl file has a torn trailing + line (previous append crashed mid-write?); terminating it before + appending" (`xai-grok-shell/src/session/storage/jsonl/mod.rs:313`), + so damage is bounded to one corrupt line that lenient readers skip. +- Full-file rewrites (the only way files shrink) go through a crash-atomic + temp-file-then-rename path that bypasses the append lock: "a crash / + `ENOSPC` mid-write can't truncate the existing file" + (`jsonl/mod.rs:491-496`). +- Two readers exist for the same data: a lenient streaming reader for loads + (warns and skips malformed lines) and a strict reader used only on the + rewrite path, which aborts the whole rewrite and preserves the original + file if any line fails to parse. +- Ordering is positional: line order in `updates.jsonl` is the event order. + There is no per-entry sequence number and no optimistic-concurrency + precondition; single-writer-per-session is assumed and enforced socially + by the active-sessions registry plus file locks. + +## Versioning of stored data + +- `Summary.chat_format_version: u8` versions the conversation cache format + (0 = legacy `ChatRequestMessage`, 1 = `ConversationItem`). +- Envelope reads branch on the presence of a `method` key to accept legacy + pre-envelope lines: "Backwards compatibility: old format without envelope + wrapper" (`storage/mod.rs:650`; a second legacy branch is at + `storage/mod.rs:683`). +- Elsewhere evolution is additive serde: `#[serde(default)]` fields, + `#[serde(other)] Unknown` catch-alls, and `FlexiblePath` (relative + preferred, absolute accepted for older sessions). Rewind points have no + format-version field at all. + +## Listing, summaries, and search + +- Listing is a filesystem scan, not an index: every list walks + `{sessions_root}/{cwd}/{session}/summary.json` via `RelocationView`, + deserializes `Summary`, filters hidden sessions, and sorts by last + activity. The one fast path stats mtimes first and parses only the top N: + "On a machine with ~12K sessions this reduces cold-boot workspace_list + from ~3s to ~200ms" (`jsonl/mod.rs:196-197`). A Criterion bench models 3,000 + workspaces and 9,864 summaries. +- `Summary` is a wide denormalized struct (title and `title_is_manual`, + created/updated/last-active timestamps, `current_model_id`, message + counts, fork lineage, git root/remotes/branch/commit, `session_kind`, + `hidden`, sandbox profile, reasoning effort). There is no tags field. +- Search is architecturally separate and genuinely indexed: SQLite FTS5 in + `session_search.sqlite` (`meta` k/v, `session_docs`, content-synced + `session_docs_fts`), bootstrapped on first search, incrementally updated + by a debounced `notify_session_updated` hook on save/rename, deduped by + `content_hash = blake3(title + \0 + content)`. Schema migration is a + one-way ratchet (drop and rebuild only on upgrade) so concurrently + running grok binary generations can share the file; every search + re-verifies the on-disk bootstrap marker because a peer process "may wipe + or downgrade it" (`search_fts.rs`, `search.rs`). A delta-indexing path + keyed on a persisted byte offset into `updates.jsonl` is implemented but + not yet wired; every reindex today rescans the file. An optional, + default-off feature zstd-compresses the whole index to GCS and installs a + remote copy on startup, with an acknowledged incomplete staleness check. +- Admin: rename patches `summary.json` under its lock and reindexes FTS; + delete is remote-first when a writeback backend applies (HTTP 404 treated + as success), then local `remove_dir_all` plus FTS eviction. The CLI + deliberately locates delete targets by id with no cwd filter. + +## Compaction + +Compaction shrinks the model-visible view, never the durable log: + +- `updates.jsonl` is append-only through compaction. Each compaction + appends a lightweight `CompactionCheckpoint` marker line (checkpoint id, + `prompt_index_at_compaction`, schema_version, file pointer) and writes + the full compacted conversation to a separate + `compaction_checkpoints/{checkpoint_id}.json`: "writes the compacted + history to a separate file and records a `CompactionCheckpoint` marker in + `updates.jsonl`" (`compaction.rs:2152-2153`). +- Only `chat_history.jsonl` shrinks: it is atomically rewritten with the + compacted item list ("Replacing chat history (compaction)"). +- Normal resume just loads the already-compacted `chat_history.jsonl`. + Rewinding past a compaction boundary reconstructs from raw + `updates.jsonl` plus the checkpoint file, which is also required to + recover the historical `original_user_info`; if the checkpoint file is + missing the rewind fails rather than proceeding with wrong data ("Cannot + safely rewind past the compaction point.", `helpers/replay.rs`). +- Triggers: manual `/compact` (optionally with user guidance text), a + threshold check against estimated tokens, a post-tool-call preflight + overflow check, and a reactive trigger off model context-length errors. + A two-pass mechanism speculatively summarizes ~95% of history in the + background before the threshold is crossed (prefire), then a second pass + folds in the tail; it is a latency optimization and changes no on-disk + artifacts. +- Fork interplay: `Summary.inherited_prefix_len` marks the conversation + prefix copied from the parent; "During compaction, items below this index + are preserved as-is (the 'inherited prefix'). Only items after this + boundary are summarized" (`persistence.rs:855-860`). If preserving the + prefix would keep the fork over the auto-compact threshold, the prefix is + released permanently (sticky `prefix_released`). + +## Rewind and file-state checkpoints + +- Every prompt boundary is implicitly a checkpoint: "Every prompt is a + checkpoint, the list always contains `[0, 1, ..., N-1]` where N is the + current prompt_index" (`acp_session_impl/rewind.rs:11-34`). +- Conversation rewind appends a `RewindMarker` to `updates.jsonl`; nothing + is deleted. Replay applies dead-branch filtering: content before the + rewind target survives, content between the rewound turn and the marker + is dropped, content after the marker is included. The search indexer + applies the same semantics. +- File-state checkpoints (`rewind_points.jsonl`) store the full literal + text of every touched file, twice per prompt (before and after maps), + with no diffing, hashing, dedup, or compression. The file "can be + hundreds of MB" (`file_state.rs:382-383`), so resume skips it entirely + and loads lazily on an actual rewind; the rewind picker scans metadata + with a visitor that counts map entries without allocating content. +- Rewind is the only shrink path: full rewind truncates points at or after + the target via atomic rewrite; conversation-only rewind folds later + points into the prior one (earliest before-snapshot wins, latest + after-snapshot wins) so file-undo history is preserved. +- A git-native checkpoint domain (`GitCheckpointStore`: HEAD SHA plus + staged paths per prompt) exists behind a default-off env flag but is held + only in memory and does not survive restart. + +## Fork + +`ForkSessionRequest` carries source session and cwd, optional new session +id, model, target prompt index, and session kind. Forking copies +`chat_history.jsonl`, `updates.jsonl`, and plan state to the new session +directory, and the child `Summary` records `parent_session_id`, +`forked_at`, `fork_context_source`, `fork_parent_prompt_id`, and +`inherited_prefix_len` (`session/fork.rs:15-46`, +`persistence.rs:819-860`). Fork is copy-plus-lineage, not a shared-prefix +reference. + +## Subagent sessions + +- A subagent gets its own full session directory under the standard + cwd-keyed scheme (a sibling of the parent when they share a cwd), with + its own `updates.jsonl`, `chat_history.jsonl`, and `summary.json`. It + does not write into the parent's transcript. +- `Summary.session_kind` is stamped `"subagent"`, `"subagent_fork"`, or + `"subagent_resume"`, and `is_hidden()` defaults to true for any + `session_kind` starting with `"subagent"` (`persistence.rs:977-982`), so + subagent sessions are excluded from listing, the roster, and FTS + indexing. +- The durable parent-child link is + `{parent_session_dir}/subagents/{subagent_id}/meta.json` (`SubagentMeta`: + ids, cwd, worktree, model, status running/completed/failed/cancelled). + Nesting is hard-capped at depth 1: "Subagents cannot spawn further + subagents" (`xai-grok-tools/.../task/mod.rs:29-31`). +- Crash recovery on resume: replay scans the rewind-filtered timeline for + `SubagentSpawned` without a matching `SubagentFinished`, unions that with + on-disk `meta.json` entries still marked running, then either re-emits + the real result, flips the meta to cancelled and emits a synthetic + finish, or re-emits an already-terminal status + (`reconcile_orphaned_subagents`, `subagent/mod.rs:2878-2985`). +- Deleting the parent removes only the parent's directory (including the + `subagents/` pointers) and orphans the hidden child directories; rewind + likewise leaves child directories and worktrees behind. No GC for + orphaned subagent session directories was found (subagent worktrees do + have age-based GC in the worktree registry). + +## Crash detection, multi-process, and multi-host + +- `~/.grok/active_sessions.json` tracks open TUI processes (session id, + pid, cwd, opened_at) under an exclusive lock with atomic writes: "Clean + exit removes the entry; crash leaves it behind" + (`active_sessions.rs:1-3`); next launch partitions out entries whose pid + is dead. +- The `xai-sqlite-journal` crate picks WAL on local filesystems and a + TRUNCATE rollback journal on network filesystems (WAL's mmap'd `-shm` + index "relies on coherent shared memory plus reliable POSIX locks, + guarantees network filesystems do not provide"), applies a 5s busy + timeout, and on network mounts renames each SQLite DB to a per-host + sibling so no peer host can flip it back to WAL. All SQLite stores here + are rebuildable indexes, never the transcript. A + `GROK_SQLITE_JOURNAL_MODE` env var is the field kill-switch. +- Remote lanes exist but are secondary: a writeback backend for session + data (remote-first delete and rename sync), a remote session registry + merged into unified listing (local wins on collisions, source becomes + "both"), and the optional GCS sync of the search index. + +## Foreign session stores (reading other products) + +`xai-grok-workspace/src/foreign_sessions/` does bounded, read-only, +metadata-only discovery of other agents' native session stores to populate +grok's session picker; it never converts foreign transcripts into its own +format: + +- **Claude Code**: reads `$CLAUDE_CONFIG_DIR` or + `~/.claude/projects//.jsonl`, reconstructing the + sanitization (non-alphanumeric to `-`, 200-char cap) and scanning the + repo's linked worktrees too. Titles fall back through `customTitle`, + `aiTitle`, `lastPrompt`, `summary`, then first real user prompt, reading + a bounded head window (up to 4 MiB) and 64 KiB tail; transcripts whose + first line contains `"isSidechain":true` are excluded. +- **Codex** (CLI/VSCode/Atlas/ChatGPT): reads `~/.codex`'s + `state_.sqlite` `threads` table with a schema-adaptive query + (columns probed via `PRAGMA table_info`), falling back to rollout files + `sessions/YYYY/MM/DD/rollout--.jsonl(.zst)` whose first + record is `session_meta`. Timestamps below 2020-01-01 in ms are treated + as legacy seconds. zstd reads are bounded (window log cap, single frame, + size caps) so crafted files fail closed. +- **Cursor**: enum and UI plumbing only; the scanner closures are + hardcoded no-ops. +- All access goes through a capability-gated `ApprovedRoot` (openat-style + relative opens, `O_NOFOLLOW`, canonical-root containment; reparse-point + checks on Windows), and foreign SQLite opens are read-only with + `PRAGMA query_only=ON` inside a rolled-back transaction, and only when + the DB sits on a local (WAL-classified) filesystem. Scans are bounded + (50 sessions per tool, 30-day age cap, per-call read budgets), and a + truncated enumeration reports Incomplete rather than a possibly wrong + "most recent" answer. +- "Resume" of a foreign session starts a new grok session and injects a + `/resume- ` prompt handled by a bundled skill outside + this crate; grok's store never imports the foreign transcript body. + +## What this implies for our Session Store (our inference) + +grok-build is the strongest industry corroboration yet that the durable +session is an append-only event log with derived projections: + +- One ordered log per session (`updates.jsonl`) is the source of truth; + the conversation cache, summary sidecar, and FTS index are all explicitly + rebuildable projections of it. The design keeps the write path append-only + even for retroactive semantics: rewind is an appended marker interpreted + at replay (dead-branch filtering), and compaction is an appended marker + plus an external snapshot, with replay across the boundary reconstructing + from the log. These are stream-native solutions to the same problems our + decider stack solves with events, snapshots, and fold-time interpretation. +- The summary sidecar maintained at write time for listing is the same + pattern as the Claude Agent SDK's `listSessionSummaries` fold and our + projections; grok additionally shows the failure mode of skipping a real + index (full directory scans, mtime stat fast paths, ~12K-session scale + pain) and of bolting search on as a separately-consistent SQLite index + with ratcheted schema migrations. +- Concurrency control is advisory file locks plus a pid registry, with no + expected-position OCC anywhere; multi-host is handled by giving up + (per-host SQLite files on network mounts, rebuildable indexes only). An + event-store-backed design gets OCC, multi-writer safety, and multi-host + access natively, which is precisely the gap this filesystem design works + around. +- Costs visible at scale here that our design should avoid: full-content + file snapshots with no dedup (hundreds of MB per session, mitigated only + by lazy loading), unbounded log growth with retention expressed as a + blunt per-file mtime janitor (30-day TTL) rather than policy tied to the + data model, orphaned child sessions after parent deletion, and derived + caches whose invalidation is manual (`notify_session_updated`). +- Their subagent model (child session as a first-class sibling session + with a lineage pointer and reconciliation of orphans at resume) matches + ADR 0031's child-Session direction and argues for explicit parent/child + lifecycle facts rather than nested storage. + +## Open questions + +- The stale doc comments claiming subagent transcripts nest under the + parent directory suggest an abandoned earlier layout; only the pointer + files nest today. +- No test was found covering the TTL janitor's whole-directory removal + path, and no server-side retention story for the writeback backend was + traced. +- `SessionStoreEntry`-style schema evolution is ad hoc (serde defaults, + legacy-format sniffing); nothing versions `rewind_points.jsonl`. +- Whether parent shutdown/delete/rewind should cascade cancellation to + still-running subagents is unresolved in the code (currently they run to + completion independently). +- The delta FTS indexing path (persisted byte offset into `updates.jsonl`) + is implemented but unwired; its enablement plan is unknown. diff --git a/docs/research/session-store/products/hermes-agent.md b/docs/research/session-store/products/hermes-agent.md new file mode 100644 index 000000000..c9013849c --- /dev/null +++ b/docs/research/session-store/products/hermes-agent.md @@ -0,0 +1,524 @@ +# Hermes (Nous Research): how session transcripts are stored and resumed + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT](../RESEARCH_PROMPT.md). +Evidence snapshot: local checkout of +[NousResearch/hermes-agent](https://github.com/NousResearch/hermes-agent) at +commit `d9165d7a678d4105f42921a7fc1886df3804531b` (committed 2026-07-23), the +source of Nous Research's self-improving terminal/gateway agent ("Hermes"). Every +citation below was re-verified against this exact commit on 2026-07-23 (working +tree clean, `origin/main`). The session subsystem is not a pluggable adapter: it +is one concrete class, `SessionDB`, in the repo-root module `hermes_state.py` +(~442 KB, the single source of truth for persistence), with call sites in +`run_agent.py` (the agent write/flush path), `gateway/session.py` (the gateway +transcript queue), `hermes_cli/*` (slash commands, resume, listing), and +`tools/async_delegation.py` (durable subagent-completion outbox). Citations use +repo-relative `path:line` shorthand. + +## The storage model + +The durable session is a **single per-profile SQLite database**, +`{HERMES_HOME}/state.db` (`hermes_state.py:154`, +`DEFAULT_DB_PATH = get_hermes_home() / "state.db"`). A session is one **row** in +the `sessions` table (`hermes_state.py:999-1048`) plus an ordered set of **rows** +in the `messages` table (`hermes_state.py:1050-1072`), joined by +`messages.session_id → sessions.id`. The class docstring states it plainly: +"SQLite-backed session storage with FTS5 search. Thread-safe for the common +gateway pattern (multiple reader threads, single writer via WAL mode)" +(`hermes_state.py:1510-1515`). + +This is best described as **session-as-row-set** (a relational record with a +child message table), not session-as-log and not session-as-directory. The +transcript is *append-dominant but mutable*: messages are almost always added by +`INSERT` (`append_message`, `hermes_state.py:5627`), but the durable record is +edited in place through three flag columns rather than being a pure immutable +log: + +- `active INTEGER NOT NULL DEFAULT 1` — live vs. soft-deleted + (`hermes_state.py:1069`). +- `compacted INTEGER NOT NULL DEFAULT 0` — summarized-away vs. normal + (`hermes_state.py:1070`). + +The two flags encode three durable states, spelled out in the code: +`active=1` (live), `active=0, compacted=0` ("the user took it back" — rewind/undo), +and `active=0, compacted=1` ("summarized away" — compaction-archived) +(`hermes_state.py:5941-5946`, `7151-7156`). Rewind and compaction therefore +mutate existing rows (`UPDATE ... SET active=0`) rather than appending markers, so +the on-disk row set is authoritative and the "log" is reconstructed by filtering +flags at read time. + +What is authoritative vs. derived: + +- **Authoritative**: the `sessions` and `messages` rows. Nothing is rebuilt from + an external source of truth; the rows *are* the truth. +- **Derived / rebuildable**: the FTS5 indexes (`messages_fts`, + `messages_fts_trigram`, `messages_fts_cjk`) are external-content indexes kept in + sync by triggers and fully rebuildable from `messages` via the FTS5 `'rebuild'` + command (`rebuild_fts`, `hermes_state.py:9570`; runtime auto-repair, + `hermes_state.py:2083-2130`). The per-session denormalized counters on the + `sessions` row (`message_count`, `tool_call_count`, token totals) are + maintained at write time and are a projection of the message rows. +- **Sidecar / ephemeral**: gateway request dumps and per-session log files + (`{session_id}.json`, `{session_id}.jsonl`, `request_dump_{session_id}_*.json`, + `logs_dir/session_{sid}.json`) are debug artifacts the store deletes on session + deletion (`_remove_session_files`, `hermes_state.py:8437-8461`; + `run_agent.py:2768`), never the source of truth. + +## Keying and identity + +- **Store scope**: one SQLite file per Hermes home/profile. Profiles are + separated by `HERMES_HOME` (`get_hermes_home`, `hermes_constants.py:106-131`); + there is no cwd- or project-encoded path in the key. All sessions for a profile + — CLI, gateway platforms, subagents, branches — live in the same `state.db` and + are distinguished by columns, not directories. +- **Primary key**: `sessions.id TEXT PRIMARY KEY` (`hermes_state.py:1000`). +- **Id minting**: client-supplied, generated as + `f"{timestamp_str}_{short_uuid}"` where `timestamp_str = + strftime("%Y%m%d_%H%M%S")` and `short_uuid = uuid.uuid4().hex[:6]` + (`cli.py:4208-4210`, `cli.py:7393`, branch path + `hermes_cli/cli_commands_mixin.py:900-903`). The scheme encodes **wall-clock + ordering** in the lexical prefix but is not a UUIDv7 and carries only 6 hex of + entropy (~24 bits), so ordering is second-resolution and collisions within the + same second are theoretically possible. Resume reuses the existing id verbatim + (`cli.py:4204-4206`). +- **Secondary identity for gateway routing**: `source`, `user_id`, + `session_key`, `chat_id`, `chat_type`, `thread_id`, `origin_json` + (`hermes_state.py:1001-1008`) address a session by its external messaging + origin (Telegram/Discord/Slack/etc.). `gateway_routing` (a separate table, + `hermes_state.py:1101-1107`) maps a `(scope, session_key)` to a JSON routing + entry, and `find_session_by_origin` / `find_latest_gateway_session_for_peer` + (`hermes_state.py:3625`, `3724`) resolve a platform peer to its live session id. +- **Listing scope**: global within the profile DB. `list_sessions_rich` + (`hermes_state.py:5171`) queries the whole `sessions` table with optional + `source` / `cwd_prefix` filters; there is no per-directory enumeration. A + read-only cross-profile aggregation path exists (open another profile's + `state.db` `mode=ro`, `hermes_state.py:1574-1591`). +- **Relocation / rename reconciliation**: `cwd`, `git_branch`, + `git_repo_root` are plain columns updated in place (`update_session_cwd`, + `hermes_state.py:3865`); moving a working directory does not change identity + because the key is not path-derived. Renames are `set_session_title` + (`hermes_state.py:4909`), a metadata update, not an identity change. + +## The store interface + +There is **no pluggable store protocol**; `SessionDB` is a concrete class and +callers invoke its methods directly. The reconstructed operational contract (every +method opens its own cursor; all mutations go through `_execute_write`, which wraps +`BEGIN IMMEDIATE` + commit + jittered retry, `hermes_state.py:1997-2065`) is: + +Session lifecycle: +- `create_session(session_id, source, **kwargs) -> str` + (`hermes_state.py:3441`) — INSERT a `sessions` row (via `_insert_session_row`, + `hermes_state.py:3294`, an UPSERT with `ON CONFLICT` merge). +- `ensure_session(...)` (`hermes_state.py:4587`) — idempotent + create-or-touch used before the first append. +- `end_session(session_id, end_reason)` / `reopen_session(session_id)` + (`hermes_state.py:3789`, `3807`) — set/clear `ended_at`, `end_reason`. +- `promote_to_session_reset(...)` (`hermes_state.py:3816`) — compression-continuation bookkeeping. +- `update_session_cwd / _meta / _model / _billing_route / set_session_title / + set_session_archived` (`hermes_state.py:3865, 4244, 4272, 4286, 4909, 4937`) — + in-place metadata mutation. + +Message write: +- `append_message(session_id, role, content, ...) -> int` + (`hermes_state.py:5627`) — INSERT one message row, return autoincrement id, + bump session counters. The single normal write path. +- `replace_messages(session_id, messages, active_only=False)` + (`hermes_state.py:5851`) — **destructive** DELETE-then-INSERT of the whole (or + live-only) transcript, one transaction. Used by /retry, /undo, /compress. +- `archive_and_compact(session_id, compacted_messages) -> int` + (`hermes_state.py:5913`) — **non-destructive** soft-archive + (`active=0, compacted=1`) of live rows then INSERT the compacted set. +- `set_latest_user_api_content(...)` (`hermes_state.py:5965`) — backfill the + `api_content` sidecar onto the newest active user row. + +Message read: +- `get_messages(session_id, include_inactive=False, limit, offset)` + (`hermes_state.py:5997`) — ordered by autoincrement `id` (insertion order). +- `get_messages_as_conversation(session_id, include_ancestors, include_inactive, + repair_alternation)` (`hermes_state.py:6334`) — OpenAI role/content format for + replay. +- `resolve_resume_session_id(session_id) -> str` (`hermes_state.py:6245`) — + redirect a resume target forward across the compression-continuation chain. +- `get_resume_conversations / get_ancestor_display_prefix / get_conversation_root + / _session_lineage_root_to_tip / get_compression_lineage` + (`hermes_state.py:6512, 6561, 6602, 6616, 7942`) — lineage walks. + +History mutation (retroactive): +- `rewind_to_message(session_id, target_message_id) -> dict` + (`hermes_state.py:6656`) — soft-delete (`active=0`) every row with + `id >= target`, bump `rewind_count`. +- `restore_rewound(session_id, since_message_id) -> int` + (`hermes_state.py:6743`) — undo a rewind (flip back to `active=1`). +- `clear_messages(session_id)` (`hermes_state.py:8424`) — DELETE all rows for a session. + +Listing / search / count: +- `list_sessions_rich(...) -> list` (`hermes_state.py:5171`), + `search_sessions(...)` (`hermes_state.py:7794`), + `search_sessions_by_id(...)` (`hermes_state.py:7747`), + `search_messages(query, ...)` (`hermes_state.py:7049`, FTS5), + `session_count / message_count` (`hermes_state.py:7834, 7892`), + `distinct_session_cwds` (`hermes_state.py:5145`). + +Deletion / retention: +- `delete_session(session_id, sessions_dir)` (`hermes_state.py:8463`), + `delete_session_if_empty` (`hermes_state.py:8504`), + `delete_sessions([...])` (`hermes_state.py:8546`), + `delete_empty_sessions` (`hermes_state.py:8654`), + `archive_sessions` (`hermes_state.py:8869`), + `prune_sessions(older_than_days=90, ...)` (`hermes_state.py:8895`). + +Import / export: +- `export_session / export_session_lineage / export_all` + (`hermes_state.py:7987, 7995, 8015`) — dict / JSONL shapes. +- `import_sessions([...]) -> dict` (`hermes_state.py:8097`) — bounded restore + (caps at `hermes_state.py:1545-1549`). + +Consistency guarantees for all of the above: single writer connection guarded by a +Python `threading.Lock` plus SQLite `BEGIN IMMEDIATE` (`hermes_state.py:2018-2019`); +ordering by autoincrement `id`; no expected-version precondition on any operation. + +## Write and append path (ordering, durability, concurrency, delivery) + +- **Commit shape**: every new turn is an `INSERT` into `messages` plus an + `UPDATE sessions SET message_count = message_count + 1` (and + `tool_call_count`), inside one `_execute_write` transaction + (`append_message`, `hermes_state.py:5709-5754`). Full rewrites + (`replace_messages`) and compaction (`archive_and_compact`) are the only + non-append writes and are single transactions. +- **Ordering**: positional by `messages.id INTEGER PRIMARY KEY AUTOINCREMENT` + (`hermes_state.py:1051`). Reads order by `id`, not `timestamp`, deliberately — + "Ordered by AUTOINCREMENT id (true insertion order) rather than timestamp — see + c03acca50 for the WSL2 clock-regression rationale" (`hermes_state.py:6011-6012`). + `timestamp REAL` is stored but advisory. +- **Durability / atomicity**: WAL journal mode with a DELETE-mode fallback for + WAL-incompatible filesystems (`apply_wal_with_fallback`, + `hermes_state.py:464-527`); on macOS a checkpoint barrier and + `synchronous=FULL` are enforced (`hermes_state.py:505-514`). `BEGIN IMMEDIATE` + takes the write lock at transaction start so contention surfaces immediately + (`hermes_state.py:2007-2008`). A malformed/corrupt-FTS write triggers a one-shot + in-place FTS rebuild and retry, preserving canonical message rows + (`hermes_state.py:2049-2061`, `2083-2130`). An offline schema-repair path backs + up and de-duplicates a malformed `sqlite_master` (`hermes_state.py:827-991`, + invoked from `__init__` at `hermes_state.py:1616-1628`). +- **Concurrency**: designed as **single-writer-per-DB, multi-reader** ("single + writer via WAL mode", `hermes_state.py:1514`). Multiple processes (gateway + CLI + sessions + worktree agents) share one `state.db`, so contention is handled at + the application level: SQLite timeout is kept at 1s and retries use random + 20–150 ms jitter over up to 15 attempts to break the convoy effect + (`hermes_state.py:1517-1528`, `2036-2048`). There is **no optimistic-concurrency + / expected-position precondition** anywhere; correctness under concurrent + writers to the *same* session relies on the app keeping one logical writer per + session. +- **Delivery semantics**: from the live agent, best-effort. The flush + (`_flush_messages_to_session_db_unlocked`, `run_agent.py:1871-2096`) walks the + in-memory conversation, skips already-persisted dicts, appends the rest, and + swallows exceptions ("Session DB append_message failed: %s", + `run_agent.py:2095-2096`). Idempotence/dedup is an **in-memory** intrinsic + marker `_DB_PERSISTED_MARKER` stamped on each written dict + (`run_agent.py:1878-1889, 1970-1976, 2089`), *not* a durable dedup key — there is + no unique constraint on message content, so a re-flush after a process restart + that lost the marker could duplicate rows. The gateway path adds a bounded + **in-memory retry queue** per session (`_dirty_transcripts`, + `_MAX_PENDING_PER_SESSION`; drops oldest on overflow) with retry-on-failure and + FTS-rebuild recovery (`gateway/session.py:2600-2687`) — at-least-once with a + bounded buffer, not a durable outbox. + +## Read and resume path + +- Resume reads the **durable store** directly; there is no separate local cache. + `--resume {id}` reuses the id (`cli.py:4204-4206`), then the conversation is + rebuilt by `get_messages_as_conversation(session_id, ...)` + (`hermes_state.py:6334`), a single ordered `SELECT` over `active=1` message rows + transformed into OpenAI role/content dicts. +- **Full ordered read**, not incremental cursor replay: the whole active + transcript is materialized on resume (the agent then tracks + `_last_flushed_db_idx` to know which tail is new going forward, + `run_agent.py:2094`). +- Resume redirects forward across compression continuations: + `resolve_resume_session_id` follows `get_compression_tip` and the + `parent_session_id` chain (depth cap 32) to the descendant holding the live + messages, so resuming a compressed parent lands on the continuation child + (`hermes_state.py:6245-6332`). +- `repair_alternation=True` runs `repair_message_sequence` over the loaded list + for live replay so a durable `user;user` gap doesn't re-trigger repair every + request (`hermes_state.py:6349-6355`). +- Pagination/bounds: `get_messages` accepts `limit`/`offset` + (`hermes_state.py:5997-6002`); import caps bound restore size + (`hermes_state.py:1545-1549`), but there is no hard cap on live transcript size + — growth is managed by compaction, below. + +## Listing, summaries, and search + +- **Listing** is a single SQL query with correlated subqueries, not N queries and + not a directory scan (`list_sessions_rich`, "Uses a single query with correlated + subqueries instead of N+2 queries", `hermes_state.py:5194`). Child sessions + (subagent runs, compression continuations) are hidden by default; a recursive + CTE walks compression-continuation edges so `LIMIT`/`OFFSET` still apply and one + logical conversation surfaces as one row projected to its live tip + (`hermes_state.py:5196-5213`). `compact_rows=True` omits the `system_prompt` + blob from the SELECT to avoid copying tens of KB per row + (`hermes_state.py:5221-5225`). No scale numbers are quoted in-source. +- **Summary sidecar**: there is no separate summary file — the `sessions` row + *is* the denormalized read model. It carries `title`, `started_at`, `ended_at`, + `message_count`, `tool_call_count`, token totals, cost fields, `cwd`, + `git_branch`, `git_repo_root`, `model`, `profile_name`, `archived`, + `rewind_count`, handoff state (`hermes_state.py:999-1048`). Counters are kept + consistent by being updated in the same transaction as the message insert + (`hermes_state.py:5741-5751`). +- **Search** is a first-class, separately-indexed FTS5 subsystem with three + virtual tables: `messages_fts` (unicode61), `messages_fts_trigram` (substring / + CJK), and `messages_fts_cjk` (a loadable `cjk_unicode61` bigram tokenizer from + `~/.hermes/lib/libfts5_cjk.so`) (`hermes_state.py:1186-1360`). They are + **external-content** indexes synced by INSERT/DELETE/UPDATE triggers on + `messages`, so they never store the canonical text and are fully rebuildable + (`rebuild_fts`, `hermes_state.py:9570`). Query routing chooses FTS5 / trigram / + CJK / LIKE-scan by script and token length, with slow-query logging + (`_describe_search_path`, `hermes_state.py:7097-7117`; `search_messages` + instrumentation, `hermes_state.py:7060-7095`). Rewound rows + (`active=0, compacted=0`) are excluded; compaction-archived rows + (`active=0, compacted=1`) remain discoverable + (`hermes_state.py:7151-7156`). Index maintenance is amortized: PASSIVE WAL + checkpoint every 50 writes and FTS5 `optimize` every 1000 writes to stop + segment fragmentation from lengthening write-lock holds + (`hermes_state.py:1529-1540`, `2031-2034`). +- **FTS layout versioning is a separate ratchet** from the schema version (see + next section): `fts_storage_version` in `state_meta`, advanced only on a fresh + DB or explicit `hermes sessions optimize-storage`, with legacy DBs left on an + inline layout until the user opts in (`hermes_state.py:156-165`). + +## Entry/message structure and versioning + +- **Message row shape** (`hermes_state.py:1050-1072`): envelope-ish flat columns — + `id` (autoincrement, the ordering + identity field), `session_id`, `role`, + `content` (TEXT; multimodal lists are JSON-encoded via `_encode_content`), + `tool_call_id`, `tool_calls` (JSON), `tool_name`, `effect_disposition`, + `timestamp REAL`, `token_count`, `finish_reason`, `reasoning`, + `reasoning_content`, `reasoning_details` (JSON), `codex_reasoning_items` / + `codex_message_items` (JSON — provider-specific reasoning payloads), + `platform_message_id` (external platform id, e.g. Telegram update_id — distinct + from the PK), `observed`, `active`, `compacted`, and `api_content` (the exact + bytes sent to the API when they differ from `content`, a "byte-fidelity sidecar + for prompt-cache-stable replay", `hermes_state.py:5660-5666`). +- **Store interpretation**: the entry is **not opaque** — the store parses and + interprets it. It distinguishes message types by `role`/`tool_*`, JSON-encodes + structured fields, scrubs lone surrogates sqlite3 cannot bind + (`_scrub_surrogates`), and strips base64 images to a text summary before + persisting multimodal tool results (`run_agent.py:2051-2064`). The identity / + dedup field the store relies on is the autoincrement `id`; live-agent dedup + additionally uses the in-memory `_DB_PERSISTED_MARKER`. +- **Chaining**: messages link to a session by `session_id` and to each other only + by insertion order (`id`); there is no per-message parent/uuid pointer. + Cross-session lineage is carried on the `sessions` row via `parent_session_id` + (FK to `sessions.id`, `hermes_state.py:1013, 1047`) plus JSON markers in + `model_config` (`_branched_from`, `_delegate_from`). +- **Schema evolution**: `SCHEMA_VERSION = 23` (`hermes_state.py:156`). Column + additions are **declarative and self-healing**, not version-gated: on every + startup `_reconcile_columns` diffs live `PRAGMA table_info` against `SCHEMA_SQL` + and `ALTER TABLE ... ADD COLUMN`s any missing ones (the Beets/sqlite-utils + pattern, `hermes_state.py:2812-2854`). The `schema_version` table is retained + only for row-transforming data migrations (`hermes_state.py:2866-2867`). + `schema_version` is advanced forward on open for all non-FTS migrations + (`hermes_state.py:3182-3195`). This is effectively a **one-way forward ratchet** + (no down-migrations); legacy pre-`active` DBs backfill `active=1` + (`hermes_state.py:2909`). + +## Compaction and history management + +- Compaction shrinks the **model-visible** view while keeping the durable record. + The preferred path, `archive_and_compact`, keeps **one session id for life** + (#38763): it soft-archives the live turns (`UPDATE ... SET active=0, compacted=1`) + and inserts the compacted summary set as fresh `active=1` rows, atomically + (`hermes_state.py:5940-5961`). The live-context load filters `active=1`, so the + model reloads only the compacted set; the archived pre-compaction turns stay on + disk, stay in the FTS index (flipping `active` is a content-preserving UPDATE + that doesn't fire the FTS delete trigger), and stay searchable and recoverable + via `get_messages(..., include_inactive=True)` (`hermes_state.py:5926-5933`). +- **Compaction is an upstream (agent) concern** that calls the store: the + summary payload (`compacted_messages`) is produced by + `trajectory_compressor.py` and handed to `archive_and_compact`. The durable + artifact it leaves is the flag flip plus the inserted summary rows — an in-place + soft rewrite, not an external snapshot file or an appended marker line. +- An **older compaction mode still exists**: ending the current session and + **forking a continuation child** linked by `parent_session_id` with + `end_reason='compression'` (`resolve_resume_session_id`, + `hermes_state.py:6245-6287`; lineage via `get_compression_lineage`, + `hermes_state.py:7942`). The whole chain is one logical conversation for + listing/export (`export_session_lineage`, `hermes_state.py:7995-8013`). +- Resume behavior across the boundary: normal resume just loads the `active=1` + set (post-compaction). Crossing back requires `include_inactive=True`, which + reads the archived rows. There is no fold-time reconstruction from a raw log, + because there is no raw log — the archived rows are the history. +- `replace_messages` (`hermes_state.py:5851`) is the **destructive** alternative + used by /retry, /undo, /compress: it DELETEs and reinserts, so it does not + preserve pre-compaction history and is explicitly warned against for compaction. + +## Rewind, checkpoints, and fork + +- **Rewind** is a non-destructive, reversible soft-delete, not a destructive edit + and not an appended marker: `rewind_to_message` flips every row with + `id >= target` to `active=0`, keeps them on disk "for audit / forensic + inspection", and bumps `sessions.rewind_count` (`hermes_state.py:6656-6725`). + It is idempotent on the `active` flag and reversible via `restore_rewound` + (flip back to `active=1`, `hermes_state.py:6743-6763`; noted as "not wired to a + slash command in v1"). Rewound rows (`active=0, compacted=0`) are hidden from + live load and search, distinguishing them from compaction-archived rows. +- **File-state / environment checkpoints**: there is no per-turn file-content + checkpoint stored *in the session store*. A separate git-native shadow-repo + checkpoint system lives under `~/.hermes/checkpoints/` with opportunistic + auto-prune (`cli.py:4195-4198`), outside `state.db`; it is not tied to message + rows and was not traced to a durable per-turn snapshot in the transcript. +- **Fork / branch** is **copy-plus-lineage**, not a shared-prefix reference. The + `/branch` command mints a new session id, ends the parent with + `end_reason='branched'`, creates the child with `parent_session_id` and a + `model_config._branched_from` marker, then **copies every message row** into the + new id (including the `api_content` sidecar so the branch's first turn replays + the parent's exact wire bytes for cache warmth) + (`hermes_cli/cli_commands_mixin.py:879-974`). Lineage metadata recorded: + `parent_session_id` + `_branched_from`. Compression continuation is the same + shape with `end_reason='compression'`; delegate subagents use `_delegate_from`. + +## Subagents and nested sessions + +- A subagent / delegate is a **first-class sibling session** in the same + `state.db`, not nested storage and not entries in the parent transcript. It gets + its own `sessions` row and its own `messages` rows, linked to the parent by + `parent_session_id` plus a `model_config._delegate_from` marker + (`tools/delegate_tool.py:1391-1429, 2839, 3016`). The child has its own isolated + transcript. +- **Async delegation** additionally has a **durable outbox table**, + `async_delegations` (`hermes_state.py:1116-1135`), which is the closest thing in + Hermes to an event-sourced delivery log. It records `delegation_id` (PK), + `origin_session`, `parent_session_id`, `state`, `dispatched_at`/`completed_at`, + `event_json`/`result_json`, and a full **at-least-once delivery protocol**: + `delivery_state` (pending/…), `delivery_attempts`, `delivered_at`, + `delivery_claim` + `delivery_claimed_at` (a claim token for + exactly-once-per-consumer delivery), and `owner_pid` + `owner_started_at` for + crash detection (`tools/async_delegation.py:300-426`). +- **Crash reconciliation**: `recover_abandoned_delegations` + (`tools/async_delegation.py:226-270`) scans `running`/`finalizing` rows, checks + whether the owner pid is still alive (matching `owner_started_at` to defeat pid + reuse), and reclassifies dead-owner delegations to `state='unknown'` with a + synthetic "outcome unknown" result and `delivery_state='pending'`. + `restore_undelivered_completions` (`tools/async_delegation.py:273-297`) + re-enqueues durable pending completions on the next process, stamped `restored` + (in-memory only) and gated on positive ownership so a new session can't adopt a + dead session's results (#64484). +- **Nesting bound**: not enforced by the store schema (any session may have a + `parent_session_id`); depth caps live in the delegation tool layer, not + `SessionDB`. +- **Parent delete cascade**: `delete_session` cascade-deletes *delegate* children + (`_delegate_from`) so they don't resurface as orphans, but **orphans** + branch/compression children (`parent_session_id → NULL`) so they stay + independently accessible (`hermes_state.py:8463-8495`). `prune_sessions` + orphans out-of-window children rather than cascading + (`hermes_state.py:8927-8929`). + +## Retention, deletion, and multi-host + +- **Retention is product-driven, not store-enforced.** There is no background TTL + sweeper inside `SessionDB`; retention is `prune_sessions(older_than_days=90, + ...)` (`hermes_state.py:8895`) invoked from the CLI (`hermes_cli/main.py:16027`), + the web dashboard (`hermes_cli/web_server.py:11676`), and an internal caller at + `hermes_state.py:9679`. It deletes only *ended* sessions matching a rich filter + set (age, title/model/branch substrings, message/token/cost bounds, cwd prefix, + archived tri-state). The `compression_locks` table has an `expires_at` TTL + (`hermes_state.py:1113`, default 300 s) but that is a lock lease, not session + retention. +- **Delete behavior**: `delete_session` DELETEs the `messages` then the + `sessions` row in one transaction (FK-satisfying: cascade delegate children, + orphan the rest), then best-effort removes on-disk sidecar files outside the + transaction (`hermes_state.py:8463-8501`). `delete_session_if_empty` guards CLI + churn (ported from gemini-cli#27770, `hermes_state.py:8504-8543`). Bulk delete is + one transaction (`delete_sessions`, `hermes_state.py:8546`). `session_model_usage` + rows cascade via `ON DELETE CASCADE` (`hermes_state.py:1075`). +- **Multi-host / multi-process**: the design assumes a **shared local + filesystem**, one `state.db` opened by many processes. WAL is the fast path; + network filesystems (NFS/SMB/FUSE) fall back to DELETE journal mode because WAL's + shared-memory index needs coherent mmap + reliable POSIX locks that those mounts + don't provide (`apply_wal_with_fallback`, `hermes_state.py:464-527`). There is a + guard against a known SQLite WAL-reset corruption bug that refuses to enable WAL + on fresh files on vulnerable builds (`hermes_state.py:496-498, 530-560`). + Cross-*host* is not a first-class path: no remote writeback, no distributed + coordination — remote/serverless deployment (Modal, Daytona, SSH) hibernates a + single host's filesystem rather than sharing the DB across hosts. A separate + `hermes_cli/active_sessions.py` tracks open sessions with lease ids and + atomic-rename temp files (`active_sessions.py:162, 247`) for crash/liveness + detection, analogous to a pid registry. + +## Interop with foreign session stores + +Not applicable. Hermes does not read other agent products' native session stores +to discover, import, or resume them. The only "Codex" / "Anthropic" references in +scope are OAuth credential import for provider auth (`hermes_cli/auth_commands.py`), +and provider-specific reasoning payloads persisted in Hermes's own message columns +(`codex_reasoning_items`, `codex_message_items`, `hermes_state.py:1065-1066`) — not +foreign-store ingestion. Hermes's own import/export +(`import_sessions`/`export_session`, `hermes_state.py:8097, 7987`) round-trips its +own JSON/JSONL dump format only. + +## What this implies for our Session Store (our inference) + +*(Our inference, clearly marked as such.)* Hermes is the corpus's clearest example +of **session-as-mutable-relational-record**, and the least event-sourced of the +products studied: the durable session is a SQLite row plus a child message table +in one per-profile `state.db`, and retroactive operations (rewind, compaction) are +implemented as **in-place soft-flag UPDATEs** on existing rows +(`active`/`compacted`) rather than as appended events interpreted at fold time. +The "log" is reconstructed by *filtering flags*, which is expressive enough for +rewind/undo/compaction-with-audit but gives up the properties our event-sourced +design is built on. Implications: + +- **It validates the read-model-as-denormalized-row pattern**: the `sessions` row + is the listing/summary projection, maintained transactionally with the message + insert, and search is a cleanly-separate rebuildable FTS index synced by + triggers — the same authoritative-vs-derived split our design draws, achieved + without a separate projection store. The FTS layout ratchet + (`fts_storage_version` tracked independently of the schema version) is a useful + precedent for versioning a derived index apart from the log. +- **It shows the cost of not being append-only**: concurrency is single-writer by + convention with no optimistic-concurrency / expected-position precondition, so + correctness under concurrent same-session writers depends entirely on the app + serializing them; dedup/idempotence is an **in-memory** marker with no durable + key, so a crash mid-flush risks duplicate or lost rows (the gateway's bounded + in-memory retry queue is a mitigation, not a durable outbox). An event store with + OCC on expected version and idempotent append by event id closes exactly these + gaps. +- **Fork is copy-plus-lineage** (full row copy + `parent_session_id`), not a + shared-prefix reference — simple and cache-friendly but O(history) in storage per + branch; it argues for our design keeping fork as lineage metadata over a shared + event prefix rather than physical copy. +- **Subagents as first-class sibling sessions with a `parent_session_id` link** + matches ADR 0031's child-Session direction, and the `async_delegations` table is + a concrete, well-thought-out **durable outbox with at-least-once delivery, + claims, and pid-based crash reconciliation** — the one genuinely event-log-shaped + component here, and a good reference for how we express delegation-completion + facts and their delivery lifecycle. +- **Retention is a blunt product-invoked `prune_sessions(older_than_days=90)` + DELETE**, not a data-model-tied lifecycle policy — the same anti-pattern grok's + mtime janitor showed; our design should tie retention to the log/projection model + rather than a periodic bulk DELETE of "ended" rows. + +## Open questions + +- Message-write idempotence across a crash: the durable schema has no unique + constraint or dedup key on `messages`, and dedup relies on the in-memory + `_DB_PERSISTED_MARKER` (`run_agent.py:1878-1889`). Whether a process restart + mid-flush can duplicate or drop rows in practice was not exercised here. +- The session id scheme (`YYYYMMDD_HHMMSS_` + 6 hex, `cli.py:4208-4210`) has only + ~24 bits of entropy per second; the collision-handling behavior of + `create_session`/`_insert_session_row`'s UPSERT on a same-second id clash was not + fully traced. +- The relationship/overlap between the two compaction modes + (`archive_and_compact` in-place vs. the fork-a-continuation-child mode) and which + is the current default at what trigger was not pinned to a single dispatch site. +- The git-native checkpoint system under `~/.hermes/checkpoints/` + (`cli.py:4195-4198`) was not traced to a durable per-turn file-state snapshot + linked to message rows; whether rewind restores file state or only conversation + state is unresolved. +- No down-migration / reversible-migration path was found; `schema_version` + appears to be a strict forward ratchet, but the exact behavior when an older + binary opens a newer `state.db` was not verified. +- Cross-host concurrent access to one `state.db` (as opposed to one host's + many processes) is assumed unsupported; no test or code path was found that + coordinates writers across hosts. diff --git a/docs/research/session-store/products/langgraph.md b/docs/research/session-store/products/langgraph.md new file mode 100644 index 000000000..d361c0f3e --- /dev/null +++ b/docs/research/session-store/products/langgraph.md @@ -0,0 +1,439 @@ +# LangGraph: how session (thread) state is stored and resumed + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT](../RESEARCH_PROMPT.md). +Evidence snapshot: local shallow checkout of `langchain-ai/langgraph` +(`https://github.com/langchain-ai/langgraph.git`) at commit +`31f90df3e6b0268fa77fd2d118a917d420b84a68` (committed 2026-07-21). Every +citation below was verified against this exact commit on 2026-07-23. Citations +use repo-relative `path:line` shorthand. + +Authoritative anchors: + +- `langchain-ai/langgraph` @ `31f90df3e6b0268fa77fd2d118a917d420b84a68` +- `libs/checkpoint/langgraph/checkpoint/base/__init__.py` (the `BaseCheckpointSaver` + contract + `Checkpoint`/`CheckpointTuple`/`CheckpointMetadata` types) +- `libs/checkpoint/langgraph/checkpoint/base/id.py` (checkpoint id = UUID6) +- `libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/__init__.py` (SQLite saver) +- `libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py` + + `shallow.py` (Postgres saver, migrations, content-addressed channel blobs) +- `libs/checkpoint/langgraph/checkpoint/memory/__init__.py` (in-memory saver) + +> Framing note. LangGraph is a **library**, not a CLI, so "a session" is a +> **thread** and the durable session state is a **checkpointer** (a pluggable +> `BaseCheckpointSaver`). Unlike the transcript-oriented products in this corpus, +> LangGraph does not store a message transcript per se — it stores **state +> snapshots of a graph's channels** plus **pending intermediate writes**, keyed +> by thread. A conversational message history is just one channel's value. This +> is the most explicitly *pluggable-store-interface* product in the study and the +> closest to a formal checkpoint/event model. + +## The storage model + +The durable unit is a **`Checkpoint`: a state snapshot at a point in time** +(`base/__init__.py:92-123`): + +```python +class Checkpoint(TypedDict): + v: int # format version, currently 1 + id: str # unique + monotonically increasing (sortable) + ts: str # ISO 8601 timestamp + channel_values: dict[str, Any] + channel_versions: ChannelVersions # channel -> monotonic version str + versions_seen: dict[str, ChannelVersions] # node -> channel -> version seen + updated_channels: list[str] | None +``` + +Two durable record kinds make up a thread's state: + +1. **Checkpoints** — immutable, id-addressed snapshots forming a **parent-linked + chain** (each carries `parent_checkpoint_id`, `base/__init__.py:145`, + `CheckpointMetadata.parents`, `:56-60`). A thread is the ordered chain of its + checkpoints. +2. **Pending writes** — `PendingWrite = tuple[str, str, Any]` = `(task_id, + channel, value)` (`base/__init__.py:31`), the intermediate outputs a task + produced against a specific checkpoint, stored so an interrupted/failed + superstep can resume without recomputation (`put_writes`, `:300-318`). + +What is authoritative vs. derived: + +- **Authoritative**: the checkpoint chain + pending writes, keyed by + `(thread_id, checkpoint_ns, checkpoint_id)`. In the production Postgres saver + the *channel values themselves* are stored **content-addressed by `(channel, + version)`** in a separate `checkpoint_blobs` table with `ON CONFLICT DO + NOTHING` — i.e. immutable, deduplicated, versioned value blobs + (`postgres/base.py:57-65`, `131-135`). The `checkpoints` row then holds only the + metadata + `channel_versions` *pointers*, and full `channel_values` are + reconstructed by joining `checkpoint.channel_versions → checkpoint_blobs` + (`SELECT_SQL`, `postgres/base.py:101-118`). +- **Derived / optional**: the "shallow" savers, which "ONLY store the most recent + checkpoint and do NOT retain any history … with the exception of time travel" + (`postgres/shallow.py:169-174`). A shallow saver is effectively a + latest-state-only projection of the same model. + +Conceptual model: **session-as-log-of-immutable-snapshots** (an append-only, +parent-linked checkpoint chain) with **content-addressed versioned channel +values** as the deduplicated value store and **per-checkpoint pending writes** as +the in-flight delta. It is materially closer to event-sourcing than the +transcript products: values are immutable and versioned, and the head state is a +fold over the chain. + +## Keying and identity + +- **Primary key = `thread_id`.** "The `thread_id` is the primary key used to + store and retrieve checkpoints. Without it, the checkpointer cannot save state, + resume from interrupts, or enable time-travel debugging" + (`base/__init__.py:190-192`). It is client-supplied via + `config["configurable"]["thread_id"]` (`base/__init__.py:186`). +- **Full key hierarchy** is `(thread_id, checkpoint_ns, checkpoint_id)` — the + composite primary key of every backend's `checkpoints` table + (`sqlite/__init__.py:150`, `postgres/base.py:55`). `checkpoint_ns` (namespace, + default `''`) scopes checkpoints produced by nested/subgraph executions; + `checkpoint_id` identifies the specific snapshot. Writes add `(task_id, idx)` + to that key (`sqlite/__init__.py:161`, `postgres/base.py:75`). +- **Checkpoint id minting = UUID6.** `checkpoint["id"]` is a `uuid6()` + (`base/__init__.py:18`; generator in `base/id.py`), chosen because it is "both + unique and monotonically increasing, so can be used for sorting checkpoints + from first to last" (`base/__init__.py:99-101`). So ids encode creation + ordering (time-ordered UUID), and listing sorts by `checkpoint_id DESC` + (`sqlite/__init__.py:240`, `338`). +- **Channel versions** are a separate monotonic sequence per channel: + `get_next_version` defaults to integer increment (`current + 1`), and any + override must be "monotonically increasing" (`base/__init__.py:692-711`). These + versions are what `checkpoint_blobs` is keyed on. +- **Listing scope**: per-thread (and optionally per-namespace). `list`/`alist` + take a `config` (thread) plus `filter`/`before`/`limit` + (`base/__init__.py:253-275`). There is no cross-thread global enumeration in the + saver contract — enumerating threads is the host application's concern (the + LangGraph Platform server layer, not this OSS store). +- **Relocation / rename**: not applicable — there is no cwd/filesystem coupling. + Identity is the caller's `thread_id`. Moving a thread's state is an explicit + `copy_thread(source, target)` operation (`base/__init__.py:350-372`). + +## The store interface + +This is a **first-class pluggable interface**: `BaseCheckpointSaver[V]` +(`libs/checkpoint/langgraph/checkpoint/base/__init__.py:176-722`). Reproduced +verbatim (sync surface; every method has an `a`-prefixed async twin): + +```python +class BaseCheckpointSaver(Generic[V]): + serde: SerializerProtocol = JsonPlusSerializer() + + @property + def config_specs(self) -> list: ... + + def get(self, config: RunnableConfig) -> Checkpoint | None: ... + def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None: # required + raise NotImplementedError + def list(self, config: RunnableConfig | None, *, filter=None, + before=None, limit=None) -> Iterator[CheckpointTuple]: # required + raise NotImplementedError + def put(self, config, checkpoint: Checkpoint, metadata: CheckpointMetadata, + new_versions: ChannelVersions) -> RunnableConfig: # required + raise NotImplementedError + def put_writes(self, config, writes: Sequence[tuple[str, Any]], + task_id: str, task_path: str = "") -> None: # required + raise NotImplementedError + def delete_thread(self, thread_id: str) -> None: ... + def delete_for_runs(self, run_ids: Sequence[str]) -> None: ... + def copy_thread(self, source_thread_id: str, target_thread_id: str) -> None: ... + def prune(self, thread_ids: Sequence[str], *, strategy="keep_latest") -> None: ... + def get_delta_channel_history(self, *, config, channels) -> Mapping[str, DeltaChannelHistory]: ... + def get_next_version(self, current: V | None, channel: None) -> V: ... + def with_allowlist(self, extra_allowlist) -> BaseCheckpointSaver[V]: ... +``` + +Contract notes drawn from the docstrings: + +- **Required to implement**: `get_tuple`, `list`, `put`, `put_writes` (and the + async equivalents) — all `raise NotImplementedError` in the base + (`:239-318`, `429-509`). `get`/`aget` are conveniences built on the tuple + getters (`:227-237`, `417-427`). +- **`put`** stores one checkpoint and returns the updated config carrying the new + `checkpoint_id`; `new_versions` are the channel versions as of this write + (`:277-298`). +- **`put_writes`** stores intermediate `(channel, value)` writes for a `task_id` + against the config's checkpoint; idempotent by `(…, task_id, idx)` (below). +- **`CheckpointTuple`** is the read shape: `(config, checkpoint, metadata, + parent_config, pending_writes)` (`:139-146`). +- **Lifecycle ops**: `delete_thread`, `delete_for_runs`, `copy_thread`, `prune` + (`keep_latest` vs `delete`) — all optional, several carrying explicit + **`DeltaChannel` correctness warnings** that copies/prunes must preserve the + ancestor chain back to the nearest `_DeltaSnapshot` or silently corrupt delta + channels (`:320-415`, `540-580`). +- **Serialization** is itself pluggable via `SerializerProtocol` + (`serde: SerializerProtocol = JsonPlusSerializer()`, `:209`), and the msgpack + allowlist can be narrowed with `with_allowlist` (`:713-722`). + +A **conformance test suite** (`libs/checkpoint-conformance`) exists to validate +third-party savers against this contract, underscoring that the interface is the +product's real extension point. + +## Write and append path (ordering, durability, concurrency, delivery) + +- **`put` = upsert one immutable snapshot.** SQLite: `INSERT OR REPLACE INTO + checkpoints (…) VALUES (…)` keyed by `(thread_id, checkpoint_ns, + checkpoint_id)`, with `parent_checkpoint_id` taken from the incoming config's + `checkpoint_id` (`sqlite/__init__.py:424-436`). Postgres: `INSERT … ON CONFLICT + … DO UPDATE SET checkpoint = EXCLUDED.checkpoint, metadata = EXCLUDED.metadata` + (`postgres/base.py:137-144`). Because each checkpoint has a fresh UUID6, the + normal path *appends a new row*; the upsert semantics matter only for + re-writing the same id. +- **Channel values written content-addressed + deduplicated.** Postgres upserts + each channel value into `checkpoint_blobs` keyed by `(thread_id, ns, channel, + version)` with `ON CONFLICT DO NOTHING` (`postgres/base.py:131-135`), so a value + is stored once per version and shared across every checkpoint that references + that version. This is real cross-checkpoint dedup. +- **Ordering** is by `checkpoint_id` (monotonic UUID6): `get_tuple` for the + latest does `ORDER BY checkpoint_id DESC LIMIT 1` (`sqlite/__init__.py:240`); + `list` does `ORDER BY checkpoint_id DESC` (`:338`). The parent chain + (`parent_checkpoint_id`) gives the logical order independent of wall-clock. +- **Durability / atomicity**: delegated to the backend. SQLite runs in WAL mode + (`PRAGMA journal_mode=WAL`, `sqlite/__init__.py:141`) with a process lock around + the cursor and a transaction per cursor context (`:168-182`). Postgres uses + transactions/pipelines. The in-memory saver has no durability + (`memory/__init__.py:38`). +- **Concurrency model**: the OSS savers do **not** implement optimistic + concurrency or an expected-version precondition on `put` — `put` simply writes + the checkpoint the Pregel loop computed. Safety against concurrent runs of the + *same thread* is expected to be enforced above the saver (the LangGraph Platform + serializes runs per thread); the store contract itself is last-write-wins on a + given `checkpoint_id`. +- **`put_writes` delivery = idempotent, with a special-channel override.** For + ordinary channels writes are `INSERT OR IGNORE` / `ON CONFLICT DO NOTHING` + keyed by `(thread_id, ns, checkpoint_id, task_id, idx)` — **at-least-once with + dedup by write position** (`sqlite/__init__.py:462-482`, + `postgres/base.py:155-159`). For "special" channels in `WRITES_IDX_MAP` (e.g. + the `RESUME` channel) it uses `INSERT OR REPLACE` / `DO UPDATE` so a re-sent + resume value overwrites (`sqlite/__init__.py:462-466`, + `postgres/base.py:146-153`). So writes are idempotent by construction, which is + what lets a crashed superstep re-run safely. + +## Read and resume path + +- **Resume = fetch the latest checkpoint tuple for the thread.** With no + `checkpoint_id` in config, `get_tuple` selects the newest checkpoint for + `(thread_id, checkpoint_ns)` and attaches its `pending_writes` + (`sqlite/__init__.py:239-266`). The Pregel loop resumes from that snapshot, + applying pending writes so an interrupted step continues rather than restarts. +- **Time travel = fetch a specific checkpoint id.** With a `checkpoint_id` in + config, `get_tuple` fetches that exact snapshot; `list(before=…, limit=…)` + pages the chain (`base/__init__.py:253-275`). This is the "time-travel + debugging" the docs reference (`:192`). +- **Reads reconstruct full state from the store.** Postgres reads join + `checkpoint_blobs` on the checkpoint's `channel_versions` to rebuild + `channel_values`, and aggregate `checkpoint_writes` into `pending_writes`, in + one SELECT (`postgres/base.py:93-118`). SQLite stores the whole serialized + checkpoint blob inline and loads writes separately (`sqlite/__init__.py:240`, + `263`). +- **`DeltaChannel` reconstruction** walks the parent chain: for a delta channel, + `channel_values` holds only a sentinel except at snapshot points, so + `get_delta_channel_history` accumulates on-path `pending_writes` from ancestors + until it hits the nearest ancestor whose `channel_values[ch]` is populated (a + `_DeltaSnapshot`), returned as the `seed` (`base/__init__.py:582-649`). A + snapshot fires every `snapshot_frequency` updates or after a system-wide + superstep bound (default 5000) (`:78-82`). This is an explicit + log-fold-with-periodic-snapshot mechanism. +- **Bounds / pagination**: `list` takes an explicit `limit` and a `before` + cursor (`base/__init__.py:253-275`). There is no built-in transcript-size cap; + history growth is bounded by `prune`/shallow savers, not by read limits. + +## Listing, summaries, and search + +- **Listing checkpoints** within a thread is a SQL scan ordered by + `checkpoint_id DESC`, filterable by metadata (`sqlite/__init__.py:295-360`; + `list` contract `base/__init__.py:253-275`). Metadata filtering matches against + the JSON(B) `metadata` column (`CheckpointMetadata`: `source`, `step`, + `parents`, `run_id`, …, `:38-86`). +- **No separate summary sidecar per thread** in the OSS store. The `metadata` + column *is* the queryable read model (source/step/parents/run_id), maintained at + write time by `put` (`get_checkpoint_metadata(config, metadata)`, + `sqlite/__init__.py:421-423`). Per-thread indexes exist on `thread_id` + (`checkpoints_thread_id_idx`, etc., `postgres/base.py:82-89`). +- **No FTS/vector search over checkpoints.** Semantic search is a *different* + subsystem — the `BaseStore` / long-term memory store + (`libs/checkpoint/langgraph/store/base/**`, with an embeddings/`embed` module) + — which is orthogonal to the checkpointer and stores namespaced key-value + "memories," not session transcripts. It is out of scope for session + persistence. + +## Entry/message structure and versioning + +- **Checkpoint envelope**: the `Checkpoint` TypedDict itself (`v`, `id`, `ts`, + `channel_values`, `channel_versions`, `versions_seen`, `updated_channels`) + (`base/__init__.py:92-123`), stored serialized. `v` is the **format version, + currently 1** (`:95-96`) — an explicit schema-version field on every snapshot. +- **Write envelope**: `checkpoint_writes` rows are `(thread_id, ns, + checkpoint_id, task_id, task_path, idx, channel, type, blob)` + (`postgres/base.py:66-76`, `90`). The store relies on `(task_id, idx)` for write + identity/dedup. +- **Store interpretation**: values are **opaque to the store** — serialized via + the pluggable `SerializerProtocol`. The default `JsonPlusSerializer` uses + `ormsgpack` with a JSON fallback and an ext-hook allowlist for safe type + reconstruction (`serde/jsonplus.py:30`, `83-125`); `dumps_typed` returns a + `(type, bytes)` pair and the `type` column records which codec produced the + blob. The store persists and returns bytes verbatim; it does not parse channel + values. +- **Versioning of the format**: three layers. (1) The per-checkpoint `v` field + (`:95-96`). (2) **Numbered backend migrations** — Postgres keeps an ordered + `MIGRATIONS` list where "the position of the migration in the list is the + version number," tracked in a `checkpoint_migrations(v)` table + (`postgres/base.py:40-91`), including additive `ALTER TABLE … ADD COLUMN IF NOT + EXISTS` (e.g. `task_path`, `:90`). This is a forward-only ratchet. (3) **Serde + compatibility** — pre-msgpack checkpoints remain loadable, and the msgpack + allowlist is versioned by `SAFE_MSGPACK_TYPES` (`serde/jsonplus.py:64-70`). + +## Compaction and history management + +- **Compaction/summarization of message history is an application concern, not a + store concern.** LangGraph's store keeps whatever channel values the graph + computes; if an app summarizes chat history, that summary is just the new value + of a channel in the next checkpoint. The store neither triggers nor + understands it. +- **History growth is managed by `prune` or shallow savers.** `prune(thread_ids, + strategy="keep_latest" | "delete")` retains only the most recent checkpoint per + namespace or removes all (`base/__init__.py:374-415`). Shallow savers + structurally keep only the latest checkpoint (`postgres/shallow.py:169-174`). +- **`DeltaChannel` is the store-level compaction analogue**: instead of storing a + full channel value per checkpoint, it stores periodic `_DeltaSnapshot` blobs + plus on-path write deltas, and reconstructs by folding forward from the nearest + snapshot (`base/__init__.py:63-86`, `582-649`). Crossing a "compaction boundary" + (a snapshot point) on read just means the fold seeds from that snapshot rather + than the chain root. The durable rows are not rewritten in place; snapshots are + new blobs. + +## Rewind, checkpoints, and fork + +- **Every step is a checkpoint; rewind is native.** Because each superstep writes + a parent-linked checkpoint, "rewind" is simply resuming from an earlier + `checkpoint_id` (time travel, `base/__init__.py:192`). Nothing is destroyed — you + select an older snapshot in the chain. +- **Fork is a first-class metadata concept.** `CheckpointMetadata.source` includes + `"fork"` — "The checkpoint was created as a copy of another checkpoint" + (`:41-48`), and `parents` maps namespace → parent checkpoint id (`:56-60`). A + fork produces a new checkpoint whose `parent_checkpoint_id`/`parents` point at + the branch point, sharing the (immutable, content-addressed) ancestor blobs. + `copy_thread(source, target)` copies an entire thread's checkpoints + writes to + a new thread id, and its docstring **requires copying the complete parent + chain** so `DeltaChannel` state remains reconstructable (`:350-372`). +- **File-state/environment checkpoints**: not applicable — LangGraph checkpoints + application *graph state* (channel values), not workspace files. There is no + git-snapshot or filesystem checkpoint concept. + +## Subagents and nested sessions + +- **Nested/subgraph executions are namespaced within the same thread.** + `checkpoint_ns` (default `''`) is part of the key precisely so a subgraph's + checkpoints live under the parent thread but in a distinct namespace + (`sqlite/__init__.py:144`, `150`; `postgres/base.py:49`, `55`). + `CheckpointMetadata.parents` is explicitly a **map from checkpoint namespace to + parent checkpoint id** (`base/__init__.py:56-60`), linking a child namespace's + checkpoint to its parent. +- **Durable parent-child link** is therefore `(thread_id, checkpoint_ns)` + + `parents[ns]`. The child shares the thread and its content-addressed blobs; it + is isolated by namespace rather than by a separate thread/file. +- **Cascade**: `delete_thread(thread_id)` deletes *all* checkpoints and writes for + the thread across namespaces — SQLite `DELETE FROM checkpoints WHERE thread_id = + ?` (`sqlite/__init__.py:484-496`) — so nested namespaces cascade with the + parent thread. (A fully separate subagent running under its *own* `thread_id` + would be an independent thread with no automatic cascade.) + +## Retention, deletion, and multi-host + +- **Retention**: caller-owned. The store provides the *mechanisms* — + `prune(strategy=…)`, shallow savers, `delete_for_runs(run_ids)` — but enforces + no TTL or automatic lifecycle (`base/__init__.py:331-415`). `delete_for_runs` + targets checkpoints by `run_id` and carries the `DeltaChannel` warning that + deleting ancestor rows a live thread depends on will corrupt reconstruction + (`:331-348`). +- **Deletion**: `delete_thread(thread_id)` removes all of a thread's rows across + the `checkpoints`/`checkpoint_blobs`/`checkpoint_writes` tables (backend + implementations delete from each) (`sqlite/__init__.py:484-496`). It is a hard + delete, not a tombstone; there is no remote-first/local-cache split at this + layer. +- **Multi-host**: **first-class via the backend.** Because the store is a shared + database (Postgres in production, or SQLite for single-node), multiple hosts can + read/write the same thread through the same Postgres instance. WAL mode + per-DB + transactions provide the concurrency substrate (`sqlite/__init__.py:141`), + content-addressed blob upserts are conflict-free + (`postgres/base.py:131-135`), and idempotent `put_writes` tolerates retries + (`:155-159`). The store does not itself implement cross-host leasing or + single-writer arbitration — that is layered above (the Platform run queue) — but + a shared Postgres checkpointer is explicitly the intended multi-host deployment, + unlike the local-filesystem CLIs in this corpus. + +## Interop with foreign session stores + +Not applicable. LangGraph reads only its own checkpoint schema (across its own +saver backends and migration versions). There is no discovery/import/resume of a +foreign product's session store. (It can *migrate its own* pre-msgpack +checkpoints via serde compatibility, `serde/jsonplus.py:64-70`, and its own +schema via numbered migrations, `postgres/base.py:40-91`.) + +## What this implies for our Session Store (our inference) + +*(Our inference, clearly marked as such.)* LangGraph's durable session is **a +per-thread, parent-linked chain of immutable, id-addressed state snapshots +(`Checkpoint`) plus per-checkpoint pending writes, over a pluggable saver +interface, with channel values stored content-addressed by `(channel, version)` +and deduplicated across snapshots.** This is the most event-sourcing-adjacent +model in the study and the one whose *interface* most resembles what we want. +Transferable ideas: + +- **A small, conformance-tested store interface.** `BaseCheckpointSaver`'s + four required methods (`get_tuple`, `list`, `put`, `put_writes`) plus optional + lifecycle ops, validated by a conformance suite, is a clean template for our own + pluggable Session Store contract — separate the read/append core from the + lifecycle (delete/copy/prune) extras. +- **Content-addressed, version-keyed value blobs with `ON CONFLICT DO NOTHING`** + (`postgres/base.py:57-65`, `131-135`) is an excellent dedup strategy: store each + distinct value once per version and let snapshots reference it. We should adopt + value-level content addressing rather than inlining full state per event. +- **Idempotent writes keyed by `(task_id, idx)`** (`put_writes`, + `sqlite/__init__.py:462-482`) give exactly the at-least-once-with-dedup delivery + our design wants, and the special-channel `DO UPDATE` override + (`postgres/base.py:146-153`) shows a principled exception (resume signals) to the + otherwise append-only rule. +- **Monotonic, time-ordered ids (UUID6) as the sort key** (`base/id.py`, + `base/__init__.py:99-101`) plus a **separate monotonic per-channel version** + (`get_next_version`, `:692-711`) cleanly separate "ordering of snapshots" from + "ordering of a value's revisions" — worth mirroring. +- **`DeltaChannel` = periodic-snapshot + delta-fold** (`:63-86`, `582-649`) is a + concrete pattern for bounding log-replay cost: snapshot every N updates, store + deltas between, fold forward from the nearest snapshot. This is exactly the + snapshot/retention story the file-based products in this corpus lack, and we + should design an equivalent from the start. +- **Namespaces (`checkpoint_ns`) for nested/subgraph state** under one thread key, + with `parents[ns]` links and cascade on `delete_thread`, is a tidy alternative + to separate child sessions when the child is truly part of the same run. +- **Cautions**: (1) **No expected-version/OCC in the OSS savers** — `put` is + last-write-wins on an id, and single-writer-per-thread is assumed to be enforced + above the store; our multi-host design should add an explicit expected-position + precondition rather than rely on an upstream queue. (2) **Correctness coupling + between prune/copy/delete and the delta chain** — the repeated `DeltaChannel` + warnings (`:340-415`, `540-580`) show how snapshot-based history makes deletion + dangerous: any retention/GC we build must be snapshot-chain-aware or it will + silently corrupt reconstructed state. (3) **It is state-snapshot-oriented, not + transcript-oriented** — a "message history" is just one channel's value, so if + our store must also serve a first-class, queryable message transcript, that is + an additional projection LangGraph does not provide at the store layer. + +## Open questions + +- **Thread enumeration / cross-thread listing**: the OSS saver contract has no + "list all threads" method; how the LangGraph Platform server enumerates and + scopes threads (per user/assistant) sits above this store and was not examined. +- **`v` format-version migrations**: the checkpoint `v` is documented as + "currently 1"; how a future `v=2` would be migrated on read (vs. the SQL + `MIGRATIONS` ratchet) was not traced. +- **Exact concurrency guarantees under two hosts writing the same thread**: the + store relies on backend transactions and idempotent writes, but the precise + outcome of two simultaneous `put`s minting sibling checkpoints (branch vs. + clobber) was not exercised. +- **`checkpoint_blobs` GC**: content-addressed blobs are shared across + checkpoints; whether/when orphaned `(channel, version)` blobs are reclaimed after + `prune`/`delete_for_runs` was not confirmed. +- **Shallow-saver semantics vs. pending writes**: how the shallow savers handle + pending writes and interrupts when only the latest checkpoint is retained was + not fully traced. diff --git a/docs/research/session-store/products/opencode.md b/docs/research/session-store/products/opencode.md new file mode 100644 index 000000000..b4ab430f3 --- /dev/null +++ b/docs/research/session-store/products/opencode.md @@ -0,0 +1,610 @@ +# OpenCode: how session transcripts are stored and resumed + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT](../RESEARCH_PROMPT.md). +Evidence snapshot: local checkout of the `anomalyco/opencode` fork +(`git@github.com:anomalyco/opencode.git`) on branch `dev` at commit +`62e4641235d7847dadc60da37cca8a023dd54fc1` (committed 2026-07-23). Every +citation below was verified against this exact commit on 2026-07-23. Citations +use repo-relative `path:line` shorthand. + +> Scope note. This is the `anomalyco` fork, a heavily reworked OpenCode built on +> a TypeScript monorepo running the Effect runtime. It is **mid-migration** +> between two persistence models that both exist in the tree: +> +> 1. **Legacy filesystem store** (upstream OpenCode's model): per-key JSON files +> on disk under `~/.local/share/opencode/storage/`, a mutable +> session-as-directory model (`packages/opencode/src/storage/storage.ts`). +> 2. **New event-sourced SQLite store** ("v2"): an append-only event log with +> rebuildable SQL projections, under `packages/core/src/**` and served by the +> newer `packages/server`. This is the direction of travel and the design most +> relevant to our event-sourced Session Store. +> +> This dossier centers on the **v2 event-sourced store** (the authoritative, +> current design) and documents the legacy filesystem store where it clarifies +> the migration or a still-live code path. Where a section applies only to one +> tier, it says so. + +The v2 persistence subsystem is split across three packages: `packages/schema` +(the event/message/session Effect-Schema contracts), `packages/core/src/event*` +(the durable event store), and `packages/core/src/session/**` (the session +projections, projector, and read/resume logic). Structurally it is the same +**CQRS / event-sourced** shape as T3 Code in this corpus: a durable append-only +log plus rebuildable read-model projections, on Effect + SQLite. + +## The storage model + +The durable v2 session is an **append-only event log**: the SQLite table `event`, +one row per durable event, keyed per aggregate with a monotonic per-aggregate +sequence (`packages/core/src/event/sql.ts:10-25`): + +```ts +export const EventSequenceTable = sqliteTable("event_sequence", { + aggregate_id: text().notNull().primaryKey(), + seq: integer().notNull(), + owner_id: text(), +}) + +export const EventTable = sqliteTable( + "event", + { + id: text().$type().primaryKey(), + aggregate_id: text().notNull().references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }), + seq: integer().notNull(), + type: text().notNull(), + data: text({ mode: "json" }).$type>().notNull(), + }, + (table) => [ + uniqueIndex("event_aggregate_seq_idx").on(table.aggregate_id, table.seq), + index("event_aggregate_type_seq_idx").on(table.aggregate_id, table.type, table.seq), + ], +) +``` + +The aggregate for a session is the **`sessionID`**: every session event +definition declares `durable: { aggregate: "sessionID", version: N }` +(`packages/schema/src/session-event.ts:38-49`, `502-507`). So a "session" is the +fold of all `event` rows with `aggregate_id = `, ordered by `seq`. +This is unambiguously **session-as-log** (event-sourced). + +What is authoritative vs. derived: + +- **Authoritative**: the `event` table (`packages/core/src/event/sql.ts:10-25`) + and its head-sequence sidecar `event_sequence`. Rows are append-only on the + normal path; nothing rewrites an existing `(aggregate_id, seq)`. +- **Derived / rebuildable projections** (all in + `packages/core/src/session/sql.ts`): `session` (the read-model row / summary), + `session_message` (the v2 message projection, seq-ordered), `message` + `part` + (the legacy-v1-shaped message projection), `session_input` (admitted/promoted + prompt queue), `session_context_epoch` (the compaction/system-context + baseline), and `todo`. Each is rebuilt by replaying the log through the + projectors (`packages/core/src/session/projector.ts:211-455`). +- **Content-addressed side store**: filesystem snapshots of the working tree per + assistant step, stored as git trees in a per-project shadow git dir + (`packages/core/src/snapshot.ts:91-135`), referenced from assistant messages + by tree id (`Assistant.snapshot.start/end`, + `packages/schema/src/session-message.ts:171-175`). File content is stored/deduped + by git, not inlined in the log. + +A crucial detail: the v2 projector consumes **both** the new `session.next.*` +durable events **and** the legacy v1 `session.created` / `message.updated` / +`message.part.updated` events, projecting all of them into the same SQL tables +(`packages/core/src/session/projector.ts:215-330`). The unified durable manifest +is literally the union of v1 and v2 durable definitions +(`packages/schema/src/durable-event-manifest.ts:12-15`). So the one event log is +the source of truth; the `session`/`message`/`part` tables are v1-shaped +projections and `session_message` is the v2-shaped projection of that same log. + +The **legacy filesystem store** (still used by much of `packages/opencode`) is, +by contrast, **session-as-directory of mutable JSON documents**: a key-value +store where each key is a path and each value a `.json` file +(`packages/opencode/src/storage/storage.ts:53-65`), with `read`/`write`/`update`/ +`remove`/`list` operations and forward-only on-disk migrations +(`storage.ts:81-211`). That is a mutable-document model, not a log. + +## Keying and identity + +- **Store scope (v2)**: one SQLite database per install/channel, default + `~/.local/share/opencode/opencode.db` (or `opencode-.db` for dev + channels), overridable by `OPENCODE_DB` (`packages/core/src/database/database.ts:43-55`). + All projects, workspaces, and sessions share one DB and are separated by + columns, not by directory. WAL mode, `synchronous = NORMAL`, foreign keys on + (`database.ts:27-31`). +- **Event key**: `(aggregate_id, seq)` unique + (`packages/core/src/event/sql.ts:22`), where `aggregate_id` is the `sessionID`. + Global ordering is not a single autoincrement column (unlike T3); ordering is + strictly **per-aggregate `seq`**. +- **Session id minting**: `SessionID` is a branded string `ses_` + a + **time-ordered, descending-sortable** 26-char identifier + (`packages/schema/src/session-id.ts:5-15`). The generator packs + `timestamp*0x1000 + counter` and, for `descending()`, bit-inverts it so newer + ids sort *first* lexicographically, then appends random bytes + (`packages/schema/src/identifier.ts:15-30`). So the id **encodes creation time + and ordering** (client/process-minted, ULID-like). Event ids (`evt_`) and + message ids (`msg_`) use the **ascending** variant + (`packages/schema/src/event.ts:9-12`, `packages/schema/src/session-message.ts:12-15`). +- **Listing scope**: global within the DB, filtered by column. `list` accepts a + directory, a project id (+ optional subpath), or nothing (all sessions), plus + an optional `workspaceID` (`packages/core/src/session.ts:55-77`, `268-303`). + Cross-project enumeration is the no-filter case. +- **Location vs identity**: a session row carries `project_id`, `workspace_id`, + `directory` (absolute), and `path` (relative subpath within the project) + separately from its `id` (`packages/core/src/session/sql.ts:22-60`). Because the + key is the `sessionID`, **relocation does not change identity**: a + `session.next.moved` event updates `directory`/`path`/`workspace_id` in place + and resets the context epoch (`packages/core/src/session/projector.ts:243-258`). + Moving directories is a mutable projection field on a stable id, and directory + paths are normalized cross-platform at the column boundary + (`packages/core/src/database/path.ts:27-75`). + +## The store interface + +There are two relevant contracts. First, the **generic durable event store**, +`EventV2.Service`, which every aggregate (sessions included) writes through. It +is an Effect `Context.Service`, so the contract is exported verbatim +(`packages/core/src/event.ts:126-148`): + +```ts +export interface Interface { + readonly publish: ( + definition: D, + data: Data, + options?: PublishOptions, + ) => Effect.Effect> + readonly subscribe: (definition: D) => Stream.Stream> + readonly all: () => Stream.Stream + readonly durable: (input: { readonly aggregateID: string; readonly after?: number }) => Stream.Stream + /** @deprecated Use `all()` and consume the returned stream. */ + readonly listen: (listener: Subscriber) => Effect.Effect + readonly project: (definition: D, projector: Subscriber) => Effect.Effect + readonly replay: ( + event: SerializedEvent, + options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean }, + ) => Effect.Effect + readonly replayAll: ( + events: SerializedEvent[], + options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean }, + ) => Effect.Effect + readonly remove: (aggregateID: string) => Effect.Effect + readonly claim: (aggregateID: string, ownerID: string) => Effect.Effect +} +``` + +`PublishOptions` includes a **`commit(seq)` hook** — "Local operational +projection committed atomically with a new durable event" +(`packages/core/src/event.ts:118-124`). `publish` appends one event (assigning +the next `seq`), runs registered projectors, runs the commit hook, and writes the +row — all inside one transaction (see next section). `project(definition, +projector)` registers a synchronous projector that runs inside that transaction. +`durable({aggregateID, after})` is a resumable historical-then-live stream of one +aggregate's events. `replay`/`replayAll` are idempotent re-application (used for +migration and multi-host sync). `remove` physically deletes an aggregate's log. +`claim` sets the owner of an aggregate. + +The module-level helpers `latestSequence(db, aggregateID)` (current head, `-1` if +none, `packages/core/src/event.ts:21-32`) and `readAggregate(db, {aggregateID, +after, limit, manifest})` (paged decode of the log, `event.ts:63-108`) round out +the raw store surface. + +Second, the **session-specific read store**, `SessionStore.Service`, a read-only +projection accessor (`packages/core/src/session/store.ts:14-24`): + +```ts +export interface Interface { + readonly get: (sessionID: SessionSchema.ID) => Effect.Effect + readonly context: (sessionID: SessionSchema.ID) => Effect.Effect + readonly runnerContext: ( + sessionID: SessionSchema.ID, + baselineSeq: number, + ) => Effect.Effect + readonly message: ( + messageID: SessionMessage.ID, + ) => Effect.Effect<{ readonly sessionID: SessionSchema.ID; readonly message: SessionMessage.Message } | undefined> +} +``` + +The higher-level, caller-facing **`SessionV2.Service`** wraps both and is the +operational contract the server and tools actually use +(`packages/core/src/session.ts:113-180`). Its operations (verbatim signatures in +that block): + +- `create(input) -> Info` — mints a session id, resolves/creates the project row, + and publishes a `session.created` event; idempotent by id + (`session.ts:208-262`). +- `get(sessionID) -> Info | NotFoundError` (`session.ts:263-267`). +- `list(input?) -> Info[]` — keyset-paginated SQL over `session` + (`session.ts:268-303`). +- `messages({sessionID, limit?, order?, cursor?}) -> Message[]` — seq-ordered, + cursor-paginated read of the `session_message` projection + (`session.ts:304-337`). +- `message({sessionID, messageID}) -> Message | undefined` (`session.ts:338-341`). +- `context(sessionID) -> Message[]` — the model-visible context (compaction- and + epoch-aware fold, see Read/resume) (`session.ts:342-345`). +- `events({sessionID, after?}) -> Stream` — live durable tail of the + session's log (`session.ts:346-351`). +- `history({sessionID, after?, limit}) -> {events, hasMore}` — paged raw event + history (`session.ts:352-359`). +- `switchAgent` / `switchModel` — publish the corresponding events + (`session.ts:393-416`). +- `prompt({id?, sessionID, prompt, delivery?, resume?}) -> Admitted` — admits a + user prompt into the input queue and (unless `resume:false`) wakes execution + (`session.ts:360-386`). +- `shell` / `skill` / `compact` / `wait` — currently return + `OperationUnavailableError` in this build (`session.ts:387-424`). +- `resume(sessionID)` / `interrupt(sessionID)` / `active` — execution control + (`session.ts:425-432`). +- `revert.stage` / `revert.clear` / `revert.commit` — retroactive rewind + (`session.ts:433-453`). + +Notably, **there is no `delete` on the v2 `SessionV2.Service`** (the interface +block `session.ts:113-180` has none). Deletion exists only as a projector for the +legacy `session.deleted` event (`projector.ts:259-261`) and the raw +`events.remove(aggregateID)` (`event.ts:514-523`); see Retention/deletion. + +## Write and append path (ordering, durability, concurrency, delivery) + +- **Commit shape**: callers never `INSERT` directly. They call + `events.publish(Definition, data, options?)`, which builds a payload and calls + `commitDurableEvent` (`packages/core/src/event.ts:205-367`). For a durable + event, that runs an **uninterruptible SQLite transaction** + (`event.ts:237-353`, `db.transaction(..., { behavior: "immediate" })`) that: + 1. reads the current head seq and owner for the aggregate (`event.ts:243-249`); + 2. computes `seq = latest + 1` (`event.ts:294`); + 3. runs every registered projector for the event type **inside the same + transaction** (`event.ts:320-322`); + 4. runs the optional `commit(seq)` hook (`event.ts:323`); + 5. upserts `event_sequence.seq` and inserts the `event` row (`event.ts:324-348`). + So the log append and all read-model projections **commit atomically** (WAL). +- **Ordering**: the per-aggregate `seq` (`event_sequence` head + unique + `(aggregate_id, seq)` index) is the sole ordering authority. The + `session_message` projection carries that same `seq` and has a + `uniqueIndex(session_id, seq)` (`packages/core/src/session/sql.ts:119-138`), so + message order mirrors event order exactly. Message/session ids additionally + encode time but are not the ordering key. +- **Atomicity / durability**: single immediate transaction per event, WAL + + `synchronous = NORMAL` + `busy_timeout = 5000` + (`packages/core/src/database/database.ts:27-31`). There is no temp-file-and- + rename; durability is SQLite's. (The **legacy** store instead uses per-file + JSON writes guarded by an in-process reentrant read/write lock per key, + `packages/opencode/src/storage/storage.ts:218-299` — no fsync/rename dance + either.) +- **Concurrency / OCC**: append uses an **implicit optimistic-concurrency guard**. + On a normal publish there is no caller-supplied expected version — `seq` is + just `latest + 1`, and the unique `(aggregate_id, seq)` index makes a concurrent + double-append fail. On **replay** the caller *does* pass an expected `seq`, and + the store enforces `seq === latest + 1`, dying with a "Sequence mismatch" if not + (`event.ts:295-302`). Replays are also **idempotent**: if the incoming seq is + `<= latest`, the store compares the stored row's id/type/data and returns + quietly when they match, or dies with "Replay diverged" when they do not + (`event.ts:262-290`). Duplicate event ids at a new seq are rejected + (`event.ts:303-315`). +- **Ownership (multi-writer across hosts)**: `event_sequence.owner_id` plus the + `strictOwner` replay flag implement per-aggregate ownership. A replay with + `strictOwner:true` dies if the stored owner differs + (`event.ts:254-261`); `claim(aggregateID, ownerID)` reassigns it + (`event.ts:525-532`). This is the multi-host coordination primitive (see + Retention/multi-host). +- **Delivery / idempotence at the prompt layer**: user prompts are a two-phase, + idempotent-by-message-id flow. `SessionInput.admit` first looks up an existing + row by `messageID` and returns it if present, otherwise publishes + `session.next.prompt.admitted` recording the `admitted_seq` + (`packages/core/src/session/input.ts:41-81`); on a lifecycle-conflict defect it + re-reads and returns the stored admission. Later, the input is **promoted** to a + `session.next.prompted` event recording `promoted_seq` + (`input.ts:118-168`, `216-243`). `session_input` has unique indexes on both + `admitted_seq` and `promoted_seq` (`session/sql.ts:157-165`). Delivery is thus + **at-least-once with dedup by message id**, split into `"steer"` (promoted at a + turn cutoff) and `"queue"` (promoted one-at-a-time when idle) + (`packages/schema/src/session-delivery.ts:5-6`, `input.ts:245-288`). + +## Read and resume path + +- **Session detail / listing** read the **projections**, not the log. `get` is a + single-row `SELECT` from `session` (`packages/core/src/session/store.ts:35-38`); + `messages` is a seq-ordered, cursor-paginated `SELECT` from `session_message` + (`packages/core/src/session.ts:304-337`); `list` is a keyset-paginated `SELECT` + from `session` ordered by `(time_created, id)` with an anchor for + previous/next paging (`session.ts:268-303`). +- **Model-visible context** (`context` / `runnerContext`) is a + **compaction- and epoch-aware fold** of `session_message`, not a raw read + (`packages/core/src/session/history.ts:66-99`). `load` finds the latest + `compaction` message and the context-epoch `baseline_seq`, then selects messages + with `seq >= compaction.seq` (plus `system` messages after the baseline), so the + returned transcript is the post-compaction window carried forward from the + epoch baseline (`history.ts:24-80`). `runnerContext(sessionID, baselineSeq)` is + the same fold parameterized for a live run (`history.ts:82-99`). +- **Resume of execution** is orchestrated by `SessionExecution`/`SessionRunner` + (`packages/core/src/session/execution.ts`), backed by a per-key **single-writer + run coordinator**: it serializes execution per session id while letting + different sessions run concurrently, coalesces follow-up wakeups, and supports + interrupt (`packages/core/src/session/run-coordinator.ts:5-104`). `prompt` calls + `execution.wake(sessionID)` after admitting input (`session.ts:382`), and + `resume(sessionID)` calls `execution.resume` (`session.ts:426-429`). So resume + reads the durable projections to reconstruct context, then drives the model. +- **Live tail**: `events({sessionID, after})` returns + `EventV2.durable({aggregateID, after})`, which reads historical rows after the + cursor and then switches to a live subscription via a per-aggregate wake PubSub + (`packages/core/src/event.ts:565-604`). This is the reconnect/catch-up path for + UIs. +- **Bounds / pagination**: `messages` and `history` take explicit `limit` + + cursor/anchor (`session.ts:304-359`); `history` returns `hasMore` + (`readAggregate` fetches `limit + 1`, `event.ts:87-107`). No hard transcript-size + cap was found in the v2 read path; the model-visible window is bounded by + compaction, not by a row cap. + +## Listing, summaries, and search + +- **Listing** is SQL over the `session` projection, ordered by + `(time_created, id)` with keyset anchors, filtered by directory / project / + workspace (`packages/core/src/session.ts:268-303`; indexes + `session_project_idx`, `session_workspace_idx`, `session_parent_idx`, + `packages/core/src/session/sql.ts:61-65`). No directory scan, no log replay. No + scale numbers are quoted in-source. +- **Summary sidecar / read model**: the `session` row **is** the denormalized + read model and is maintained at write time in the same transaction as the log + append. It denormalizes `title`, `slug`, `directory`/`path`, `agent`, `model`, + `share_url`, running `cost` and the six token counters, `summary_additions/ + deletions/files/diffs`, `revert` state, `permission`, and `time_*` + (`packages/core/src/session/sql.ts:22-60`). Token/cost totals are incremented + transactionally as step/part events project (`applyUsage`, + `packages/core/src/session/projector.ts:90-110`, `312-329`), and reversed with + `sign = -1` when a message/part is removed — so the denormalized totals stay + consistent with the log by construction. +- **Search**: there is **no FTS or vector subsystem**. `list`'s `search` is a + `LIKE '%...%'` filter over `session.title` only + (`packages/core/src/session.ts:277`). Nothing to bootstrap or keep in sync. + +## Entry/message structure and versioning + +There are two layered structures: the **durable event** (what is stored) and the +**projected message** (what `context`/`messages` return). + +- **Event envelope** (`packages/schema/src/event.ts:29-40`, `47-73`): `id` + (`evt_…`), `type` (dotted string, e.g. `session.next.step.started`), optional + `metadata`, optional `location`, a per-type `data` struct, and a `durable` + descriptor `{ aggregateID, seq, version }`. Every durable session event shares a + `Base` of `{ timestamp, sessionID }` (`packages/schema/src/session-event.ts:27-30`). + The event catalog is large and fine-grained — agent/model switch, moved, + prompted/prompt.admitted, context.updated, synthetic, shell start/end, step + start/end/failed, text start/delta/end, reasoning start/delta/end, tool + input/called/progress/success/failed, retried, compaction start/delta/end, and + revert staged/cleared/committed (`session-event.ts:54-512`). **Stream-fragment + events (`*.delta`) are deliberately non-durable** — only the `*.ended` + full-value boundary is replayable (`session-event.ts:209-210`, `247`, `291`); + the `DurableDefinitions` inventory excludes the deltas (`session-event.ts:448-477` + vs the full `Definitions` `479-512`). +- **Projected message type** (`packages/schema/src/session-message.ts:200-213`): + a tagged union `Message = AgentSwitched | ModelSwitched | User | Synthetic | + System | Shell | Assistant | Compaction`. An `Assistant` message carries a + `content` array of `text | reasoning | tool` parts, a `ToolState` union + (`pending | running | completed | error`, `session-message.ts:81-119`), + `snapshot {start, end, files}`, `finish`, `cost`, and `tokens` + (`session-message.ts:164-189`). The projector folds streaming events into these + rows via an immer-based updater keyed by assistant/tool/text/reasoning id + (`packages/core/src/session/message-updater.ts:78-395`). +- **Store interpretation**: `event.data` is stored as JSON `TEXT` + (`packages/core/src/event/sql.ts:19`) but is **decoded and validated through + Effect Schema on both append and read** (`Schema.encodeUnknownSync` / + `decodeSerializedEvent`, `event.ts:50-61`, `250-251`). The projection tables' + `data` columns are likewise typed JSON (`session/sql.ts:130`, `77`, `92`). The + store relies on `event.id` (primary key) and `(aggregate_id, seq)` for identity/ + dedup; message identity is `session_message.id` (the `msg_…`). +- **Versioning**: each durable event definition carries an explicit integer + `version`, and the stored `event.type` is the **versioned type** + `"${type}.${version}"` (`packages/schema/src/event.ts:118`, + `packages/core/src/event.ts:343`). Most session events are `version: 1`; step + settlement events (`Step.Ended`, `Step.Failed`) are `version: 2` + (`session-event.ts:44-49`, `162-194`). The durable manifest is a map keyed by + versioned type (`packages/schema/src/event.ts:105-113`), and the read path + decodes by looking the definition up by versioned type — so **multiple event + versions can coexist in the log**. This is a genuine schema-version ratchet at + the event level, complementing additive schema defaults. +- **DB schema migrations** for the projections are a forward-only, generated set + applied at startup (`packages/core/src/database/migration.ts` + + `migration.gen.ts`, invoked from `database.ts:33`), e.g. + `20260511173437_session-metadata` and `20260601010001_normalize_storage_paths`. +- **Legacy on-disk format** evolved via numbered filesystem migrations recorded + in a `migration` marker file (`packages/opencode/src/storage/storage.ts:81-243`), + which is how project/session/message/part JSON was reshuffled between layouts. + +## Compaction and history management + +- **Compaction is upstream of the store** (a run-time concern) but leaves a + **durable marker** in the log. `SessionCompaction` summarizes the transcript + with the LLM when the estimated request exceeds the model's context minus a + buffer (`packages/core/src/session/compaction.ts:225-236`), publishing + `session.next.compaction.started` and then + `session.next.compaction.ended { reason, text (summary), recent }` + (`compaction.ts:186-222`). The `Ended` event projects into a `compaction` + message row (`packages/core/src/session/message-updater.ts:377-389`). +- **The model-visible view shrinks; the durable log does not.** `context` reads + only messages at/after the latest `compaction` seq (plus post-baseline system + messages) (`packages/core/src/session/history.ts:13-80`), so the summary plus + the retained "recent" tail replaces the pre-compaction body in what the model + sees, while every original event stays in the `event` table. +- **Context epoch / baseline**: the `session_context_epoch` row holds a + `baseline` + `baseline_seq` + a `SystemContext.Snapshot` + (`packages/core/src/session/sql.ts:168-176`). It is advanced/replaced as the + system context reconciles across turns and compactions + (`packages/core/src/session/context-epoch.ts:40-78`), and is **reset** on + `session.next.moved` and on revert-commit (`projector.ts:256`, `452`). Resume + reads honor this baseline so a moved/reverted session rebuilds its system + context cleanly. + +## Rewind, checkpoints, and fork + +- **Rewind is expressed as appended events, interpreted at fold/replay time.** A + three-step flow: `revert.stage` restores files and publishes + `session.next.revert.staged` recording the `Revert.State` + (`packages/core/src/session/revert.ts:60-96`); `revert.clear` restores and + publishes `revert.cleared` (`revert.ts:98-111`); `revert.commit` publishes + `revert.committed { messageID }` (`revert.ts:113-121`). The **committed** + projector then physically **truncates the projections** — deletes + `session_message` rows with `seq > boundary.seq`, deletes later `session_input` + rows, clears `revert`, and resets the context epoch + (`packages/core/src/session/projector.ts:415-454`). The underlying `event` rows + are **not** deleted; because `revert.committed` is itself a durable event later + in the log, replaying the log re-applies the same truncation deterministically. + This is the append-marker-interpreted-at-replay pattern (contrast the legacy / + in-place styles elsewhere in the corpus). +- **File-state checkpoints** are git-tree snapshots, not inlined content. `stage` + captures the worktree (`Snapshot.capture`) and later restores selected paths + from a captured tree (`packages/core/src/session/revert.ts:60-96`); the snapshot + service maintains a **per-project shadow git directory** under + `~/.local/share/opencode/snapshot//` and captures/ + restores via git tree objects (`packages/core/src/snapshot.ts:91-135`, + `43-91`). Each assistant step records `snapshot.start` / `snapshot.end` tree ids + and the `files` it touched (`packages/schema/src/session-message.ts:171-175`; + set from `Step.Started`/`Step.Ended` in `message-updater.ts:196-221`). Cost is + git-dedup, not per-turn full copies. +- **Fork**: no first-class `fork`/branch operation was found on the v2 + `SessionV2.Service` (`session.ts:113-180`). Parent/child lineage exists + (`parent_id`, below) but a copy-plus-lineage or shared-prefix fork primitive was + not present in the v2 store at this commit (see Open questions). + +## Subagents and nested sessions + +- A subagent is a **first-class sibling session** with its own `sessionID` (its + own event stream / aggregate), linked to its parent by `parent_id`. The `task` + tool creates the child with `parentID: ctx.sessionID` + (`packages/opencode/src/tool/task.ts:155-172`); the column and index are + `session.parent_id` + `session_parent_idx` + (`packages/core/src/session/sql.ts:31`, `64`), surfaced as `Info.parentID` + (`packages/core/src/session/info.ts:19`, `packages/schema/src/session.ts:20`). +- The child **isolates its own transcript** (separate aggregate, separate + `session_message` rows); it does not write into the parent's log. Parent-walk + logic exists in the tooling (`task.ts:107-110` walks `current.parentID`). +- **Delete/cascade**: within the DB, `session` rows do **not** cascade on + `parent_id` (only `project_id` cascades, `session/sql.ts:26-30`), so deleting a + parent session row does not delete children — they would orphan with a dangling + `parent_id` (matching T3's behavior). Nesting bounds live in the tool/agent + layer, not the store schema (not enumerated here; see Open questions). + +## Retention, deletion, and multi-host + +- **Retention**: none found. No TTL, lifecycle sweep, or scheduled cleanup of the + `event` table or projections. The log is retained indefinitely. There is a + `time_archived` field on `session` (`packages/core/src/session/sql.ts:59`, + surfaced as `Info.time.archived`) but no v2 archive/expiry operation was traced. +- **Deletion**: the v2 `SessionV2.Service` exposes **no delete**. Deletion is + possible two ways: (1) the legacy `session.deleted` event projects to + `db.delete(SessionTable)`, which **cascades** to `message`/`part`/ + `session_message`/`session_input`/`session_context_epoch`/`todo` via + `onDelete: "cascade"` foreign keys (`packages/core/src/session/projector.ts:259-261`, + `session/sql.ts:72-176`) — but the `event` rows for that aggregate remain; + (2) `EventV2.remove(aggregateID)` transactionally deletes both `event_sequence` + and `event` rows for the aggregate (`packages/core/src/event.ts:514-523`), which + is the only path that physically erases the log. So "delete the projection" and + "delete the log" are distinct operations. +- **Multi-host is a first-class path** here, unlike most of the corpus. Sessions + synchronize across hosts by **replaying events**, gated by owner: + - Each workspace runs a `syncWorkspaceLoop` that opens an SSE stream to a global + endpoint and, on each `{type:"sync", syncEvent}` message, calls + `events.replay(syncEvent, { publish: true, ownerID: space.id })` + (`packages/opencode/src/control-plane/workspace.ts:395-409`); on connect it + back-fills via a `syncHistory` fetch that `replay`s each missing event + (`workspace.ts:345-363`). + - The instance sync HTTP API exposes `replay` (accept a batch, `replayAll(..., + { ownerID, strictOwner: true })`), `steal` (reassign a session's owning + workspace via `session.setWorkspace`), and `history` (return `event` rows the + caller is missing, computed from a per-aggregate high-water map) + (`packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts:34-85`). + - Ownership transfer uses `events.claim(sessionID, workspaceID)` + (`workspace.ts:589`). The `strictOwner` replay guard prevents two hosts from + diverging a shared aggregate (`packages/core/src/event.ts:254-261`). + The append transaction is `behavior: "immediate"` per DB, and cross-host safety + rests on **owner claims + idempotent, expected-seq replay**, not on a shared + filesystem. This is a real distributed event-sync design layered on the same + append-only log. + +## Interop with foreign session stores + +Not applicable as a session-store feature in the v2 core. OpenCode's own history +has a **self-interop / migration** story rather than a foreign-store one: the +legacy filesystem store ran numbered migrations that discovered old +project/session/message/part JSON layouts and rewrote them into the current +layout (`packages/opencode/src/storage/storage.ts:81-243`), and the v2 projector +ingests legacy v1 `session.*`/`message.*` durable events into the SQL projections +(`packages/core/src/session/projector.ts:215-330`, +`packages/schema/src/durable-event-manifest.ts:12-15`). No discovery/import of +*other products'* native session stores (Claude Code, Codex, etc.) was found in +the store layer. + +## What this implies for our Session Store (our inference) + +*(Our inference, clearly marked as such.)* OpenCode's v2 store is a second strong +event-sourced datapoint alongside T3 Code, and it is architecturally close to our +target: **the durable session is an append-only `event` log keyed by +`(aggregate_id = sessionID, seq)`, and every session/message/summary/context view +is a rebuildable SQLite projection materialized by projectors that run inside the +same transaction as the append.** What it adds beyond T3: + +- **Per-aggregate sequence as the only ordering + OCC key.** OpenCode keys and + orders strictly per session (`event_sequence.seq` + unique `(aggregate_id, + seq)`), with no global autoincrement. On normal publish OCC is implicit + (`seq = latest + 1`), and on replay it becomes an **explicit expected-version + precondition** with idempotent match-or-diverge semantics + (`packages/core/src/event.ts:262-302`). This is exactly the expected-version + contract our design wants, and it shows one store supporting both the + single-writer fast path and the replicated/replay path. +- **Atomic append + projection + local `commit(seq)` hook** in one transaction + (`event.ts:237-353`, `118-124`) is a clean way to keep denormalized read models + (token/cost totals, input queue) consistent with the log by construction. We + should keep the transactional-project step and consider an equivalent local + commit hook for operational side-state. +- **Owner-scoped, idempotent multi-host replay** (`owner_id`, `claim`, + `strictOwner`, SSE sync loop, `steal`, `history` back-fill) is the most + transferable idea here and the piece T3 lacked. It demonstrates that an + append-only per-aggregate log can be **synchronized across hosts by replaying + events with an ownership guard**, no shared filesystem — directly relevant to a + multi-host Session Store. +- **Durable events are the full-value boundaries; stream deltas are non-durable.** + Only `*.ended` events (full text/reasoning/tool-input values) are replayable; + `*.delta` fragments are live-only (`session-event.ts:209-210`, `448-477`). This + keeps the log compact and replay deterministic — a good rule for our event + taxonomy (persist settled facts, stream the in-between). +- **Explicit per-event `version` with versioned stored type** + (`packages/schema/src/event.ts:118`, `packages/core/src/event.ts:343`) lets + multiple event versions coexist in one log and decodes by versioned type. This + is a cleaner evolution ratchet than T3's implicit additive-defaults approach and + worth adopting. +- **Rewind as an appended marker re-applied at replay** (`revert.committed` + truncating projections while the log keeps every event, + `projector.ts:415-454`) validates our append-only rewind model. +- **Cautions**: (1) **No retention / log truncation / snapshotting** — the log + grows unbounded and projection rebuild is a full per-aggregate replay; our + design needs the snapshot/retention story theirs lacks. (2) **Two live models in + one tree** (filesystem JSON vs event-sourced SQLite) is a migration-cost signal: + a clean cutover and a legacy-ingest path (as their v1-event projector shows) are + worth planning up front. (3) **Delete is ambiguous** — deleting the projection + (cascade) leaves the log intact, and only `EventV2.remove` erases the log; we + must decide deliberately whether "delete" means "forget the view" or "erase the + facts," especially given the privacy implications of an indefinitely retained + log. (4) **No child cascade** on parent-session delete (children orphan); our + design should define subagent lifecycle on parent delete/rewind explicitly. + +## Open questions + +- **Snapshotting / log growth**: no truncation, compaction, or periodic snapshot + of the `event` table was found; whether large installs ever bound the log or + precompute projection snapshots (beyond `session_context_epoch`) is unresolved. +- **Session delete UX in v2**: the v2 `SessionV2.Service` exposes no `delete`; + which layer (server handler, control plane) drives `session.deleted` / + `EventV2.remove`, and whether it erases the log or only the projection in the + shipping product, was not fully traced. +- **Fork/branch in v2**: parent/child lineage exists, but no copy-plus-lineage or + shared-prefix fork operation was found on the v2 store; whether fork is a + higher-layer feature (or not yet ported from legacy) is unresolved. +- **Subagent nesting bounds and cascade**: the depth/concurrency limits and the + intended child lifecycle on parent delete/rewind/crash were not pinned down in + the store layer. +- **Filesystem vs SQLite authority in the shipped binary**: `packages/opencode` + still imports the legacy filesystem `Storage` in many paths (compaction, revert, + share, message-v2) while `packages/server` uses `SessionV2`; exactly which store + is authoritative for a given command in the default distribution was not + exhaustively mapped. +- **`shell`/`skill`/`compact`/`wait` unavailable**: these return + `OperationUnavailableError` in v2 at this commit (`session.ts:387-424`); whether + that is a temporary migration state or a deliberate removal is unclear. + + diff --git a/docs/research/session-store/products/t3code.md b/docs/research/session-store/products/t3code.md new file mode 100644 index 000000000..ee0c6095e --- /dev/null +++ b/docs/research/session-store/products/t3code.md @@ -0,0 +1,483 @@ +# T3 Code: how session transcripts are stored and resumed + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT](../RESEARCH_PROMPT.md). +Evidence snapshot: local checkout of +[pingdotgg/t3code](https://github.com/pingdotgg/t3code) (checked out from the +`TrogonStack/t3code` fork) at commit +`b4b66491e39a9dd72e7f52de47a10cb5c41dc78a` (committed 2026-07-21), the source +of "T3 Code", a web GUI that wraps coding-agent CLIs (Codex, Claude, Cursor, +OpenCode). Every citation below was re-verified against this exact commit on +2026-07-23 (working tree clean). Citations use repo-relative `path:line` +shorthand. + +Unlike most products in this corpus, T3 Code does not run the model itself: it +orchestrates provider CLIs and is, structurally, a **CQRS / event-sourced +orchestration server** written in TypeScript on the Effect runtime. The durable +"session" is therefore split across two tiers: T3's own append-only event log +(the orchestration truth) and the provider CLI's native conversation memory +(referenced by an opaque resume cursor). The persistence subsystem lives under +`apps/server/src/persistence/` (the event store, migrations, projection +repositories) and `apps/server/src/orchestration/` (the decider, projector, +engine, and reactors). + +## The storage model + +The durable session is an **append-only event log**: the SQLite table +`orchestration_events` in a single per-server database +`{baseDir}/userdata/state.sqlite` (`apps/server/src/config.ts:101-105`, +`stateDir` is `userdata` in prod / `dev` in dev, `dbPath = join(stateDir, +"state.sqlite")`). The database opens in WAL mode with foreign keys on +(`apps/server/src/persistence/Layers/Sqlite.ts:36-37`). The event store's own +docstring states its role plainly: it "owns durable append/replay access to the +orchestration event stream. It does not reduce events into read models or apply +command validation rules" (`apps/server/src/persistence/Services/OrchestrationEventStore.ts:4-6`). + +The event row is the source of truth +(`apps/server/src/persistence/Migrations/001_OrchestrationEvents.ts:8-23`): + +```sql +CREATE TABLE IF NOT EXISTS orchestration_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL UNIQUE, + aggregate_kind TEXT NOT NULL, + stream_id TEXT NOT NULL, + stream_version INTEGER NOT NULL, + event_type TEXT NOT NULL, + occurred_at TEXT NOT NULL, + command_id TEXT, + causation_event_id TEXT, + correlation_id TEXT, + actor_kind TEXT NOT NULL, + payload_json TEXT NOT NULL, + metadata_json TEXT NOT NULL +) +``` + +This is unambiguously **session-as-log** (event-sourced). There are two +aggregate kinds — `project` and `thread` +(`packages/contracts/src/orchestration.ts:899`) — and a "session" in product +terms is a **thread** (`OrchestrationThread`, +`packages/contracts/src/orchestration.ts:347-381`), reconstructed by folding the +thread-scoped events of one `stream_id`. + +What is authoritative vs. derived: + +- **Authoritative**: `orchestration_events`. It is append-only; nothing rewrites + or deletes rows on the normal path. +- **Derived / rebuildable**: the whole `projection_*` family of tables + (`projection_projects`, `projection_threads`, `projection_thread_messages`, + `projection_thread_activities`, `projection_thread_sessions`, + `projection_turns`, `projection_pending_approvals`, plus the projector cursor + table `projection_state`, `apps/server/src/persistence/Migrations/005_Projections.ts`). + Each is materialized by a projector that replays the log from its own + `last_applied_sequence` (`apps/server/src/orchestration/Layers/ProjectionPipeline.ts:1592-1606`), + so they are pure projections and fully rebuildable. +- **External runtime handle (not the log, not a projection)**: the + `provider_session_runtime` table holds a per-thread opaque `resume_cursor_json` + that points into the *provider CLI's own* native session store + (`apps/server/src/persistence/Migrations/004_ProviderSessionRuntime.ts:8-18`, + `apps/server/src/persistence/ProviderSessionRuntime.ts:50`). The actual + model-side transcript for, e.g., a Codex thread lives in Codex's store; T3 + references it by cursor. +- **Content-addressed side store**: git-ref checkpoints of the working tree per + turn, with a diff cache table `checkpoint_diff_blobs` + (`apps/server/src/persistence/Migrations/003_CheckpointDiffBlobs.ts`), plus + attachment blobs under `{stateDir}/attachments` (`config.ts:106`). + +## Keying and identity + +- **Store scope**: one `state.sqlite` per server install/base directory + (`config.ts:101-105`). There is no cwd- or project-path-encoded key in the + event key; all projects and threads share one DB and are separated by columns. +- **Event key**: `(aggregate_kind, stream_id, stream_version)` with a global + monotonic `sequence`. `stream_id` is a `ProjectId` or `ThreadId` + (`OrchestrationEventStore.ts` `Schema.Union([ProjectId, ThreadId])`, + `apps/server/src/persistence/Layers/OrchestrationEventStore.ts:53-54`; + event base fields `packages/contracts/src/orchestration.ts:1101-1111`). +- **Id minting**: all ids are branded, trimmed, non-empty strings + (`packages/contracts/src/baseSchemas.ts:26-60`). Command-facing ids + (`commandId`, `threadId`, `projectId`, `messageId`) are **client-supplied**; + the server mints `eventId` as a UUIDv4 via `crypto.randomUUIDv4` + (`apps/server/src/orchestration/decider.ts:41-50`). No id encodes ordering or + location — ordering comes entirely from `sequence` (global) and + `stream_version` (per aggregate), not from the id. +- **Listing scope**: global within the DB. Threads are scoped to a project by + `project_id`; a project is anchored to a `workspaceRoot` filesystem path + (`OrchestrationProject`, `packages/contracts/src/orchestration.ts:211-222`). +- **Relocation / rename**: a thread's `branch` and `worktreePath` are mutable + fields carried by `thread.meta-updated` events + (`decider.ts:417-452`, projector `projector.ts:388-405`); moving the working + directory does not change identity because the key is the `threadId`, not a + path. Title/model renames are likewise `thread.meta-updated` events. + +## The store interface + +The event store **is** a pluggable service (an Effect `Context.Service`), so the +contract is exported verbatim +(`apps/server/src/persistence/Services/OrchestrationEventStore.ts:22-55`): + +```ts +export interface OrchestrationEventStoreShape { + readonly append: ( + event: Omit, + ) => Effect.Effect; + + readonly readFromSequence: ( + sequenceExclusive: number, + limit?: number, + ) => Stream.Stream; + + readonly readAll: () => Stream.Stream; +} +``` + +`append` assigns `sequence` and `stream_version` at insert time and returns the +stored event; `readFromSequence` is an exclusive-cursor paged replay; `readAll` +is `readFromSequence(0, MAX_SAFE_INTEGER)` +(`apps/server/src/persistence/Layers/OrchestrationEventStore.ts:184-267`). The +store never validates commands or builds read models. + +The write side that callers actually use is the **OrchestrationEngine** service +(`apps/server/src/orchestration/Services/OrchestrationEngine.ts`), whose +reconstructed contract is: + +- `dispatch(command) -> Effect<{ sequence }, OrchestrationDispatchError>` — the + single mutation entrypoint. It enqueues onto a single-writer command queue and + awaits the result (`Layers/OrchestrationEngine.ts:318-327`). +- `readEvents(fromSequenceExclusive, limit) -> Stream` — thin + pass-through to `eventStore.readFromSequence` (`Layers/OrchestrationEngine.ts:315-316`). +- `streamDomainEvents -> Stream` — a fresh PubSub + subscription per consumer for live fan-out (`Layers/OrchestrationEngine.ts:335-337`). + +Supporting durable repositories (each its own `Context.Service`, reconstructed): + +- **Command receipts** (idempotency ledger): `upsert(receipt)`, + `getByCommandId({commandId}) -> Option` + (`apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts:44-63`), + backed by table from `Migrations/002_OrchestrationCommandReceipts.ts`. +- **Projection repositories** (one per read-model table) plus a + `ProjectionState` cursor repo, driven by the pipeline's `bootstrap` and + `projectEvent` operations (`Services/ProjectionPipeline.ts`, + `Layers/ProjectionPipeline.ts:1608-1644`). +- **ProjectionSnapshotQuery**: `getSnapshot()` and `getCommandReadModel()` build + the full read model from the projection tables in one transaction + (`apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts:954-1243`). +- **ProviderSessionRuntimeRepository**: `upsert`, `getByThreadId`, `list`, + `deleteByThreadId` over the opaque resume-cursor rows + (`apps/server/src/persistence/ProviderSessionRuntime.ts:64-103`). + +The client-facing RPC surface over WebSocket is +`orchestration.dispatchCommand`, `orchestration.replayEvents`, +`orchestration.subscribeThread`, `orchestration.subscribeShell`, +`orchestration.getTurnDiff`, `orchestration.getFullThreadDiff`, +`orchestration.getArchivedShellSnapshot` +(`packages/contracts/src/orchestration.ts:25-33`, `1341-1370`). + +## Write and append path (ordering, durability, concurrency, delivery) + +- **Commit shape**: a command is decided into one or more events by a pure + decider (`decideOrchestrationCommand(command, readModel)`, + `apps/server/src/orchestration/decider.ts:97-876`), then each event is + `INSERT`ed. The append computes the next per-stream version inline + (`apps/server/src/persistence/Layers/OrchestrationEventStore.ts:106-157`): + + ```sql + COALESCE( + (SELECT stream_version + 1 FROM orchestration_events + WHERE aggregate_kind = ? AND stream_id = ? + ORDER BY stream_version DESC LIMIT 1), + 0) + ``` + +- **Ordering**: two levels. `sequence INTEGER PRIMARY KEY AUTOINCREMENT` is the + global order; `stream_version` is the per-aggregate order, enforced unique by + `idx_orch_events_stream_version ON (aggregate_kind, stream_id, stream_version)` + (`Migrations/001_OrchestrationEvents.ts:26-28`). That unique index is the + optimistic-concurrency guard: two events cannot occupy the same stream version. +- **Atomicity**: `OrchestrationEngine.processEnvelope` wraps the whole + command in `sql.withTransaction`: for each planned event it appends, folds it + into the in-memory command read model, runs the projection pipeline, then + upserts the command receipt — all in one transaction + (`Layers/OrchestrationEngine.ts:175-219`). So the log append and the SQLite + projections commit together (WAL, `Sqlite.ts:36`). +- **Concurrency**: **single-writer per server**. Every command flows through one + unbounded queue consumed by exactly one worker fiber + (`Layers/OrchestrationEngine.ts:96`, `309-310`), so commands are strictly + serialized. There is **no caller-supplied expected-version precondition**; OCC + is implicit in the single-writer queue plus the unique `stream_version` index. +- **Delivery / idempotence**: **exactly-once command application** via the + command-receipt ledger. Before deciding, the engine looks up the receipt by + `commandId`: an `accepted` receipt short-circuits and returns the stored + `resultSequence`; a `rejected` receipt fails fast; otherwise it proceeds and + writes a new receipt inside the same transaction + (`Layers/OrchestrationEngine.ts:144-157`, `196-204`). Since `commandId` is + client-supplied, a retried dispatch is deduplicated. On dispatch failure the + engine reconciles its in-memory read model by re-reading persisted events from + the pre-dispatch sequence (`Layers/OrchestrationEngine.ts:119-132`, `270-298`). + +## Read and resume path + +- **Server startup**: projections are bootstrapped by replaying the log from + each projector's `last_applied_sequence` + (`Layers/ProjectionPipeline.ts:1592-1606`), then the engine seeds its in-memory + command read model from the projection tables, not from a full replay + (`getCommandReadModel`, `Layers/OrchestrationEngine.ts:306-307`). So resume of + the *engine* reads the derived projections; the log is replayed only to fill + any projector gap. +- **Client resume**: `subscribeThread` / `subscribeShell` take an optional + `afterSequence` cursor. When present, the server skips the full snapshot and + replays events after that sequence before switching to live streaming; + overlapping events are deduped by sequence on the client + (`packages/contracts/src/orchestration.ts:474-508`). Without a cursor, the + server sends a full projection snapshot (`getSnapshot`, + `ProjectionSnapshotQuery.ts:954-1243`) then live events. A raw + `orchestration.replayEvents` RPC reads the log from a sequence + (`orchestration.ts:1333-1339`). +- **Provider conversation resume** is a **separate tier**: the model-side + transcript is not in T3's log. Resuming the underlying agent uses the opaque + `resumeCursor` from `provider_session_runtime` handed back to the provider + adapter (`ProviderSessionRuntime.ts:50`; Codex resume/fork returns a + `CodexResumeCursor { threadId }`, `apps/server/src/provider/Layers/CodexAdapter.ts:1667`). +- **Bounds / pagination**: event replay pages at `READ_PAGE_SIZE = 500` with a + default limit of `1_000` (`Layers/OrchestrationEventStore.ts:67-68`). Read-model + materialization is capped: `MAX_THREAD_MESSAGES = 2_000`, + `MAX_THREAD_CHECKPOINTS = 500` (`apps/server/src/orchestration/projector.ts:33-34`), + activities capped at 500 and proposed plans at 200 (`projector.ts:734`, `579`). + These caps bound the *view*, not the log. + +## Listing, summaries, and search + +- **Listing** is a set of SQL queries over the projection tables, not a directory + scan or a log replay. The shell snapshot lists `projection_projects` and + `projection_threads` (`ProjectionSnapshotQuery.ts:300-420`), and the thread row + is denormalized with picker-facing summary fields: `latest_user_message_at`, + `pending_approval_count`, `pending_user_input_count`, + `has_actionable_proposed_plan`, `latest_turn_id`, `archived_at` + (`ProjectionThreadShell`, `packages/contracts/src/orchestration.ts:403-428`; + columns added by later migrations, e.g. `023`, `017`, `033`). No scale numbers + are quoted in-source. +- **Summary consistency**: the projection row *is* the denormalized read model. + It is kept consistent by being written in the same transaction as the log + append on the live path (`Layers/OrchestrationEngine.ts:184`), and each + projector records its `last_applied_sequence` in `projection_state` atomically + with its own writes (`Layers/ProjectionPipeline.ts:1568-1578`), so a projector + can resume exactly where it left off. +- **Search**: there is **no full-text or vector search subsystem** (in contrast + to Hermes's FTS5). Discovery is ordinary SQL filtering over the projection + tables; there is nothing to bootstrap or keep in sync beyond the projections + themselves. + +## Entry/message structure and versioning + +- **Event envelope** (`packages/contracts/src/orchestration.ts:1101-1111`): + `sequence`, `eventId`, `aggregateKind`, `aggregateId`, `occurredAt` (ISO + string), `commandId`, `causationEventId`, `correlationId`, `metadata`, plus a + discriminant `type` and a per-type `payload`. `metadata` carries provider + provenance (`providerTurnId`, `providerItemId`, `adapterKey`, `requestId`, + `ingestedAt`, `orchestration.ts:1092-1098`). There are **23 event types** + (`OrchestrationEventType`, `orchestration.ts:872-897`), each with a typed + payload schema and a corresponding union member + (`OrchestrationEvent`, `orchestration.ts:1113-1230`). +- **Store interpretation**: `payload` and `metadata` are persisted as JSON `TEXT` + (`payload_json`, `metadata_json`), so the row is opaque bytes at rest, but the + store **decodes and validates them through Effect Schema** on both append and + read (`decodeEvent`, `Layers/OrchestrationEventStore.ts:31-33`, `204-208`). The + store also derives `actor_kind` (`client`/`server`/`provider`) from the + command id prefix and metadata (`inferActorKind`, + `Layers/OrchestrationEventStore.ts:70-90`). Dedup/identity keys: `event_id` + (UNIQUE) for the event, `command_id` for command idempotency. +- **Chaining**: `causationEventId` links an event to the event that caused it — + e.g. `thread.turn-start-requested` is stamped with the `thread.message-sent` + event's id as its cause (`decider.ts:557`). `correlationId` is by design the + originating `commandId` (`orchestration.ts:146-148`). Thread lineage is carried + in payload fields (`parentThreadId`, `forkedFromThreadId`, + `forkedUpToMessageId`), not in envelope pointers. +- **Versioning**: there is **no explicit per-event schema-version field**. + Format evolution is handled additively by the schemas themselves — + `withDecodingDefault` supplies defaults for fields added later (e.g. + `runtimeMode`, `interactionMode`, `orchestration.ts:934-937`), and a + pre-decoding transform absorbs the legacy `{provider}` → `{instanceId}` model + selection shape without a migration (`ModelSelection`, + `orchestration.ts:81-114`). Because projections are rebuildable, read-model + schema changes replay from the log. +- **DB schema migrations** are a **forward-only ratchet** run at startup via + `effect_sql_migrations`, statically imported and sorted by id, applied only if + greater than the last recorded id (`apps/server/src/persistence/Migrations.ts:61-136`). + Migrations run 1–33 then jump to 41 (`Migrations.ts:61-96`), a gap not explained + in-source. No down-migrations exist. + +## Compaction and history management + +- There is **no compaction, summarization, or truncation of the event log**. The + durable `orchestration_events` stream grows monotonically. The only "shrinking" + is view-side: read-model materialization caps (`MAX_THREAD_MESSAGES = 2_000` + etc., `projector.ts:33-34`, `484`) bound what the UI holds, not what is stored. +- Model-visible context compaction is an **upstream / provider concern**: the + wrapped CLI manages its own context window. T3 records the outcome of turns as + events but does not compact them. +- The one summarization primitive is **summary-mode fork** (below), which + produces a single new assistant message in a *new* thread rather than rewriting + the source thread's history. + +## Rewind, checkpoints, and fork + +- **Rewind/revert is expressed as appended events, interpreted at fold time — + never a destructive edit.** A `thread.checkpoint.revert` command produces a + `thread.checkpoint-revert-requested` event (`decider.ts:649-669`); the + CheckpointReactor restores the working tree and then dispatches + `thread.revert.complete`, producing `thread.reverted` (`decider.ts:816-835`). + The projector folds `thread.reverted` by *filtering* the view to entities whose + `checkpointTurnCount <= turnCount` — dropping later messages, activities, + proposed plans, and checkpoints from the projection while the underlying events + remain in the log (`projector.ts:665-714`). This is exactly the + append-marker-replayed pattern (as opposed to Hermes's in-place flag mutation). +- **File-state checkpoints** are git-ref based, one per `(thread, turnCount)`. + The CheckpointReactor captures a real git checkpoint when a turn completes, + keyed by `checkpointRefForThreadTurn(threadId, turnCount)`, and only in a git + workspace (`apps/server/src/orchestration/Layers/CheckpointReactor.ts:177-319`). + The turn's file summary and `checkpointRef` are carried in + `thread.turn-diff-completed` (`ThreadTurnDiffCompletedPayload`, + `orchestration.ts:1076-1085`); the full diff text is cached in + `checkpoint_diff_blobs` (`Migrations/003_CheckpointDiffBlobs.ts`). File content + is therefore stored/deduped by git (content-addressed refs), not inlined in the + event log. +- **Fork** is a two-phase, **copy-plus-lineage** operation into a new stream. A + `thread.fork` command must go through `ThreadForkService` — the plain decider + explicitly rejects it (`decider.ts:264-269`). The service attempts a **native + provider fork** first (Codex advertises `capabilities.nativeFork: true` and its + `forkThread` returns a new provider thread id as the resume cursor, + `CodexAdapter.ts:1647-1667`, `1759`), then dispatches `thread.fork.finalize`, + which the decider turns into a `thread.forked` event + (`decider.ts:271-348`). For `full-history` mode the source messages up to the + cutoff are **copied** into the new thread; for `summary` mode a single + generated assistant summary message is produced (`decider.ts:283-320`). The + `thread.forked` payload records `forkedFromThreadId`, `forkedUpToMessageId`, + `forkMode`, and `pendingForkContextText` (injected ahead of the first prompt + when the new thread has no native provider memory), and the projector inserts + the new thread with its copied messages (`projector.ts:309-353`). + +## Subagents and nested sessions + +- A subagent is a **first-class sibling thread** in the same project, not nested + storage and not entries in the parent transcript. It gets its own `threadId` + (its own event stream) and is linked to its parent by `parentThreadId`, carried + on `thread.created` (`ThreadCreatedPayload.parentThreadId`, + `orchestration.ts:940`; projector `projector.ts:286`). The design is documented + in the fork ADR: "each subagent appears as its own thread: openable, + inspectable mid-flight, steerable, and resumable" + (`docs/fork/0003-native-subagent-threads.md`). +- Subagents can run on a **different provider/model** than the parent, and each + defaults to its own git worktree; read-only children can share the parent's + checkout (`docs/fork/0003`). +- **Nesting is bounded**: "subagent trees can nest up to five levels deep, and at + most eight subagents may run at once across a tree" (`docs/fork/0003`). These + guardrails live in the delegation/tool layer, not in the store schema. +- **Parent delete**: `thread.deleted` triggers only that thread's runtime cleanup + (stop provider session, close terminals with `deleteHistory: true`) via the + ThreadDeletionReactor (`apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts:44-64`). + No cascade to child threads was found in the event/decider path — children keep + their `parentThreadId` and would be orphaned rather than cascade-deleted (see + Open questions). + +## Retention, deletion, and multi-host + +- **Retention**: none. No TTL, lifecycle sweep, or scheduled cleanup of + `orchestration_events` or projections was found. The log is retained + indefinitely. +- **Deletion** is command-driven and **soft in the read model, physical only for + external artifacts**. `project.delete` fans out to `thread.delete` for each + active thread and then deletes the project, as one decided command sequence + (`decider.ts:184-227`). A `thread.deleted` event is appended (the event itself + is never removed); the projector sets `deleted_at` on the projection row and + schedules physical removal of that thread's attachment directory as a projector + side-effect (`deletedThreadIds` → `deleteThreadAttachments`, + `Layers/ProjectionPipeline.ts:436-463`); the ThreadDeletionReactor best-effort + stops the provider session and deletes terminal history + (`ThreadDeletionReactor.ts:44-64`). Snapshot queries filter `deleted_at IS + NULL` for active listings (`ProjectionSnapshotQuery.ts:381`). +- **Multi-host**: not a first-class path. The model assumes a single server + process holding one local `state.sqlite` (single-writer command queue, + `Layers/OrchestrationEngine.ts:96`, `309-310`). Remote *access* is a networking + concern — the server is exposed over Tailscale/SSH (the `tailscale` and `ssh` + packages, `docs/architecture/remote.md`) — but the database is never shared + across hosts, and there is no distributed coordination or remote writeback. + +## Interop with foreign session stores + +T3 Code's entire purpose is to drive foreign agent CLIs (Codex `app-server`, +Claude Code, Cursor CLI, OpenCode), so it interoperates at the **runtime** level +rather than the **storage** level. It does **not** discover, import, or parse +those CLIs' native on-disk session files. Instead, for each thread it stores an +opaque `resume_cursor_json` in `provider_session_runtime` +(`Migrations/004_ProviderSessionRuntime.ts:15`, +`ProviderSessionRuntime.ts:50`) and delegates start/resume/fork to the provider +adapter, which reconstitutes the provider's own session from that cursor +(`CodexResumeCursor { threadId }`, `CodexAdapter.ts:1667`). The provider CLI +remains the owner of the raw model transcript; T3's event log is the +orchestration layer over it. The `provider_session_runtime` row is a mutable +upsert-by-`threadId` record (`ProviderSessionRuntime.ts:150-187`), not part of +the append-only log. + +## What this implies for our Session Store (our inference) + +*(Our inference, clearly marked as such.)* T3 Code is the corpus's **cleanest +event-sourced example**: the durable session is an append-only +`orchestration_events` log keyed by `(aggregate_kind, stream_id, +stream_version)` with a global `sequence`, and every list/summary/detail view is +a **rebuildable SQLite projection** driven by per-projector `last_applied_sequence` +cursors. This is almost exactly the shape our design targets, and it validates +several specific choices: + +- **Decider/projector/read-model split with a single-writer command queue and a + transactional append+project step** is a working, coherent pattern + (`decider.ts`, `projector.ts`, `Layers/OrchestrationEngine.ts:175-219`). It + gives OCC "for free" via a unique `(aggregate_kind, stream_id, stream_version)` + index without a caller-supplied expected version — worth contrasting with an + explicit expected-version precondition, which would be needed the moment + writers are no longer serialized by one process. +- **Command receipts keyed by client-supplied `commandId`** are a clean, + durable idempotence mechanism (exactly-once command application), directly + addressing the crash-mid-write dedup gap that Hermes solved only with an + in-memory marker. We should adopt an equivalent command/idempotency ledger. +- **Causation/correlation on every event** (`causationEventId`, `correlationId = + commandId`) is a low-cost provenance backbone we should keep. +- **Retroactive operations as replayed events, not destructive edits** + (`thread.reverted` filtered at fold time, `projector.ts:665-714`) is the + behavior our event-sourced design wants, and a strong precedent over Hermes's + in-place flag mutation. +- **Two-tier custody** is the most transferable idea here: T3 owns the + orchestration *facts* in its event log but does **not** own the model + transcript — it stores only an opaque resume handle and delegates to the + provider. This suggests our Session Store can be the authoritative orchestration + log while the heavy raw transcript lives elsewhere, provided the durable log + records the resume handle and enough provenance to re-derive views. +- **Cautions**: (1) **no retention or log-truncation/snapshotting** — the log + grows unbounded and projection bootstrap is a full replay from each cursor; + our design needs an explicit snapshot/retention story that theirs lacks. (2) + **Fork is physical copy-plus-lineage into a new stream** (`decider.ts:283-320`), + not a shared-prefix reference — simple but O(history) per fork; our design + could reference a shared event prefix instead. (3) Delete is soft in + projections but events are retained forever, so "deleted" data is still fully + present in the log — a privacy/retention consideration we must decide + deliberately. + +## Open questions + +- **Log growth / snapshotting**: no truncation, compaction, or snapshot of + `orchestration_events` was found. Whether large installs ever bound the log or + precompute projector snapshots (beyond the per-projector cursor) is unresolved. +- **Child cascade on parent delete**: `thread.deleted` cleanup targets only the + deleted thread's runtime (`ThreadDeletionReactor.ts:44-64`); no cascade to + subagent children was traced, so children appear to orphan (dangling + `parentThreadId`). The intended lifecycle of children on parent delete/revert + was not pinned down. +- **Migration id gap 33 → 41** (`Migrations.ts:94-95`): whether 34–40 were + dropped, renumbered, or reserved is not explained in-source. +- **Resume-cursor contents per provider**: only `CodexResumeCursor { threadId }` + was traced (`CodexAdapter.ts:1667`); the cursor/`runtime_payload_json` shapes + for Claude, Cursor, and OpenCode adapters were not fully enumerated. +- **Multi-process safety**: correctness relies on a single server process's + single-writer queue; the behavior if two server processes opened the same + `state.sqlite` (the unique `stream_version` index would reject a clash, but the + in-memory read models would diverge) was not exercised. diff --git a/docs/research/session-store/synthesis.md b/docs/research/session-store/synthesis.md new file mode 100644 index 000000000..742ae0dea --- /dev/null +++ b/docs/research/session-store/synthesis.md @@ -0,0 +1,441 @@ +# Synthesis: what the industry means by a "stored session" + +Part of Session Store Research. +Nine product dossiers, one question: when an agent product persists, +resumes, lists, and retires a session, what does it actually keep on disk +and how close is that shape to an append-only log with derived +projections? Purpose: extract the invariant core our own event-sourced +Session Store must model, and the axes where products deliberately +diverge. This synthesis is frozen as decision-time input: where a +conclusion here differs from an accepted record in the +[ADR index](../../adr/index.md), the ADR is authoritative. + +The through-line is the append-log-vs-mutable-record spectrum. At one end +sit [T3 Code](./products/t3code.md) and [OpenCode](./products/opencode.md)'s +v2 subsystem, whose durable session **is** an event table with rebuildable +SQL projections. At the other sits [Goose](./products/goose.md) and +[Hermes](./products/hermes-agent.md), whose durable session is a mutable +SQLite row that retroactive operations DELETE and re-INSERT or flip flags +on. The CLIs in between, [Claude Agent SDK](./products/claude-agent-sdk.md), +[Codex CLI](./products/codex-cli.md), [Gemini CLI](./products/gemini-cli.md), +and [Grok Build](./products/grok-build.md), converge on append-only JSONL +transcripts with derived read models bolted alongside (a SQLite index for +Codex CLI; JSON sidecars/registries for Claude Agent SDK), which is +directionally the same shape with looser discipline: no expected-version +precondition anywhere in the group, though Codex CLI's SQLite projection +carries a formal rebuild/read-repair cursor contract that the others +lack. [LangGraph](./products/langgraph.md) +is the odd one out structurally: its unit is a parent-linked chain of +immutable state snapshots, not a message transcript, and it is the second +cleanest event-sourcing analog in the corpus after T3 Code. + +## Convergence + +**1. Nobody stores model-visible context as the durable artifact; a +separate durable log or table always exists, and the model's view is +derived from it.** [Claude Agent SDK](./products/claude-agent-sdk.md): "a +session whose store holds 503 raw entries may return 18 messages from +`getSessionMessages`." [Codex CLI](./products/codex-cli.md): "the live and +persisted histories remain identical" even as `replacement_history` +replaces what the model re-reads. [Grok Build](./products/grok-build.md): +"Rebuild the derived `chat_history.jsonl` cache from `updates.jsonl`, the +durable source of truth." [T3 Code](./products/t3code.md) and +[OpenCode](./products/opencode.md) hold this as policy: "the only +'shrinking' is view-side ... bound what the UI holds, not what is stored." +Even [Goose](./products/goose.md), +the corpus's most mutable store, keeps pre-compaction turns as +`agent_invisible` rows rather than deleting them. + +**2. JSONL append-only transcripts are the majority default for CLI +products, and the append discipline is remarkably specific.** [Claude Agent +SDK](./products/claude-agent-sdk.md), [Codex CLI](./products/codex-cli.md), +[Gemini CLI](./products/gemini-cli.md), and [Grok Build](./products/grok-build.md) +all write one line per event/entry to a per-session `.jsonl` file with no +in-place edits on the hot path. Two independently built torn-write defenses +converge on the same fix: Codex repairs the rollout file to be +newline-terminated on open ("the file is repaired to be newline-terminated +so a torn final line cannot corrupt the next append"), and Grok Build heals +"a torn trailing line (previous append crashed mid-write?)" before +appending, the same failure mode, the same remedy, arrived at separately. + +**3. Rewind is implemented as an appended marker interpreted at replay, not +a destructive edit, everywhere except the pure-SQL stores.** Claude Agent +SDK's `/rewind` menu is "a non-destructive view operation over an +append-only log." Codex CLI's `ThreadRolledBack` event means "skip the next +N user-turn segments we finalize"; nothing is deleted. Gemini CLI's +`$rewindTo` line means the reducer "deletes it plus everything after" only +from the in-memory fold, not the file. Grok Build: "Every prompt is a +checkpoint, the list always contains [0, 1, ..., N-1]..."; separately, the +dossier notes nothing is deleted and replay applies dead-branch filtering. +T3 Code: "exactly +the append-marker-replayed pattern (as opposed to Hermes's in-place flag +mutation)." OpenCode's v2 `revert.commit` truncates only the *projection*; +"the underlying event rows are not deleted." The two exceptions prove the +axis: Goose's rewind is "a destructive delete" (`truncate_conversation`), +and Hermes's is an in-place flag flip (`active=0`). + +**4. Compaction is universally an upstream/agent-loop concern that the +store merely records, never triggers or understands.** [LangGraph](./products/langgraph.md): +"the store neither triggers nor understands it," a summary is just the +next value of a channel. [Claude Agent SDK](./products/claude-agent-sdk.md): +compaction produces "another appended entry," an `isCompactSummary` marker. +[Codex CLI](./products/codex-cli.md) appends a `Compacted` item with +`replacement_history`. [OpenCode](./products/opencode.md): "Compaction is +upstream of the store... but leaves a durable marker in the log." Even +[Goose](./products/goose.md), which rewrites rows for compaction, treats the +summarization call itself as an agent-loop decision the store just +persists the result of. + +**5. Fork/branch always mints a new identity; nobody reuses the source +session id.** [Claude Agent SDK](./products/claude-agent-sdk.md)'s +`forkSession` "rewrites every `sessionId` field and remaps message UUIDs... +An adapter-level copy... would produce a transcript that still references +the old session ID, so the SDK does not use one." [Codex CLI](./products/codex-cli.md) +mints a new `thread_id` and stitches lineage via `SessionMeta.forked_from_id` +plus `history_base`. [T3 Code](./products/t3code.md) requires a dedicated +`ThreadForkService` and produces a `thread.forked` event on a new stream. +[Grok Build](./products/grok-build.md): "Fork is copy-plus-lineage, not a +shared-prefix reference." [Goose](./products/goose.md) mints a fresh +`YYYYMMDD_N` id via `copy_session`. Only [LangGraph](./products/langgraph.md) +gets a genuinely cheap fork, because content-addressed channel blobs are +shared by reference across the copied chain. + +**6. Subagents are (almost) always a sibling stream/session linked by a +parent pointer, never entries inlined in the parent's transcript.** +[Codex CLI](./products/codex-cli.md): `SessionMeta.parent_thread_id`, "a +first-class sibling thread." [T3 Code](./products/t3code.md): "each +subagent appears as its own thread: openable, inspectable mid-flight, +steerable, and resumable." [OpenCode](./products/opencode.md): "a +first-class sibling session," linked by `parent_id`. [Grok Build](./products/grok-build.md): +its own directory plus a `SubagentMeta` pointer file. [Goose](./products/goose.md): +its own row, linked by `parent_session_id`. [Hermes](./products/hermes-agent.md): +its own row plus a durable delivery outbox (`async_delegations`) for +crash-safe result reconciliation. The sole structural exception is +[Claude Agent SDK](./products/claude-agent-sdk.md), which nests subagent +`.jsonl` files physically *inside* the parent's session directory rather +than as a database-level sibling, still a separate transcript, just a +different addressing scheme. + +**7. Cascade-on-delete for subagents is inconsistent and mostly unhandled, +and nobody has a clean answer.** [Codex CLI](./products/codex-cli.md): "a +missing parent produces a 'malformed lineage' error rather than silent +cascade." [Goose](./products/goose.md): "no cascade to children... leaving +a subagent row with a dangling `parent_session_id`." [OpenCode](./products/opencode.md): +"deleting a parent session row does not delete children, they would +orphan." [T3 Code](./products/t3code.md): "No cascade to child threads was +found... children keep their `parentThreadId` and would be orphaned." +[Grok Build](./products/grok-build.md): "No GC for orphaned subagent +session directories was found." This is a convergence in the sense that +every product that has subagents has the *same unresolved gap*. + +**8. No product implements true optimistic-concurrency control at the +store's write boundary, and the exceptions that come closest are the two +purest event-sourced designs.** [Claude Agent SDK](./products/claude-agent-sdk.md): +"there is no expected-position precondition on `append`." [Goose](./products/goose.md): +"no expected-version precondition anywhere; there is no CAS." [Hermes](./products/hermes-agent.md): +"there is no optimistic-concurrency / expected-position precondition +anywhere." [Gemini CLI](./products/gemini-cli.md) and [Codex CLI](./products/codex-cli.md) +rely on a single-writer-per-session assumption with no lock. Only +[T3 Code](./products/t3code.md) (a unique `(aggregate_kind, stream_id, +stream_version)` index plus a single-writer command queue) and +[OpenCode](./products/opencode.md) (an explicit expected-seq check on +replay: "Sequence mismatch") give the write path any real +conflict-detection teeth; [LangGraph](./products/langgraph.md)'s unique +`(thread_id, checkpoint_id)` key is unrelated to conflict detection, its +own dossier flags "no expected-version/OCC in the OSS savers" and notes +`put` is last-write-wins on a given checkpoint id. + +## Divergence + +**A. Append-only log vs mutable row, the central axis.** Pure log: +[T3 Code](./products/t3code.md) ("unambiguously session-as-log +(event-sourced)"), [OpenCode](./products/opencode.md) v2 ("This is +unambiguously session-as-log"), [LangGraph](./products/langgraph.md) +(immutable, id-addressed, parent-linked snapshots). Log-shaped but looser: +[Claude Agent SDK](./products/claude-agent-sdk.md), [Codex CLI](./products/codex-cli.md), +[Gemini CLI](./products/gemini-cli.md), [Grok Build](./products/grok-build.md), +all JSONL, but looser in different ways: tolerated multi-writer +interleaving (Claude Agent SDK), message-level last-write-wins re-appends +(Gemini), no ordinal at all (legacy Codex), or a single-writer-per-session +assumption enforced only socially, via an exclusive per-append file lock +plus a pid registry rather than a store-level contract (Grok Build). +Mutable row: [Goose](./products/goose.md) ("a mutable row plus an +in-place-editable ordered message table... explicitly not session-as-log") +and [Hermes](./products/hermes-agent.md) ("session-as-mutable-relational- +record... the least event-sourced of the products studied"). **Our +service must decide: is the append-only guarantee enforced by the store +(reject any operation that isn't an append), or merely a convention the +caller can violate?** T3 Code and OpenCode enforce it structurally (no +delete/rewrite path exists below the projector); the JSONL products only +enforce it by omission (nothing offers an edit API, but nothing prevents +one either). + +**B. Identity minting: client-random UUID vs client time-ordered UUIDv7 vs +server-assigned date+ordinal vs server-assigned monotonic sequence.** +Random, v4-shaped (observed on disk, not documented): [Claude Agent SDK](./products/claude-agent-sdk.md) +session id. Time-ordered UUIDv7/ULID-like, client-minted: [Codex CLI](./products/codex-cli.md) +("Codex-generated thread IDs are UUIDv7, and some use cases rely on that"), +[OpenCode](./products/opencode.md) (`ses_` ids pack timestamp + counter, +bit-inverted for descending sort), [LangGraph](./products/langgraph.md) +(checkpoint id is UUID6, "unique and monotonically increasing, so can be +used for sorting"). Time-ordered UUIDv7, server-assigned as a fallback: +[Grok Build](./products/grok-build.md) (session ids "are minted as UUIDv7 +when the ACP client does not supply one," via `uuid::now_v7()`). +Server-assigned, human-legible, low-entropy: +[Goose](./products/goose.md) (`YYYYMMDD_N`, a per-day counter, "not a +UUID... but no location"), [Hermes](./products/hermes-agent.md) session id +(`{timestamp}_{6-hex}`, "~24 bits of entropy, second-resolution collisions +theoretically possible"). Pure sequence, no id semantics at all: +[T3 Code](./products/t3code.md) (`stream_version` per aggregate plus a +global `sequence`) and [OpenCode](./products/opencode.md) (per-aggregate +`seq`). **Divergence to resolve: do we want an id that is sortable by +construction (UUIDv7/ULID) or an id that is opaque and let a separate +sequence column carry order?** The two cleanest event-sourced designs +(T3 Code, OpenCode) use opaque ids for identity and a *separate* strictly +monotonic sequence for order; they do not conflate the two concerns the +way UUIDv7-as-directory-name products do. + +**C. Scope of the store: per-project directory vs single global +database.** Directory-per-project, no cross-project store: [Claude Agent +SDK](./products/claude-agent-sdk.md) (`projectKey` flattens the cwd into +the path), [Codex CLI](./products/codex-cli.md) (time-sharded but global +within `$CODEX_HOME`, filtered by `cwd_filters`), [Gemini CLI](./products/gemini-cli.md) +(`projectShortId` directory). Single database, cwd as a plain filter +column: [Goose](./products/goose.md) ("no cwd/project path is encoded into +the key... project_id is just a nullable column"), [Hermes](./products/hermes-agent.md) +(one `state.db` per profile, `cwd` a plain column), [T3 Code](./products/t3code.md) +and [OpenCode](./products/opencode.md) (one DB, `project_id`/`workspace_id` +columns), [Grok Build](./products/grok-build.md) (cwd-encoded directory +path, but a remote registry merges cross-host listings). This directly +determines whether "move the working directory" is a relocation problem +(directory-keyed stores all have bespoke migration/registry code for this: +Claude Agent SDK's `/cd` relocation, Gemini CLI's `ProjectRegistry` plus +`performMigration`, Grok Build's `RelocationJournal`) or a cheap column +update (Goose, Hermes, and T3 Code just `UPDATE working_dir`/`project_id` +on a plain column; OpenCode is the middle case, still appending a +`session.next.moved` event that projects into `directory`/`path`/ +`workspace_id` in the same transaction, so relocation stays cheap and +non-migratory without becoming a bare out-of-band UPDATE). + +**D. Compaction's durable shape: in-place row rewrite vs external snapshot +file vs pure append marker.** Rewrite in place: [Goose](./products/goose.md) +(`DELETE all rows, re-INSERT` via `replace_conversation`) and +[Hermes](./products/hermes-agent.md) (`UPDATE active=0, compacted=1` then +insert new rows, "a content-preserving UPDATE"). External snapshot plus a +log marker: [Grok Build](./products/grok-build.md) (`CompactionCheckpoint` +marker in `updates.jsonl` plus a full separate `compaction_checkpoints/ +{id}.json` file, required to rewind past the boundary, and rewind fails +closed if the file is missing). Pure append, no external file needed: +[Claude Agent SDK](./products/claude-agent-sdk.md) (`isCompactSummary` +entry in the same log), [Codex CLI](./products/codex-cli.md) (`Compacted` +item with `replacement_history` inline), [T3 Code](./products/t3code.md) +(no compaction of the log at all, it is unbounded and grows forever). +**Divergence to resolve: does a compaction boundary require a sidecar +artifact recoverable independently of the log (Grok Build's model, with an +explicit fail-closed on missing sidecar), or is a same-stream marker +sufficient?** The sidecar approach adds a second thing that can go missing; +the same-stream approach keeps one recovery story but grows the log +un-compactably. + +**E. Retention: nobody enforces it at the store layer, but who is +*expected* to differs.** Explicitly the caller's job, store provides +mechanism only: [Claude Agent SDK](./products/claude-agent-sdk.md) ("The +SDK never deletes from your store on its own... TTLs, S3 lifecycle +policies... are the adapter's responsibility"), [LangGraph](./products/langgraph.md) +(`prune(strategy=)`, no automatic lifecycle). Product-owned sweep with a +concrete default: [Claude Agent SDK](./products/claude-agent-sdk.md)'s own +CLI (`cleanupPeriodDays`, default 30), [Gemini CLI](./products/gemini-cli.md) +(delete-on-exit-if-not-resumable), [Hermes](./products/hermes-agent.md) +(`prune_sessions(older_than_days=90)`, invoked, not scheduled). No +retention story at all, log grows forever: [T3 Code](./products/t3code.md) +("no retention or log-truncation/snapshotting, the log grows unbounded") +and [OpenCode](./products/opencode.md) ("none found... the log is retained +indefinitely"). **The two purest event-sourced designs are also the two +with zero retention story**, an event-sourced Session Store gets rewind +and audit for free but inherits an explicit obligation to design retention +deliberately, since nothing in the pattern forces it. + +**F. Multi-host / multi-writer posture.** Single-host by design, no +coordination: [Codex CLI](./products/codex-cli.md), [Gemini CLI](./products/gemini-cli.md), +[Goose](./products/goose.md) (SQLite write-lock only), [T3 Code](./products/t3code.md) +("the database is never shared across hosts"). Single-host with +network-filesystem awareness: [Hermes](./products/hermes-agent.md) (WAL on +local disks, falls back to DELETE-mode journal on NFS/SMB/FUSE because "WAL's +shared-memory index needs coherent mmap... those mounts don't provide"). +Multi-host as a first-class adapter concern, pushed above the core +interface: [Claude Agent SDK](./products/claude-agent-sdk.md) ("Serverless +functions, autoscaled workers, and CI runners don't share a filesystem. A +shared store lets any replica resume any session," with a documented +clock-skew failure mode in the reference S3 adapter). Multi-host avoided +rather than solved: [Grok Build](./products/grok-build.md) (per-host +SQLite files on network mounts, rebuildable indexes only, "concurrency +control is advisory file locks plus a pid registry... multi-host is +handled by giving up," with a remote registry merge as a secondary +best-effort lane). Multi-host as a first-class *designed* protocol: +[OpenCode](./products/opencode.md) (`events.replay` with an `ownerID` + +`strictOwner` guard, `events.claim` to transfer ownership, a per-aggregate +high-water history-fetch API, "a real distributed event-sync design +layered on the same append-only log"). +Multi-host via a shared database instance: [LangGraph](./products/langgraph.md) +(Postgres backend, content-addressed blob upserts are conflict-free by +construction). **This is the widest-open axis**: only OpenCode has +actually solved cross-host replication of an event-sourced session on top +of an ownership-claim protocol; everyone else either assumes single-host or +punts the problem to the storage substrate. + +**G. What "the store" persists vs what it treats as opaque.** Fully +opaque entries, store is a pure byte-transport: [Claude Agent SDK](./products/claude-agent-sdk.md) +(`SessionStoreEntry` is "a `{ type: string; ... }` object," treated as +opaque JSON by contract). Parsed and validated on every read/write: +[OpenCode](./products/opencode.md) (event `data` "decoded and validated +through Effect Schema on both append and read"), [T3 Code](./products/t3code.md) +(same, via Effect Schema, plus derived `actor_kind`). Partially parsed, +targeted introspection: [Goose](./products/goose.md) ("neither fully +opaque nor a normalized schema, JSON columns with targeted introspection" +via `json_extract`/`json_each`). **Divergence: does the Session Store +validate event payloads against a schema at the storage boundary, or does +it store bytes and leave validation to the caller?** The schema-validating +designs (T3 Code, OpenCode) get validated event shapes at the storage +boundary; T3 Code decodes/validates every event via Effect Schema on +append and read but carries no explicit per-event-type version field, +handling schema evolution additively (defaults for new fields, +pre-decoding transforms for shape changes) rather than by branching on a +version number. The opaque-byte designs (Claude Agent SDK) get an easier +storage contract but push all versioning discipline onto the consuming +application. + +## Conceptual models in play + +| Model | Exemplars | +| --- | --- | +| session-as-log (append-only, source of truth) | T3 Code, OpenCode (v2) | +| session-as-log-of-immutable-snapshots (event-sourcing-adjacent, unit is a state chain not a message stream) | LangGraph | +| session-as-log with looser discipline (append-only JSONL, no formal OCC contract; projector-contract rigor varies — Codex CLI's SQLite projection has an explicit rebuild/read-repair cursor) | Claude Agent SDK / Claude Code, Codex CLI, Gemini CLI, Grok Build | +| session-as-directory (path/filename encodes identity; scope encoding varies — Codex CLI's path is time-sharded only, with cwd scope applied as a query-time filter, not a path segment) | Claude Agent SDK, Codex CLI, Gemini CLI, Grok Build, OpenCode (legacy) | +| session-as-row (mutable, single record + child table) | Goose, Hermes | +| session-as-mutable-relational-record (flag-mutation instead of events) | Hermes (Goose achieves a similar visibility effect only via a full delete+re-insert rewrite of the message table, not an in-place flag mutation) | +| session-as-document (whole-file rewrite, last-write-wins per id) | Gemini CLI (legacy `.json`) | + +These are not mutually exclusive; several products carry two labels at +once (e.g. Claude Agent SDK is both session-as-log and session-as-directory: +the log is the file, and the file's path is the addressing scheme). + +## Comparison table + +| Product | Durable session is a... | Source of truth | Keying / id scheme | Compaction artifact | Rewind/fork | Append-log closeness | +| --- | --- | --- | --- | --- | --- | --- | +| [Claude Agent SDK](./products/claude-agent-sdk.md) | append-only JSONL transcript | the `.jsonl` file (mirrored, not replaced, by the SDK's `SessionStore`) | client v4-shaped UUID (observed); path = `projectKey/sessionId/subpath` | in-log `isCompactSummary` entry, raw entries retained | rewind = view op over log; fork = `forkSession` rewrites ids into a new key | high, but no OCC precondition and tolerates multi-writer interleave | +| [Codex CLI](./products/codex-cli.md) | append-only JSONL rollout + derived SQLite index | `RolloutLine` log; SQLite is read-repaired from it | client UUIDv7 `thread_id`; filename = timestamp+id, time-sharded dir | in-log `Compacted` item with `replacement_history` | `ThreadRolledBack` marker replay; fork = new `thread_id` + `history_base` prefix pointer | high; explicit CQRS-shaped log+SQLite-projection design | +| [Gemini CLI](./products/gemini-cli.md) | append-only JSONL folded by a replay reducer | the `.jsonl` file; `ConversationRecord` is "the materialized projection, not what is stored line-by-line" | client `promptId`; path = `projectShortId/chats/session--.jsonl` | checkpoint `$set:{messages}` line replaces message set | `$rewindTo` marker, non-destructive; no first-class fork (resume continues same file) | medium; message-level last-write-wins per id, not immutable events | +| [Goose](./products/goose.md) | mutable SQLite row + message table | the `sessions`/`messages` rows themselves | server date+counter `YYYYMMDD_N`; global DB, no path key | `replace_conversation`: DELETE all rows, re-INSERT with visibility flags | `truncate_conversation`, a destructive delete; fork = `copy_session`, full physical copy | low; explicitly "not session-as-log" | +| [Grok Build](./products/grok-build.md) | append-only JSONL (`updates.jsonl`) + derived caches/index | `updates.jsonl`; cache/summary/FTS all rebuildable | server UUIDv7 session id; path = `sessions/{encoded_cwd}/{id}/` | append marker (`CompactionCheckpoint`) plus external snapshot file, fails closed if missing | `RewindMarker` + dead-branch-filter replay; fork = copy files + lineage fields | high; "event sourcing on the filesystem in all but name" | +| [Hermes](./products/hermes-agent.md) | mutable SQLite row + message table | the `sessions`/`messages` rows | client `{timestamp}_{6hex}` id; global per-profile DB | `active=0, compacted=1` in-place flag flip, content-preserving | `rewind_to_message`: soft-delete via flag flip (reversible); fork = `/branch`, full row copy | low; "least event-sourced of the products studied" | +| [LangGraph](./products/langgraph.md) | parent-linked chain of immutable state snapshots | the `checkpoints` table; channel values content-addressed by `(channel, version)` | caller-supplied `thread_id`; checkpoint id = UUID6 | no store-level compaction; `prune`/shallow-saver drop history, `DeltaChannel` snapshots for large channels | rewind = select an older checkpoint (nothing destroyed); fork = new checkpoint sharing ancestor blobs, `copy_thread` | very high; "materially closer to event-sourcing than the transcript products" | +| [OpenCode](./products/opencode.md) | append-only SQLite event log (v2) / mutable JSON-per-path store (legacy) | the `event` table, keyed `(aggregate_id, seq)` | client-minted ULID-like `ses_`/`evt_` ids; per-aggregate `seq` | in-log `compaction.ended` event; model-visible view folds from latest compaction seq | `revert.commit` truncates only the projection, event rows kept; no first-class fork found | very high; "unambiguously session-as-log (event-sourced)" | +| [T3 Code](./products/t3code.md) | append-only SQLite event log | the `orchestration_events` table, keyed `(aggregate_kind, stream_id, stream_version)` | client-supplied `threadId`; server UUIDv4 `eventId`; global `sequence` + per-stream `stream_version` | none; log is never compacted, only view-side caps | `thread.reverted` event filters the projection, log kept; fork = new stream via `ThreadForkService`, O(history) copy | highest in corpus; "the corpus's cleanest event-sourced example" | + +## Working definition + +> A stored session is an ordered, addressable record of everything that +> happened in one agent run, durable enough to survive a crash, complete +> enough that the model-visible context and every read model (listing, +> search, summary) can be rebuilt from it alone. + +Design decisions the evidence forces for our event-sourced Session Store, +each with the industry's answer where one exists: + +1. **The append operation must be the only mutation primitive; rewind, + compaction, and revert are new appended events, never edits or + deletes.** Industry's answer: T3 Code and OpenCode enforce this + structurally; every JSONL product does it by convention only; Goose and + Hermes are the cautionary counterexamples — Goose via DELETE+re-INSERT + (`replace_conversation`), Hermes via both a destructive DELETE+re-INSERT + (`replace_messages`, used by /retry, /undo, /compress) and a + non-destructive flag-flip (`archive_and_compact`) — both flagged in + their own dossiers as crash-risk and history-loss hazards. + +2. **Separate identity from order: an opaque event/session id for + addressing, a strictly monotonic per-aggregate sequence for ordering.** + Industry's answer: T3 Code (`stream_version` unique per + `(aggregate_kind, stream_id)`) and OpenCode (`seq` unique per + `aggregate_id`) both do this; time-ordered ids (UUIDv7/ULID, used by + Codex CLI, Grok Build, OpenCode's `ses_`) are a convenience for + directory/lexical sort, not a substitute for a real sequence. + +3. **Require an expected-version precondition on every append (real + optimistic concurrency), not just a single-writer assumption.** + Industry's answer: only OpenCode (explicit "Sequence mismatch" check on + replay) enforces a real caller-supplied precondition; T3 Code gets + OCC-like protection only implicitly, via a single-writer command queue + combined with a unique `(aggregate_kind, stream_id, stream_version)` + index, with no caller-supplied expected-version check at all; the rest + (Claude Agent SDK, Goose, Hermes, Gemini CLI) admit they have none and + rely on tolerated multi-writer interleaving or a social single-writer + convention. + +4. **Compaction is upstream: the store persists an event carrying the + summary/replacement content, it does not trigger, understand, or + rewrite prior events.** Industry's answer: universal (Convergence #4). + Decide whether the compaction event also needs an out-of-band recovery + artifact for rewinding past it (Grok Build's fail-closed + `compaction_checkpoints/{id}.json`) or whether an in-stream marker + suffices (Claude Agent SDK, Codex CLI, T3 Code's absence of compaction + entirely). + +5. **Fork always mints a new stream/session id and rewrites identity + fields in the copied prefix; it must never let a copy reference the + source id.** Industry's answer: unanimous outside LangGraph (Claude + Agent SDK's explicit rationale for not using `CopyObject`; Codex CLI's + `history_base` prefix-pointer as the efficient variant of the same + rule). Decide whether fork is a physical copy (T3 Code, Goose, Hermes: + O(history) per fork) or a shared-prefix reference (Codex CLI's + `history_base`, LangGraph's shared content-addressed blobs); the + shared-prefix design is strictly cheaper and available to us because + events are immutable. + +6. **Subagents are sibling streams linked by a parent pointer, and cascade + behavior on parent delete/rewind must be decided explicitly; the + industry has not decided it.** Industry's answer: convergence on + sibling-stream-plus-pointer (Convergence #6) but zero consistent answer + on cascade (Convergence #7); every product either orphans children or + doesn't say. This is a genuine gap we get to close rather than copy. + +7. **Retention and log truncation are not solved by event-sourcing and + must be designed deliberately, not deferred.** Industry's answer: the + two purest event-sourced stores (T3 Code, OpenCode) have *no* retention + story at all and grow unbounded; the JSONL products that do have + retention treat it as an out-of-store caller responsibility (Claude + Agent SDK's explicit "the SDK never deletes from your store on its own") + with concrete but ad hoc defaults (30-90 days) enforced by a sweep, not + a store primitive. + +8. **Multi-host correctness needs an explicit ownership/claim protocol on + the write path, not an assumption of a shared filesystem.** Industry's + answer: only OpenCode has one (`events.claim`, `ownerID` + + `strictOwner` replay guard, per-aggregate high-water history sync); + everyone else either assumes single-host (Codex CLI, Gemini CLI, Goose, + T3 Code, Hermes) or pushes the problem to the adapter/storage substrate + (Claude Agent SDK's pluggable mirror, Grok Build's per-host SQLite + files, LangGraph's shared Postgres). + +9. **Event payloads should be schema-validated at the storage boundary, + not treated as opaque bytes.** Industry's answer: split. T3 Code and + OpenCode validate every event type through a schema library on both + append and read — T3 Code via Effect Schema, though without an explicit + per-event-type version field, handling evolution additively rather than + by branching on a version number; Claude Agent SDK deliberately keeps + entries opaque (`{type: string}`) to maximize adapter portability. + Given we control both ends of our own store, the schema-validating + design (T3 Code/OpenCode) buys us a much cheaper way to catch malformed + events than the opaque design, which pushes all of that discipline onto + the consuming application. + +The one-line reading of the whole study: the industry has already proven +the event-sourced session store pattern is implementable and exercised in +real, evolving codebases at two independent shops (T3 Code, OpenCode) — +though for OpenCode specifically, the evidence comes from a private fork +mid-migration where which store (legacy filesystem vs. v2) is authoritative +in the shipped distribution remains an open question — and approximated it +everywhere else with append-only JSONL, which means our job is not to +invent the pattern but to close the two gaps nobody has closed yet: +subagent cascade semantics and retention on an unbounded log. \ No newline at end of file