diff --git a/.config/mise/tasks/github-actions/lint-adr-refs b/.config/mise/tasks/github-actions/lint-adr-refs index 76f2bb1cb..e4f27497b 100755 --- a/.config/mise/tasks/github-actions/lint-adr-refs +++ b/.config/mise/tasks/github-actions/lint-adr-refs @@ -65,8 +65,9 @@ def adr_number_for(path: Path) -> str | None: # Directories that never contain committed docs: dependencies, git internals, -# and gitignored agent scratch notes (`.trogonai/**/*.internal.trogonai.md`). -IGNORED_DIRS = {"node_modules", ".git", ".trogonai"} +# gitignored agent scratch notes (`.trogonai/**/*.internal.trogonai.md`), and +# Conductor workspace scratch (`.context`, excluded via .git/info/exclude). +IGNORED_DIRS = {"node_modules", ".git", ".trogonai", ".context"} def iter_markdown() -> list[Path]: diff --git a/docs/adr/0031-agent-implementation-and-session-plan.md b/docs/adr/0031-agent-implementation-and-session-plan.md index 9a5dab862..16952fa1d 100644 --- a/docs/adr/0031-agent-implementation-and-session-plan.md +++ b/docs/adr/0031-agent-implementation-and-session-plan.md @@ -36,17 +36,17 @@ This decision uses a smaller model: immutable record of the exact revision, implementation, models, provider routes, and dependencies admitted for one Session -Codex and Claude Code are implementations in this model. An OpenAI or Claude -model is a separate model selection. A process, container, microVM, or remote -service may host an implementation, but hosting is not itself an Agent -implementation. +The platform-managed harness loop is the normative v1 implementation in this +model. Codex and Claude Code are possible future edge integrations, not the +source of the core Session schema. An OpenAI or Claude model is a separate model +selection. A process, container, microVM, or remote service may host an +implementation, but hosting is not itself an Agent implementation. OpenClaw uses its own precise product vocabulary: an agent runtime owns a prepared model loop, and a harness implements that runtime. The platform does -not copy those nouns into its core model. For a verified configuration, an -OpenClaw adapter maps the exact behavioral component to -AgentImplementationVersion and preserves OpenClaw's native terms inside typed -OpenClaw configuration. +not copy those nouns into its core model. A future OpenClaw adapter would map +the exact behavioral component to AgentImplementationVersion and preserve +OpenClaw's native terms inside typed OpenClaw configuration. This ADR refines the ownership described by draft [ADR#0025](./0025-agent-definition-data-ownership.md). The exact @@ -75,10 +75,11 @@ The repository already establishes adjacent constraints: - The generated protobuf types use unknown_fields=false. Older readers can discard unknown arms and unknown fields, so version skew must fail closed. -This ADR defines logical ownership, Session admission, the implementation -adapter, remote verification, and protobuf modeling. It does not select a -container orchestrator, define a general hosting resource, or move provider -credentials into Agent configuration. +This ADR defines logical ownership, Session admission, the platform harness +boundary, constraints on future implementation adapters, remote verification, +and protobuf modeling. It does not select a container orchestrator, define a +general hosting resource, or move provider credentials into Agent +configuration. ## Decision @@ -88,11 +89,12 @@ Use these normative logical records: AgentImplementationVersion implementation_version_id - built-in kind or registered extension identity - native product version - adapter artifact reference and digest - native artifact references and digests - adapter contract version + kind = platform_harness | registered_extension + platform_harness: + harness artifact references and digests + harness contract version + registered_extension: + immutable extension version reference and definition digest configuration contract version supported model protocols and capabilities definition_digest @@ -128,10 +130,12 @@ precondition is unmet. The consequence recorded in rest of this ADR, including implementation pinning, plan immutability, and attestation, is unaffected by it. -AgentImplementationVersion describes a reusable immutable release. Its -definition digest commits to the product and adapter artifacts, native product -version, supported contracts, and implementation capabilities. A mutable tag -such as latest is not an exact version. +AgentImplementationVersion describes a reusable immutable release. For the +built-in arm, its definition digest commits to the platform harness artifacts, +supported contracts, and implementation capabilities. A mutable tag such as +latest is not an exact version. A registered edge extension pins any additional +product and adapter artifacts inside its immutable extension version, not in +the built-in harness fields. Every behavior-significant artifact is pinned transitively. This includes a bundled CLI or SDK, plugins, nested components, composition rules, and generated @@ -147,7 +151,7 @@ activation. ModelSelection identifies an exact versioned model catalog record, not a display name, mutable provider alias, auto value, or provider credential. Its parameters are part of AgentConfiguration. The implementation cannot replace -that model with a native default or fallback. Enforcing that last sentence +that model with a harness default or fallback. Enforcing that last sentence requires the platform to read the pinned model, which runtime-owned model selection does not currently grant. @@ -184,9 +188,10 @@ The following boundary decides where a value belongs: Session launch state. An OCI image may therefore be an implementation artifact when it is the -immutable distribution of the pinned product. The cluster, node, process -supervisor, filesystem allocation, and network placement that run it remain -hosting details. +immutable distribution of the pinned platform harness. A future registered +extension pins a product image inside its extension version. The cluster, node, +process supervisor, filesystem allocation, and network placement that run it +remain hosting details. ### 2. Bind one immutable SessionExecutionPlan @@ -198,14 +203,14 @@ Before a Session becomes runnable, create one SessionExecutionPlan: agent_configuration_ref + configuration_digest agent_implementation_version_ref + definition_digest implementation_configuration_digest - effective_native_configuration_digest + effective_harness_configuration_digest primary_model_selection primary_resolved_model_route auxiliary_model_selections + resolved routes resolved variable bindings resolved tool, delegate, memory, and skill versions work contract and input references - adapter contract version + harness contract version session-plan contract version resolved-model-route contract version @@ -220,12 +225,34 @@ ModelSelection. They cannot substitute another model. Attempt-scoped [model-access grants](../glossary/modelaccessgrant), secret values, and short-lived provider credentials never enter the plan. +The plan's model fields inherit the contested ownership recorded in section 1. +The shipped AgentConfiguration contract exposes no typed ModelSelection for +admission to read, so `primary_model_selection`, `auxiliary_model_selections`, +and their resolved model routes are provisional, together with admission +steps 4 and 5, the model-protocol check in step 7, the model-access grants in +step 10, and the ExactModelUnavailable and ExactModelMismatch failure +categories below; none of them has anything to read until typed selections +land, the same block +[ADR#0032](./0032-model-route-and-credential-binding.md) records for route +admission. Until the reconciliation tracked in +[ADR#0025](./0025-agent-definition-data-ownership.md) lands, the authoritative +interim source of model selection is the runtime-owned settings inside the +pinned AgentConfiguration, when they record one: that selection cannot change +within a revision, because `configuration_digest` commits to those settings, +but the platform cannot read it as a typed value, resolve a provider route +for it, or enforce the no-substitution rule above. When those settings are +absent, the shipped contract lets the implementation's own defaults choose +the model, which is exactly the invisibility this decision exists to remove; +the reconciliation must close that gap. The plan fields and admission steps +that do not depend on model selection are normative now. + Admission proceeds in this order: 1. Load the requested AgentRevision and verify AgentConfiguration bytes and digest. -2. Load the exact AgentImplementationVersion and verify its definition, - native product artifacts, and adapter artifact digests. +2. Load the exact AgentImplementationVersion and verify its definition and + harness artifact digests. A registered edge extension verifies its own + pinned product and adapter artifacts under the extension contract. 3. Validate the typed implementation configuration against the exact configuration contract version. 4. Read the exact primary and auxiliary ModelSelection values from @@ -236,7 +263,7 @@ Admission proceeds in this order: Session dependencies. 7. Verify that the implementation supports every model protocol and required Session capability. -8. Build the exact native configuration projection and its expected digest. +8. Build the exact harness configuration projection and its expected digest. 9. Store the canonical SessionExecutionPlan bytes and digest atomically with SessionStarted. 10. Authorize the first launch only after observing that durable start fact. @@ -262,7 +289,7 @@ failure categories are: - CheckpointIncompatible; and - PinnedDependencyRevoked. -These failures never trigger native or platform fallback. A caller may correct +These failures never trigger harness or platform fallback. A caller may correct the configuration, activate another reviewed AgentRevision, or start a new Session after the unavailable dependency is restored. @@ -279,9 +306,11 @@ plan to recreate the digest. ### 3. Keep every implementation attached to its platform Session -An AgentImplementationAdapter is the bidirectional boundary between the -platform Session and one native implementation. Its version and artifact -digest are pinned by AgentImplementationVersion. +The Session coordinator and platform-owned harness form the bidirectional +execution boundary. A future AgentImplementationAdapter translates an external +product into that boundary without changing the core Session contract. Its +version and artifact digest are pinned by the immutable registered extension +version. The platform Session owns: @@ -292,33 +321,139 @@ The platform Session owns: - cancellation intent and terminal outcome; and - delivery of a child Session result to its waiting parent. -The native implementation owns its private loop state while it runs. A durable -checkpoint is an opaque typed artifact or reference whose schema, digest, and -implementation version are recorded by the Session. - -The logical adapter exchange is: +The platform-owned harness runs the loop for a Session. Four durable records +must not be conflated: + +- The typed Session event log is the authoritative record of platform facts. + It alone rebuilds the Session aggregate and every read model. +- An aggregate [snapshot](../glossary/snapshot) is an advisory persisted fold of + that log. If it is missing or invalid, replay starts earlier without changing + Session meaning. +- A harness recovery checkpoint is opaque process state used only when the + platform needs to continue an in-flight loop under the same pinned plan. It + cannot replace event replay, prove a platform fact, or act as a Session + snapshot. +- A projection or consumer [checkpoint](../glossary/checkpoint) is only a + processed stream position and is not any of these state artifacts. + +The protobuf named `Checkpoint` retains its wire name, but this ADR calls that +object a harness recovery checkpoint to keep the meanings distinct. + +A harness recovery checkpoint is admissible only after capture completes and +the Session command boundary verifies all the following before +`CheckpointProduced` is recorded: + +- the sealed state includes every harness-relevant effective Session fact from + the beginning of the Session through the declared `covers_through` cut, so + restoring it produces the same harness state as a fresh replay through that + cut, proven by the capture attestation below rather than assumed; +- the state has been sealed as a durable artifact independently of the process + memory that produced it; +- independently fetching the sealed bytes and recomputing their digest + succeeds; +- the producing ExecutionAttempt and immutable SessionExecutionPlan digest + match the Session; +- the harness can correlate its cut to one settled platform `covers_through` + ordinal without guessing; and +- `checkpoint_type` identifies a format supported by the implementation version + committed by the plan. + +Semantic coverage and replay equivalence cannot be recomputed from opaque +bytes, so the first verification rests on a capture attestation rather than on +the harness's word. When capture completes, the platform-controlled supervisor +of the producing attempt (section 4) computes an effective-history digest over +the harness-relevant effective Session facts it delivered, in fold order, from +the beginning of the Session through `covers_through`, and signs, under that +attempt's confirmation key, a binding of the artifact reference and digest, +`checkpoint_id`, `checkpoint_type`, producing ExecutionAttempt, +SessionExecutionPlan digest, `covers_through`, and that effective-history +digest. Admission verifies the signature against the producing attempt's +confirmation-key thumbprint, requires every attested value to equal the +corresponding field of the checkpoint evidence being admitted, recomputes the +effective-history digest from authoritative Session history, and requires +equality with the attested value. An attestation is not transferable: +evidence whose artifact reference, digest, id, type, cut, attempt, or plan +digest differs from what the supervisor signed is rejected, never partially +matched. +The admitted checkpoint evidence retains the attestation reference and digest +beside the effective-history digest, so restoration re-verifies the same proof +before trusting any bytes. The attestation proves the binding: the measured +harness and supervisor boundary verified under section 4 captured the sealed +state from exactly the attested effective history. A missing, unverifiable, or +mismatched attestation rejects the checkpoint. + +`Checkpoint.covers_through` is the core Session replay cut. Internal harness +coordinates stay inside the opaque, versioned artifact and never become core +Session facts or `SessionOrdinal` values. If any admission proof is +unavailable, that checkpoint cannot continue the in-flight loop. The platform +then replays authoritative Session history and starts a fresh ExecutionAttempt; +only incomplete authoritative history or an indeterminate side effect requiring +reconciliation can block recovery. + +Restoration is one guarded workflow: + +1. `StartExecutionAttempt` folds the Session at current head `H`, selects an + eligible admitted checkpoint, and appends `ExecutionAttemptStarted` under + `At(H)` with that exact checkpoint evidence. +2. The supervisor fetches the sealed artifact again and verifies its digest, + format, capture attestation, producing attempt, plan, and effective + `covers_through` cut before trusting any bytes. +3. The harness restores the sealed state, then replays the exact + harness-relevant effective tail after `covers_through` through `H`, using the + same rewind, redaction, and compaction interpretation as a fresh replay. +4. Only after the tail reaches `H` may the attempt record Ready or receive new + work. Facts appended after `H` remain queued for normal delivery. + +Eligibility in step 1 demands more than an intact artifact: the covered prefix +must still mean at `H` what it meant when the checkpoint was admitted. Tail +replay applies interpretation only to facts after `covers_through`; it cannot +rebuild the sealed prefix. Any fact folded through `H` that reinterprets +history at or before the cut therefore disqualifies the checkpoint: a rewind +that makes `covers_through` ineffective, a redaction targeting any event at or +before it, or an artifact erasure reaching an artifact recorded at or before +it. Without this rule, a restored attempt would keep content a fresh replay +masks. A compaction marker in the tail does not disqualify, whatever range it +covers: applying it is the loop's ordinary live operation, the restored +attempt and a fresh replay hold the same covered facts and fold the same +self-sufficient marker, and unlike redaction and erasure it removes nothing a +fresh replay would still deliver. An ineligible checkpoint falls back to +fresh replay from authoritative history, which applies the changed +interpretation from the first fact. + +This makes checkpoint restore observationally equivalent to rebuilding the +harness from authoritative history through the same selected head. A checkpoint +is an optimization for process-local state, never an alternate history. + +Claude, Codex, or another product may be integrated later by translating at +the platform edge. Such an integration cannot add product session ids, +transcript layouts, bridge cursors, or product-specific resume semantics to the +core Session schema. Its external recovery material remains outside this +platform harness contract. + +The logical harness exchange is: | Direction | Operation | Required effect | | --- | --- | --- | -| Platform to adapter | Start | Bind the exact Session, plan bytes, and plan digest. | -| Adapter to platform | Ready | Prove the admitted implementation and effective configuration are running. | -| Platform to adapter | DeliverInput | Deliver immutable work or continuation input. | -| Adapter to platform | Output | Record ordered model-visible output. | -| Adapter to platform | ToolRequested | Ask the platform to authorize and dispatch a declared tool. | -| Platform to adapter | ToolResult | Return the typed result or denial. | -| Adapter to platform | DelegateRequested | Ask the platform to create an authorized child Session or external delegation operation. | -| Platform to adapter | DelegateResult | Return the recorded result to the waiting loop. | -| Adapter to platform | ModelRequested | Ask the Session model proxy to call one planned model route. | -| Platform to adapter | ModelResult | Return the response for the same planned operation. | -| Adapter to platform | CheckpointProduced | Record an opaque checkpoint reference and digest. | -| Platform to adapter | Cancel | Stop new work and acknowledge cancellation. | -| Adapter to platform | Completed or Failed | Record one typed terminal outcome. | +| Platform to harness | Start | Bind the exact Session, plan bytes, and plan digest. | +| Harness to platform | Ready | Prove the admitted implementation and effective configuration are running. | +| Platform to harness | DeliverInput | Deliver immutable work or continuation input. | +| Harness to platform | Output | Record ordered model-visible output. | +| Harness to platform | ToolRequested | Ask the platform to authorize and dispatch a declared tool. | +| Platform to harness | ToolResult | Return the typed result or denial. | +| Harness to platform | DelegateRequested | Ask the platform to create an authorized child Session or external delegation operation. | +| Platform to harness | DelegateResult | Return the recorded result to the waiting loop. | +| Harness to platform | ModelRequested | Ask the Session model proxy to call one planned model route. | +| Platform to harness | ModelResult | Return the response for the same planned operation. | +| Harness to platform | CheckpointProduced | Record an admitted harness recovery checkpoint. | +| Platform to harness | Cancel | Stop new work and acknowledge cancellation. | +| Harness to platform | Completed or Failed | Record one typed terminal outcome. | Every exchange binds the Session id and plan digest. Retryable requests carry a stable operation id and request digest. Ordered output carries a monotonic -sequence or equivalent acknowledged cursor. Reconnect resumes from the last -acknowledged position. If continuity cannot be proven, the coordinator restores -an admitted checkpoint or fails the Session. +sequence, while reconnect behavior stays inside the harness transport and +operation ledger. If continuity cannot be proven, the coordinator restores an +admitted harness recovery checkpoint or replays authoritative history into a +fresh attempt. The Session keeps a durable operation ledger. Before a tool or delegation side effect, it reserves the operation id and typed request digest. A retry with the @@ -332,7 +467,7 @@ stable dispatch identity or support outcome reconciliation. If a crash leaves a non-idempotent outcome indeterminate, recovery records ToolOutcomeUnknown and does not automatically repeat the side effect. -Native spawning cannot create hidden collaboration state. A spawn must map +Harness spawning cannot create hidden collaboration state. A spawn must map one-for-one to either an authorized child Session or an authorized external delegation operation, then wait for DelegateResult. Otherwise it is disabled. Each child Session has its own revision, plan, authorization, transcript, and @@ -342,7 +477,7 @@ An external delegated agent does not become a child Session. The parent Session ledger records an ExternalDelegationOperation with the stable operation id, parent Session and plan digest, resolved delegate reference from the plan, authenticated remote subject, authorization reference, request digest, status, -correlation id, and response or failure digest. The adapter receives only the +correlation id, and response or failure digest. The harness receives only the resulting DelegateResult. This gives the parent loop a durable return path without claiming knowledge of the external system's implementation, model, internal tools, transcript, or execution plan. @@ -351,18 +486,18 @@ The delegation or integration plane owns the external destination binding, endpoint, and authentication data. SessionExecutionPlan copies only the resolved non-secret reference and digest required to authorize dispatch. At dispatch, that plane authenticates the [transport](../glossary/transport) without exposing credential -material to AgentConfiguration, the native implementation, the prompt, or the +material to AgentConfiguration, the harness, the prompt, or the operation payload. -### 4. Verify local and remote implementations before Ready +### 4. Verify the platform harness before Ready Ready is an admission proof, not only a health signal. It binds: - Session id and plan digest; - AgentImplementationVersion reference and definition digest; -- measured native product identity, version, and artifact digest; -- measured adapter or supervisor artifact digest; -- effective native configuration digest; +- measured platform harness identity, version, and artifact digest; +- measured supervisor artifact digest; +- effective harness configuration digest; - supervisor confirmation-key thumbprint; - restored continuation evidence when resuming; and - the authenticated execution identity that produced the evidence. @@ -371,32 +506,24 @@ The Session does not become runnable until the coordinator validates Ready and confirms that every required model-access grant and live launch authorization is active. -The effective configuration digest covers the exact non-secret native +The effective configuration digest covers the exact non-secret harness configuration projected from AgentConfiguration and SessionExecutionPlan. Secrets, temporary credentials, and sender-constrained proof keys are excluded. The digest must equal the expected value already stored in the plan. -For an in-process or platform-managed launch, a platform-controlled supervisor -verifies artifacts and produces Ready evidence. For a remote pinned -implementation, Ready additionally requires attestation that the native -product build and effective configuration actually deployed at the remote -boundary match the plan. Attesting only the local adapter, remote endpoint, or -transport driver is insufficient. - -V1 verified remote model access requires a platform-controlled attested -supervisor beside the remote implementation. The native product sends a -Session-bound ModelRequested operation to that supervisor over an authenticated -local or private channel. The supervisor verifies the Session, plan, route, -operation id, and request digest, then presents the sender-constrained -ModelAccessGrant to the platform model proxy. The native product never receives -the grant token, proof private key, provider API key, or renewable credential. - -If that supervisor and attestation boundary cannot be established, the remote -system is treated as an external delegated agent. This is a boundary -classification, not another platform resource type. The platform may authorize -a delegation request and record its result, but it does not claim that the -remote internal implementation, configuration, model calls, or tool calls -satisfy this SessionExecutionPlan. +For an in-process or remote platform-harness launch, a platform-controlled +supervisor verifies the exact harness artifact and effective configuration and +produces Ready evidence. A remote launch must attest the deployed harness and +supervisor boundary; attesting only an endpoint or transport driver is +insufficient. + +**Future registered edge extensions.** If a later ADR admits an external +product, its extension contract additionally pins and attests the native product +and adapter artifacts. A platform-controlled supervisor must still mediate +Session-bound model requests without exposing a grant token, proof private key, +provider API key, or renewable credential to that product. If the product and +adapter boundary cannot provide this evidence, the system is an external +delegated agent rather than a verified Session implementation. Hosting is deliberately not a first-class resource in this ADR. Launch attempts may record placement, process, container, remote endpoint, health, restart, and @@ -414,8 +541,11 @@ an append-only sequence of immutable facts: attempt_number previous_attempt_id? restored_checkpoint? - reference + type + digest + implementation_version - resume_cursor? + checkpoint_id + reference + checkpoint_type + digest + implementation_version + producing_execution_attempt_id + covers_through + session_execution_plan_digest + capture attestation reference + digest + effective history digest host artifact or driver reference + digest authenticated remote subject? isolation and placement facts @@ -435,50 +565,43 @@ ExecutionAttempt facts are evidence about one launch, not reusable configuration and not a reusable execution-runtime resource. Restart never edits the prior attempt. It creates a new attempt under the same immutable plan and records its lineage. Admission rejects a new attempt when its host changes -implementation behavior or cannot reproduce the planned native artifact and +implementation behavior or cannot reproduce the planned harness artifact and effective configuration. Cancellation intent belongs to the Session, while ExecutionAttemptEnded records how that intent affected the attempt. -A continuing attempt records the exact admitted checkpoint and acknowledged -cursor it restores. Ready attests that restoration. The prior attempt's model -grants are revoked, and the new supervisor creates a fresh confirmation key. -Only after Ready validates may the platform issue new grants bound to the new -ExecutionAttempt and the unchanged resolved routes. If checkpoint continuity, -cursor continuity, or grant rebinding cannot be proven, the Session fails -instead of replaying work or rewriting its plan. +A continuing attempt records the exact admitted harness recovery checkpoint it +restores. Ready attests that the artifact was verified, its state restored, and +the effective tail replayed through the head selected by the start command. The +prior attempt's model grants are revoked, and the new supervisor creates a fresh +confirmation key. Only after Ready validates may the platform issue new grants +bound to the new ExecutionAttempt and the unchanged resolved routes. If +checkpoint continuity or grant rebinding cannot be proven, that continuation is +rejected and the platform starts a fresh attempt from authoritative history +instead of rewriting the plan. ### 5. Use typed protobuf unions and registered extensions -Built-in implementations use explicit oneof arms. The oneof case is the -implementation kind. Do not add an enum discriminator beside generic -configuration because the two values could disagree. +The platform-managed harness is the only built-in v1 implementation arm. A +future product integration uses the registered edge-extension arm unless a +later ADR changes the core contract. The oneof case is the implementation kind; +an enum discriminator beside it could disagree with the selected value. The following shapes are illustrative. Concrete packages, field names, and supporting value objects require their own Buf-validated contract design. message AgentImplementation { oneof implementation { - CodexImplementation codex = 1; - ClaudeCodeImplementation claude_code = 2; - ManagedImplementation managed = 3; - OpenClawImplementation openclaw = 4; - CompositeImplementation composite = 5; + PlatformHarnessImplementation platform_harness = 1; RegisteredImplementation registered_extension = 100; } Digest definition_digest = 101; Digest configuration_digest = 102; } - message CodexImplementation { - AgentImplementationVersionRef version = 1; - ConfigurationContractVersion configuration_contract = 2; - CodexConfiguration configuration = 3; - } - - message ClaudeCodeImplementation { + message PlatformHarnessImplementation { AgentImplementationVersionRef version = 1; ConfigurationContractVersion configuration_contract = 2; - ClaudeCodeConfiguration configuration = 3; + PlatformHarnessConfiguration configuration = 3; } message RegisteredImplementation { @@ -493,12 +616,12 @@ supporting value objects require their own Buf-validated contract design. AgentImplementationVersionRef implementation_version = 4; Digest implementation_definition_digest = 5; Digest implementation_configuration_digest = 6; - Digest effective_native_configuration_digest = 7; + Digest effective_harness_configuration_digest = 7; ModelSelection primary_model_selection = 8; ResolvedModelRoute primary_model_route = 9; repeated AuxiliaryModelRoute auxiliary_models = 10; SessionDependencies dependencies = 11; - AdapterContractVersion adapter_contract = 12; + HarnessContractVersion harness_contract = 12; SessionPlanContractVersion plan_contract = 13; ModelRouteContractVersion model_route_contract = 14; } @@ -509,9 +632,9 @@ supporting value objects require their own Buf-validated contract design. } The kind recorded by the selected AgentImplementationVersion must agree with -the AgentImplementation oneof arm. A Codex version in a ClaudeCodeImplementation -arm, a standalone OpenClaw version in a CompositeImplementation arm, or any -other mismatch is ImplementationKindMismatch. +the AgentImplementation oneof arm. A platform-harness version in the registered +extension arm, an extension version in the platform-harness arm, or any other +mismatch is ImplementationKindMismatch. The registered_extension arm is the only place this decision permits google.protobuf.Any. Its immutable extension version pins one allowed [type URL](../glossary/type-url), @@ -532,14 +655,15 @@ Apply these rules: 1. Every boundary validates that exactly one known implementation arm exists. A missing arm is unsupported, never a default. -2. Every built-in arm carries an explicit configuration contract version. - Writers, admission services, adapters, and supervisors advertise the exact - contract versions they can interpret. +2. The built-in harness arm carries an explicit configuration contract version. + Writers, admission services, harnesses, supervisors, and registered extension + handlers advertise the exact contract versions they can interpret. 3. Admission requires support for the selected arm and configuration contract version. A newer behavior-affecting field requires a newer contract version, even when protobuf considers the field additive. 4. SessionExecutionPlan and ResolvedModelRoute carry independent contract - versions. Coordinators, adapters, supervisors, and the [model access service](../glossary/model-access-service) + versions. Coordinators, harnesses, supervisors, registered extension + handlers, and the [model access service](../glossary/model-access-service) advertise the exact versions they interpret. Admission requires every plan consumer to support both versions. A missing or unsupported version is a typed admission failure, never a default. Any behavior-affecting or @@ -561,8 +685,9 @@ form. Unknown JSON keys are not an extension mechanism. Durable [event envelopes](../glossary/event-envelope) store the stable full name and exact bytes of each concrete [event](../glossary/event). SessionStarted stores StoredSessionExecutionPlan once in the Session stream. Do not persist the bytes of a top-level event oneof wrapper. -Large immutable implementation definitions and checkpoints may be external -only when the event retains their exact reference, type, and digest. +Large immutable implementation definitions and harness recovery +checkpoints may be external only when the event retains their exact reference, +type, and digest. ### 7. Apply the model to concrete products @@ -570,26 +695,20 @@ The Model ownership column below assumes platform-owned model selection, which the shipped contract does not provide; read it as intended design pending the reconciliation recorded in [ADR#0025](./0025-agent-definition-data-ownership.md). +The platform-managed loop is the only normative v1 row. Product rows summarize +research and possible edge integrations; they do not reserve core oneof arms or +field numbers. | Arrangement | AgentConfiguration implementation | Model ownership | Classification | | --- | --- | --- | --- | -| Codex | Exact Codex product and adapter version, artifacts, and typed options | Exact compatible model selections are separate AgentConfiguration fields | Verified implementation | -| Claude Code | Exact Claude Code product and adapter version, artifacts, and typed options | Exact compatible model selections are separate AgentConfiguration fields | Verified implementation | -| Platform managed loop | Exact managed implementation version and typed options | Exact model selections remain in AgentConfiguration | Verified implementation | -| Fully pinned OpenClaw | OpenClawImplementation pins the exact build, plugins, effective configuration, and allowed delegation behavior | Every model role is explicit in AgentConfiguration; native auto selection and failover are disabled | Verified standalone implementation only with Ready attestation | -| OpenClaw transparently hosting Codex | Codex is the implementation; OpenClaw is an attested launch fact | Codex uses the exact AgentConfiguration model selections | Verified only when OpenClaw cannot alter loop semantics | -| OpenClaw delegating to Codex | OpenClaw parent Session and Codex child Session | Each AgentConfiguration owns exact model selections and its revision binds them | Two verified Sessions | -| OpenClaw plus Codex layered loop | CompositeImplementation pins every component version and composition rule | Every model role is explicit and pinned | Verified composite implementation | -| Unpinned or auto-selecting OpenClaw | None | Internal model and component choices are opaque | External delegated agent only | - -OpenClaw may still make dynamic decisions while running pinned code and -configuration. Dynamic output is not configuration mutability. Dynamically -substituting an unpinned implementation, plugin, model, or hidden child agent -is prohibited for a verified Session. - -When evidence cannot prove that OpenClaw is a transparent host, use the -composite classification or treat it as an external delegated agent. Do not -infer transparency from product naming or transport protocol. +| Platform managed loop | `platform_harness` with its exact version and typed options | Exact model selections remain in AgentConfiguration | Normative built-in v1 implementation | +| Codex or Claude Code | No built-in arm; a later accepted integration uses `registered_extension` and edge-owned product state | Exact compatible model selections remain separate AgentConfiguration fields | Future edge integration research | +| OpenClaw or another composite product | No built-in or composite arm; use external delegation unless a later accepted edge extension can prove the required behavior | Every platform-authorized model role remains explicit in AgentConfiguration | Future edge integration or external delegation | + +Product composition, hidden spawning, native fallback, and product-specific +resume behavior cannot become core Session semantics through the extension +arm. An integration either translates them into existing platform commands or +keeps them outside the verified Session boundary. ### 8. Bound mutability explicitly @@ -640,11 +759,11 @@ The current decision only requires exact implementation artifacts and auditable launch facts. A reusable hosting resource would add policy and versioning before a proven domain invariant requires it. -### Generic configuration for built-ins +### Generic configuration for the built-in harness An enum plus map, Struct, bytes, or unrestricted Any loses type safety and -allows the discriminator to disagree with the value. Built-ins use typed -oneof arms. Any is reserved for registered extensions. +allows the discriminator to disagree with the value. The built-in harness uses +a typed oneof arm. Any is reserved for registered extensions. ### Treat dynamic OpenClaw as an ordinary verified implementation @@ -660,13 +779,15 @@ attested. Otherwise it is treated as an external delegated agent. hidden Session resolution. - Every Session records the exact implementation, configuration projection, model routes, dependencies, and canonical plan bytes it admitted. +- Session events, aggregate snapshots, harness recovery checkpoints, + and read-side checkpoints have separate authority and failure behavior. - Provider credentials remain outside Agent and implementation configuration. -- Local and remote implementations share one adapter contract, but remote - verified execution requires stronger native artifact, configuration, and - supervisor attestation. -- Codex and Claude Code provide the primary implementation model. OpenClaw - remains supported through explicit pinned, composite, delegated, or external - classifications. +- A future registered edge extension owns its product and adapter attestation + inside the extension contract; it does not add product fields to the built-in + harness plan. +- The platform-managed harness loop provides the normative v1 implementation + model. Codex, Claude Code, and OpenClaw remain future edge compatibility + cases and cannot shape the core Session contract. - The platform avoids premature hosting resources while preserving launch evidence in Session and deployment records. - Protobuf evolution requires explicit configuration contract capability diff --git a/docs/adr/0035-session-store-decider-aggregate.md b/docs/adr/0035-session-store-decider-aggregate.md index 629cec274..16af6b846 100644 --- a/docs/adr/0035-session-store-decider-aggregate.md +++ b/docs/adr/0035-session-store-decider-aggregate.md @@ -56,8 +56,9 @@ implemented in the substrate today: already satisfied on this substrate for free, unless an aggregate opts out. - **Physical sequence is the order of record.** [ADR#0013](./0013-origin-stream-sequence-header.md) makes the current JetStream - stream sequence authoritative for checkpoints, high-water marks, and optimistic - concurrency, and confines `Trogon-Origin-Stream-Sequence` to provenance on + stream sequence authoritative for [read-side checkpoints](../glossary/checkpoint), + high-water marks, and optimistic concurrency, and confines + `Trogon-Origin-Stream-Sequence` to provenance on restore/backfill/migration/rebuild only. Prior art exists but on the wrong mechanism. The `origin/platform` branch carries @@ -124,6 +125,20 @@ are accepted and those obligations are met. `v1alpha1` is also the room in which breaking rename. `events.proto` carries a file-level comment naming this promotion criteria. +Within `v1alpha1`, a field may still be added as `LEGACY_REQUIRED`, and the +reason it is admissible is narrower than the version suffix: no deployed producer +has written these events yet. A new required field breaks by having a current +validator reject already-stored bytes, and there are no stored bytes until a +producer ships. The window is therefore open only while both conditions hold -- +no deployed producer, and the package still `v1alpha1` -- and it closes at +whichever comes first. A producer shipping on `v1alpha1` closes it early, because +that is what creates the stored bytes; promotion closes it regardless of +producers, because promotion is the act of accepting the compatibility +obligation. Once it closes, a new required field needs a new package version. Note +that `buf breaking` under `WIRE_JSON` passes either way, since it compares fields +present on both sides and a field new to one side is not among them; the check +here is a review obligation, not a mechanical one. + ### 2. Append-only mutation, opaque identity, ordinal anchors, and per-command optimistic concurrency Append is the only mutation primitive. `decide` returns only new events, `evolve` @@ -140,8 +155,9 @@ flag flips). `SessionId` is an opaque addressing key; order and durable cross-references are separate concerns from identity (forced decision #2). A payload that must reference another event's position -- a fork's inherited-prefix boundary, a -rewind's inclusive keep-through boundary, a compaction's covered range, a checkpoint's -coverage, a delegated child's dispatch point -- uses `SessionOrdinal`: the +rewind's inclusive keep-through boundary, a compaction's covered range, a +harness recovery checkpoint's coverage, a delegated child's dispatch +point -- uses `SessionOrdinal`: the 1-indexed position of an already-appended event within its own subject's fold order, derived by counting at fold time, never read from JetStream message metadata. Because it is fold-derived rather than physically assigned, it is @@ -171,7 +187,8 @@ even though both are per-session integers: the prior art's `Seq` was assigned ADR supersedes. `SessionOrdinal` guards nothing and is assigned by nobody; it exists purely as a fold-derived reference to something that already happened. Physical JetStream sequence remains authoritative for OCC guards, processor -checkpoints, and consumption ([ADR#0013](./0013-origin-stream-sequence-header.md)); +[checkpoints](../glossary/checkpoint), and consumption +([ADR#0013](./0013-origin-stream-sequence-header.md)); a `SessionOrdinal` never substitutes for it, and a physical sequence never enters a domain payload. @@ -183,12 +200,12 @@ command by a three-way classification of the fact being appended, not uniformly | Precondition | Commands (named by the fact recorded) | Why | | --- | --- | --- | | `NoStream` (guard `0`) | `CreateSession` records `[SessionStarted]`; `ForkSession` records `[SessionStarted, SessionForked]`; delegated child creation records `[SessionStarted, ParentLinked]` | Creation is atomic and exactly-once; the stream must not already exist. | -| `At(current_position)` | `SessionClosed`, `SessionCancelled`, `SessionFailed`, `SessionHidden`, `SessionRewound`, `Compacted`, `ExecutionAttemptStarted`, `ExecutionAttemptReady`, `ExecutionAttemptEnded`, `ToolCallApproved`, `ToolCallDenied`, `OperationReserved`, `OperationOutcomeRecorded`, `OperationCancellationRequested`, `DelegationDispatched`, `ExternalDelegationDispatched`, `ParentTerminated`, `ParentHistoryInvalidated`, `DelegationDetached`, `ParentDetached`, `RedactionApplied`, `ArtifactErased` | `decide` genuinely branches on the current head for each of these: one active attempt, Ready-after-Started, mutually exclusive approve/deny and complete/fail decisions, one terminal outcome per ledger operation, one saga step per dispatch or detach. A stale decision here would violate an invariant, so it must be rejected, not appended. | +| `At(current_position)` | `SessionClosed`, `SessionCancelled`, `SessionFailed`, `SessionHidden`, `SessionRewound`, `Compacted`, `ExecutionAttemptStarted`, `ExecutionAttemptReady`, `ExecutionAttemptEnded`, `ToolCallApproved`, `ToolCallDenied`, `OperationReserved`, `OperationOutcomeRecorded`, `OperationCancellationRequested`, `DelegationDispatched`, `ExternalDelegationDispatched`, `ParentTerminated`, `ParentHistoryInvalidated`, `DelegationDetached`, `ParentDetached`, `RedactionApplied`, `ArtifactErased` | `decide` genuinely branches on the current head for each of these: one active attempt, Ready-after-Started, mutually exclusive approve/deny decisions, one outcome per execution attempt, one terminal outcome per ledger operation, and one saga step per dispatch or detach. A stale decision here would violate an invariant, so it must be rejected, not appended. | | `Any` (no server-side guard) | `UserMessageRecorded`, `AssistantMessageStarted`, `AssistantMessageCompleted`, `AssistantMessageFailed`, `ToolCallRequested`, `ToolCallStarted`, `ToolCallCompleted`, `ToolCallFailed`, `ArtifactRecorded`, `FileChanged`, `CheckpointProduced`, `SystemNoticeRecorded`, `TodoUpdated`, `SessionRenamed`, `SessionArchived`, `SessionUnarchived` | These commute: `decide` does not need the exact head to be correct, appends never overwrite, and the highest-volume path stays retry-free. | `StreamExists` is never used, because it sends no server-side guard. -Two of these lists need one more rule each, because commuting is not the same +Some `Any` facts need an explicit fold rule, because commuting is not the same claim as conflict-free (forced decision #4's correction): a handful of `Any` facts can still disagree about a shared entity, and an append-only log cannot resolve that by refusing the write. The fold resolves it deterministically @@ -202,12 +219,34 @@ instead: surfaced by a projection flag, never folded into state. Because fold order is the stream's own total order, this is replay-deterministic regardless of arrival timing. +- **An orphan happened-fact folds as unjoined, never as rejected.** The same + absence of a head guard that lets two outcomes land also lets a fact land + with nothing to join to: a `ToolCallStarted` whose `tool_call_id` matches no + `ToolCallRequested`, an `AssistantMessageCompleted` or + `AssistantMessageFailed` whose `message_id` matches no + `AssistantMessageStarted`, or a completion whose `model` disagrees with its + start. `decide` reads no state for any of these, so nothing can refuse them. + Where the entity exists and the fact disagrees with it, the started entity's + own value stands (the start's `model` wins over a completion that contradicts + it); where the entity does not exist, the fact joins to nothing and changes no + entity state. Either way it is retained on the log and surfaced by the same + projection flag as a late conflicting outcome. These are producer bugs, and + the store's job is to make them visible, not to pretend an unguarded append + could have prevented them. - **`TodoUpdated`: highest-`revision`-wins.** Every update carries a required, monotonic `revision` from the session's single logical writer (the active attempt's loop); the fold keeps whichever update has the highest revision seen so far, independent of arrival order -- which is what makes it truly commuting rather than merely unguarded. Ties resolve to the first occurrence in fold order. +- **`CheckpointProduced`: first evidence per checkpoint id wins.** The first + admitted artifact evidence for a `checkpoint_id` is the value restoration may + select. A later payload reusing that id is retained for audit but cannot + replace the first value. The command idempotency key includes a canonical + digest of the complete checkpoint evidence (the exact `Checkpoint` bytes the + command persists), not only the artifact digest, so a later payload that + differs in any evidence field remains visible while byte-identical + redelivery collapses through the event identity contract below. - **Post-terminal happened-facts remain audit-only**, generalizing the existing rule: a session's first terminal marker is authoritative, and any happened-fact folded after it (a late `ToolCallCompleted`, for instance) is @@ -294,9 +333,10 @@ the other substrate obligations below, not hoped for). A duplicate id beyond that horizon is impossible by that invariant, not merely unlikely. Entity-keyed facts are additionally immune regardless of the id set: a duplicate terminal outcome no-ops under first-terminal-outcome-wins per entity id, a repeated -`TodoUpdated` no-ops under highest-revision-wins, and checkpoint, artifact, and -operation facts collapse on their own stable ids -- the seen-key horizon is -defense for the purely arrival-ordered facts (`UserMessageRecorded`, +`TodoUpdated` no-ops under highest-revision-wins, harness recovery checkpoint +evidence applies first-wins per checkpoint id, and artifact and operation facts +collapse on their own stable ids -- +the seen-key horizon is defense for the purely arrival-ordered facts (`UserMessageRecorded`, `FileChanged`, `SystemNoticeRecorded`). Beyond the dedup window, a guarded (`At`) command's retry re-replays and no-ops on its idempotency key as before; an `Any` fact past the window relies on this reader-side collapse by identical @@ -342,13 +382,27 @@ every event file, and `state`/`projections`/`checkpoints` sibling subtrees whose read-model value types are redefined locally to decouple their evolution from the write side. -Every event is decoded and validated through its generated codec on both append -and replay (forced decision #9, -[ADR#0021](./0021-typed-decode-over-passthrough-forwarding.md)): a malformed -payload is rejected at the boundary and never reaches durable storage. This ADR -adds an observable decode-failure metric for session events -- the decider crate -emits no such metric today and its append/replay decode-error paths are currently -silent -- following [ADR#0021](./0021-typed-decode-over-passthrough-forwarding.md)'s principle that boundary decode failures must be +Validation belongs to a Session-owned append and replay boundary, not the +generic runtime or NATS adapter. Otherwise domain-specific ownership and stream +address checks either leak into infrastructure or can be skipped by another +Session write path. That boundary validates a whole batch before publish, +requires each decoded event to be an owned Session event whose payload +`session_id` matches the addressed stream, and revalidates replay before +`evolve` (forced decision #9, +[ADR#0021](./0021-typed-decode-over-passthrough-forwarding.md)). +`validate_session_event` owns local same-event shape checks. Plan, lifecycle, +and history relationships remain Session `decide` and `evolve` invariants, +protected by the command's OCC classification instead of being pushed into a +generic codec. A malformed batch is rejected before any member reaches durable +storage; malformed replay fails closed before state changes. +Decode, local validation, and stream-identity failures are typed, observable, +and non-retryable until the input or addressed stream changes, preventing a +poison message from cycling without new evidence. + +This ADR adds an observable decode-failure metric for session events -- the +decider crate emits no such metric today and its append/replay decode-error +paths are currently silent -- following +[ADR#0021](./0021-typed-decode-over-passthrough-forwarding.md)'s principle that boundary decode failures must be measured, not dropped. Unlike the prior art, no `InvalidEventRejected` event is persisted: rejection happens before the write, so there is nothing to record. Schema evolution is additive (new optional fields, reserved retired numbers), @@ -382,17 +436,90 @@ is not exactly-once execution. Every command is gated before `decide` by the proposed `CommandPrincipal`/`CommandAuthorizer` of draft [ADR#0026](./0026-command-authorization-principal.md), once those land. -**Checkpoint provenance.** The domain `Checkpoint` embedded in -`CheckpointProduced` and in `ExecutionAttemptStarted.restored_checkpoint` gains +**Four records with separate authority.** These records solve different +failures: + +1. The typed event log is authoritative. It is the only record from which the + Session aggregate and read models are rebuilt. +2. An aggregate [snapshot](../glossary/snapshot) is an advisory cached fold of + that log. Corruption or incompatibility falls back to earlier replay. +3. A harness recovery checkpoint is an opaque artifact used only when the + platform continues process state from an in-flight harness loop. It cannot + replace event replay or satisfy an aggregate snapshot. +4. A read-side [checkpoint](../glossary/checkpoint) is only a consumer's + processed stream position. + +The protobuf `Checkpoint` keeps its existing wire name, but ADR prose uses +harness recovery checkpoint for it so these concepts do not collapse into one +another. + +**Harness recovery checkpoint admission.** The `Checkpoint` embedded in +`CheckpointProduced` and in `ExecutionAttemptStarted.restored_checkpoint` has its own `checkpoint_id`, `producing_execution_attempt_id`, `covers_through` (a `SessionOrdinal`, facet 2), and `session_execution_plan_digest`, alongside its -existing `reference`, `checkpoint_type`, `digest`, and `implementation_version`. -`CheckpointProduced` itself slims to `{session_id, checkpoint}`. -`ExecutionAttemptStarted.restored_checkpoint` stays an embedded `Checkpoint` -deliberately -- it is attempt evidence of exactly what was restored, -digest-verified, and now unambiguously joined to its producing event via -`checkpoint_id`; the validator requires the restored plan digest to match the -session's plan. +`reference`, `checkpoint_type`, `digest`, and `implementation_version`, plus +the capture-attestation reference and digest and the effective-history digest +that [ADR#0031](./0031-agent-implementation-and-session-plan.md) requires as +the admission proof of semantic coverage. +`CheckpointProduced` stays `{session_id, checkpoint}` and +`ExecutionAttemptStarted.restored_checkpoint` stays embedded because the latter +is evidence of exactly what the new attempt restored. + +The Session-owned command boundary applies the full harness recovery +checkpoint admission contract from +[ADR#0031](./0031-agent-implementation-and-session-plan.md) before append. The +standalone payload validator enforces local shape, including equality between a +restored checkpoint's plan digest and the plan digest on its containing +`ExecutionAttemptStarted`. The Session decider separately enforces attempt, +stored-plan, and ordinal relationships against folded state. Artifact +verification enforces sealing, digest, and compatibility with the harness +implementation version committed by the plan, plus the capture attestation: +its signature, field-for-field equality between the attested values and the +admitted checkpoint evidence, and equality between the attested +effective-history digest and the platform's own recomputation. + +`CheckpointProduced` records evidence about a cut that is already settled. The +evidence remains historically valid when later Session events append, although +a later rewind, redaction, or artifact erasure reaching history at or before +its cut makes it ineligible for restoration. Production therefore uses +`Any`. The command boundary verifies that the producing attempt exists, its plan +digest matches the Session, and `covers_through` names settled in-session +history. + +Restoration is the head-dependent choice. At the current head selected by +`StartExecutionAttempt`, `decide` requires all of the following: + +- `checkpoint_id` resolves to the first admitted `CheckpointProduced` evidence + selected by the fold; +- the complete embedded `Checkpoint` value exactly equals that first evidence, + not only its plan digest; +- `covers_through` remains in effective Session history after every rewind + folded through the selected head; +- no `RedactionApplied` folded through the selected head targets an event at + or before `covers_through`, and no `ArtifactErased` erases an artifact + recorded at or before it, because tail replay cannot rebuild the sealed + prefix a reinterpretation retargets (a `Compacted` marker in the tail does + not disqualify, whatever range it covers: the restored attempt and a fresh + replay hold the same covered facts and fold the same self-sufficient + marker); and +- the producing attempt and stored Session plan still satisfy the recovery + contract. + +`ExecutionAttemptStarted` therefore remains `At(current_position)`. After it is +appended, the supervisor verifies and restores the artifact, then replays the +exact effective tail after `covers_through` through the head selected by the +start command. Ready and new work wait until that replay completes, as specified +by [ADR#0031](./0031-agent-implementation-and-session-plan.md). + +`Checkpoint.covers_through` is the core Session replay cut. Internal harness +coordinates remain inside the opaque, versioned artifact, not core event fields +or `SessionOrdinal` values. A partial, mutable, digest-mismatched, ambiguously +correlated, or incompatible artifact is not admissible. Missing or invalid +checkpoint evidence falls back to authoritative event replay and a fresh +ExecutionAttempt. Future +Claude, Codex, or other product integrations translate at the edge and cannot +add their session identities, transcript layouts, or resume coordinates to the +core Session schema. **Tool-fact ownership.** `ToolCallRequested` and the provider-visible `ToolUseBlock`/`ToolResultBlock` embedded in message events are not the same @@ -505,13 +632,11 @@ and `ExternalArtifact.fetched_at`, whose comments are clarified to occurrence time, not append time. Everything else relies on append order, not clocks. This is a narrow, named exception, not a general license to add timestamps. -**Validation obligations at the append boundary.** `LEGACY_REQUIRED` stays -exactly as this facet requires: it enforces presence, not semantic validity, so -a named validator at the append boundary plus `decide`-time state checks carry -the rest. At minimum the validator rejects: +**Validation ownership at the append boundary.** `LEGACY_REQUIRED` enforces +presence, not semantic validity. `validate_session_event` therefore owns only +checks answerable from one decoded event. At minimum it rejects: -- Non-empty, correctly shaped identifiers that match the addressed stream where - applicable. +- Empty or malformed identifiers within the event. - Nonzero, supported enum values wherever a field is a required enum, including `CascadePolicy` (persisted values only `1` or `2`; the command layer applies the safe default before append, per the [cascade policy](../glossary/cascade-policy) @@ -522,18 +647,18 @@ the rest. At minimum the validator rejects: `ResourceObservation.outcome`. - Role agreement: `UserMessageRecorded.message.role` is `USER`; `AssistantMessageCompleted.message.role` is `ASSISTANT`. -- Started-and-completed assistant message id and model agreement. -- Tool lifecycle id joins across phases. - `FILE_CHANGE_KIND_RENAMED` requires `previous_path`; non-renames must omit it. -- Ordered, in-session compaction ranges (inclusive `covers_from`/`covers_through`). +- Locally ordered compaction bounds (`covers_from <= covers_through`). - `matched_stop_sequence` present if and only if `FINISH_REASON_STOP_SEQUENCE`. -- Positive, monotonic attempt numbers and valid previous-attempt lineage. +- Positive attempt numbers and the within-event coupling between attempt number + and presence of `previous_attempt_id`. +- A restored checkpoint plan digest equal to its containing + `ExecutionAttemptStarted` plan digest. - Supported digest algorithms with length checks matching the algorithm (claim-check digests are sha256 in `v1alpha1`; `Digest.algorithm` is the additive escape hatch for a later one). -- Valid `input_json`, with the operation digest computed over the exact - persisted bytes. +- Well-formed `input_json`. - Valid ISO 4217 currency codes. - Valid timestamps, and non-negative durations with sub-second nanos. - Unique todo ids with valid statuses. @@ -549,8 +674,42 @@ the rest. At minimum the validator rejects: `range.length` when a range is given, and no `range` when the outcome is absent. -Every unset oneof, unspecified enum, and malformed shape above is rejected -before append, never persisted and reconciled later. +The Session-owned append and replay boundary, outside +`validate_session_event`, verifies that the decoded type belongs to Session and +that its payload `session_id` matches the addressed stream. The Session command +boundary also computes request digests over the exact bytes it will persist. +`decide` and `evolve` own every history-dependent relationship, and the state +read is what places a given one: where a command reads no state there is nothing +to reject against, so its relationship can only be a fold rule. +`decide` owns the relationships whose command declares a state read in the +command matrix, and checks them before the append: in-session ordinal existence +and compaction ordering, exact attempt lineage, a checkpoint's producing attempt +and plan digest and settled `covers_through`, complete restored-checkpoint +equality with the fold-selected evidence, continued effectiveness of +`covers_through` after rewind, and equality with the stored Session plan. These +are rejected if they fail. +`evolve` owns the relationships carried by commuting facts whose state read is +`none` -- the assistant start/completion id and model joins, and the tool +lifecycle joins. Those append under `Any` with nothing to check against, so they +are fold rules rather than append-time rejections: an unmatched or disagreeing +fact lands on the log and is surfaced by a projection flag, exactly as the `Any` +fold rules above prescribe. +`ProduceCheckpoint` is the one command whose relationships split across both, so +the state read alone does not place all of them. Its admissibility checks are +append-time and rejectable, as listed above; its **first-evidence-wins selection +per `checkpoint_id` is a fold rule, not a precondition**. Rejecting a later +conflicting payload at append would destroy the audit record the checkpoint +contract promises: a later payload reusing a `checkpoint_id` is retained and +visible, distinguished by the canonical digest of the complete evidence, and +merely never selected. This is why restoration compares against the evidence the +fold selected rather than against whatever the store saw last. +This split prevents a local payload validator from claiming facts that only +the command context or folded history can prove, and it keeps the command +matrix honest: a command whose state read is `none` cannot enforce a join, so +the matrix states its join as the fold rule it is. + +Every unset oneof, unspecified enum, and malformed same-event shape above is +rejected before append, never persisted and reconciled later. ### 4. Compaction is a self-sufficient in-stream marker the store only records @@ -785,13 +944,21 @@ duplicate of that event too -- there is no second copy under a different id for a masking pass to miss. Redacting a source stream also automatically masks every fork's inherited context, because a fork reads source events by reference rather than by copy (facet 5): there is exactly one place the bytes -live, so exactly one redaction covers every view of them. +live, so exactly one redaction covers every view of them. Redacting an event +at or before an admitted harness recovery checkpoint's `covers_through` also +makes that checkpoint ineligible for restoration (facet 3), so sealed harness +state never resurrects masked content either; restoration falls back to +authoritative replay, which applies the mask from the first fact. **`ArtifactErased` separates artifact-byte lifecycle from event-log retention.** New event `ArtifactErased{session_id, artifact_id, reason}` (`At`-guarded) records out-of-band destruction of claim-checked artifact bytes; the artifact's digest and metadata remain on the log as provenance even after the -bytes themselves are gone. +bytes themselves are gone. Erasing an artifact recorded at or before an +admitted harness recovery checkpoint's `covers_through` also makes that +checkpoint ineligible for restoration (facet 3): sealed state may retain +fetched artifact content the log no longer serves, so restoration falls back +to authoritative replay. **Ingress rules keep secrets out in the first place.** Credential-bearing URLs, signed URLs, and other secrets are prohibited in durable fields; @@ -814,9 +981,10 @@ supersession explicitly rather than leaving the two decisions in tension. Storage is otherwise managed without ever removing a fact: -- **Snapshots bound replay, not storage.** The runtime resumes from the newest - snapshot and replays only the tail after it (facet 8), so a long session costs - O(tail) to load even though its log grows forever. +- **Aggregate snapshots bound replay, not storage.** The runtime resumes the + Session aggregate from the newest snapshot and replays only the tail after it + (facet 8), so a long session costs O(tail) to load even though its log grows + forever. This does not restore process-local harness state. - **Cold-storage tiering is an optional, reversible, non-semantic relocation.** If a deployment must bound the hot JetStream stream, already-immutable old events may be copied to the JetStream Object Store, evicted from the hot stream, and restored on @@ -849,7 +1017,13 @@ Resume rebuilds a session's state the way the runtime rebuilds any decider aggregate: load the newest snapshot for the session, then replay only the tail after it -- a fork resumes purely from its own child-stream snapshot, since it never folded source events into its aggregate state to begin with (facet 5) -- -so resume cost tracks snapshot cadence, not transcript length. +so aggregate resume cost tracks snapshot cadence, not transcript length. If the +platform harness supports restoring process-local in-flight state, that +continuation uses an admitted harness recovery checkpoint under facet 3. A +missing or invalid checkpoint never changes what the event log says happened; +the platform replays authoritative history and starts a fresh attempt. Only +incomplete authoritative history or an indeterminate side effect that requires +reconciliation can block recovery. ### Command-by-command matrix @@ -874,8 +1048,8 @@ decision. | `HideSession` | head | `At` | `[SessionHidden]` | none beyond head match | hide-request id | | `RewindSession` | head | `At` | `[SessionRewound]` | `keep_through` within current log | rewind-request id | | `CompactSession` | head | `At` | `[Compacted]` | `covers_from <= covers_through`, ordered, in-session | compaction-request id | -| `StartExecutionAttempt` | head, active-attempt state | `At` | `[ExecutionAttemptStarted]` | one active attempt; monotonic attempt number | attempt id | -| `MarkExecutionAttemptReady` | head, attempt state | `At` | `[ExecutionAttemptReady]` | Ready only after Started | attempt id | +| `StartExecutionAttempt` | head, active-attempt state, effective history, checkpoint evidence | `At` | `[ExecutionAttemptStarted]` | one active attempt; monotonic attempt number; restored checkpoint exactly equals first admitted evidence; `covers_through` remains effective, with no later redaction or artifact erasure reaching at or before it; restore and tail replay through the selected head precede Ready | attempt id | +| `MarkExecutionAttemptReady` | head, attempt state | `At` | `[ExecutionAttemptReady]` | Ready only after Started and, for restore, verified artifact recovery plus effective-tail replay through the start head | attempt id | | `EndExecutionAttempt` | head, attempt state | `At` | `[ExecutionAttemptEnded]` | one outcome per attempt | attempt id | | `ApproveToolCall` | head, tool-call state | `At` | `[ToolCallApproved]` | mutually exclusive with deny | `tool_call_id` | | `DenyToolCall` | head, tool-call state | `At` | `[ToolCallDenied]` | mutually exclusive with approve; blocks start/complete | `tool_call_id` | @@ -891,15 +1065,15 @@ decision. | `EraseArtifact` | head | `At` | `[ArtifactErased]` | artifact exists and is claim-checked | `artifact_id` + erasure-request id | | `RecordUserMessage` | none | `Any` | `[UserMessageRecorded]` | role is `USER` | `message_id` | | `StartAssistantMessage` | none | `Any` | `[AssistantMessageStarted]` | none | `message_id` | -| `CompleteAssistantMessage` | none | `Any` | `[AssistantMessageCompleted]` | role is `ASSISTANT`; id/model agree with start | `message_id` | -| `FailAssistantMessage` | none | `Any` | `[AssistantMessageFailed]` | id references a started message; first terminal outcome per id wins | `message_id` | +| `CompleteAssistantMessage` | none | `Any` | `[AssistantMessageCompleted]` | role is `ASSISTANT` (append-time payload check); id and model agree with start (fold rule: the start's model stands, the disagreement is flagged) | `message_id` | +| `FailAssistantMessage` | none | `Any` | `[AssistantMessageFailed]` | id references a started message, and first terminal outcome per id wins (both fold rules: an orphan or late outcome is flagged, not refused) | `message_id` | | `RequestToolCall` | none | `Any` | `[ToolCallRequested]` | none | `tool_call_id` | -| `StartToolCall` | none | `Any` | `[ToolCallStarted]` | `tool_call_id` matches a request | `tool_call_id` | -| `CompleteToolCall` | none | `Any` | `[ToolCallCompleted]` | first-terminal-outcome-wins vs. `ToolCallFailed` | `tool_execution_id` | -| `FailToolCall` | none | `Any` | `[ToolCallFailed]` | first-terminal-outcome-wins vs. `ToolCallCompleted` | `tool_execution_id` | +| `StartToolCall` | none | `Any` | `[ToolCallStarted]` | `tool_call_id` matches a request (fold rule: an orphan start joins to nothing and is flagged) | `tool_call_id` | +| `CompleteToolCall` | none | `Any` | `[ToolCallCompleted]` | first-terminal-outcome-wins vs. `ToolCallFailed` (fold rule) | `tool_execution_id` | +| `FailToolCall` | none | `Any` | `[ToolCallFailed]` | first-terminal-outcome-wins vs. `ToolCallCompleted` (fold rule) | `tool_execution_id` | | `RecordArtifact` | none | `Any` | `[ArtifactRecorded]` | source oneof set; MIME fallback rule | `artifact_id` | | `RecordFileChange` | none | `Any` | `[FileChanged]` | `RENAMED` requires `previous_path`; others omit it | change id | -| `ProduceCheckpoint` | none | `Any` | `[CheckpointProduced]` | `checkpoint_id` unique; plan digest matches session | `checkpoint_id` | +| `ProduceCheckpoint` | settled history, plan, producing attempt | `Any` | `[CheckpointProduced]` | artifact admissible, attempt and plan match, `covers_through` settled (all append-time); first evidence wins per `checkpoint_id` (fold rule: a later conflicting payload is retained and visible, never selected) | `checkpoint_id` + canonical digest of the complete checkpoint evidence | | `RecordSystemNotice` | none | `Any` | `[SystemNoticeRecorded]` | none | notice id | | `UpdateTodo` | none | `Any` | `[TodoUpdated]` | `revision` monotonic from single logical writer; highest-revision-wins fold | `session_id` + `revision` | | `RenameSession` | none | `Any` | `[SessionRenamed]` | none | rename-request id | @@ -1023,8 +1197,8 @@ hot path without inheriting its weak lifecycle guarantees. envelope, and the idempotency-key representation are now decided in the `v1alpha1` proto package (facets 1-7 above); what remains implementation-level follow-up is commands, aggregate `initial_state`/`evolve`/`decide`, - projections, snapshots, and the substrate obligations facet 2 lists as - prerequisites. + projections, aggregate snapshots, harness recovery checkpoint + admission, and the substrate obligations facet 2 lists as prerequisites. ## Consequences @@ -1042,14 +1216,22 @@ hot path without inheriting its weak lifecycle guarantees. translation layer (facet 2); [ADR#0013](./0013-origin-stream-sequence-header.md)'s origin-sequence header remains purely a provenance concern for these payloads, never a domain reference. +- The event log remains authoritative when an aggregate snapshot or harness + recovery artifact is missing. A bad aggregate snapshot falls back to replay; + a bad harness recovery checkpoint falls back to replay and a fresh attempt. + Only incomplete authoritative history or an indeterminate side effect can + prevent that recovery path. - Because commuting facts append without a head guard, two conflicting outcomes for the same entity can both land on the log (a `ToolCallCompleted` and a `ToolCallFailed` for the same `tool_execution_id`, say). The fold resolves these deterministically -- first-terminal-outcome-wins per entity, highest-revision-wins for `TodoUpdated`, first terminal marker wins for the session itself -- rather than the store rejecting either at write time - (facet 2); every read model over the transcript must be written to honor - these fold rules, not just tolerate late facts. + (facet 2). The same applies to a fact with nothing to join to, since these + commands read no state: an orphan `ToolCallStarted` or an + `AssistantMessageCompleted` naming a `message_id` that was never started folds + as unjoined and flagged. Every read model over the transcript must be written + to honor these fold rules, not just tolerate late facts. - No caller migration is needed on the curated line (greenfield). The platform `crates/session` domain model is salvaged; its persistence is rewritten as a decider, and the switching subsystem is dropped pending [ADR#0031](./0031-agent-implementation-and-session-plan.md). diff --git a/docs/glossary/checkpoint.md b/docs/glossary/checkpoint.md index 3b1e9ed59..439c9b90f 100644 --- a/docs/glossary/checkpoint.md +++ b/docs/glossary/checkpoint.md @@ -6,5 +6,11 @@ order: 11 # Checkpoint -The last stream sequence a projection or consumer has processed, so it can resume -without reprocessing. See [Decider Platform](../architecture/decider.md#read-side-primitives-projector-and-processor). +The last physical stream sequence a projection or consumer has processed, so it +can resume without reprocessing. This read-side position is not an aggregate +[Snapshot](./snapshot) and is not the `trogonai.session.sessions.v1alpha1.Checkpoint` +protobuf. [ADR#0031](../adr/0031-agent-implementation-and-session-plan.md) and +[ADR#0035](../adr/0035-session-store-decider-aggregate.md) call that opaque +platform harness state a harness recovery checkpoint to keep the failure modes +distinct. +See [Decider Platform](../architecture/decider.md#read-side-primitives-projector-and-processor). diff --git a/docs/research/acp/products/gemini-cli.md b/docs/research/acp/products/gemini-cli.md index ac6a27e39..a0084065f 100644 --- a/docs/research/acp/products/gemini-cli.md +++ b/docs/research/acp/products/gemini-cli.md @@ -12,7 +12,7 @@ ACP mode is invoked with `gemini --acp` (add `--debug` for verbose ACP tracing) ### Integration wiring -Process model: the ACP client (an editor/IDE) spawns `gemini --acp` as a stdio subprocess; gemini-cli never listens on a socket in this mode. Zed's own gemini-cli agent page describes this explicitly as spawning the same CLI binary as a subprocess speaking ACP (https://zed.dev/acp/agent/gemini-cli). Filesystem access is proxied: gemini-cli does not touch the workspace filesystem itself in ACP mode, it issues fs/terminal RPCs back to the client, which enforces what paths are visible (https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/acp-mode.md). MCP passthrough happens during `initialize`: the client advertises its own MCP server, gemini-cli connects to it and folds the discovered tools into the same tool-calling loop it uses for its native MCP client support. Session lifecycle (new/load/resume) rides on ACP's `newSession`/`loadSession`, but the underlying durable transcript is gemini-cli's own local append-only JSONL log under `~/.gemini/tmp//chats/`, single-writer, no multi-host coordination; see the [session store research](../../session-store/products/gemini-cli.md) for detail. +Process model: the ACP client (an editor/IDE) spawns `gemini --acp` as a stdio subprocess; gemini-cli never listens on a socket in this mode. Zed's own gemini-cli agent page describes this explicitly as spawning the same CLI binary as a subprocess speaking ACP (https://zed.dev/acp/agent/gemini-cli). Filesystem access is proxied: gemini-cli does not touch the workspace filesystem itself in ACP mode, it issues fs/terminal RPCs back to the client, which enforces what paths are visible (https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/acp-mode.md). MCP passthrough happens during `initialize`: the client advertises its own MCP server, gemini-cli connects to it and folds the discovered tools into the same tool-calling loop it uses for its native MCP client support. Session lifecycle (new/load/resume) rides on ACP's `newSession`/`loadSession`, but the underlying durable transcript is gemini-cli's own local append-only JSONL log under `~/.gemini/tmp//chats/`, single-writer, no multi-host coordination; see the [session store research](../../session-store/products/gemini-cli/index.md) for detail. ### Channel mapping diff --git a/docs/research/acp/products/opencode.md b/docs/research/acp/products/opencode.md index b3c638c8d..e50f499ca 100644 --- a/docs/research/acp/products/opencode.md +++ b/docs/research/acp/products/opencode.md @@ -43,7 +43,7 @@ Copy: exposing ACP as a thin, additive surface over an agent's existing tool/ses - https://github.com/kortix-ai/opencode-channels - https://github.com/ominiverdi/opencode-chat-bridge - https://agentclientprotocol.com/overview/clients -- [session store research](../../session-store/products/opencode.md) (anomalyco/opencode fork, commit 62e4641235d7847dadc60da37cca8a023dd54fc1) +- [session store research](../../session-store/products/opencode/index.md) (anomalyco/opencode fork, commit 62e4641235d7847dadc60da37cca8a023dd54fc1) ## Adversarial verification diff --git a/docs/research/session-store/RESEARCH_PROMPT.md b/docs/research/session-store/RESEARCH_PROMPT.md index de2367c91..1aa66164b 100644 --- a/docs/research/session-store/RESEARCH_PROMPT.md +++ b/docs/research/session-store/RESEARCH_PROMPT.md @@ -1,7 +1,7 @@ # 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 +goes into `docs/research/session-store/products/{slug}/index.md`, following the section skeleton below. Add the dossier to `index.md` when done. ## Task @@ -102,6 +102,18 @@ to the durable session. 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. +- **An envelope is not the payload.** If the entry type inherits its content + field from a base class, or carries the message in a generic `params`, `data`, + or `content` field, open that type too and quote it. Quoting only the + wrapper's own declared fields satisfies "quote the type definitions" while + leaving the actual message undocumented, and two dossiers here did exactly + that: Google ADK's records every field `Event` declares but never opens its + superclass, where the payload lives (`content: Optional[types.Content]`, + `src/google/adk/models/llm_response.py:62`), and Grok Build's names + `SessionUpdateEnvelope{timestamp, method, params}` as the source-of-truth log + line without ever opening `params` or the `ConversationItem` in the cache it + derives. In both cases the envelope is the easy half and the payload is the + half a reader needs. - 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? @@ -166,21 +178,38 @@ to the durable session. 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 +2. **Cite repo-root-relative paths, not bare filenames.** Write + `crates/agent/src/db.rs:671`, not `db.rs:671`. Large trees hold many files + with the same basename (Zed has three `db.rs` and three `migrations.rs`; + Pi has ten `types.ts`), and a bare basename cannot be mechanically resolved + by a later auditor, which is exactly when the citation matters most. A + short form is acceptable only for repeat references within the same section + *after* the full path has appeared there, and only when that basename is + unique in the tree. Prefer the `:123` bare-line form for repeats within a + section that has already named one file. +3. 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, +4. **Quotation marks mean transcribed, never summarized.** If text sits inside + quotes, it must match the source character for character, including its + punctuation. Tightening a comment while leaving the quotes on it produces a + claim that survives every mechanical check and is still false: a Mastra + default-throw message was rendered as "this is likely a bug -- all adapters + should implement this" when the source reads "This is likely a bug - all + Mastra storage adapters should implement resource support." Paraphrase + freely, but then drop the quotes and cite the line. +5. Record the retrieval date with `date +%F`; never guess it. +6. 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 +7. 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 +8. 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. @@ -190,7 +219,7 @@ to the durable session. # {PRODUCT}: how session transcripts are stored and resumed Part of Session Store Research. -Produced by running [RESEARCH_PROMPT](../RESEARCH_PROMPT.md). +Produced by running [RESEARCH_PROMPT](../../RESEARCH_PROMPT.md). Evidence snapshot retrieved YYYY-MM-DD. Version-sensitive claims were checked against these authoritative anchors: diff --git a/docs/research/session-store/RESEARCH_PROMPT_COMPARISON.md b/docs/research/session-store/RESEARCH_PROMPT_COMPARISON.md new file mode 100644 index 000000000..310e7bd4b --- /dev/null +++ b/docs/research/session-store/RESEARCH_PROMPT_COMPARISON.md @@ -0,0 +1,197 @@ +# Research Prompt: how {PRODUCT} compares to our Session Store + +Reusable prompt for stage two of the session-store study. Stage one is +[RESEARCH_PROMPT](./RESEARCH_PROMPT.md), which produces a standalone dossier +describing how a product stores sessions. This prompt consumes that dossier +and produces a comparison against our own design, plus a ranked set of +changes we should consider making. + +Run once per product. Output goes to +`docs/research/session-store/products/{slug}/vs-session-events.md` and is +linked from `index.md` under its stage-one dossier. + +[fx compared to our session event catalog](./products/fx/vs-session-events.md) +is the worked reference implementation of this prompt. Match its shape. + +## Preconditions + +Do not run this prompt until the stage-one dossier for {PRODUCT} exists and +its claims are pinned to a commit or a dated document snapshot. A comparison +built on an unverified dossier inherits its errors and launders them into a +recommendation. + +## Inputs + +Read all of these before writing anything: + +- The stage-one dossier: `./products/{slug}/index.md`. +- Our decision record: + [ADR#0035: Session Store as a Decider Aggregate on NATS JetStream](../../adr/0035-session-store-decider-aggregate.md). + This is authoritative. Where the dossier's conclusion conflicts with an + accepted ADR, note the difference; do not override it. +- Our event catalog: `proto/trogonai/session/sessions/v1alpha1/`. Read the + actual `.proto` files. Do not compare against a remembered version of the + catalog. +- The cross-product [synthesis](./synthesis.md), so a recommendation already + argued there is cited rather than re-derived. + +## Break-change latitude + +The catalog is `v1alpha1` and [ADR#0035](../../adr/0035-session-store-decider-aggregate.md) is a draft. **Breaking changes are on +the table.** A recommendation must not be watered down into an additive +half-measure merely to preserve wire compatibility. + +What this does *not* mean is that breaking changes are free. Every +recommendation must state its blast radius explicitly: + +- **Additive** -- new field, new event type, new optional value. No migration. +- **Breaking, cheap** -- rename, retype, or field removal with no persisted + data to carry forward, or a mechanical regeneration. +- **Breaking, expensive** -- changes the meaning of already-persisted events, + requires a replay/rewrite, or splits or merges an existing event type. +- **Breaking the decision, not the schema** -- contradicts a numbered decision + in [ADR#0035](../../adr/0035-session-store-decider-aggregate.md). Name the decision by number. These are the most valuable + findings and the most expensive to act on. + +Prefer the honest expensive recommendation over the dishonest cheap one. +State the cost; let the ADR owner make the trade. + +## Maturity weighting + +Weight evidence by how proven the **store** is, not by how popular the +product is. A 48k-star product whose sessions are an unversioned markdown +log is weak evidence. A 2k-star vendor CLI with eight SQL migrations is +strong evidence, because its schema demonstrably survived contact with +shipped users. + +Score each axis 0-3 and record the evidence inline. Do not report a bare +number without the artifact that justifies it. + +| Axis | What earns a high score | Evidence to cite | +| --- | --- | --- | +| **Evolution scars** | The store format changed under load and carried its data forward | Migration files, schema-version fields, legacy-format sniffing, back-compat read paths, format-version constants | +| **Operational age** | The store has been in the field long enough to hit real failure modes | First commit touching the store, not repo creation date; issues reporting corruption, growth, or lock contention, and their fixes | +| **Exposure** | Real users depend on resume working across crashes, upgrades, and hosts | Vendor-shipped distribution, paid product, or adoption scale; multi-host or network-filesystem handling in the code | +| **Design independence** | The store is an original design, not inherited from an upstream fork | Whether the store code diverges from the fork parent, with paths | + +Sum to a 0-12 **store maturity score**. Record it in the comparison's front +matter. It is not a ranking of products; it is the weight the reader should +place on this product's answer when it disagrees with another product's. + +Two rules follow from the score: + +1. When products disagree, the higher-scoring store's answer is the default, + and the comparison must say why the lower-scoring one diverged (deliberate + trade-off, immaturity, or different problem). +2. A recommendation supported only by stores scoring under 6 must be labelled + **thin evidence** and must not be presented as an industry norm. + +## Research questions + +### 14. Comparison against our catalog + +- **The one structural difference everything else follows from.** Most + comparisons reduce to a single divergence (commit granularity, identity + model, mutability, ownership of derived state) that explains the rest of + the diffs as consequences. Find it and lead with it. If there genuinely + isn't one, say so rather than manufacturing one. +- **Fact-by-fact mapping.** For every durable field or entry type in the + product's store, name our equivalent event or field, or record that we have + none. Use a table. Include the reverse direction: what we record that they + do not, and whether their omission looks deliberate. +- **Semantic mismatches.** Where both sides have a nominal equivalent that + means something different (a "session id" that is a path in one and an + opaque id in the other; a "checkpoint" that is a marker in one and a + snapshot in the other), call it out. These are more dangerous than gaps + because they survive a naive mapping. +- **Where our design is already ahead.** Required, not optional. A comparison + that only finds gaps is a comparison that was not read critically. + +### 15. What we should consider changing + +Each recommendation is a numbered subsection, ordered most-consequential +first, and must carry all of: + +- **The change**, stated concretely against a named `.proto` file, event + type, or [ADR#0035](../../adr/0035-session-store-decider-aggregate.md) decision number. +- **The evidence anchor**: the product, its store maturity score, and the + `path:line` or quote that supports it. No anchor, no recommendation. +- **Blast radius**, using the four categories above. +- **Why it is a good idea, or why it is not.** A recommendation may conclude + *do not do this*. Recording a rejected change with its reasoning is as + valuable as recording an accepted one, and stops it being re-proposed. +- **What it costs us** beyond the migration: added write-path work, larger + events, a new projection to maintain, a new failure mode. + +Then three closing buckets, all required: + +- **Trade-offs, not gaps.** Differences that are defensible on both sides. + Say what each side bought and paid. +- **What not to copy.** Patterns present in the product that we should + explicitly reject, with the reason. This section prevents a future reader + mistaking the dossier's description for endorsement. +- **Open questions for the ADR.** Anything the comparison surfaced that the + ADR does not currently answer, phrased as a question the ADR owner can + decide. + +### 16. Feed the two gaps the industry has not closed + +The synthesis concludes that the industry has approximated the event-sourced +session store everywhere, and that the two things nobody in the corpus has +closed are **subagent cascade semantics** and **retention on an unbounded +log**. + +These are gaps in *the industry*, not in our design. [ADR#0035](../../adr/0035-session-store-decider-aggregate.md) decisions 6 and +7 already take detailed positions on both, so the job here is to **test those +decisions against this product's evidence**, not to describe them as missing. +Name the decision, say whether the evidence validates, refines, or challenges +it, and where the product's answer is worse, say that too. Writing this +section as though we have no position on cascade or retention is the single +most likely way to get this comparison wrong. Every comparison must answer +both explicitly, even if the answer is "this product has no position on it": + +- What does this product do when a parent session is deleted, rewound, or + crashes while a child session is live? Quote the code path, not the docs. +- What stops this product's durable record from growing without bound? If + nothing does, find the issue reports where that became a user-visible + problem. + +## Method + +1. Every claim about the product traces to the stage-one dossier or to a + pinned `path:line`. Every claim about our design traces to a `.proto` file + or an [ADR#0035](../../adr/0035-session-store-decider-aggregate.md) decision number. Assertions with neither do not ship. +2. Record the retrieval date with `date +%F`; never guess it. +3. Quote our own proto fields exactly. The catalog moves; a paraphrase from + memory will silently go stale. +4. Mark inference as inference. The dossiers keep description and conclusion + separate on purpose; keep that discipline here. +5. Put anything unresolved under **Open questions** rather than resolving it + with a guess. + +## Output skeleton (per product file) + +```markdown +# {PRODUCT} compared to our session event catalog + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT_COMPARISON](../../RESEARCH_PROMPT_COMPARISON.md). +Stage-one dossier: [{PRODUCT}](./index.md). +Compared against `proto/trogonai/session/sessions/v1alpha1/` and [ADR#0035](../../adr/0035-session-store-decider-aggregate.md) on YYYY-MM-DD. + +**Store maturity: N/12** -- evolution scars N/3 ({evidence}), operational age +N/3 ({evidence}), exposure N/3 ({evidence}), design independence N/3 ({evidence}). + +## The one structural difference everything else follows from +## Mapping +## What we should consider changing +### 1. {change} +### 2. {change} +## What our design already does better +## Trade-offs, not gaps +## What not to copy +## The two gaps the industry has not closed +### Subagent cascade +### Retention on an unbounded log +## Open questions for the ADR +``` diff --git a/docs/research/session-store/backlog.md b/docs/research/session-store/backlog.md new file mode 100644 index 000000000..045127baf --- /dev/null +++ b/docs/research/session-store/backlog.md @@ -0,0 +1,342 @@ +# Session store research backlog + +Eighteen products queued for the two-stage study: +[stage one](./RESEARCH_PROMPT.md) produces the dossier, +[stage two](./RESEARCH_PROMPT_COMPARISON.md) produces the comparison against +our catalog and the ranked change recommendations. + +Commits pinned 2026-08-04 against each repository's default branch. Re-pin +before starting a product if its dossier has not been written yet; these +anchors exist so a dossier can cite an exact tree, not so the backlog can go +stale quietly. + +## Ordering + +Two factors, in this order: + +1. **Store maturity.** Evidence from a store that has migrated its own data + under shipped users outweighs evidence from a store that has never had to. + The rubric is in the [stage-two prompt](./RESEARCH_PROMPT_COMPARISON.md). +2. **Relevance to the two open gaps** the synthesis leaves for us: subagent + cascade semantics and retention on an unbounded log. + +Star counts deliberately do not appear below. They measure product adoption, +not whether the storage format survived contact with reality. Amazon Q CLI +has the fewest stars on this list and one of the most-migrated schemas. + +## Wave 1 -- mature stores that speak to both open gaps + +| Product | Repo @ pinned commit | License | Why first | +| --- | --- | --- | --- | +| OpenHands | `OpenHands/software-agent-sdk` @ `973c35134f0b` (primary), `OpenHands/OpenHands` @ `866512a485c8` (app) | MIT | Only candidate that addresses both gaps: agent delegation recorded as events in the parent stream, and a condenser as a live retention story on an append-only log | +| Pi | `earendil-works/pi` @ `a96fb984d8c8` | MIT | Three numbered session-format versions with auto-migration on load, a checked-in format spec, and a pluggable repository interface. Young, but the strongest evolution evidence on the list | +| Cline | `cline/cline` @ `5ec2d47b21b3` | Apache-2.0 | Subtask parent/child model, plus the only documented user-visible failure of unbounded transcript growth. Failure evidence outranks another success story | +| Zed | `zed-industries/zed` @ `4aad57fd1f00` | Per-crate Apache-2.0 + GPL | Oldest codebase on the list, `sqlez` domain migrations with an explicit backfill-key pattern, and the only store whose schema is ACP-shaped. Cross-reference the ACP corpus | +| Continue | `continuedev/continue` @ `5522c6f44ca0` | Apache-2.0 | Ships legacy-format filtering in the read path, which is direct evolution evidence, and its per-session-file plus `sessions.json` index is a worked index-drift failure mode | + +## Wave 2 -- mature stores, narrower lesson + +| Product | Repo @ pinned commit | License | Why | +| --- | --- | --- | --- | +| Amazon Q CLI | `aws/amazon-q-developer-cli` @ `15cc8f3cd18c` | Apache-2.0 | Eight named SQL migrations under a vendor-shipped CLI. Store is a single mutable `ConversationState` blob keyed by cwd, which is the degenerate endpoint of the retention spectrum: no history to retain | +| Crush | `charmbracelet/crush` @ `fcfad839bbef` | FSL-1.1-MIT | Seven goose migrations in fifteen months. `parent_session_id` plus cascade foreign keys expresses the subagent cascade policy directly in DDL. Also stores versioned file content inside the session database | +| Letta | `letta-ai/letta` @ `ff19ffeafeb5` | Apache-2.0 | MemGPT lineage, longest-running attempt at separating agent state from the message log, and an archival tier that is a real retention answer rather than an absence of one | +| Aider | `Aider-AI/aider` @ `5dc9490bb35f` | Apache-2.0 | Mature product, deliberately thin store. Expect a low maturity score despite the product's age; the finding is what a widely used tool chose *not* to persist | + +## Wave 3 -- younger stores and framework abstractions + +| Product | Repo @ pinned commit | License | Why | +| --- | --- | --- | --- | +| Google ADK | `google/adk-python` @ `cbedafd9e4c1` | Apache-2.0 | `BaseSessionService` over in-memory, database, sqlite, and Vertex, with its own migration and schema directories. Product-side counterpart to LangGraph's checkpointer | +| OpenAI Agents SDK | `openai/openai-agents-python` @ `7b7587425a17` | MIT | A second OpenAI session model incompatible with Codex rollout files. The divergence inside one vendor is the finding | +| AWS Strands | `strands-agents/harness-sdk` @ `23541039fa1f` | Apache-2.0 | Interface-first `session/` module with file and S3 repositories. Note the repo redirect from `sdk-python`; code lives under `strands-py/src/strands/session/` | +| Mastra | `mastra-ai/mastra` @ `9e1dad8f7b1c` | Apache-2.0 with `ee/` carve-out | Thread and message shape held constant across seven backends. Evidence about which parts of a session model are backend-independent | + +## Wave 4 -- thin stores, short entries + +| Product | Repo @ pinned commit | License | Why | +| --- | --- | --- | --- | +| SWE-agent | `SWE-agent/SWE-agent` @ `3ea751c087f3` | MIT | `.traj` trajectory files. Benchmark-driven rather than resume-driven, which makes it a clean contrast case for what a session is *for* | +| Void | `voideditor/void` @ `b3166e7ef2ae` | Apache-2.0 | Persistence layer not yet located; last commit 2026-06-02. Timebox it, and if there is no coherent store, record that as the finding and stop | + +## Wave 5 -- forks, delta-only + +Do not write full dossiers. Answer one question: what diverged from +upstream's store, with paths. "Nothing diverged" is a complete and useful +answer. + +| Product | Repo @ pinned commit | License | Upstream | +| --- | --- | --- | --- | +| Roo Code | `RooCodeInc/Roo-Code` @ `b867ec914575` | Apache-2.0 | Cline | +| Kilo Code | `Kilo-Org/kilocode` @ `6ec20f23952b` | MIT | Cline, via Roo Code | +| Qwen Code | `QwenLM/qwen-code` @ `06cc41ee3f50` | Apache-2.0 | Gemini CLI | + +## Where the authoritative spec lives + +Located and confirmed to exist. A dossier author starts here rather than +rediscovering it, and any product whose row says *needs discovery* should be +timeboxed before it consumes a full research slot. + +| Product | Authoritative types and format | +| --- | --- | +| Pi | `packages/coding-agent/docs/session-format.md` is a checked-in written spec, with version 1 linear, version 2 `id`/`parentId` tree, version 3 role rename, auto-migrated on load. Types in `packages/coding-agent/src/core/session-manager.ts` and `messages.ts`, `packages/ai/src/types.ts`, `packages/agent/src/types.ts`. Store interface in `packages/agent/src/harness/session/repository.ts` with `jsonl-repo.ts` and `memory-repo.ts` implementations, plus `scripts/migrate-sessions.sh`. The spec's links point at `pi-mono`, which is the pre-rename name of the same repo | +| OpenHands | `openhands-sdk/openhands/sdk/conversation/event_store.py`, `state.py`, `persistence_const.py`; server side in `openhands-agent-server/openhands/agent_server/persistence/{models,store}.py`. Dedicated tests at `tests/sdk/conversation/test_event_store.py`, `test_state_serialization.py`, `tests/sdk/event/test_event_serialization.py` | +| Cline | `apps/vscode/src/core/storage/disk.ts`, `StateManager.ts`, `state-migrations.ts`, with a migration test suite in `__tests__/state-migrations.test.ts`. Monorepo layout: storage moved under `apps/vscode/` | +| Zed | `crates/agent_ui/src/thread_metadata_store.rs`, `agent::ThreadStore`, and the ACP schema at `agent_client_protocol::schema::v1` | +| Crush | `internal/db/migrations/*.sql` is the schema of record; sqlc output in `internal/db/models.go`, `sessions.sql.go`, `messages.sql.go`. The `messages.parts` column is JSON, so the entry type itself lives in Go outside `internal/db` and needs discovery | +| Continue | `core/util/history.ts` and `core/util/paths.ts`; `Session` and `BaseSessionMetadata` types in `core/index.d.ts` | +| Amazon Q CLI | `crates/chat-cli/src/database/mod.rs` plus `crates/chat-cli/src/database/sqlite_migrations/*.sql`; `ConversationState` under `crates/chat-cli/src/cli/` | +| Letta | `letta/schemas/message.py`, `conversation.py`, `letta_message.py`, `agent.py`, `archive.py`; service layer in `letta/services/message_manager.py` | +| Google ADK | `src/google/adk/sessions/session.py`, `base_session_service.py`, `database_session_service.py`, plus `schemas/` and `migration/` | +| AWS Strands | `strands-py/src/strands/session/file_session_manager.py` and `__init__.py` | +| OpenAI Agents SDK | `src/agents/memory/sqlite_session.py`, reference at `docs/ref/memory.md` | +| Mastra | Storage domain interfaces under `packages/core/src/storage/`, per-backend adapters under `stores/*/src/storage/domains/memory/` | +| Aider | `aider/io.py` (`chat_history_file`). Expect no schema; the absence is the finding | +| SWE-agent | Trajectory writer in `sweagent/agent/agents.py`; entry type needs discovery | +| Void | Needs discovery. Timebox and drop if nothing coherent exists | +| Roo Code, Kilo Code, Qwen Code | Diff against upstream's paths above | + +## Completed + +**Wave 1, dossiers written and verified**: Cline, Continue, OpenHands, Pi, Zed. + +Verification was mechanical then manual, because the two catch different +errors. Mechanically, every `path:line` in each dossier's prose was resolved +against the pinned tree: 434 citations across the five, with no unresolvable +path in any of them. Manually, each dossier's load-bearing claims were then +read back against source, because a line number that resolves says nothing +about whether the line supports the claim attached to it, and that is the +failure mode a citation checker cannot see. + +That manual pass changed three things, which is the argument for doing it: + +- Zed's `sqlez` ratchet was described as content-hashed. It stores each + migration's full text and compares it after `sqlformat` normalization, with + an escape-hatch callback. Corrected, because "hashed" would have implied a + cheaper drift check than the one that exists. +- Zed's subagent cascade was described as breadth-first. The code pops from + the back of a `frontier` vector, so it is depth-first. Corrected. The + traversal order does not matter for delete-everything semantics, but the + claim should still be true. +- Cline's cascade delete was described as a guarantee against orphaning. It is + guarded by `if (!row.isSubagent)` and does not recurse, so it holds only + because the graph is in practice one level deep. Added, because the + distinction between a cascade that is transitive and one that merely looks + transitive on a flat graph is exactly what wave 1 was commissioned to find. + +One prompt defect surfaced and was fixed before wave 2 could inherit it: +dossiers cited bare basenames (`db.rs:671`), which cannot be mechanically +resolved in trees holding three `db.rs` and three `migrations.rs`, or ten +`types.ts`. The stage-one prompt now requires repo-root-relative citations. + +**Waves 2 through 4, dossiers written and verified**: Aider, Amazon Q +Developer CLI, AWS Strands, Crush, Google ADK, Letta, OpenAI Agents SDK, +SWE-agent, Void. + +**Wave 5, fork deltas written and verified**: Roo Code, Kilo Code, Qwen Code. + +Both verification layers ran on all of these. The mechanical layer found and +fixed real citation defects rather than merely passing: Amazon Q shipped 18 +unresolvable citations across two different files both named `mod.rs` in a tree +holding 43 of them, plus four more split between two files named +`checkpoint.rs`, all now fully qualified; Letta cited an Alembic revision under +an elided filename and gave a line range 105 lines past the end of +`summarizer.py`. The bare-basename defect therefore survived the stage-one +prompt fix in agents working from long context, which is worth knowing: the +rule needs enforcement, not just statement. + +The manual layer again found what the mechanical layer structurally cannot, +this time in the Kilo Code delta, and the corrections mattered more than wave +1's: + +- Kilo was credited with authoring a recursive cascade delete. The recursion + carries no `// kilocode_change` marker, so it arrived with the vendored + OpenCode core. Re-attributed, because crediting it to Kilo would have + double-counted one upstream's evidence as two independent data points. +- The same delta asserted Roo Code has "no cascade-delete subsystem of its + own". Roo recurses over `childIds` at + `src/core/webview/ClineProvider.ts:1747-1762`, which the Roo delta had + already established as the corpus's cleanest counter-example on cascade + semantics. Corrected, and the two deltas now agree. +- The delta recommended cross-checking Kilo's engine against an OpenCode + dossier "if this corpus later adds" one. The corpus already had one. + Corrected to point at it, with the caveat that the two are pinned at + different upstream generations, so a diff between them may be version skew + rather than a Kilo patch. +- One flagged open question was resolved rather than carried: `SessionV2` is + neither a parallel engine nor an in-progress rewrite, it is a dependency of + the engine the report treats as authoritative. + +A second prompt defect surfaced during the stage-two calibration batch (Cline, +OpenHands, Zed) and was fixed before the remaining comparisons were +commissioned. The comparison skeleton titled a section "The two open gaps", +which reads as though subagent cascade and retention are unresolved in *our* +design. They are unresolved in the industry; [ADR#0035](../../adr/0035-session-store-decider-aggregate.md) decisions 6 and 7 take +detailed positions on both. All three calibration documents got the substance +right and tested the decisions properly, so the defect was latent rather than +realized, but the heading would have propagated to twelve more documents and +misled anyone skimming. The section is now "The two gaps the industry has not +closed", and the prompt states outright that writing it as though we have no +position is the single most likely way to get a comparison wrong. + +**Stage-two comparisons written and verified**: Aider, Amazon Q Developer CLI, +AWS Strands Agents, Continue, Crush, Google ADK, Letta, Mastra, OpenAI Agents +SDK, Pi, SWE-agent, Void, on top of the fx reference implementation and the +Cline, OpenHands, and Zed calibration batch. The prompt fix above held: all +sixteen use the corrected heading and test decisions 6 and 7 rather than +presenting our design as having a hole. + +Mastra scores 11/12, the corpus's joint-highest store maturity alongside Letta, +and it is the first product whose evidence is a set of backends disagreeing with +each other: four adapters reach four different atomicity conclusions from one +shared abstract interface, from a real Postgres transaction down to DynamoDB's +sequential writes plus a rollback that can itself fail and is logged and +swallowed. That disagreement is the finding, not a defect in any one adapter. + +Both layers ran on every one. Three defect classes recurred often enough to be +worth naming, and the third is new to stage two: + +- The bare-basename defect survived a second prompt statement. Amazon Q's + comparison reproduced the exact defect already fixed in its own dossier, two + bare `mod.rs` citations in a tree holding 148 candidates, and Pi's comparison + shipped four bare `types.ts` and one bare `messages.ts` in a tree holding ten + and two respectively. Stating the rule twice is not enforcement; the checker + is. +- Right line number, wrong file. Aider's comparison attributed + `self.aider_commit_hashes = set()` to `aider/commands.py:349`, which is an + unrelated `/commit` dirty check; the assignment is at + `aider/coders/base_coder.py:349`. Pi's comparison attributed a tree-entry + envelope to `packages/coding-agent/src/core/types.ts:375-380`, a path that + does not exist: `SessionTreeEntryBase` is at + `packages/agent/src/harness/types.ts:375-380` and the CLI's own + `SessionEntryBase` is in `session-manager.ts`. Both resolve mechanically only + if the checker is not told which tree to look in, and neither is visible + without opening the file. +- An absence established against a file that does not exist. Pi's comparison + supported a recommendation with "zero matches for `retainedTail` in + `packages/coding-agent/src/core/compaction.ts`", but `compaction.ts` is a + directory there. The finding survived on stronger evidence once re-derived, + zero matches anywhere under `packages/coding-agent/src` against five in that + package's own `docs/session-format.md`, which is the sharper claim. A grep + that returns nothing because the path is wrong looks exactly like a grep that + returns nothing because the code is absent. + +Two smaller patterns were corrected across the wave rather than per-document. A +comparison citing its own dossier by line number, 39 sites in Void and 9 in +Amazon Q, now cites by section link instead, and only for grep-established +absences and dossier conclusions; the Void conversion went further and traced 27 +of its 39 back to product source. And several proto citations named a field that +sat just outside the range they cited, which is why the rule is now to open the +file and confirm the field is inside the range. + +**Per-artifact verification was not enough.** Running the checker once over +every artifact with a local clone found defects in six files that individual +waves had already reported clean: seven flattened paths in the Cline comparison +(each omitting a `stores/`, `services/`, or `models/` segment), ten bare +`types.ts` and `messages.ts` citations in the Pi dossier, thirteen bare +`manager.py` and `registry.py` citations in the OpenHands dossier, and, in both +Zed artifacts, a set of bare `db.rs` citations spanning two different crates, +one of which attributed `open_fallback_db` to `crates/agent/src/db.rs:215` when +it lives at `crates/db/src/db.rs:215`. The corpus now stands at 1912 citations +with everything resolved. The lesson is procedural: a per-artifact check run at +landing time uses whatever root set was convenient then, and a bare basename +unique in one root becomes ambiguous the moment a sibling root is added. Only +the whole-corpus run with the correct root set per artifact is meaningful. + +Reading `open_fallback_db` in full to fix its path also closed two of the Zed +dossier's own open questions, which is worth noting as a side effect of +verification rather than a separate task: the fallback's trigger conditions are +now confirmed rather than inferred from its name, and both of Zed's identifier +mint sites turn out to be `Uuid::new_v4()`, so the identifier scheme the dossier +listed as undetermined is settled. Fixing a citation means opening the file, and +opening the file answers questions the original pass had left open. + +## Stage three -- the per-provider payload catalog + +Both stages so far take a product as the unit of study. Neither takes a +provider, and the omission is measurable rather than arguable. Across the whole +corpus, "Anthropic" appears four times, one of which is our own proto comment +offering `"anthropic"` as an example value; "Messages API", "Bedrock", and +"LiteLLM" appear zero times; "Responses API" appears once. There is no catalog +anywhere of Anthropic content-block types, OpenAI Responses API item types, +Google GenAI `types.Content` and `Part` variants, or Bedrock Converse blocks. + +This matters because `ProviderBlock` +(`proto/trogonai/session/sessions/v1alpha1/message.proto:62-70`) exists to +absorb precisely what the typed `ContentBlock` arms cannot model. It carries a +`provider` string and a `block_type` described as "the provider's own +discriminator for the block, verbatim". We designed the escape hatch and never +enumerated what goes through it, so we cannot currently say whether the seven +typed arms are the right seven, which is the question the schema's shape +actually turns on. The fx comparison raises the same doubt from the other side +and leaves it open: whether a provider-native escape hatch belongs in a +canonical catalog at all. + +Two data points bound the problem. Google ADK, the corpus's joint +second-strongest store at 10/12, inherits its payload from `LlmResponse` rather +than declaring it, so the shape a reader needs is one class away from the shape +the dossier documents. The OpenAI Agents SDK, at 5/12, stores +`TResponseInputItem = ResponseInputItemParam`, a bare alias onto the provider's +own wire type with no envelope and no version field, which means its durable +payload *is* the provider format. That is the exact failure mode `ProviderBlock` +is meant to avoid, and it is currently the corpus's only worked example of it, +supplied by one of its weakest stores. + +What a stage-three prompt must answer, per provider rather than per product: +enumerate the block and item discriminators from the published API surface, mark +which map onto a typed `ContentBlock` arm and which can only land in +`ProviderBlock`, and decide whether `block_type` needs a registry or stays +genuinely opaque. + +**Two dossier repairs this surfaced**, both the same defect and now covered by +rule 7 of the stage-one prompt: + +- **Google ADK dossier**: document the inherited payload. `Event` extends + `LlmResponse`, which is named twice and only as a superclass; the actual + content field and its `parts` structure are absent. +- **Grok Build dossier**: open `SessionUpdateEnvelope.params` and + `ConversationItem`. Both are named in the directory-contents table and never + again, so the payload inside the source-of-truth append log is undocumented. + This dossier also has no entry-structure section at all, unlike the other + twenty-four. + +## Verification state at queue time + +The pinned commits and licenses above are verified. The store descriptions +are not uniformly verified, and the dossier author should treat them as +leads, not findings: + +- **Source read this session**: Crush (full initial migration and migration + list), Continue (`core/util/history.ts`), Zed + (`crates/agent_ui/src/thread_metadata_store.rs`), Amazon Q CLI + (`crates/chat-cli/src/database/mod.rs`). +- **Checked-in format spec read, source not yet read**: Pi + (`packages/coding-agent/docs/session-format.md`). +- **Module or path listing only**: OpenHands, Cline, Google ADK, Strands, + Letta, OpenAI Agents SDK, Mastra, Aider, SWE-agent. +- **Unverified**: Roo Code, Kilo Code, Qwen Code, Void. + +Two pins moved after locating the specs. OpenHands' persistence is in +`software-agent-sdk`, not the `OpenHands/OpenHands` app repo, and Pi moved +from wave three to wave one: the maturity rubric weights evolution scars +highest, and three numbered format versions with an auto-migrating loader is +the strongest such evidence on the list, which outweighs the store's age. + +## License flags + +Three products need their provenance stated before anything from them is +cited as an open-source precedent: + +- **Crush** is FSL-1.1-MIT, source-available rather than OSI open source, + converting to MIT on a delay. +- **Zed** is licensed per-crate across Apache-2.0 and GPL. Check the crate a + quote comes from. +- **Mastra** is Apache-2.0 with an `ee/` enterprise carve-out. + +## Excluded, with reason + +- `jentic/standard-agent`: its entire persistence surface is + `agents/memory/dict_memory.py`, an in-memory dict. No durable session, so + nothing to compare. +- Closed-source harnesses (Cursor, Amp, Copilot CLI, Windsurf, Factory + Droid): no primary source, and the corpus rule is primary sources first. diff --git a/docs/research/session-store/index.md b/docs/research/session-store/index.md index 6a5f48ce9..3642685a0 100644 --- a/docs/research/session-store/index.md +++ b/docs/research/session-store/index.md @@ -9,32 +9,180 @@ 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. +The study runs in two stages, and both prompts are preserved so the scope and +evidence rules behind every artifact remain reproducible. + +- [Stage one](./RESEARCH_PROMPT.md) produces a standalone dossier describing + how a product persists, resumes, lists, and retires sessions. +- [Stage two](./RESEARCH_PROMPT_COMPARISON.md) consumes that dossier and + produces a comparison against our event catalog and [ADR#0035](../../adr/0035-session-store-decider-aggregate.md), weighting + each product's evidence by how proven its store is rather than how popular + the product is. ## Status -Synthesis complete. The product dossiers and the cross-product synthesis -exist, and the decision record now exists as draft +Synthesis complete for the first nine products, and the decision record exists +as draft [ADR#0035: Session Store as a Decider Aggregate on NATS JetStream](../../adr/0035-session-store-decider-aggregate.md). The fx artifacts (the dossier, the session detail JSON reference, and the comparison against our event catalog) were added after the synthesis and are not yet folded into it. +The corpus is being extended to twenty-eight products. The queue, its +ordering, and the verification state of each candidate are in the +[backlog](./backlog.md). fx was the reference implementation of the stage-two +prompt; Cline, OpenHands, and Zed were the calibration batch that exercised it +against fresh dossiers, and the remaining comparisons follow their shape. + +Stage-two comparisons now exist for sixteen of those products and are listed +under their dossiers below. The synthesis's frozen decision-time text has not +absorbed them, but its cross-corpus results are recorded in +[a closing section](./synthesis.md#stage-two-results-not-yet-absorbed-above) +rather than left in nobody's hands. Where a comparison's ranked recommendations +disagree with the frozen text, the comparison is the newer reading and the ADR is +still authoritative over both. + +Every dossier and comparison listed below has been verified in two layers. +First, mechanically: each `path:line` citation is resolved against the pinned +tree by `verify-citations.py`, which distinguishes a missing file from an +ambiguous basename so that shorthand does not read as fabrication. Second, by +hand: each artifact's load-bearing claims are read back against source, because +a resolvable line number says nothing about whether the line supports the claim +made about it. + +The mechanical layer is only trustworthy when it is run over the whole corpus +at once. Run per-artifact as each landed, it reported clean on files that were +not: a sweep of every artifact with a local clone found flattened paths, wrong +crates, and bare basenames in six files that individual waves had already +passed. The current state of that sweep is 1912 citations, all resolved, no +missing file and no out-of-range line, across all eighteen products whose +source is checked out locally. The remaining dossiers (fx, Claude Agent SDK, +Codex CLI, Gemini CLI, Goose, Grok Build, Hermes, LangGraph, OpenCode, T3 Code) +predate that sweep and were verified by hand only, so they carry weaker +mechanical guarantees than the number above suggests. + +The second layer is the one that earns its cost. It is what caught a migration +ratchet described as content-hashed when the source compares stored text, a +graph walk described as breadth-first when it is depth-first, a cascade +presented as complete when it stops one level from a root, and a fork credited +with a recursive delete it had vendored from a third codebase. Every one of +those cited a real line. None of them could have been caught mechanically. + +The comparisons added two failure modes of their own, both now checked for +explicitly. The first is a citation that resolves to the right line number in +the wrong file, which reads as precise and is not: a commit-hash set was +attributed to `aider/commands.py:349`, where line 349 is an unrelated dirty +check, and a tree-entry envelope was attributed to a `types.ts` path that does +not exist in the package it names. The second is an absence established against +a file that is not there, which proves nothing: a field was reported as having +"zero matches" in a `compaction.ts` that is a directory, not a file. A +comparison may cite its own dossier, but by section link rather than line +number, and only for grep-established absences and for the dossier's own +conclusions; any claim about how code behaves carries a product source path +opened during the comparison. + +A third failure mode surfaced in the Mastra dossier and is the hardest of the +citation defects to catch: a paraphrase presented inside quotation marks. A default-throw +message was quoted as "this is likely a bug -- all adapters should implement +this" when the source reads "This is likely a bug - all Mastra storage adapters +should implement resource support." The citation resolved, the claim it +supported was sound, and only reading the line back word for word showed the +quotation marks were doing work the source did not authorize. Quoted text is +now transcribed, never summarized inside quotes. + +A fourth failure mode is not about citations at all but about what a section +leaves out, and it survived every check above because nothing it says is wrong. +Two dossiers document an entry's envelope and never open the payload inside it: +Google ADK's records every field `Event` declares while its content is inherited +from a superclass the dossier names only in passing, and Grok Build's names +`SessionUpdateEnvelope{timestamp, method, params}` as the source-of-truth log +line without opening `params`. Both quoted a type definition, as the prompt +asked; both quoted the wrong half. Rule 7 of the [stage-one +prompt](./RESEARCH_PROMPT.md) now says an envelope is not the payload, and the +two repairs are queued in the [backlog](./backlog.md), together with a queued +stage three that takes a provider rather than a product as its unit of study, +since nothing in the corpus enumerates what a `ProviderBlock` would carry. + ## Product dossiers -- [Claude Agent SDK and Claude Code](./products/claude-agent-sdk.md) -- [Codex CLI (OpenAI)](./products/codex-cli.md) -- [fx (Vercel)](./products/fx.md) - - [fx session detail JSON reference](./products/fx-session-detail-json-reference.md) - - [fx compared to our session event catalog](./products/fx-vs-session-events.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) +Each product owns a directory under `products/`. The stage-one dossier is that +directory's `index.md`, the stage-two comparison is `vs-session-events.md`, and +any further evidence artifacts sit alongside them. The sibling +[ACP](../acp/index.md) and [agent platform](../agent-platform/index.md) corpora +keep a flat `products/*.md` because each of their products has a single +artifact; this corpus nests because every product here has at least two. + +- [Aider](./products/aider/index.md) + - [Aider compared to our session event catalog](./products/aider/vs-session-events.md) +- [Amazon Q Developer CLI](./products/amazon-q/index.md) + - [Amazon Q Developer CLI compared to our session event + catalog](./products/amazon-q/vs-session-events.md) +- [AWS Strands Agents](./products/aws-strands/index.md) + - [AWS Strands Agents compared to our session event + catalog](./products/aws-strands/vs-session-events.md) +- [Claude Agent SDK and Claude Code](./products/claude-agent-sdk/index.md) + - [Claude Agent SDK 0.3.220 session type snapshot and platform + comparison](./products/claude-agent-sdk/session-types.md) +- [Cline](./products/cline/index.md) + - [Cline compared to our session event catalog](./products/cline/vs-session-events.md) +- [Codex CLI (OpenAI)](./products/codex-cli/index.md) +- [Continue](./products/continue/index.md) + - [Continue compared to our session event catalog](./products/continue/vs-session-events.md) +- [Crush (Charm)](./products/crush/index.md) + - [Crush compared to our session event catalog](./products/crush/vs-session-events.md) +- [fx (Vercel)](./products/fx/index.md) + - [fx session detail JSON reference](./products/fx/session-detail-json-reference.md) + - [fx compared to our session event catalog](./products/fx/vs-session-events.md) +- [Gemini CLI (Google)](./products/gemini-cli/index.md) +- [Google ADK](./products/google-adk/index.md) + - [Google ADK compared to our session event catalog](./products/google-adk/vs-session-events.md) +- [Goose (Block)](./products/goose/index.md) +- [Grok Build](./products/grok-build/index.md) +- [Hermes (Nous Research)](./products/hermes-agent/index.md) +- [LangGraph (LangChain)](./products/langgraph/index.md) +- [Letta](./products/letta/index.md) + - [Letta compared to our session event catalog](./products/letta/vs-session-events.md) +- [Mastra](./products/mastra/index.md) + - [Mastra compared to our session event catalog](./products/mastra/vs-session-events.md) +- [OpenAI Agents SDK](./products/openai-agents-sdk/index.md) + - [OpenAI Agents SDK compared to our session event + catalog](./products/openai-agents-sdk/vs-session-events.md) +- [OpenCode](./products/opencode/index.md) +- [OpenHands](./products/openhands/index.md) + - [OpenHands compared to our session event catalog](./products/openhands/vs-session-events.md) +- [Pi](./products/pi/index.md) + - [Pi compared to our session event catalog](./products/pi/vs-session-events.md) +- [SWE-agent](./products/swe-agent/index.md) + - [SWE-agent compared to our session event catalog](./products/swe-agent/vs-session-events.md) +- [T3 Code](./products/t3code/index.md) +- [Void](./products/void/index.md) + - [Void compared to our session event catalog](./products/void/vs-session-events.md) +- [Zed](./products/zed/index.md) + - [Zed compared to our session event catalog](./products/zed/vs-session-events.md) + +## Fork deltas + +Forks of a product already in the corpus get a delta report instead of a +dossier: one question, what diverged from upstream's store, with paths. +"Nothing diverged" is a complete answer. The rationale and the queue are in the +[backlog](./backlog.md) under Wave 5. + +These are not lesser artifacts. Roo Code was queued as a presumed restatement +of Cline and turned out to hold the corpus's cleanest counter-example on +cascade semantics: it recurses over the full child-task tree where its own +upstream stops at one level. + +- [Roo Code, diverged from Cline](./products/roo-code/index.md) +- [Kilo Code, diverged from Cline via Roo Code](./products/kilo-code/index.md) +- [Qwen Code, diverged from Gemini CLI](./products/qwen-code/index.md) + +Kilo Code is the limit case of the form. It did not evolve the store it +inherited, it replaced the whole subsystem with a vendored copy of +[OpenCode](./products/opencode/index.md)'s, so its delta answers "everything +diverged" and its evidence belongs to a lineage the fork question did not ask +about. Its `// kilocode_change` markers are what separate OpenCode's design +decisions from Kilo's own patches, and without that discriminator the report +would have credited Kilo with a cascade design it merely vendored. ## Synthesis diff --git a/docs/research/session-store/products/aider/index.md b/docs/research/session-store/products/aider/index.md new file mode 100644 index 000000000..e9063c050 --- /dev/null +++ b/docs/research/session-store/products/aider/index.md @@ -0,0 +1,360 @@ +# Aider: how session transcripts are stored and resumed + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT](../../RESEARCH_PROMPT.md). +Evidence snapshot retrieved 2026-08-04. Aider is Apache-2.0 licensed +(`LICENSE.txt:1`). Version-sensitive claims were checked against this +authoritative anchor: + +- Repo `Aider-AI/aider`, pinned commit `5dc9490bb35f9729ef2c95d00a19ccd30c26339c` (`aider/__init__.py` + reports `__version__ = "0.86.3.dev"`). All `path:line` citations below are + repo-root-relative to this tree at this commit. + +**Headline finding, stated up front because it governs every section below:** +Aider deliberately has no session store. There is no session id, no +resumable transcript, no schema, and no migration path. What looks like a +transcript (`.aider.chat.history.md`) is written but, outside one narrow +opt-in flag, never read back by the program. The only things Aider makes +durable across restarts are the git history of the workspace and a rebuildable +code-search cache, neither of which is a conversation. This dossier documents +that absence as rigorously as a rich store would be documented, per the +instructions for this product. + +## The storage model + +There is no durable "session" object anywhere in the source. What exists is +a set of independent, unkeyed files scoped to a working directory: + +| Path | Written by | Read back by the program? | +| --- | --- | --- | +| `.aider.chat.history.md` (`aider/args.py:274-276`) | `InputOutput.append_chat_history` (`aider/io.py:1117`) | Only if `--restore-chat-history` is passed, and only once, at `Coder.__init__` (`aider/coders/base_coder.py:519-523`). | +| `.aider.input.history` (`aider/args.py:271-272`) | `InputOutput.add_to_input_history` via `prompt_toolkit`'s `FileHistory` (`aider/io.py:740-745`, wrapping `append_string`) | Yes, every launch, for readline-style up-arrow recall (`InputOutput.get_input_history`, `aider/io.py:747-751`). This is terminal input history, not the conversation. | +| `.aider.llm.history` (opt-in, `aider/args.py:296-299`) | `InputOutput.log_llm_history` (`aider/io.py:755-765`) | No read call found anywhere in the tree. | +| `.aider.tags.cache.v{3,4}/` (`aider/repomap.py:43`) | `RepoMap` via `diskcache.Cache` (`aider/repomap.py:195`, `:220`) | Yes, every launch -- but this is a repo-map (code search) cache, not conversation state. | +| git commits made by aider | `GitRepo` / `Coder.aider_commit_hashes` | Yes, but only the in-process `set()` (`aider/coders/base_coder.py:349`), not anything on disk; see Rewind section. | + +None of these is an append-only *event* log with a derived projection. The +chat history file is closest to a "log," but it is a flat Markdown transcript +with no framing, no sequence numbers, and -- critically -- is write-only output +for humans in the overwhelming majority of runs. The repo map cache is a +key-value cache of source-file tag extractions, keyed by file identity, not +by conversation. + +**Conceptual model: none of the skeleton's categories fit cleanly.** If forced +to pick the closest label, `.aider.chat.history.md` is +**session-as-append-only-log written for humans**, and workspace durability +lives entirely in git (session-as-side-effect-on-the-repo). There is no +session-as-document, session-as-directory, or session-as-row anywhere in this +codebase. + +## Keying and identity + +There is no session id, session token, or any identifier minted per run. +Identity is purely **the working directory / git root**, expressed as fixed, +non-parameterized file names: + +```python +default_input_history_file = ( + os.path.join(git_root, ".aider.input.history") if git_root else ".aider.input.history" +) +default_chat_history_file = ( + os.path.join(git_root, ".aider.chat.history.md") if git_root else ".aider.chat.history.md" +) +``` +(`aider/args.py:271-276`) + +Consequences of this scheme: + +- **One transcript file per repo, shared by every invocation and every user** + of that checkout. There is no per-launch, per-user, or per-branch + partitioning; two people running aider against the same clone append to the + same `.aider.chat.history.md`, with no author field distinguishing them. +- **No cross-project enumeration and no listing.** There is no command that + lists "past sessions" -- nothing to list, because nothing is keyed as a + session. `aider/commands.py` has no `cmd_sessions` or equivalent. +- **No relocation/rename reconciliation**, because there is no id to reconcile. + If the working directory moves, the default paths simply resolve to a + different (or absent) file; the old file is orphaned with no linkage. +- The file paths are overridable via `--chat-history-file` / + `--input-history-file` (`aider/args.py:283-288`), so a user *could* impose + their own keying discipline by hand, but Aider itself does not. + +## The store interface + +Aider has no pluggable store adapter or exported storage type. The table +below is a **reconstruction** of the effective operations from call sites; +there is no interface to quote verbatim. + +| Operation | Call site | Inputs | Effect / guarantee | +| --- | --- | --- | --- | +| append (chat) | `InputOutput.append_chat_history` (`aider/io.py:1117-1136`) | formatted text | Opens the file in `"a"` mode, writes, closes. No lock, no fsync call, no return value checked by callers. On `PermissionError`/`OSError` it prints a warning and sets `self.chat_history_file = None`, permanently disabling further writes for that process (`aider/io.py:1133-1136`). | +| session-start marker | `InputOutput.__init__` (`aider/io.py:336`) | current timestamp | Appends `\n# aider chat started at {current_time}\n\n` once per process start -- the only structural marker in the file. | +| read-back (opt-in) | `Coder.__init__` (`aider/coders/base_coder.py:519-523`) | `self.io.chat_history_file` | Reads the **entire file** with `io.read_text`, parses it with `utils.split_chat_history_markdown`, and seeds `self.done_messages`. Gated by `restore_chat_history`, default `False` (`aider/args.py:289-294`). | +| clear (in-memory only) | `Commands._clear_chat_history` (`aider/commands.py:435-437`), invoked by `/clear` and `/reset` | none | Empties `self.coder.done_messages` and `self.coder.cur_messages`. **Does not touch the file on disk.** | +| append (input history) | `InputOutput.add_to_input_history` (`aider/io.py:740-745`) | one line of user input | `prompt_toolkit.history.FileHistory(...).append_string(inp)`. | +| read (input history) | `InputOutput.get_input_history` (`aider/io.py:747-751`) | none | `FileHistory(...).load_history_strings()`, used for readline recall, every launch. | +| append (LLM log, opt-in) | `InputOutput.log_llm_history` (`aider/io.py:755-765`) | role, content | Appends a `"{ROLE} {iso-timestamp}\n{content}\n"` block. No read call exists anywhere in the tree. | +| cache read/write (repo map) | `RepoMap.load_tags_cache` / cache miss path (`aider/repomap.py:217-224`, `:186-215`) | file mtime/path | `diskcache.Cache` keyed lookup; falls back to an in-memory `dict()` if the sqlite-backed cache can't be created (`aider/repomap.py:207-215`). This is a code-search cache, not conversation storage. | +| ad-hoc reparse (dev tool only) | `editblock_coder.py:main()` (`aider/coders/editblock_coder.py:630-651`) | a chat-history file path given as `argv[1]` | Standalone `if __name__ == "__main__"` debug entry point that reparses a saved history file to re-extract edit blocks for testing. Not invoked by the normal `aider` CLI path. | + +There is no delete, list, summarize, fork, or pagination operation, because +there is no session object those verbs would act on. + +## Write and append path + +- **Append, one call per UI event**, not one call per turn: `user_input`, + `ai_output`, `confirm_ask`, `prompt_ask`, and `_tool_message`/`tool_output` + all call `append_chat_history` independently (`aider/io.py:789`, `:795`, + `:905`, `:923`, `:960`, `:970`, `:973`, `:999`). A single turn therefore + produces several separate appends: the user's message, then zero or more + confirm/prompt Q&A lines (blockquoted), then the assistant's final content. +- **Ordering** is purely file-append order; there is no sequence number, + UUID, or monotonic id on any line. Two concurrent aider processes writing + to the same path can interleave their lines with no detection mechanism. +- **Durability/atomicity.** Plain `open(..., "a")` / `write()` / implicit + close via the `with` block (`aider/io.py:1131`). No temp-file-and-rename, + no explicit `fsync`, no transaction. Aider only guards against the file + becoming *unwritable* (`PermissionError`/`OSError`), not against a torn or + interleaved write. +- **Concurrency model:** effectively "best-effort, unmanaged multi-writer." + No lock file, no advisory lock, no writer-identity field anywhere in + `aider/io.py`. This is the opposite of the single-writer-per-session-with- + fencing pattern seen in richer stores. +- **Delivery semantics:** best-effort, in-process only. A crash between the + in-memory conversation state and the next `append_chat_history` call loses + nothing extra (each call already wrote synchronously before returning), but + there is no idempotence key, and a killed process mid-write can leave a + partial trailing line with no healing path on the next launch. + +## Read and resume path + +- **Default behavior: the file is never read.** `restore_chat_history` + defaults to `False` (`aider/args.py:289-294`), so a fresh `aider` invocation + starts with empty `done_messages`/`cur_messages` regardless of how large + `.aider.chat.history.md` has grown. +- **With `--restore-chat-history`:** `Coder.__init__` does one **full, + eager** read of the whole file via `io.read_text`, then + `utils.split_chat_history_markdown(history_md)` (`aider/coders/base_coder.py:519-522`), + then immediately calls `self.summarize_start()` (`:523`) to run the restored + messages back through `ChatSummary` if they exceed the model's history token + budget (`aider/history.py`). There is no incremental read, no cursor, no + offset, and no pagination -- the entire file is parsed on every restore. +- **Parsing is heuristic Markdown line-classification**, not a structured + format: lines starting `"# "` are dropped (headers), `"> "` becomes a tool + message, `"#### "` starts a new user message, anything else accumulates as + assistant content (`aider/utils.py:148-188`, `split_chat_history_markdown`). + This is a lossy reconstruction: original message boundaries are inferred + from Markdown prefixes rather than stored as data. +- **What restore is for, in the project's own words:** the FAQ frames it as + bringing *recent* context into a **new** session, not resuming a specific + prior one -- "the chat history already includes recent changes made during + the current session, so this tip is most useful when starting a new aider + session" (`aider/website/docs/faq.md:142`). There is no concept of resuming + *a particular* past conversation; `--restore-chat-history` replays whatever + is currently in the one shared file for that repo. +- Resume does not read a database or an API; it reads the same flat file the + human-readable log is written to. There is no separate "local cache vs + durable store" distinction to draw, because there is only the one file. + +## Listing, summaries, and search + +- **No listing.** There is nothing to enumerate -- one file, one path, no + index. `grep` across `aider/commands.py` finds no `cmd_sessions`, + `cmd_history`, or `cmd_list` command. +- **No metadata sidecar.** The chat-started marker + (`aider/io.py:336`) is the only structural annotation ever written into the + file, and it is not indexed anywhere. +- **No search subsystem.** No FTS, no vector index, no grep-based search + helper over the chat history exists in the source. (The FAQ's own + aspirational note -- "Vector and keyword search against the chat history, + repo map or codebase may help here," `aider/website/docs/ctags.md:232` -- is + documentation of an *unimplemented* idea, not a shipped feature; it is + quoted here because it is direct evidence the maintainers considered and + did not build this.) +- The closest thing to "listing" is manual and human-driven: the FAQ tells + users to open `.aider.chat.history.md` themselves and copy content out to + make a GitHub Gist if they want to share a transcript + (`aider/website/docs/faq.md:343`). + +## Entry/message structure and versioning + +There is no typed entry format. The stored unit is a **Markdown text +fragment per UI event**, not a tagged record: + +- `user_input`: joins input lines with `" \n#### "`, wrapped as + `\n#### {line1}\n#### {line2}...` (`aider/io.py:779-789`). The `"#### "` + prefix is a Markdown H4 marker used purely as a role tag for the parser. +- `ai_output`: the raw assistant text, stripped and padded with newlines, + written verbatim with no wrapping (`aider/io.py:793-795`). +- `confirm_ask` / `prompt_ask` / `_tool_message` / `tool_output`: each writes + a blockquoted line, `"> {text}"`, again with no field structure beyond the + `>` prefix (`aider/io.py:905`, `:923`, `:960`, `:970`, `:973`, `:999`, + `:1117-1121`). +- There is **no envelope**: no timestamp per line (only one timestamp at + process start, `aider/io.py:336`), no message id, no parent/thread + reference, no role enum beyond the three Markdown-prefix conventions the + parser infers (`aider/utils.py:148-188`). +- The entry is **not opaque to any store**, because there is no store parsing + it at write time; the only parser is the optional restore path, and it + infers structure from prose formatting rather than reading a schema. +- **No format version field exists anywhere in the file.** There is nothing + resembling `schema_version`. Because the parser is a heuristic Markdown + splitter rather than a strict format reader, `split_chat_history_markdown` + will silently accept a hand-edited or foreign Markdown file -- there is no + version check to reject it, and correspondingly no migration mechanism, + because there is no format to migrate from or to. + +## Compaction and history management + +- **In-memory only, and only on the opt-in restore path.** `ChatSummary` + (`aider/history.py:7-13`) summarizes `done_messages` when they exceed + `main_model.max_chat_history_tokens`. It is invoked from two places: at + `Coder.__init__` right after a `--restore-chat-history` load + (`aider/coders/base_coder.py:523`, `summarize_start()`), and on + edit-format switches inside `Coder.create` when `summarize_from_coder` is + true (`aider/coders/base_coder.py:158-166`). +- Summarization **rewrites the in-memory message list**; it never touches + `.aider.chat.history.md`. The file keeps every line ever written, whether + or not that content is still part of the model-visible context. This is the + inverse of a "durable log survives, view shrinks" design: here, the *view* + (in-memory `done_messages`) shrinks or gets replaced, while the file simply + keeps growing with no corresponding compaction marker ever written back to + it. +- **No explicit truncation, rotation, or size cap was found** for + `.aider.chat.history.md`, `.aider.input.history`, or `.aider.llm.history`. + All three are open-ended append targets for the lifetime of the repo + checkout. Whether this unbounded growth is a real user-visible problem + could not be confirmed from source alone (no size-warning code path, no + linked issue in this tree) -- noted under Open questions rather than + asserted as a known bug. + +## Rewind, checkpoints, and fork + +This is where Aider's actual durability investment shows up, and it is +**workspace/file-state durability, not conversation-session durability**: + +- **`/undo` is real, git-based rewind of the workspace** -- not of the chat + transcript. `Commands.raw_cmd_undo` (`aider/commands.py:560-618`) checks + that the last commit's hash is in `self.coder.aider_commit_hashes` + (`:573`), refuses if any changed file is dirty (`:591-595`) or absent from + the parent tree (`:598-605`), then does the equivalent of `git reset` back + to the parent commit for exactly those files. This is a **git operation on + the working tree**, gated by an **in-memory, per-process** set of commit + hashes aider itself made (`self.aider_commit_hashes = set()`, + `aider/coders/base_coder.py:349`) -- restart the process and that set is + empty, so `/undo` explicitly refuses commits from a prior process: "The + last commit was not made by aider in this chat session" + (`aider/commands.py:574`). Undo-ability is therefore per-process state + layered on top of durable git commits, not a durable "checkpoint" record + itself. +- **No conversation checkpoint, no branch/fork of a transcript exists.** There + is no operation that snapshots `done_messages`/`cur_messages` to disk at a + point in time, and no command that forks a conversation into a sibling. +- **The repo map tag cache is the other durable-but-not-conversational + artifact.** `RepoMap.TAGS_CACHE_DIR = f".aider.tags.cache.v{CACHE_VERSION}"` + (`aider/repomap.py:43`) is a `diskcache.Cache` (sqlite-backed) directory at + the repo root, populated by `load_tags_cache` + (`aider/repomap.py:217-222`) and rebuilt from scratch on any error + (`tags_cache_error`, `aider/repomap.py:186-215`). It is keyed by source + file identity (path/mtime), fully rebuildable from the working tree, and + has nothing to do with session identity -- it exists to avoid re-parsing + unchanged source files with tree-sitter on every launch. +- **`/save` and `/load` are file-context macros, not conversation + persistence**, despite the naming. `cmd_save` writes a script of `/drop`, + `/add`, `/read-only` commands that reconstructs which files are in context + (`aider/commands.py:1497-1522`); `cmd_load` replays an arbitrary command + script (`aider/commands.py:1465-1493`). Neither touches + `done_messages`/`cur_messages` or the chat history file. This is a + plausible naming collision an auditor could mistake for session save/load, + so it is called out explicitly here. + +## Subagents and nested sessions + +Aider has no subagent concept. The nearest analog is **mode switching** +(`/code`, `/ask`, `/architect`) via `Coder.create(from_coder=...)` +(`aider/coders/base_coder.py:125-181`): switching formats copies +`done_messages`, `cur_messages`, `aider_commit_hashes`, and other state +directly between two in-process Python objects that **share the same `io` +instance** (`aider/coders/base_coder.py:146`, `:171-179`), so both "modes" +append to the identical `.aider.chat.history.md`. Architect mode goes one +step further: `ArchitectCoder.reply_completed` builds a fresh `editor_coder` +via `Coder.create(from_coder=self, ...)`, resets its `cur_messages`/ +`done_messages` to empty, runs it, then folds its cost and commit hashes back +into the architect coder (`aider/coders/architect_coder.py:9-46`). This is an +**in-memory, same-process, same-file handoff** -- there is no nested session +directory, no parent-child link recorded anywhere durable, and no +child-transcript isolation: everything lands in the one shared chat-history +file, undifferentiated by which mode produced which line. + +## Retention, deletion, and multi-host + +- **No retention policy, no TTL, no scheduled cleanup** exists for any of the + `.aider.*` files. Aider's own `check_gitignore` (`aider/main.py:155-171`) + adds a `.aider*` glob to `.gitignore` so these files are excluded from the + user's own git history (`aider/main.py:163-164`), which is the only + "lifecycle" action taken on them -- keeping them out of version control, not + managing their size or age. +- **No delete verb.** Nothing in `aider/commands.py` removes + `.aider.chat.history.md`, `.aider.input.history`, or the tags cache + directory; a user does this by hand with the filesystem. +- **Multi-host / multi-process is an unmanaged shared-filesystem assumption.** + Every one of these files is a plain path under (or relative to) the git + root, opened with ordinary POSIX append semantics and no lock file. Two + processes (two terminals, two machines sharing a mounted checkout) writing + concurrently is not detected, arbitrated, or fenced anywhere in + `aider/io.py`. This is a structural non-goal rather than a guarded-against + failure mode: there is no `authority`/lock file family of the kind seen in + products that do build a store. + +## Interop with foreign session stores + +Not applicable. Aider does not import, discover, or resume any other +product's session/transcript format. No such code path exists in this tree. + +## What this implies for our Session Store (our inference) + +**Our inference:** in Aider, nothing is "a stored session" in the sense our +platform means the term. The product's durability budget went entirely into +the workspace (git commits, an in-memory guard restricting undo to +commits the current process made, and a rebuildable code-search cache), and +explicitly not into the conversation: the one file that looks like a +transcript is fire-and-forget output for humans, read back by the program +only behind an opt-in flag whose own documentation frames it as "seed a new +session with recent context," not "resume this exact prior session." There is +no append-only-log-with-derived-projection design to borrow here, because +there is no projection and, for the default path, no read at all. + +The value of this dossier for our design is negative-space confirmation +rather than a pattern to import: + +- It shows a mature, widely deployed, single-user-workstation product can + ship for years with **zero** session-store investment, which is direct + evidence that a durable, resumable, structured session store is a product + choice, not a technical necessity for an LLM coding agent to be useful. +- It is a cautionary example on the append path: an unkeyed, unlocked, + multi-writer-unsafe append target (`.aider.chat.history.md`) is exactly the + failure mode our event-sourced store's per-session identity and + single-writer fencing are meant to prevent. +- It reinforces that "workspace state" (git commits, undo-ability, file + content) and "conversation state" (turns, messages, tool calls) are + separable durability concerns with different natural stores -- Aider chose + git for the former and nothing for the latter, which is a data point in + favor of not conflating the two in our own model. + +## Open questions + +- Whether unbounded growth of `.aider.chat.history.md` (or `.aider.llm.history`, + `.aider.input.history`) is a reported real-world pain point for + long-lived repos could not be confirmed from source in this tree; no + size-check, size-warning, or related code path was found either way. +- Whether any interleaved-write corruption from concurrent aider processes on + a shared checkout has ever been observed or reported is not determinable + from source; the code has no detection for it either way. +- Whether `--restore-chat-history` is commonly used in practice, versus being + a rarely-set flag (default `False`), is not determinable from this tree; + only the default and the gated code path are verifiable facts. +- `editblock_coder.py`'s `main()` (`aider/coders/editblock_coder.py:630-651`) + is a standalone script that reparses a saved chat-history file for + extracting edit blocks (apparently a debugging/analysis aid). Whether it is + used in any documented workflow, or is purely a developer utility, was not + determined. diff --git a/docs/research/session-store/products/aider/vs-session-events.md b/docs/research/session-store/products/aider/vs-session-events.md new file mode 100644 index 000000000..0be255481 --- /dev/null +++ b/docs/research/session-store/products/aider/vs-session-events.md @@ -0,0 +1,293 @@ +# Aider compared to our session event catalog + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT_COMPARISON](../../RESEARCH_PROMPT_COMPARISON.md). +Stage-one dossier: [Aider](./index.md). +Compared against `proto/trogonai/session/sessions/v1alpha1/` and [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) on 2026-08-04. + +**Store maturity: 4/12** ("thin evidence" per the research prompt's rule for +scores under 6; weight everything below accordingly): evolution scars 0/3 +(no format-version field anywhere, `.aider.chat.history.md` has never needed +one because nothing reads it back by default; the parser is a heuristic +Markdown line-classifier that "will silently accept a hand-edited or foreign +Markdown file" with no version check to reject it, per the dossier's Entry +structure section); operational age 1/3 (Aider the product is old and widely +used, but the store mechanism itself shows no scarring: the dossier's own +Open questions section states plainly that "whether unbounded growth ... is a +reported real-world pain point ... could not be confirmed from source," and +no migration, no format change, and no fix-for-a-reported-failure was found +anywhere in the tree); exposure 1/3 (real, vendor-shipped, widely used +distribution, but the one behavior that would exercise the store under real +use, resume, is off by default: `restore_chat_history` defaults to `False`, +`aider/args.py:289-294`, so the store is not something the typical run +depends on at all); design independence 2/3 (the append target is a plain, +self-invented file convention, not forked from another product's persistence +code, but it is barely a design: independent because there is nothing to +copy, not because a considered alternative was rejected). + +## The one structural difference everything else follows from + +Aider draws the durability boundary around the **workspace**, not the +**conversation**. The only things that survive a restart are git commits +made in the working tree and a rebuildable code-search cache +(`aider/repomap.py:43`); the one artifact that looks like a session record, +`.aider.chat.history.md`, is fire-and-forget output for humans, not read back +by the program unless `--restore-chat-history` is explicitly passed +(`aider/args.py:289-294`), and its own FAQ frames that opt-in read as seeding +a *new* session with recent context, not resuming a specific prior one +(`aider/website/docs/faq.md:142`). There is no session id, no keying scheme +beyond the working directory (`aider/args.py:271-276`), and no operation that +lists, deletes, or forks a "session," because nothing is addressed as one. + +Every other difference in this document is a consequence of that one choice: +no identity means no addressable resume target; no read-back means no +schema-validation pressure and no need for a format-version field; no +persisted parent/child relationship (only in-process mode objects sharing +one file, `aider/coders/base_coder.py:125-181`) means no cascade question to +answer; no store to grow means no retention question the product has ever +had to face. Our own design draws the boundary the opposite way: the +conversation, tool calls, and delegation graph are the durably owned record +([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decisions 1-6), and the workspace is a referenced, versioned fact +(`SessionStarted.workspace`, a `WorkspaceRef`) rather than the thing that +*is* durable. Aider is the cleanest evidence in the corpus that these two +durability concerns can be fully decoupled, because it is the one product +that chose to durably keep only one of them. + +## Mapping + +| Aider | Ours | Verdict | +| --- | --- | --- | +| `.aider.chat.history.md`, appended per UI event, write-mostly (`aider/io.py:1117-1136`, `:789`, `:795`, `:905`, `:923`, `:960`, `:970`, `:973`, `:999`) | `UserMessageRecorded`, `AssistantMessageStarted`/`Completed`, typed and always folded into the model-visible context ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 8) | Ours, decisively: theirs is not even read back on a normal run | +| Chat-started marker, `"\n# aider chat started at {current_time}\n\n"` (`aider/io.py:336`) | `SessionStarted{session_id, execution_plan, workspace}` (`proto/trogonai/session/sessions/v1alpha1/session_started.proto:16-24`) | Semantic mismatch: theirs is a human-readable timestamp string with no identifier at all; ours is a typed creation fact carrying an opaque `session_id` and the immutable plan/workspace binding | +| No session id; identity is the working directory / git root expressed as fixed file names (`aider/args.py:271-276`) | Opaque `SessionId`, one logical stream per session on `session.sessions.events.` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 1) | Gap, deliberate on Aider's part: there is nothing to list, resume by id, or reference from outside the one shared file | +| `self.aider_commit_hashes = set()`, in-process only, gates `/undo` (`aider/coders/base_coder.py:349`) | `FileChanged{before_ref, after_ref, tool_call_id, turn_id}` folded from the durable log (`proto/trogonai/session/sessions/v1alpha1/file_changed.proto:17-43`) | Ours, decisively: Aider's record of "which commits did this session make" evaporates on process restart, which is exactly why `/undo` explicitly refuses a prior process's commits (`aider/commands.py:574`) | +| `/undo`, a git `reset`-equivalent on the working tree (`aider/commands.py:560-618`) | `SessionRewound{keep_through}` (`proto/trogonai/session/sessions/v1alpha1/session_rewound.proto:13-19`), a durable, replay-time reinterpretation of the conversation log | Semantic mismatch: "rewind" means "revert git-tracked files for commits this same process made" in Aider, and "mark events after a boundary invalid at replay" in ours; neither is a superset of the other, and ours has no effect on workspace file contents at all | +| `ChatSummary`, in-memory only (`aider/history.py:7-13`), invoked from `Coder.__init__` after a restore (`aider/coders/base_coder.py:523`) or on edit-format switch (`:158-166`) | `Compacted{summary_id, summary_content, covers_from, covers_through, trigger}` (`proto/trogonai/session/sessions/v1alpha1/compacted.proto:19-30`), a durable in-stream marker | Ours, decisively: Aider's summary is discarded every time the process exits, so a restored session that needs summarizing redoes the work from scratch | +| Mode switching (`/code`, `/ask`, `/architect`) via `Coder.create(from_coder=...)`, sharing one `io` instance and one file (`aider/coders/base_coder.py:125-181`, `:146`, `:171-179`; `aider/coders/architect_coder.py:9-46`) | `DelegationDispatched`, `ParentLinked`, `CascadePolicy` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6) | Gap, deliberate: Aider has no subagent concept at all, only an in-process object handoff with no persisted parent-child link and no independent child lifecycle | +| No retention policy, no TTL, no delete verb; `check_gitignore` only excludes the files from the user's own git history (`aider/main.py:155-171`, `:163-164`) | `SessionHidden`, `RedactionApplied`, `ArtifactErased` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7) | Ours, decisively: no analog of any kind exists in Aider | +| No format-version field; heuristic Markdown line-classification parser, silently accepts foreign input (`aider/utils.py:148-188`) | Typed protobuf events, schema-validated at the append and replay boundary (`validate_session_event`, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 3) | Ours, decisively | +| `.aider.tags.cache.v{3,4}/`, a rebuildable code-search cache keyed by file identity (`aider/repomap.py:43`, `:186-224`) | Not a session concept on either side; closest conceptual analog is a rebuildable projection ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8), but keyed by conversation, not source file | Not comparable: different problem (code search vs. conversation state), noted for completeness only | +| `/save` / `/load`, file-context macros that replay a command script (`aider/commands.py:1497-1522`, `:1465-1493`) | No equivalent; this is not a session fact in our model either | Neither side models this; the naming collision is called out under **What not to copy** | +| `--restore-chat-history`, default `False`, full-file eager read with no cursor when enabled (`aider/args.py:289-294`; `aider/coders/base_coder.py:519-523`) | Runtime resumes the aggregate from the newest snapshot and replays only the tail after it ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8) | Ours, decisively: Aider's opt-in restore is O(whole file) every time it is used, with nothing bounding that cost | +| `WorkspaceRef.revision` recorded on `SessionStarted` (`proto/trogonai/session/sessions/v1alpha1/workspace.proto:16-21`) | No equivalent; Aider records nothing about the repository's state at conversation start beyond whatever HEAD happened to be | Ours: we already durably record the source-control revision a session began at, something Aider's own in-memory commit tracking never attempts | +| No lock, no fsync, no torn-write handling; concurrent writers interleave undetected (`aider/io.py:1131-1136`) | Every write path carries an explicit `WRITE_PRECONDITION` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2), enforced by JetStream at the broker | Ours, decisively | + +## What we should consider changing + +### 1. No change is proposed from this product's evidence + +**The change.** None. + +**Evidence anchor.** Aider, store maturity 4/12: the product's entire +durability investment is git commits plus a rebuildable code-search cache; +the conversation itself is, by the dossier's own framing, "negative-space +confirmation rather than a pattern to import." There is no schema, no +migration, no identity scheme, no compaction shape, no retention mechanism, +and no subagent model to compare a `.proto` field against. + +**Blast radius.** None (no change proposed). + +**Why.** A store scoring 4/12 is explicitly "thin evidence" under the +research prompt's own rule, and Aider is thin in the specific sense that +matters here: it is not a weaker version of the pattern we are building, it +is a deliberate decision not to build the pattern at all. The one point +Aider makes with real force, that workspace durability and conversation +durability are separable concerns, is already reflected in our design +(`SessionStarted.workspace` as a referenced fact, not the authoritative +record; `FileChanged` records facts about the workspace without owning +it) and was already raised as a recommendation from the fx comparison +(`docs/research/session-store/products/fx/vs-session-events.md`, +recommendation 8, on lifting workspace binding out of the opaque execution +plan). Citing that existing recommendation is the correct move here, not +re-deriving it from a much weaker data point. + +**Cost.** None. + +## What our design already does better + +- **Durable attribution of file changes versus an in-memory-only commit + set.** Aider's record of "which commits this session made" is + `self.aider_commit_hashes`, a Python `set()` that is empty again on every + process restart, which is why `/undo` explicitly refuses to touch a commit + made by a prior process (`aider/coders/base_coder.py:349`, + `aider/commands.py:573-574`). Our + `FileChanged{before_ref, after_ref, tool_call_id, turn_id}` is durable, + replay-stable, and answers "what did this session change and which call + did it" indefinitely after the process that made the change is gone. +- **A durable compaction marker versus a summary that evaporates.** + `ChatSummary` (`aider/history.py:7-13`) exists only in process memory and + is rebuilt on the next restore; `Compacted` (`compacted.proto`) is a single + durable fact with an explicit covered range, so a resumed session never + has to redo work it already paid for. +- **Typed, schema-validated events versus a heuristic parser with no version + field.** `split_chat_history_markdown` (`aider/utils.py:148-188`) infers + message boundaries from Markdown prefixes and will silently accept a + hand-edited or foreign file; our append and replay boundary rejects a + malformed event before it is ever persisted ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 3). +- **A guarded write path versus an unlocked, unmanaged append.** Aider opens + `.aider.chat.history.md` with plain `open(..., "a")`, no lock, no fsync, no + torn-write detection, and two concurrent processes can interleave lines + undetected (`aider/io.py:1131-1136`). Every one of our writes carries an + explicit `WRITE_PRECONDITION` enforced by the broker ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2). +- **A real retention and redaction contract versus none.** `SessionHidden`, + `RedactionApplied`, and `ArtifactErased` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7) have no + analog anywhere in Aider; the only "lifecycle" action found is excluding + the files from the user's own git history via `.gitignore` + (`aider/main.py:155-171`), which is not a retention policy at all. + +## Trade-offs, not gaps + +- **Zero session-store overhead versus resumability, audit, and multi-writer + safety.** Aider's choice buys a single-user, single-host CLI zero schema + to version, zero storage to manage, and zero migration risk, at the cost of + no crash resume, no audit trail, and no protection against two people + editing the same checkout at once. Our design pays a real schema and + write-path cost to buy exactly those three things. Neither choice is wrong + for its product; Aider simply never needed what our platform's multi-user, + multi-host, audited use case requires. +- **One shared per-repo file versus a per-invocation opaque identity.** + Every invocation against a given git checkout appends to the same + `.aider.chat.history.md` (`aider/args.py:271-276`), so two people or two + terminals share one undifferentiated transcript with no author field. This + is a deliberate simplicity trade: it needs no setup and works with any + existing checkout. Our opaque `SessionId` per run trades that zero-setup + convenience for real per-run addressability and isolation. +- **Restore-as-reseed versus resume-as-replay.** Aider's own documentation + frames `--restore-chat-history` as bringing recent context into a *new* + session, explicitly not resuming a specific prior one + (`aider/website/docs/faq.md:142`). That is a real, considered product + stance, not an oversight: it is closer in spirit to our fork semantics + (`SessionForked`, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 5, inheriting a context prefix by + reference into a genuinely new session) than to our resume semantics + ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8, replaying the tail of the *same* aggregate). Aider + simply never separates the two ideas the way our design does. + +## What not to copy + +- **Plain append with no lock and no fsync.** `open(path, "a")` / + `write()` / implicit close, with no temp-file-and-rename and no explicit + fsync (`aider/io.py:1131`), and no detection of interleaved writes from + concurrent processes. This is exactly the multi-writer hazard our + per-command `WRITE_PRECONDITION` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2) exists to prevent. +- **Silently disabling writes on error instead of a typed failure.** A + `PermissionError`/`OSError` on append prints a warning and sets + `self.chat_history_file = None`, permanently disabling further writes for + that process with no typed, observable failure (`aider/io.py:1133-1136`). +- **A parser with no format-version field that accepts anything.** + `split_chat_history_markdown` (`aider/utils.py:148-188`) has no version + check and no rejection path for a foreign or hand-edited file; our + storage boundary validates every event's shape before it is trusted + ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 3). +- **In-memory-only undo-ability with no durable record of scope.** Gating + `/undo` on a per-process `set()` that is empty after every restart + (`aider/coders/base_coder.py:349`) means the product's only rewind + mechanism silently narrows in scope depending on when the process last + restarted, with no durable fact stating what "this session already + touched" actually means. +- **Naming that does not match the mechanism.** `/save` and `/load` + (`aider/commands.py:1497-1522`, `:1465-1493`) sound like session + persistence but are file-context macros; neither touches + `done_messages`/`cur_messages` or the chat history file. This is the same + class of drift the Cline comparison flagged for "shadow Git repository" + checkpoint documentation + (`docs/research/session-store/products/cline/vs-session-events.md`): keep + [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md)'s own vocabulary (harness recovery checkpoint, aggregate + snapshot, read-side checkpoint) precise for the same reason. + +## The two gaps the industry has not closed + +### Subagent cascade + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6 already takes a position: a child session is its own +logical stream linked by facts on each side (`DelegationDispatched` / +`ParentLinked`), acyclic by construction, with terminal cascade driven by a +reconciler and rewind-invalidation kept distinct from terminal cascade. The +question is whether Aider's evidence tests that position, not whether we +have one. + +**What Aider does.** Nothing that resembles a subagent exists. Mode +switching (`/code`, `/ask`, `/architect`) is `Coder.create(from_coder=...)` +copying `done_messages`, `cur_messages`, and `aider_commit_hashes` directly +between two in-process Python objects that share the same `io` instance +(`aider/coders/base_coder.py:125-181`, `:146`, `:171-179`). Architect mode +goes one step further and builds a fresh `editor_coder`, runs it, then folds +its state back into the architect coder (`aider/coders/architect_coder.py:9-46`), +but this is an in-memory, same-process, same-file handoff: there is no +nested session directory, no parent-child link recorded anywhere durable, +and no child-transcript isolation. + +**Does this validate, challenge, or refine decision 6?** Neither. Aider +plainly has no position on subagent cascade, because it has no subagent: if +the process crashes mid-"architect" turn, the entire in-memory chain, +parent and child mode object alike, disappears together, trivially and +atomically, precisely because neither one ever had independent durable life +outside that one process's memory. There is no parent to terminate, no +child to orphan, and no lineage to reconcile. This is worth recording +plainly rather than stretched into either supporting or challenging +evidence: Aider is simply not a data point on this question, and treating +its absence of a subagent model as validation of our reconciler-based +cascade would overstate what the evidence shows. + +### Retention on an unbounded log + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7 already takes a position: keep-forever, with +`SessionHidden` as a visibility tombstone, `RedactionApplied` for read-time +masking, `ArtifactErased` for artifact-byte destruction, and +snapshot-bounded replay so resume cost tracks the tail, not total log size. + +**What Aider does.** `.aider.chat.history.md`, `.aider.input.history`, and +`.aider.llm.history` are all open-ended append targets for the lifetime of +the repo checkout, with no explicit truncation, rotation, or size cap found +anywhere in the tree. The only lifecycle action taken on any of them is +`check_gitignore` excluding the `.aider*` glob from the user's own git +history (`aider/main.py:155-171`, `:163-164`), which keeps them out of +version control, not bounded in size. The dossier is explicit that it could +not confirm from source alone whether this unbounded growth is a +user-visible pain point in practice: no size-warning code path and no linked +issue were found in the tree either way. + +**Does this validate, challenge, or refine decision 7?** It weakly +corroborates the shape of the concern decision 7 already answers, but it is +not confirmed field evidence the way, for example, the Cline comparison's +`cline/cline#9011` growth failure is +(`docs/research/session-store/products/cline/vs-session-events.md`). Two +things are worth separating here, both flagged as inference, not fact: + +- Aider's default path never reads the file back at all, so unbounded + growth on that path costs nothing at read time; the growth risk is purely + disk usage, not a replay-cost problem, because there is no replay. +- The opt-in `--restore-chat-history` path *does* re-expose the same shape + of risk our snapshot-bounded design is built to avoid: a full, + eager, single-pass read of the entire file with no cursor or pagination + (`aider/coders/base_coder.py:519-523`), so a large accumulated history + makes every restore linearly more expensive, with nothing bounding that + cost the way `SessionOrdinal`-anchored snapshots bound ours. But this path + is rarely exercised (default off) and, per the dossier's own Open + questions, has no corroborating issue report the way Cline's does. + +Given the maturity score is under 6, this must be labeled thin evidence, not +presented as an industry norm: Aider suggests the same class of risk exists +whenever a store reads its history back in full, but it supplies no +confirmed failure, only an absent one that could not be ruled out either way. +It does not add new weight to decision 7 beyond what stronger stores in the +corpus already established; it is consistent with, not additional support +for, the retention design already in place. + +## Open questions for the ADR + +- Should [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md)'s Context or Consequences state explicitly that durable, + resumable, structured session storage is a deliberate product bet rather + than a technical necessity for a useful coding agent, given that at least + one widely used, long-lived product in this corpus ships without one? This + does not change any decision; it changes how confidently the ADR can lean + on "every serious product has converged on this shape" as a justification, + since Aider is direct evidence that convergence is not universal. +- Is the boundary between "resume this exact session" ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8, + replay the tail of the same aggregate) and "start a new session seeded + with a prior transcript's tail" (closer to decision 5's fork, inheriting + context by reference) worth surfacing as two distinct, named user-facing + operations? Aider conflates the two under one flag and its own + documentation is explicit that it means the latter, not the former + (`aider/website/docs/faq.md:142`); our design already keeps them as + separate primitives, and this is only a question of whether that + separation should be made more visible to whatever calls the store. diff --git a/docs/research/session-store/products/amazon-q/index.md b/docs/research/session-store/products/amazon-q/index.md new file mode 100644 index 000000000..d8e6155eb --- /dev/null +++ b/docs/research/session-store/products/amazon-q/index.md @@ -0,0 +1,715 @@ +# Amazon Q Developer CLI: how session transcripts are stored and resumed + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT](../../RESEARCH_PROMPT.md). +Evidence snapshot retrieved 2026-08-04. Version-sensitive claims were checked +against a local clone of the Amazon Q Developer CLI (crate name `chat-cli`, +binary `q`; package repo root contains `crates/chat-cli`, `crates/agent`, +`crates/chat-cli-ui`, and several `amzn-*-client` crates) pinned at commit +`15cc8f3cd18c4272925ce1c7053268eedff1ea0a` ("Update README to include issue +reporting link (#3775)", 2026-04-23). Authoritative anchors: +`crates/chat-cli/src/database/mod.rs` (the single SQLite store and its +migration ratchet), `crates/chat-cli/src/database/sqlite_migrations/*.sql` +(the eight-step migration sequence), `crates/chat-cli/src/cli/chat/conversation.rs` +(`ConversationState`, the durable session record itself), +`crates/chat-cli/src/cli/chat/checkpoint.rs` (the shadow-git checkpoint +mechanism), `crates/chat-cli/src/cli/chat/message.rs` (entry/message types), +`crates/chat-cli/src/cli/chat/tools/delegate.rs` (the "Delegate" subagent +tool), and `crates/chat-cli/src/util/paths.rs` (on-disk path layout). The +workspace root `Cargo.toml:12` declares `license = "MIT OR Apache-2.0"` +(dual-licensed, not Apache-2.0-only; `crates/chat-cli/Cargo.toml:8` inherits +this via `license.workspace = true`), confirmed against the checked-in +`LICENSE.APACHE` and `LICENSE.MIT` files at the repo root. + +The headline finding is confirmed, with one important refinement: the +durable chat session is a **single mutable JSON blob keyed by the absolute +working-directory path**, overwritten in full on every assistant turn, with +no append-only log anywhere in the content path. The refinement is that this +degenerate design is not merely passive (no history to retain) -- the code +actively, repeatedly, and irreversibly *destroys* history in place: a +10,000-entry soft cap enforced by draining the in-memory `VecDeque` before +every save, and a separate `/compact` command that drains history and +replaces it with an AI-generated summary, both of which are persisted back to +the single row on the very next turn. Nothing resembling Zed's in-place +compaction marker or fx's `compacted_summary` turn-with-full-history-retained +exists here. + +## The storage model + +The database module's own doc comments state the shape directly: + +```rust +#[derive(Debug)] +pub enum Table { + /// The state table contains persistent application state. + State, + /// The conversations tables contains user chat conversations. + Conversations, + /// The auth table contains SSO and Builder ID credentials. + Auth, +} +``` +(`crates/chat-cli/src/database/mod.rs:157-165`) + +All three tables are simple two-column SQLite key/value tables: + +- `conversations (key TEXT PRIMARY KEY, value TEXT)` -- + `crates/chat-cli/src/database/sqlite_migrations/007_conversations_table.sql:1-4`. +- `state (key TEXT PRIMARY KEY, value BLOB)` -- originally `value TEXT` + (`004_state_table.sql:1-4`), migrated to `BLOB` in + `006_make_state_blob.sql:1-6` (rename-old-table, recreate, copy, drop -- + SQLite's standard workaround for `ALTER COLUMN TYPE`, since SQLite has no + native column-type-change statement). +- `auth_kv (key TEXT PRIMARY KEY, value TEXT)` -- + `005_auth_table.sql:3-6`, whose file comment states the reason for a + separate table: "We create a separate auth_kv to ensure the data is not + available in all the same places that the state is available in" + (`005_auth_table.sql:1-2`). This is corroborated by `q settings` + (`crates/chat-cli/src/cli/settings.rs:101`) calling + `Database::get_all_entries` (`database/mod.rs:234-236`), which dumps the + entire `state` table verbatim -- `auth_kv` needed to be a separate table + precisely so that a config/settings dump could never leak credentials. + +The one and only chat-session accessor pair is: + +```rust +pub fn get_conversation_by_path(&mut self, path: impl AsRef) + -> Result, DatabaseError> { ... } +pub fn set_conversation_by_path(&mut self, path: impl AsRef, state: &ConversationState) + -> Result { ... } +``` +(`crates/chat-cli/src/database/mod.rs:385-411`) + +`set_conversation_by_path` serializes the entire `ConversationState` to a +JSON string (`self.set_json_entry(Table::Conversations, path, state)`, +`crates/chat-cli/src/database/mod.rs:410`) and issues `INSERT OR REPLACE INTO conversations (key, value) +VALUES (?1, ?2)` (`crates/chat-cli/src/database/mod.rs:470-475`, the shared `set_entry` primitive used by +every table). There is no partial update, no append, and no versioned/CAS +write anywhere in this path -- every save is a full-document upsert keyed on +the caller-supplied path string, which is always `std::env::current_dir()` +at the two call sites (`crates/chat-cli/src/cli/chat/conversation.rs:420-421`, +`crates/chat-cli/src/cli/chat/mod.rs:720-723`). + +Authoritative vs. derived: there is no derived layer at all. The row *is* +the session; there is no separate summary/index/cache table for +conversations (contrast `state`, which the `q settings` dump treats as a +flat, fully-authoritative key/value bag too -- nothing in this product is a +rebuildable projection of something else). The conceptual model is +**session-as-row**, specifically a mutable document store with exactly one +row per distinct working directory, not session-as-log and not +session-as-directory. + +## Keying and identity + +- The primary key of the `conversations` table is the **literal, absolute + working-directory path string**, not a session id. + `get_conversation_by_path`/`set_conversation_by_path` both take + `impl AsRef`, convert to `&str` via `.to_str()` (returning `Ok(None)` + / `Ok(0)` silently for non-UTF-8 paths -- `database/mod.rs:390-393,405-408`), + and use that string directly as the SQL key. There is no hashing, no + normalization, and no canonicalization visible in this path (e.g. no + symlink resolution) -- two different string spellings of the same + directory (a symlink vs. its target, or a trailing slash) would be + different keys, though we did not find a test exercising this. +- `ConversationState.conversation_id: String` (`conversation.rs:109`) is a + separate field carried *inside* the JSON blob. It is minted as + `uuid::Uuid::new_v4().to_string()` unconditionally on every `q chat` + process launch (`crates/chat-cli/src/cli/chat/mod.rs:301-302`, "Generated + new conversation id"). When resuming succeeds, this freshly-minted id is + simply discarded in favor of the loaded row's own `conversation_id` -- the + `Some(mut cs)` branch at `crates/chat-cli/src/cli/chat/mod.rs:728-751` never touches `cs.conversation_id`, + it only overwrites `tool_manager`, `agents`, `mcp_enabled`, and re-derives + `context_manager`/tool invariants. So: the working-directory path is the + *addressing* key (one row per directory), while `conversation_id` is an + internal identity field with no addressing role of its own -- it is UUIDv4 + (random), not UUIDv7 or otherwise ordering-encoding. +- **Relocation/renames are not reconciled at all.** Because the key is the + literal path string, moving or renaming the working directory produces a + cache miss on `get_conversation_by_path` -- the CLI simply falls through to + `ConversationState::new(...)` and starts a fresh conversation + (`crates/chat-cli/src/cli/chat/mod.rs:752-764`). We found no migration, symlink-following, or + path-history mechanism anywhere in `database/mod.rs` or `conversation.rs`. + This is the flip side of Zed's `PathList`-based reconciliation + (`../zed/index.md`): Amazon Q has no reconciliation layer at all, by + omission rather than by a deliberate no-op decision we could find recorded. +- **Listing is not a supported operation.** There is no method on `Database` + that enumerates the `conversations` table -- `all_entries` exists + (`crates/chat-cli/src/database/mod.rs:504-520`) but is only ever called with `Table::State` + (`crates/chat-cli/src/database/mod.rs:235`, via `get_all_entries`). A repo-wide grep for + `Table::Conversations` turns up exactly three lines, all in + `database/mod.rs` itself (the `Display` impl at `:171-172` and the two + accessor bodies at `:395,410`) -- there is no third call site anywhere that + lists, searches, or enumerates saved conversations across directories. One + directory can hold at most one saved conversation, and there is no product + surface (CLI subcommand or otherwise) for discovering what other + directories have one. +- Resume is a boolean switch tied to the *directory you are currently in*, + not to a chosen session id: `ChatArgs.resume: bool` + (`crates/chat-cli/src/cli/chat/mod.rs:233`, "Resumes the previous + conversation from this directory") is the only lever; there is no `--session + ` flag or a session picker anywhere in `ChatArgs` + (`crates/chat-cli/src/cli/chat/mod.rs:227-253`). + +## The store interface + +There is no pluggable store trait -- `Database` (`database/mod.rs:184-187`, +wrapping an `r2d2::Pool`) is a single +concrete struct with ad hoc methods, reconstructed below. + +| Operation | Signature / entry point | Notes | +| --- | --- | --- | +| Open/migrate | `Database::new()` (`database/mod.rs:190-231`) | Opens `data.sqlite3`, creates parent dir, chmods to `0600` on Unix (`:212-223`), then runs `migrate()`. In `cfg!(test)` (non-integration) builds, opens an in-memory DB instead (`:191-197`). | +| Load (get) | `get_conversation_by_path(&mut self, path)` (`:385-396`) | Single-row `SELECT`, JSON-deserialize; `Ok(None)` on miss or non-UTF-8 path. | +| Save (full overwrite) | `set_conversation_by_path(&mut self, path, state: &ConversationState)` (`:399-411`) | `INSERT OR REPLACE`, full JSON blob, no partial update. | +| Generic get/set (shared primitive) | `get_entry`/`set_entry` (`:460-475`) | Used by all three tables; `set_entry` is `INSERT OR REPLACE INTO {table} (key, value) VALUES (?1, ?2)`. | +| Generic get/set JSON (shared primitive) | `get_json_entry`/`set_json_entry` (`:477-495`) | Wraps the above with `serde_json::to_string`/`from_str`. | +| Delete | `delete_entry(&self, table, key)` (`:497-502`) | `DELETE FROM {table} WHERE key = ?1`; exists generically but is **never called with `Table::Conversations`** anywhere in the crate (grep-confirmed) -- there is no delete-a-conversation operation exposed. | +| Enumerate (state only) | `all_entries(&self, table)` / `get_all_entries()` (`:504-520`, `:234-236`) | Full-table scan; used only for `Table::State` (the `q settings` dump), never for `Table::Conversations`. | +| Secrets | `get_secret`/`set_secret`/`delete_secret` (`:413-427`) | Thin wrappers over `get_entry`/`set_entry`/`delete_entry` against `Table::Auth`. | +| Migrate | `Database::migrate(self)` (`:431-458`) | Runs pending steps from the `MIGRATIONS` const inside one `rusqlite::Transaction`, committed once at the end (`:432-455`). | + +There is no read/write interface at all for the checkpoint or delegate +subsystems through `Database` -- those are separate, file-based stores +described under Rewind/Subagents below. + +## Write and append path (ordering, durability, concurrency, delivery) + +- **Unit of write is the whole conversation.** `ConversationState:: + push_assistant_message` (`conversation.rs:403-423`) appends one + `HistoryEntry { user, assistant, request_metadata }` to the in-memory + `VecDeque` and, in the same call, immediately persists: `if + let Ok(cwd) = std::env::current_dir() { os.database. + set_conversation_by_path(cwd, self).ok(); }` (`:420-421`). Every turn is a + full JSON re-serialization of the entire `ConversationState` (transcript, + history, context manager, checkpoint manager, tangent state, everything -- + see Entry structure below), not an append of the one new entry. +- **Ordering** is whatever order `VecDeque::push_back` gives an in-process + `Vec`-like structure -- there is no sequence number, no timestamp-based + ordering key, and no expected-version precondition on the write. The + `.ok()` on the save call (`:421`) means a failed write is silently + swallowed; the in-memory turn is never rolled back and the user is never + told persistence failed. +- **Durability.** `Database::new` opens the SQLite file with + `r2d2_sqlite::SqliteConnectionManager::file(&path)` (`database/mod.rs:209`) + with no `PRAGMA` statements anywhere in the crate -- a repo-wide grep for + `journal_mode`, `PRAGMA`, `busy_timeout`, and `WAL` across + `crates/chat-cli/src` returns zero hits. This means the connection runs + under SQLite's compiled-in defaults: rollback-journal mode (not WAL) and + `synchronous=FULL` (durable per-statement fsync on commit), but also a + **default `busy_timeout` of 0** -- a second connection hitting a locked + database fails immediately with `SQLITE_BUSY` rather than waiting (this is + inference from SQLite's documented defaults combined with the absence of + any override in this codebase, not a claim we verified by forcing a lock + in this environment). There is no temp-file-and-rename pattern anywhere in + the conversation write path -- the durability story is entirely "one SQLite + `UPDATE`/`INSERT` statement, implicitly its own transaction." The only + place an explicit `rusqlite::Transaction` appears in this crate is + `Database::migrate` (`:432-455`). +- **Concurrency.** `Database` is `#[derive(Clone, Debug)]` (`:183`) with a + cloneable `r2d2::Pool` inside, so multiple async tasks or threads inside + one process share the pool and its underlying file lock. Across + *processes* (see Subagents below -- the Delegate tool spawns a second `q + chat` process against the same database file and, critically, the same + working directory), there is no application-level optimistic-concurrency + check of any kind: two processes racing to call `set_conversation_by_path` + for the same `cwd` key produce ordinary last-write-wins `INSERT OR REPLACE` + semantics, gated only by SQLite's own file locking (and, per the point + above, a zero busy-timeout that can surface as an outright error rather + than a wait). +- **Delivery semantics** are at-most-once, best-effort: the save call's + `Result` is discarded with `.ok()` (`conversation.rs:421`), so there is no + retry, no queue, and no idempotence key -- there is nothing to be idempotent + *about*, since the unit of write is the full document, not a discrete + appended entry. + +## Read and resume path + +- Resume is a single-row, single-shot `SELECT` plus JSON deserialize: + `get_conversation_by_path` (`database/mod.rs:385-396`) is called once at + session startup (`crates/chat-cli/src/cli/chat/mod.rs:720-723`), gated by + the `resume: bool` CLI flag being set and by the loaded conversation + actually having non-empty history (`crates/chat-cli/src/cli/chat/mod.rs:727`, `previous_conversation + .filter(|cs| !cs.history().is_empty())` -- this guard exists specifically + "to prevent edge case where user clears conversation then exits without + chatting," per the adjacent comment at `crates/chat-cli/src/cli/chat/mod.rs:725-726`). +- There is no cursor, no incremental read, and no entry-level pagination -- + the entire `ConversationState` blob, including the full `history` + `VecDeque` and the full `transcript` `VecDeque`, is materialized eagerly + in one deserialization call. There is no lazy-loaded portion of a + conversation in this product; it is loaded whole or not at all. +- After a successful load, several fields that were explicitly *not* + serialized are reconstructed and reattached rather than read back: + `cs.tool_manager = tool_manager;` and `cs.agents = agents;` + (`crates/chat-cli/src/cli/chat/mod.rs:731,746`) -- both fields carry `#[serde(skip)]` on the struct + definition (`conversation.rs:125-126,131-132`), so the persisted JSON never + contains them; they must be freshly constructed on every process start and + spliced into the resumed state. `cs.update_state(true).await` and + `cs.enforce_tool_use_history_invariants()` (`crates/chat-cli/src/cli/chat/mod.rs:748-749`) then run a + bounds-check pass immediately after load, before the first new user + message is processed. +- On successful resume, the CLI synthesizes an implicit new turn rather than + just silently continuing: `input = Some(input.unwrap_or("In a few words, + summarize our conversation so far.".to_owned()))` (`crates/chat-cli/src/cli/chat/mod.rs:730`) -- i.e. the + *default* resume behavior, absent an explicit prompt, is to ask the model + to re-summarize the just-loaded history back to the user. + +## Listing, summaries, and search + +There is effectively nothing here to document beyond "absent." As +established under Keying and identity: no enumeration method exists over +`Table::Conversations`, no metadata sidecar is written alongside a +conversation row, and no search index -- full-text, fuzzy, or otherwise -- was +found anywhere in `crates/chat-cli/src` for chat history content. The only +thing resembling a "listing" surface in this whole crate is `q settings` +dumping the unrelated `state` key/value table (`cli/settings.rs:101`) and +`status_all_agents` (see Subagents below), which lists *delegate task* +status files from a workspace directory, not saved conversations. + +## Entry/message structure and versioning + +`ConversationState` (`crates/chat-cli/src/cli/chat/conversation.rs:106-152`) +is the entire durable unit. Full field list, in source order: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConversationState { + conversation_id: String, + next_message: Option, + history: VecDeque, + valid_history_range: (usize, usize), + pub transcript: VecDeque, + pub tools: HashMap>, + pub context_manager: Option, + #[serde(skip)] + pub tool_manager: ToolManager, + context_message_length: Option, + latest_summary: Option<(String, RequestMetadata)>, + #[serde(skip)] + pub agents: Agents, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_info: Option, + #[serde(default)] + pub file_line_tracker: HashMap, + pub checkpoint_manager: Option, + #[serde(default = "default_true")] + pub mcp_enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + tangent_state: Option, +} +``` +(`conversation.rs:106-152`) + +Evolution is entirely additive `#[serde(default)]`/`skip_serializing_if` +fields on this one struct -- there is no `version` field on `ConversationState` +itself, no schema-version tag inside the JSON blob, and no migration function +that rewrites an old shape into a new one at load time. The comment on the +`model` field is explicit about this style: "Unused, kept only to maintain +deserialization backwards compatibility with <=v1.13.3" (`:133-134`) -- old +fields are kept around forever, rather than migrated away, purely so that +`serde` can still deserialize rows written by older client versions. This is +a materially different evolution strategy from the SQL-schema ratchet +(`MIGRATIONS` in `database/mod.rs:67-76`), which only ever touches table +*shape* (adding/renaming/dropping columns), never the JSON *payload* shape +inside the `value` column -- the two evolve independently and by different +mechanisms. + +Each history entry: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HistoryEntry { + user: UserMessage, + assistant: AssistantMessage, + #[serde(default)] + request_metadata: Option, +} +``` +(`conversation.rs:91-97`) + +```rust +pub struct UserMessage { + pub additional_context: String, + pub env_context: UserEnvContext, + pub content: UserMessageContent, + pub timestamp: Option>, + pub images: Option>, +} + +pub enum UserMessageContent { + Prompt { prompt: String }, + CancelledToolUses { prompt: Option, tool_use_results: Vec }, + ToolUseResults { tool_use_results: Vec }, +} +``` +(`crates/chat-cli/src/cli/chat/message.rs:53-76`) + +```rust +pub enum AssistantMessage { + Response { message_id: Option, content: String }, + ToolUse { message_id: Option, content: String, tool_uses: Vec }, +} +``` +(`message.rs:436-448`) + +```rust +pub struct ToolUseResult { + pub tool_use_id: String, + pub content: Vec, + pub status: ToolResultStatus, +} +``` +(`message.rs:332-339`, further fields truncated by our read window past this +point -- see Open questions) + +There is no explicit parent/uuid chaining between entries -- ordering is +purely positional (`VecDeque` index), and identity/dedup for a tool round +trip is by `tool_use_id`/`tool_use_results[].tool_use_id` string matching +inside `enforce_conversation_invariants` (`conversation.rs:1121-1217`, e.g. +the tool-name-repair loop at `:1219-1266`), not by any store-level id. The +entry is opaque to the SQLite store itself -- `Database` never parses +`ConversationState`'s internal fields; only the application layer +(de)serializes it via `serde_json`. + +The separate, human-readable `transcript: VecDeque` +(`conversation.rs:120`) is a parallel, denormalized log of the same +conversation in prose form (`> ` prefixed user lines, assistant text plus a +`[Tool uses: ...]` suffix -- `append_user_transcript`/ +`append_assistant_transcript`, `conversation.rs:892-901`), capped +independently at `MAX_CONVERSATION_STATE_HISTORY_LEN` entries via +`append_transcript`'s `pop_front`-then-`push_back` (`:903-908`, +`crates/chat-cli/src/cli/chat/consts.rs:6`, `= 10000`). It is stored in the +very same JSON blob as `history`, not a separate sidecar, and is **not** what +gets sent to the backend (`as_sendable_conversation_state` builds its +request from `history`, not `transcript`). + +## Compaction and history management + +Two independent, both **destructive**, size-management mechanisms exist -- +neither leaves the pre-shrink data recoverable in the durable record, which +is the sharpest contrast with every log-based product in this corpus. + +1. **The 10,000-entry soft cap**, enforced on every turn before a request is + even sent. `ConversationState::as_sendable_conversation_state` + (`conversation.rs:508-537`) calls + `self.enforce_conversation_invariants()` and then + `self.history.drain(self.valid_history_range.1..); self.history.drain(.. + self.valid_history_range.0);` (`:515-517`) -- an in-place `VecDeque::drain` + of the live struct, not a read-only view. The free function + `enforce_conversation_invariants` (`:1121-1217`) computes the new lower + bound only once `(history.len() * 2) > MAX_CONVERSATION_STATE_HISTORY_LEN + - 6` (`:1134`, so effectively once history exceeds ~4997 entries), finding + "the second oldest message from the user without tool results" as the new + start (`:1135-1145`) and dropping everything before it. Because + `push_assistant_message` (which saves via `set_conversation_by_path`) runs + *after* this drain in the request/response cycle, the very next save + persists the shrunk history -- the dropped turns are gone from + `data.sqlite3` as well as from the model-visible window, not just from the + model-visible window. +2. **`/compact`**, a user- or auto-triggered summarization documented as + destructive in its own help text: "Clears the conversation history to + free up space" and "Compaction will be automatically performed whenever + the context window overflows" (`crates/chat-cli/src/cli/chat/cli/compact.rs:14-30`). + The implementation, `ConversationState::replace_history_with_summary`: + ```rust + pub fn replace_history_with_summary( + &mut self, summary: String, strategy: CompactStrategy, request_metadata: RequestMetadata, + ) { + self.history.drain(..(self.history.len().saturating_sub(strategy.messages_to_exclude))); + self.latest_summary = Some((summary, request_metadata)); + } + ``` + (`conversation.rs:732-741`) drains all but the last + `strategy.messages_to_exclude` entries (default `0`, + `cli/compact.rs:82-90`) and stores the AI-generated summary string in + `latest_summary`, which is spliced back into the next backend request as + context (`conversation.rs:684,811`). The dropped `HistoryEntry` values are + not written anywhere else first -- no snapshot file, no marker entry kept + alongside the summary. The next `push_assistant_message` save overwrites + the one durable row with the now-summarized state. + +There is no compaction *marker* stored in the durable record the way Zed +inserts a `Message::Compaction` variant into its message vector while keeping +prior messages, and no separate compacted-turn record the way fx's +`compacted_summary` kind coexists with a `removed_turn_count` while (per our +inference in that dossier) the durable array is not shortened. Here, the +durable array *is* shortened, in place, permanently. + +## Rewind, checkpoints, and fork + +Two distinct, unrelated mechanisms answer this section; neither is a +durable, replay-based rewind. + +**Turn checkpoints (shadow git repo).** `CheckpointManager` +(`crates/chat-cli/src/cli/chat/checkpoint.rs:36-65`) is itself a field of +`ConversationState` (`pub checkpoint_manager: Option`, +`conversation.rs:146`) and is therefore persisted inside the same JSON blob. +Its own doc comment: "Manages a shadow git repository for tracking and +restoring workspace changes" (`crates/chat-cli/src/cli/chat/checkpoint.rs:35`). `manual_init` +(`:103-146`) creates a **bare** git repo at +`~/.aws/amazonq/cli-checkouts/` +(`get_shadow_repo_dir`, `crates/chat-cli/src/cli/chat/mod.rs:225-227`, using +`PathResolver::new(os).global().shadow_repo_dir()`, +`crates/chat-cli/src/util/paths.rs:279-281,62`) with the real working +directory as its `--work-tree`, then commits an "Initial state" and tags it +`0`. `create_checkpoint` (`crates/chat-cli/src/cli/chat/checkpoint.rs:148-204`) stages, commits (`git +commit --allow-empty --no-verify`), and tags subsequent states with a +`turn.tool` tag scheme (`get_previous_tag`, `:471-489`). Each `Checkpoint` +struct (`:74-82`) additionally embeds a **full clone of the conversation +history at that point** (`history_snapshot: VecDeque`, +`:79`) -- so file-state and conversation-state checkpoints are recorded +together, but the mechanism is genuinely `git`, not the SQLite store: file +content lives in git blobs in the bare repo, and `CheckpointManager::restore` +(`:207-244`) does either `git reset --hard ` or `git checkout -- +.` against the real working tree, then calls +`conversation.restore_to_checkpoint(checkpoint)` +(`conversation.rs:910-922`), which does `self.history = +checkpoint.history_snapshot.clone();` -- a **full, destructive replace** of +the live history, not an append or a marker. The shadow repo is deleted on +`CheckpointManager::drop` (`crates/chat-cli/src/cli/chat/checkpoint.rs:349-364`, spawning an async or +thread-based `remove_dir_all`) and via an explicit `cleanup` +(`:334-339`) -- so file-state checkpoints do not outlive their owning +conversation's checkpoint manager being torn down, in addition to the +`history_snapshot` copies themselves being fully retained (a real, if +heavyweight, form of persistence, since every checkpoint's entire history is +duplicated in memory and thus in the next JSON save). + +**Tangent mode.** A separate, lighter in-memory-only branch mechanism: +`enter_tangent_mode` (`conversation.rs:263-267`) snapshots `history`, +`next_message`, `transcript`, and `latest_summary` into a +`ConversationCheckpoint` struct (`:154-167`) stored in the +`tangent_state: Option` field, which **is** +serialized into the durable blob (no `#[serde(skip)]` on it, only +`skip_serializing_if = "Option::is_none"`, `:150`). `exit_tangent_mode` +(`:278-282`) and `exit_tangent_mode_with_tail` +(`:285-303`) restore the pre-tangent state via `restore_from_checkpoint` +(`:250-260`), the latter optionally preserving the tangent's final +entry. Because `tangent_state` is part of the saved JSON, a process crash +while inside tangent mode leaves the *tangent's* history as the live +`history` field with the pre-tangent history preserved, recoverably, inside +`tangent_state.main_history` -- the one place in this product where a prior +state is retained alongside a newer one rather than destroyed, though we +found no explicit crash-recovery code path that automatically re-surfaces an +abandoned `tangent_state` on the next resume; a user would have to run the +exit command again after resuming (see Open questions). + +**There is no fork.** No copy-with-new-identity operation, no +shared-prefix/lineage mechanism, and no equivalent of Zed's clipboard +copy-thread or fx's `session recover` was found anywhere in this crate. + +## Subagents and nested sessions + +Amazon Q's nearest equivalent to a subagent is the experimental **Delegate** +tool (`crates/chat-cli/src/cli/chat/tools/delegate.rs`), gated by +`ExperimentManager::is_enabled(os, ExperimentName::Delegate)` (`:82-84`, +`crates/chat-cli/src/cli/experiment/experiment_manager.rs:16,114-116`). It +is **not** a nested session inside the parent's `ConversationState` and not a +first-class sibling row in `data.sqlite3` -- it is a wholly separate OS +process: + +```rust +let mut cmd = tokio::process::Command::new("q"); +cmd.args(["chat", "--non-interactive"]); +... +cmd.args(["--agent", agent, task]); +``` +(`delegate.rs:341-346`) + +Critically, this `Command` never calls `.current_dir(...)` -- the only +`current_dir()` call in the file (`:371`) merely *reads* the parent's cwd to +record it as a display field on `AgentExecution`, not to set the child's +working directory. A spawned `tokio::process::Command` inherits its +parent's working directory by default, so the delegated `q chat` process +runs against the **same** working-directory path, hence -- per Keying and +identity above -- the **same primary key** in the shared `conversations` +table as the parent session, and against the **same** `data.sqlite3` file +(the store is opened via a machine-global, not per-process, path: +`GlobalPaths::database_path_static`, `crates/chat-cli/src/util/paths.rs:327-332`, +`~/.local/share/amazon-q/data.sqlite3` on Linux XDG layout / platform +equivalent via `dirs::data_local_dir()`). Because the delegated process is +launched with `--non-interactive` and no `--resume` flag, it does **not** +load the parent's saved row (`resume_conversation` defaults to `false`, +`ChatArgs::resume` default per `#[derive(Default)]`, `crates/chat-cli/src/cli/chat/mod.rs:227-230`) -- it +starts a brand-new, empty `ConversationState` with its own freshly-minted +`conversation_id`, then, on its own first turn, calls +`set_conversation_by_path(cwd, self)` against that same shared key. **This +is our inference, not an observed runtime trace**: if the parent and the +delegated child are both live and both save while sharing a working +directory, the store has no concept of "this row belongs to conversation +X" -- it is last-write-wins by directory path, so a completing delegate task +can silently clobber the parent's saved conversation (or vice versa), with +no cascade, no orphan-detection, and no merge. We did not run this scenario +to confirm the race in practice; it is a structural inference from reading +`spawn_agent_process` against `set_conversation_by_path`'s key derivation. + +Delegate task bookkeeping itself is entirely separate from +`ConversationState`/SQLite: `AgentExecution` (`delegate.rs:267-291`, +fields `agent`, `task`, `status: AgentStatus` [`Running`/`Completed`/ +`Failed`], `launched_at`, `completed_at`, `pid`, `exit_code`, `output`, +`user_notified`, `summary`, `cwd`) is serialized as pretty JSON to +**one file per agent name** -- `agent_file_path` +(`:584-587`) is `/.json`, and `subagents_dir` +(`:589-591`, `crates/chat-cli/src/util/paths.rs:248-250,50`) resolves to +`/.amazonq/.subagents/`, a **workspace-relative** directory, distinct +from the global `data.sqlite3`. Because the file is keyed by agent *name* +and fully overwritten (`save_agent_execution`, `delegate.rs:577-582`, plain +`os.fs.write`, no lock, no temp-and-rename), the doc comment's own stated +constraint is a durability fact, not a UX suggestion: "Only one task per +agent" (`:52`) -- launching a second task under the same agent name before +the first completes is explicitly rejected (`launch_agent`, +`:149-156`, checking `AgentStatus::Running`). + +Nesting is unbounded in the sense that a delegated `q chat --non-interactive` +process could itself invoke Delegate again (no depth check was found), but +we did not trace whether the experiment flag or environment propagates to +make that practically possible. + +There is no cascade/orphan/reconcile behavior for delegate tasks on parent +delete, rewind, or crash: `status_agent`/`status_all_agents` +(`:471-527`) detect a dead process only by `kill -0` on the recorded `pid` +(`is_process_alive`, `:530-546`) and only mark the execution `Failed` after +the fact -- there is no notification to, or cleanup by, whichever process +consumes this file if the *parent* (not the delegate) is the one that dies +or deletes its conversation. + +## Retention, deletion, and multi-host + +- **Retention/TTL**: no lifecycle policy, scheduled cleanup, or age-based + expiry was found for the `conversations` table. A repo-wide grep confirms + `Database::delete_entry` -- the generic delete primitive + (`database/mod.rs:497-502`) -- is **never called with `Table::Conversations`** + anywhere in the crate; there is no exposed "forget this conversation" + operation. The only two paths that shrink or replace a conversation's + content are the destructive cap and `/compact` described above, and both + operate on content, never on the row's existence. +- **Deletion**: not supported for conversations at the store layer. (`auth_kv` + secrets do have `delete_secret`/`delete_entry`, `crates/chat-cli/src/database/mod.rs:424-427`, and the + shadow-git checkpoint directory is removed via `CheckpointManager::cleanup`/ + `Drop` -- `crates/chat-cli/src/cli/chat/checkpoint.rs:333-339,349-364` -- but neither of those is a + conversation-row delete.) +- **Multi-host**: `data.sqlite3` is a single local file at a + platform-local-data path (`dirs::data_local_dir()`, + `crates/chat-cli/src/util/paths.rs:327-332`) -- there is no + remote-writeback, no shared-filesystem assumption beyond ordinary local + disk, and no cross-host sync of conversations found anywhere in this + crate. Multi-host is out of scope by construction, similar to fx, but + without fx's explicit permission/safety refusal logic -- Amazon Q simply + assumes a private, local, single-user file (it does chmod the DB file to + `0600` on Unix on every open, `database/mod.rs:212-223`, which is the one + concrete safety measure present). +- **The `history` table is dead code at this commit.** Three of the eight + migrations (`001_history_table.sql`, `002_drop_history_in_ssh_docker.sql`, + `003_improved_history_timing.sql`) create and evolve a `history` table + (`id, command, shell, pid, session_id, cwd, start_time, end_time, + duration, hostname, exit_code` after all three migrations apply) that + appears to be a **shell-command-history** feature, not a chat-session + concept -- its column names (`command`, `shell`, `pid`, `exit_code`) are + unrelated to `ConversationState`. A grep across the entire workspace (all + nine crates, not just `chat-cli`) for `FROM history` / `INTO history` + returns zero hits: **no code anywhere in this repository reads or writes + this table**. It is migrated into existence on every fresh install but + left permanently empty and unused as far as this commit's source is + concerned -- flagged as a strong inference from an exhaustive grep, not a + runtime trace, since dynamically-constructed SQL (e.g. via + `format!("...{table}...")` with a non-`Table`-enum string) could in + principle still target it; we found no such construction. + +## Interop with foreign session stores + +No evidence found. A targeted search for import/legacy-session/foreign-format +handling (`import_conversation`, `legacy conversation`, references to other +CLI agent products) returned nothing relevant to chat *sessions*. The one +"migration" concept that does exist, `PROFILE_MIGRATION_KEY`/ +`get_has_migrated`/`set_has_migrated` (`database/mod.rs:64,340-347`, +consumed by `crates/chat-cli/src/cli/agent/legacy/mod.rs:30,102,227`), is a +one-time **config-format** migration (old "profile" config to the current +"agent" config schema), not a session-transcript importer, and is out of +scope for this section. + +## What this implies for our Session Store (our inference) + +- Amazon Q is the cleanest available confirmation that "session-as-mutable- + document, keyed by location, with a hard size cap enforced by destructive + drain" is a real, shipped design, not a strawman: `set_conversation_by_path` + is a full-blob `INSERT OR REPLACE` on every single turn + (`database/mod.rs:399-411`), and both of its size-bounding mechanisms (the + 10k-entry cap and `/compact`) mutate the one durable row in place with no + tombstone, marker, or externally-retained pre-image. For our event-sourced + Session Store this is the strongest available argument *against* choosing + a document-overwrite model even for a "just make it work" v1: every + size-bounding operation here is unrecoverable by construction, which is + exactly the failure mode an append-only log with a derived, replaceable + projection is meant to avoid. +- The path-as-primary-key design collapses two orthogonal concepts -- + *location* and *conversation identity* -- into one key. This directly + produced the parent/delegate collision risk described above: a system + that mints a real identity (`conversation_id`, UUIDv4) but doesn't use it + as the storage key gets no protection from that identity at all. Our + Session Store's stream/aggregate identity should be the actual session id, + never a derived environmental value like a cwd path, precisely to avoid + this class of same-location collision between concurrently-running + sessions (parent and child/subagent, or two terminals in the same + directory). +- The dual evolution strategy -- an explicit, ordered, transactional SQL + migration ratchet for table *shape* (`MIGRATIONS`, + `database/mod.rs:67-76,431-458`) versus purely additive + `#[serde(default)]` fields with no version tag for the JSON *payload* + shape inside `ConversationState` -- is a real-world precedent for treating + "the envelope/table schema" and "the event payload schema" as separately + versioned concerns, which lines up with the asymmetry we already noted + from Zed's dossier (strict ratchet for identity-bearing structure, additive + tolerance for payload fields). +- Tangent mode is a useful small data point on ergonomic, reversible + exploration: it is implemented as a full in-memory snapshot-and-restore + rather than an appended marker, and it is itself part of the persisted + blob (so it partially survives a crash) -- but it also shows the downside + of that approach, since we could not confirm any automatic recovery path + that surfaces an abandoned tangent snapshot to the user on the next + resume. Our design's equivalent (an explicit "diverged" branch) should + make abandoned branches discoverable at resume time rather than silently + present-but-unsurfaced inside a resumed document. +- The complete absence of a listing/enumeration operation over + conversations -- not even a full-table scan exists in the source -- is a + useful negative baseline for "how little session-store surface a shipping + product can get away with": Amazon Q's UX substitutes "the directory you + are standing in" for a picker entirely. Any product wanting cross-project + session listing (which ours does) needs to build that deliberately; it is + not a byproduct of even a working single-session store. + +## Open questions + +- Whether `set_conversation_by_path`'s path-as-key collision between a + parent session and a same-directory Delegate subagent actually manifests + as data loss in practice (our inference is structural, from reading + `spawn_agent_process`, `ChatSession::new`'s resume branch, and + `set_conversation_by_path`'s key derivation, not from an observed race). +- The exact default value and full field list of `ToolUseResultBlock` and + the remainder of `ToolUseResult`'s surrounding types in + `crates/chat-cli/src/cli/chat/message.rs` past line 339 -- our read window + stopped there; content-block variants beyond `Json(Document)`/`Text(String)` + (seen used at `message.rs:271-275`) were not independently confirmed + against the type definition. +- Whether an abandoned `tangent_state` (process crashed or was killed while + in tangent mode) is ever surfaced back to the user on the next `--resume`, + or whether it silently remains dormant inside the persisted blob until a + user manually re-triggers `/tangent` -- we found the snapshot/restore + functions but no resume-time check for a non-`None` `tangent_state`. + the field. +- Whether SQLite's default `busy_timeout=0` genuinely causes observable + `SQLITE_BUSY` failures under concurrent writers in practice, versus being + masked by r2d2's connection-pool behavior or by writes being rare enough + in normal single-user CLI usage that contention essentially never occurs + -- we verified only the absence of any `PRAGMA busy_timeout` in source, not + a runtime reproduction. +- Whether any dynamically-constructed SQL elsewhere in the crate (outside + `database/mod.rs`) ever targets the dead `history` table under a table + name built by string formatting rather than the `Table` enum -- we did not + find one, but a targeted grep for literal `history` table SQL is weaker + evidence than the enum-based confirmation we have for `conversations` + and `auth_kv`. +- Whether `agent`/`chat-cli-ui` (the two sibling crates we did not read in + depth) contain any additional session-adjacent persistence -- this dossier + is scoped to `crates/chat-cli`, the crate containing the anchor paths we + were given; we did not audit `crates/agent` or `crates/chat-cli-ui` for + overlapping state. diff --git a/docs/research/session-store/products/amazon-q/vs-session-events.md b/docs/research/session-store/products/amazon-q/vs-session-events.md new file mode 100644 index 000000000..55e5f1a8f --- /dev/null +++ b/docs/research/session-store/products/amazon-q/vs-session-events.md @@ -0,0 +1,481 @@ +# Amazon Q Developer CLI compared to our session event catalog + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT_COMPARISON](../../RESEARCH_PROMPT_COMPARISON.md). +Stage-one dossier: [Amazon Q Developer CLI](./index.md). +Compared against `proto/trogonai/session/sessions/v1alpha1/` and [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) on 2026-08-04. + +**Store maturity: 8/12**: evolution scars 2/3 (a real eight-step SQL migration +ratchet across three tables, `crates/chat-cli/src/database/mod.rs:67-76`, one +step of which is a genuine `ALTER COLUMN TYPE` workaround: `006_make_state_blob.sql:1-6` renames the old table, recreates it, copies rows, +and drops the original, because SQLite has no native column-type-change +statement, plus a JSON-payload back-compat comment naming a specific prior +version, `model: Option` "kept only to maintain deserialization +backwards compatibility with <=v1.13.3" (`conversation.rs:133-134`), real +scarring, but confined to additive fields and one type-change workaround, not +a generational cutover), operational age 1/3 (no issue reports, corruption or +lock-contention fixes, or first-commit dates are cited anywhere in the +dossier; the only operational-age evidence is the version-pinned back-compat +comment above, and the dossier is explicit that its own `busy_timeout=0` +concurrency claim is "inference from SQLite's documented defaults... not a +claim we verified by forcing a lock in this environment", source-level +evidence only, not field-confirmed), exposure 2/3 (vendor-shipped by a major +cloud vendor as the `q` CLI / `chat-cli` crate, a real, adopted product, but +the dossier cites no user-scale numbers, and the product has zero multi-host +or network-filesystem handling by design: a single local `data.sqlite3`, +"multi-host is out of scope by construction", so exposure evidence is +vendor-identity only, not corroborated by usage-scale or cross-host +operational evidence), design independence 3/3 (no evidence anywhere in the +dossier of forked or inherited persistence code from another product; the one +"migration" hit, `PROFILE_MIGRATION_KEY`, is an internal config-format +migration from an old "profile" scheme to the current "agent" scheme, not an +imported foreign session store). + +8/12 clears the corpus's thin-evidence line, but only barely, and unevenly: +treat the evolution-scars and design-independence findings as solid, and the +operational-age and exposure findings as directional rather than +field-confirmed. Where Amazon Q disagrees with a higher-scoring store in this +corpus (Cline, 10/12), Cline's answer is the default per the ADR's own +maturity rule; Amazon Q is cited here for what it is: the strongest available +confirmation that the *most* mutable-record end of the spectrum is a real, +vendor-shipped design, not a strawman. + +## The one structural difference everything else follows from + +Amazon Q's durable session is a single mutable JSON blob in a SQLite +`conversations (key TEXT PRIMARY KEY, value TEXT)` table, keyed by the +**literal absolute working-directory path string**, `INSERT OR REPLACE`d in +full on every assistant turn +(`set_conversation_by_path`, `crates/chat-cli/src/database/mod.rs:399-411`). +There is no append operation anywhere in the content path, no partial update, +and, critically, past what "mutable document" alone would predict, two +independent, both destructive, size-management mechanisms mutate that one row +*in place* with no tombstone, no marker, and no externally-retained pre-image: +a 10,000-entry soft cap that drains the in-memory history before every save +(`enforce_conversation_invariants`, `conversation.rs:1121-1217`), and +`/compact`, which drains all but the last N entries and replaces them with an +AI-generated summary (`replace_history_with_summary`, `conversation.rs:732-741`). + +We commit at **fact granularity** on an **opaque, addressable identity**: +`UserMessageRecorded`, `AssistantMessageStarted`/`Completed`, +`ToolCallRequested`/`Started`/`Completed`/`Failed` are separate, durable events +on a session's own logical stream, addressed by an opaque `SessionId` +(`proto/trogonai/session/sessions/v1alpha1/events.proto`, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 1), +and no command ever purges or trims that stream ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2, decision +7). Two orthogonal choices compound into Amazon Q's design, and both cut the +opposite way from ours: + +1. **Mutability, not append.** Every save is a full-document overwrite, so + there is nothing to "keep forever"; the destructive cap and `/compact` + are not omissions of a retention policy, they are the *only* size-management + mechanism a mutable-document store can have without an append-only log + underneath it. +2. **Location as identity.** The primary key is `std::env::current_dir()` + (`conversation.rs:420-421`, `crates/chat-cli/src/cli/chat/mod.rs:720-723`), not a minted session id. + `ConversationState.conversation_id` (`conversation.rs:109`) is a real, + internal UUIDv4 identity field that plays **no addressing role at all**, and a system that mints an identity but does not use it as the storage key + gets no protection from that identity. + +Both choices are the direct cause of the two most consequential findings +below: the destructive-shrink retention story, and the parent/delegate +same-directory collision risk (an inference, not an observed race, carried +forward from the dossier, not hardened here). Everything else in this +comparison (no enumeration, no delete, no relocation reconciliation, no +cascade) is a consequence of one or the other. + +## Mapping + +| Amazon Q | Ours | Verdict | +| --- | --- | --- | +| `conversations (key TEXT PRIMARY KEY, value TEXT)` SQLite row, `INSERT OR REPLACE`d whole (`database/mod.rs:399-411`) | Per-session logical stream on subject `session.sessions.events.` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 1), append-only (decision 2) | Central structural difference | +| Absolute cwd path string as primary key (`get_conversation_by_path`/`set_conversation_by_path`, `database/mod.rs:385-411`) | Opaque `SessionId` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 1) | Ours, decisively: avoids collapsing location into identity | +| `ConversationState.conversation_id` (UUIDv4, internal, no addressing role, `conversation.rs:109`) | `SessionId` (opaque, the actual addressing key) | Semantic mismatch: Amazon Q's "id" is not the key; ours is the key | +| 10,000-entry soft cap, drained in place before every save (`enforce_conversation_invariants`, `conversation.rs:1121-1217`) | No cap; keep-forever ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7), snapshot-bounded replay (facet 8) | Ours, decisively | +| `/compact` → `replace_history_with_summary`, drains all but `messages_to_exclude` (default `0`) entries, stores AI summary in `latest_summary` (`conversation.rs:732-741`) | `Compacted{covers_from, covers_through, summary_content}` (`compacted.proto`), an in-stream marker; covered events stay on the log ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 4, decision 7) | Ours, decisively | +| `transcript: VecDeque`, a denormalized prose log capped independently at the same 10,000 entries, not sent to the backend (`conversation.rs:120`, `append_transcript`, `:903-908`) | No parallel denormalized log; `CanonicalMessage` is the single source (`message.proto`) | Ours, deliberately: nothing to drift out of sync | +| `CheckpointManager`, a shadow **bare git repo** at `~/.aws/amazonq/cli-checkouts/`; each `Checkpoint` additionally embeds a **full clone of conversation history** (`history_snapshot: VecDeque`, `crates/chat-cli/src/cli/chat/checkpoint.rs:74-82`) | `Checkpoint{reference, checkpoint_type, digest, checkpoint_id, covers_through, session_execution_plan_digest}` (`checkpoint.proto`), a claim-check reference, never inline history | Semantic mismatch: Amazon Q's "checkpoint" duplicates the entire transcript per cut; ours references, never inlines ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 3, "four records with separate authority") | +| Tangent mode: `ConversationCheckpoint` snapshot/restore of `history`/`transcript`/`latest_summary`, persisted inline in `tangent_state` (`conversation.rs:154-167,263-303`) | No equivalent | Gap: see recommendation 3 | +| Delegate tool: a wholly separate OS process (`tokio::process::Command::new("q")`, `delegate.rs:341-346`), bookkept in one JSON file per agent name under `/.amazonq/.subagents/` | `DelegationDispatched`/`ParentLinked`, a first-class linked session on its own stream ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6) | Ours, decisively | +| No cascade/orphan/reconcile behavior for Delegate on parent delete, rewind, or crash, a dead process is detected only by `kill -0` on a recorded `pid`, opportunistically | `ParentTerminated`/`SessionCancelled`, `ParentHistoryInvalidated`, a reconciler reacting to terminal markers on `session.sessions.events.>` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6) | Ours, decisively: see Subagent cascade below | +| No enumeration over `conversations` anywhere in the crate (`Table::Conversations` appears in exactly 3 grep hits, all in `database/mod.rs` itself) | `list_sessions`/`get_session`, a rebuildable KV projection ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8) | Ours, decisively | +| No delete/TTL/retention policy for conversations; `delete_entry` is never called with `Table::Conversations` anywhere in the crate | `SessionHidden` (visibility tombstone) + `RedactionApplied` (read-time mask) + `ArtifactErased` (out-of-band artifact-byte destruction) ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7) | Ours, decisively | +| `resume: bool` CLI flag tied to the current directory; no `--session ` flag, no picker (`ChatArgs`, `crates/chat-cli/src/cli/chat/mod.rs:227-253`) | Opaque `SessionId` addressing + `list_sessions` projection ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8) | Ours, decisively | +| Additive `#[serde(default)]`/`skip_serializing_if` JSON payload fields, no version tag, version-pinned back-compat comments (`conversation.rs:133-134`) | Additive-only protobuf evolution, "never a per-event version branch" ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 3) | Equivalent strategy, independently arrived at | +| SQL `MIGRATIONS` ratchet for table *shape*, 8 steps, one requiring a rename-recreate-copy-drop workaround (`database/mod.rs:67-76`, `006_make_state_blob.sql`) | No SQL table shape exists; the protobuf wire schema is the only "shape," ratcheted `v1alpha1` → `v1` by a later decision ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) §1) | Trade-off: see below | +| `ToolUseResult{tool_use_id, content, status}`, opaque to the store, deduped by `tool_use_id` string matching in `enforce_conversation_invariants` (`conversation.rs:1121-1266`) | `ToolCallCompleted`/`ToolCallFailed`, keyed by `tool_execution_id`, first-terminal-outcome-wins fold ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2, decision 4) | Ours, decisively | +| No application-level optimistic concurrency of any kind; concurrent writers race under ordinary SQLite file locking with (inferred, unverified) `busy_timeout=0` | Per-command `WRITE_PRECONDITION` (`NoStream`/`At`/`Any`), server-enforced by JetStream ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2) | Ours, decisively | +| No redaction or erasure concept anywhere in the dossier | `RedactionApplied`/`ArtifactErased` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7) | Ours, decisively | +| Save's `Result` discarded with `.ok()` (`conversation.rs:421`); a failed persist is silent and the turn is never rolled back | Typed append failures (`WrongExpectedVersion` and friends) that a command boundary must surface, not swallow ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2) | Ours in principle: see recommendation 1 for why this needs to be said explicitly, not just implied | +| Non-UTF-8 path returns `Ok(None)`/`Ok(0)` silently from the accessor pair (`database/mod.rs:390-393,405-408`) | `SessionId` is an opaque string with its own validation at the append boundary (`validate_session_event`, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 3) | Ours, decisively: identity is never a filesystem artifact that can silently fail to encode | + +## What we should consider changing + +### 1. State explicitly, as an [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) obligation, that a command boundary must never discard an append failure + +**The change.** [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 2 defines a typed `WrongExpectedVersion` on +guard conflict and treats `append_stream` as the one write path, but nowhere +does the ADR say, in so many words, that a command handler's caller must +propagate that result as command failure rather than logging-and-continuing. +It is implicit in "the store" being append-only and typed; it is not yet a +stated obligation on the code that calls it. + +**Evidence anchor.** Amazon Q, store maturity 8/12, +`conversation.rs:420-421`: `if let Ok(cwd) = std::env::current_dir() { +os.database.set_conversation_by_path(cwd, self).ok(); }`, the save's +`Result` is discarded with `.ok()`. The in-memory turn is never rolled back +and the user is never told persistence failed; the next process start simply +resumes from whatever was last actually written, silently dropping every turn +after the last successful save. + +**Blast radius.** Additive: a clarifying obligation in [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 2 or +the Consequences section, not a schema change. + +**Why.** This is not a store-shape problem, it is a discipline problem the +store's design does not automatically prevent: our substrate makes an append +failure typed and observable (`WrongExpectedVersion`, a decode-failure +metric per facet 3), but nothing stops a future command-handler +implementation from doing exactly what Amazon Q's `conversation.rs:421` does: matching on the `Result` and discarding it, especially on the high-volume +`Any`-guarded path, where "it almost never fails" is true enough in practice +to make silent discarding tempting. Amazon Q is the concrete demonstration of +what that costs: a class of data loss that is invisible until a user notices +their history is shorter than they remember, with nothing in the log to +explain why. + +**Cost.** None beyond writing the sentence and, ideally, a lint or review +checklist item; it becomes real cost only if an implementer has already +written the `.ok()`-shaped code this is meant to forbid. + +### 2. Ratify workspace-binding immutability as an explicit Non-Goal, not an implicit proto comment + +**The change.** `WorkspaceRef` (`workspace.proto`) and its comment on +`SessionStarted.workspace` (`session_started.proto:19-23`) already state "the +plan's working directory is immutable for the life of the session... changing +it requires a new session or a fork," but this lives only as a proto-file +comment, not as a decision the ADR's Non-Goals section names, unlike, for +example, mid-session model switching, which [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md)'s Non-Goals explicitly +defers. + +**Evidence anchor.** Amazon Q, store maturity 8/12: because the store's key +*is* the path, moving or renaming the working directory produces a silent +cache miss: `get_conversation_by_path` returns `None`, and the CLI falls +through to `ConversationState::new(...)`, starting a brand-new, empty +conversation with no warning, no orphan reference, and no migration path +(`crates/chat-cli/src/cli/chat/mod.rs:752-764`). The dossier is explicit that +this is "by omission rather than by a deliberate no-op decision we could find +recorded" (the dossier's [Keying and identity](./index.md#keying-and-identity) section). + +**Blast radius.** Additive: a documentation clarification, not a schema +change; `WorkspaceRef` already has the shape this recommendation asks the +ADR to ratify. + +**Why.** Our design does not have Amazon Q's specific failure mode, because +`WorkspaceRef` is a data field carried on `SessionStarted`, not the session's +addressing key; a relocated workspace cannot produce a cache-miss-to-fresh- +session silently, because nothing about session addressing depends on the +workspace's current location. But the underlying product question Amazon +Q's omission raises (what happens when the directory a session was bound to +moves on disk) is one our proto comments already answer informally +("requires a new session or a fork") without that answer being a Non-Goal the +ADR names. Amazon Q is the evidence for *why* this needs to be a decision +recorded on purpose: it is the demonstrated cost of leaving it undecided. +Recording it costs nothing and forecloses a future proposal to add silent +relocation-reconciliation logic on the strength of "well, some product does +it", and no product in this corpus does it, so Amazon Q is the clearest +evidence that *not deciding* is itself a bad default. + +**Cost.** None; this is a documentation-only change that prevents a future, +more expensive one (an ad hoc relocation-reconciliation feature added without +having weighed it against fork/new-session first). + +### 3. Consider whether a lightweight, non-forking divergence marker belongs in the catalog + +**The change.** Amazon Q's tangent mode, where `enter_tangent_mode` +(`conversation.rs:263-267`) snapshots `history`, `next_message`, +`transcript`, and `latest_summary` into a `tangent_state` field that *is* +serialized into the durable blob, and `exit_tangent_mode`/ +`exit_tangent_mode_with_tail` (`:278-303`) restore it, is a real, shipped +answer to "let me explore a side-question and come back," distinct from both +our `SessionForked` (a new, independent session identity, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision +5) and `SessionRewound` (an ordinal boundary that invalidates nothing until a +new attempt starts, decision 2). Nothing in our 41-arm catalog names "this +stretch of the stream was an aside, resume the prior context after it." + +**Evidence anchor.** Amazon Q, store maturity 8/12: +`crates/chat-cli/src/cli/chat/conversation.rs:154-167,263-303`. + +**Blast radius.** Additive if scoped to new event types (for example +`TangentEntered`/`TangentExited`, correlated by `turn_id`) that the +model-visible-context projection ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8) folds around; no +existing event shape changes. + +**Why this is worth naming, and what to solve that Amazon Q did not.** The +same dossier that documents tangent mode also documents its own open +question: "we found no explicit crash-recovery code path that automatically +re-surfaces an abandoned `tangent_state` on the next resume... a user would +have to run the exit command again after resuming" (the dossier's [Rewind, checkpoints, and fork](./index.md#rewind-checkpoints-and-fork) section, +flagged there as inference from an absent code path, not a confirmed +runtime trace). If we build this, that gap is the one thing not to +reproduce: an abandoned divergence must be a discoverable fact at resume +time (a fact the projection can report, e.g. "session has an open tangent +started at turn N"), not a silently-present field inside a resumed +document that only a re-issued command clears. + +**Cost.** A new pair of event types plus a decide/evolve rule for what +"abandoned" means (most plausibly: a tangent with no matching `TangentExited` +by the time a new `ExecutionAttemptStarted` begins), and a resume-time +projection change to surface it. This is real, if modest, work; the +recommendation is to decide whether the UX value is worth it, not that it +obviously is. + +## What our design already does better + +- **Opaque identity instead of a location-derived key.** `SessionId` is + never the working directory, the workspace, or any other environmental + value ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 1). Amazon Q's literal-path key collapses + *location* and *conversation identity* into one, which is the direct cause + of its parent/delegate collision risk (see Subagent cascade below) and of + its silent relocate-and-lose-history behavior (see recommendation 2). +- **Content is claim-checked, never inlined and duplicated.** `ArtifactRef` + (`artifact.proto`) references bytes by digest; Amazon Q's shadow-git + `CheckpointManager` embeds a **full clone of conversation history** in + every `Checkpoint` struct (`crates/chat-cli/src/cli/chat/checkpoint.rs:79`), so "every checkpoint's + entire history is duplicated in memory and thus in the next JSON save" + (the dossier's [Rewind, checkpoints, and fork](./index.md#rewind-checkpoints-and-fork) section), the opposite of our `Checkpoint`, which is a + reference plus a digest (`checkpoint.proto`), never inline history. +- **Real, server-enforced optimistic concurrency on invariant-bearing + transitions.** `WRITE_PRECONDITION` (`NoStream`/`At`/`Any`, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) + decision 2) is enforced by JetStream. Amazon Q has **no application-level + concurrency control of any kind** for conversation writes: "no + application-level optimistic-concurrency check... gated only by SQLite's + own file locking" (the dossier's [Write and append path](./index.md#write-and-append-path-ordering-durability-concurrency-delivery) section), which is a strictly weaker + position than even Cline's client-issued `statusLock` CAS + ([Cline comparison](../cline/vs-session-events.md), item 1). +- **Subagents are first-class sessions, not a side file.** `DelegationDispatched`/ + `ParentLinked` make a delegated child a real, linked stream with its own + lifecycle ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6). Amazon Q's Delegate tool is a wholly + separate OS process whose bookkeeping (`AgentExecution`) lives in one + plain-JSON file per agent name, entirely outside `data.sqlite3`, with zero + connection to the parent's conversation store beyond an accidentally + shared working-directory key. +- **Redaction and erasure are named, typed facts.** `RedactionApplied`/ + `ArtifactErased` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7) have no analogue anywhere in the + Amazon Q dossier; there is no privacy or masking concept of any kind. +- **Listing is a real, rebuildable capability.** `list_sessions`/ + `get_session` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8) exist because we chose to build them. + Amazon Q shows, concretely, how little a shipping product can get away + with instead: zero enumeration surface, substituting "the directory you + are standing in" for a picker entirely. +- **Turn identity is a stamped fact, not positional inference.** `turn_id` + ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 3) is carried on every conversational and tool event. + Amazon Q's ordering is "purely positional (`VecDeque` index)" + (the dossier's [Entry/message structure and versioning](./index.md#entrymessage-structure-and-versioning) section), with round-trip identity recovered only by + `tool_use_id` string matching inside `enforce_conversation_invariants`. +- **Typed tool-outcome resolution instead of destructive history replace.** + `ToolCallCompleted` vs. `ToolCallFailed` under first-terminal-outcome-wins + ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 4) never touches prior events. Amazon Q's + `CheckpointManager::restore` does `self.history = + checkpoint.history_snapshot.clone()`, "a full, destructive replace of the + live history, not an append or a marker" (the dossier's [Rewind, checkpoints, and fork](./index.md#rewind-checkpoints-and-fork) section), for the one + operation in Amazon Q that most resembles our rewind. + +## Trade-offs, not gaps + +**Whole-document mutability versus per-fact append.** Amazon Q's model buys +genuine implementation simplicity: one SQL statement, one JSON blob, no fold, +no projection, no ordinal scheme. The cost is that every size-bounding +operation is unrecoverable by construction; there is no way, even in +principle, to "keep forever" inside this design without changing it into +something else. Our append-only model pays in fold complexity and per-fact +write volume (mitigated by the `Any`-precondition commuting-fact path, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) +decision 2) to buy the opposite: nothing is ever destructively unrecoverable. +Neither is free; Amazon Q chose to spend its budget on simplicity, we chose +to spend ours on recoverability. + +**A location-tied `resume` flag versus an id-addressed picker.** `q chat +--resume` needs no argument beyond "am I in the right directory": zero +friction, at the cost of one conversation per directory and no way to have +two independent sessions in the same place. Our opaque `SessionId` plus +`list_sessions` buys the reverse: any number of sessions per workspace, +addressable independently, at the cost of needing an actual picker surface +for a user to choose among them. + +**Separately-versioned table shape versus a single wire schema.** Amazon +Q's SQL `MIGRATIONS` ratchet (`database/mod.rs:67-76`) and its additive JSON +payload evolution are two independent mechanisms for two independent +concerns (table shape vs. document shape) that happen to converge on "be +additive wherever possible." We have only one mechanism: protobuf wire +evolution, additive within `v1alpha1` and a deliberate breaking ratchet to +`v1` later ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) §1), because we have no SQL table shape to separately +version at all. This is not a gap on either side, just a consequence of one +store having two physical substrates (SQLite table plus JSON blob) and the +other having one (the event log itself). + +## What not to copy + +- **Path-as-primary-key, full stop.** Collapsing *location* and *conversation + identity* into one key is the single most consequential anti-pattern in + this dossier: it produces the relocate-and-silently-lose-history behavior + (recommendation 2) and the parent/delegate same-directory collision risk + (Subagent cascade, below, an inference from reading the code, not a + reproduced race, and it should stay described that way). +- **Two independent, both-destructive size-management mechanisms with no + tombstone.** The 10,000-entry drain and `/compact`'s default + `messages_to_exclude: 0` both mutate the one durable row in place; "the + dropped turns are gone from `data.sqlite3` as well as from the model-visible + window, not just from the model-visible window" (the dossier's [Compaction and history management](./index.md#compaction-and-history-management) section). This + is exactly the truncation strategy [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md)'s own Alternatives section + already rejects ("Truncating the session log (purge-only, or + archive-then-purge)... is a logical deletion... forecloses audit and + rewind past the truncation point"); Amazon Q is that rejected alternative, + shipped and load-bearing in a real vendor CLI, not a hypothetical. +- **Discarding a persistence-write `Result`.** `.ok()` on the save call + (`conversation.rs:421`) turns a failed write into invisible data loss. See + recommendation 1. +- **Full history duplication inside a "checkpoint."** Embedding + `history_snapshot: VecDeque` in every `Checkpoint` struct + means every checkpoint duplicates the entire transcript so far. Our + `Checkpoint` is a reference plus a digest for exactly this reason. +- **A subagent mechanism with zero notification path in either direction.** + Amazon Q's Delegate tool has no cascade, no orphan-detection, and no + parent-to-child or child-to-parent signal at all beyond a `kill -0` PID + check run opportunistically by whichever process happens to call + `status_agent`. This is a weaker position than even Cline's one-level, + synchronous-push cascade ([Cline comparison](../cline/vs-session-events.md), + Subagent cascade section); do not treat "no cascade" as an acceptable + fallback position merely because a vendor CLI ships it. + +## The two gaps the industry has not closed + +### Subagent cascade + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6 already takes a position: a child session is its own +logical stream, linked by facts on each side (`DelegationDispatched`/ +`ParentLinked`), acyclic by construction, with terminal cascade driven by a +reconciler reacting to Session-level terminal markers, transitively, in +O(depth) round-trips. The question here is whether Amazon Q's evidence +validates, challenges, or refines that position. + +**What Amazon Q does.** Its nearest equivalent to a subagent, the Delegate +tool, is not a nested session and not a sibling row in `data.sqlite3` at +all; it is a wholly separate OS process +(`tokio::process::Command::new("q")`, `delegate.rs:341-346`). Because that +command never calls `.current_dir(...)`, the delegated process inherits the +parent's working directory (`delegate.rs:371` only *reads* the cwd for +display), and, per the "location as identity" structural point above, that +means the delegate process shares the **same primary key** in the shared +`conversations` table as the parent (the dossier's [Subagents and nested sessions](./index.md#subagents-and-nested-sessions) section). The delegated +process launches `--non-interactive` with no `--resume`, so it starts a +fresh, empty `ConversationState` rather than loading the parent's row, but +on its own first save it targets that same shared key. The dossier is +explicit and careful here: **this is a structural inference from reading +`spawn_agent_process` against `set_conversation_by_path`'s key derivation, +not an observed runtime race** (the dossier's [Subagents and nested sessions](./index.md#subagents-and-nested-sessions) and [Open questions](./index.md#open-questions) sections). Carried forward +unchanged: nothing in this comparison hardens that inference into a +confirmed bug. Separately, and independently of the collision risk, Delegate +task bookkeeping (`AgentExecution`, one JSON file per agent name under +`.amazonq/.subagents/`) has no cascade, orphan, or reconcile behavior at all +on parent delete, rewind, or crash: a dead process is discovered only by +`kill -0` on a recorded `pid`, run opportunistically, with no notification to +or from the parent. + +**Does this validate, challenge, or refine decision 6?** It validates the +design and adds one refinement decision 6's text does not currently name. +On cascade *mechanics*: Amazon Q has none, which is a strictly weaker +position than Cline's one-level, in-practice-usually-sufficient cascade, so +Amazon Q sits at the bottom of this corpus's cascade-maturity ladder (no +coordination at all, below Cline's synchronous one-level push, below our +transitive reconciler), and is straightforward corroborating evidence that +"cascade" is a real, unsolved industry gap decision 6 is right to close +deliberately rather than leave implicit. + +The more interesting evidence is the collision risk, and what it is actually +evidence *for*. It is not, on close reading, a test of decision 6's cascade +*semantics*; it is a test of the prerequisite decision 6 silently assumes: +that a parent and a delegated child are *addressably distinct* in storage to +begin with. Decision 6 discovers children "through the parent-to-children +lineage projection folded from `DelegationDispatched`" and links them by +`ParentLinked`/`operation_id`; none of that machinery has anything to say +about a parent and child that share *the same storage key*, because +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 1 (each session, each subagent, and each fork is its own +logical stream, its own subject, addressed by an opaque `SessionId`) rules +that scenario out by construction before decision 6 ever runs. Amazon Q's +same-directory collision is what happens when a system mints a real identity +(`conversation_id`) but does not use it as the addressing key; it is +evidence for why decision 1's opaque-identity choice is a *load-bearing +prerequisite* for decision 6's cascade guarantee to mean anything at all, +not evidence that decision 6 itself needs to change. Where Amazon Q's answer +is worse than ours: it has no equivalent of decision 1 to rule the collision +out, so its Delegate feature inherits the collision risk as a byproduct of a +choice (path-as-key) made for an unrelated reason, long before subagents +existed as a feature. + +### Retention on an unbounded log + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7 already takes a position: keep-forever, with +`SessionHidden` as a visibility tombstone, `RedactionApplied` for read-time +masking, `ArtifactErased` for out-of-band artifact-byte destruction, and +snapshot-bounded replay so resume cost is O(tail) even as the log grows +forever. The question is whether Amazon Q's evidence validates that design or +exposes a cost the ADR does not bound. + +**What Amazon Q does.** It does not merely fail to bound growth (Cline's +failure mode); it forecloses unbounded growth from ever being possible in the +first place, by choosing the opposite strategy: destroy old facts to keep the +row small. Two independent mechanisms enforce this on every turn: the +10,000-entry cap (`enforce_conversation_invariants`, `conversation.rs:1121-1217`, +draining via `VecDeque::drain` before the next save) and `/compact` +(`replace_history_with_summary`, `conversation.rs:732-741`, default +`messages_to_exclude: 0`), and neither leaves the pre-shrink data recoverable +anywhere: no snapshot file, no marker entry beside the summary, no tombstone. +Separately, there is no delete/TTL policy either: `delete_entry` is never +called with `Table::Conversations` anywhere in the crate, so the *only* +size-management this store has is the destructive shrink; "keep everything" +is not a state this design can be in. + +**Does this validate, challenge, or refine decision 7?** It validates +decision 7's Alternatives-section rejection of truncation-as-retention, +concretely, in a shipped vendor product, rather than only in the abstract. +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) already rejects "any design that removes an event from the log... +[because it] forecloses audit and rewind past the truncation point"; Amazon +Q is exactly that rejected alternative, not a hypothetical: past the 10,000- +entry boundary or a `/compact` call, a dropped turn is gone from +`data.sqlite3` itself, not merely from the model-visible window, with no +`RedactionApplied`-equivalent masking-with-recovery option and no +`SessionRewound`-equivalent "undo"; the data is simply gone. Decision 7's +`SessionHidden`/`RedactionApplied`/`ArtifactErased` triad gives us a +recoverable-underneath, masked-on-top story Amazon Q has no equivalent of at +all. + +The honest caveat, matching this product's weaker maturity axis: unlike +Cline's `cline/cline#9011` (a field-reported, issue-tracker-corroborated +freeze), **we found no issue report or user complaint anywhere in the dossier +confirming that Amazon Q's destructive shrink has caused a user-visible data- +loss incident**; this section's evidence is source-level (what the code +provably does) rather than field-level (a confirmed complaint that it did +this to someone). Weight it accordingly: it is strong evidence that the +*mechanism* is real and load-bearing in a shipped vendor product, and weaker +evidence that it has caused a specific, documented user harm. That gap is +consistent with this product's own operational-age score (1/3) being the +weakest of its four maturity axes. + +## Open questions for the ADR + +1. Should [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) state explicitly, as a facet 2 obligation or a + Consequences note, that a command boundary must never discard an append + failure, following recommendation 1 above, and Amazon Q's `.ok()` + anti-pattern as the concrete cost of leaving it unsaid? +2. Should workspace-binding immutability (already implied by + `WorkspaceRef`'s comment on `SessionStarted.workspace`) be promoted to a + named Non-Goal, so a future relocation-reconciliation feature has to be + proposed against a recorded decision rather than an implicit default? +3. Does a lightweight, non-forking divergence/"tangent" marker belong in the + catalog at all, and if it does, who is responsible for surfacing an + abandoned one at resume time: the model-visible-context projection + (decision 8), or a dedicated read model? +4. Amazon Q's `/compact` auto-triggers "whenever the context window + overflows," entirely inside the agent loop, never the store + (`cli/compact.rs:14-30`), a concrete existing precedent for the open + question already raised in the [Cline comparison](../cline/vs-session-events.md) + (open question 3: who guarantees `Compacted` markers are emitted often + enough). Should the ADR name the agent loop as that owner explicitly, + following this precedent, rather than leaving it implicit under decision 4? diff --git a/docs/research/session-store/products/aws-strands/index.md b/docs/research/session-store/products/aws-strands/index.md new file mode 100644 index 000000000..3df19ecb4 --- /dev/null +++ b/docs/research/session-store/products/aws-strands/index.md @@ -0,0 +1,792 @@ +# AWS Strands Agents: how session transcripts are stored and resumed + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT](../../RESEARCH_PROMPT.md). +Evidence snapshot retrieved 2026-08-04. Source: local clone of +`strands-agents/harness-sdk` (formerly `sdk-python`; ships both a Python SDK +under `strands-py/` and a TypeScript SDK under `strands-ts/`), pinned at +commit `23541039fa1fef14bbfd738d54aface5ffefd625`, Apache-2.0. All citations +below are repo-root-relative paths within that clone. This dossier covers the +**Python SDK** (`strands-py/`) only; `strands-ts/` has a parallel +`src/session/` implementation that was not read for this dossier (see Open +questions). Version-sensitive claims were checked against these anchors: + +- `strands-py/src/strands/session/session_manager.py` -- abstract + `SessionManager` (hook-driven lifecycle interface). +- `strands-py/src/strands/session/session_repository.py` -- abstract + `SessionRepository` (CRUD interface). +- `strands-py/src/strands/session/repository_session_manager.py` -- the one + concrete `SessionManager` shipped, generic over any `SessionRepository`. +- `strands-py/src/strands/session/file_session_manager.py` and + `strands-py/src/strands/session/s3_session_manager.py` -- the two shipped + `SessionRepository` implementations. +- `strands-py/src/strands/types/session.py` -- the `Session`, `SessionAgent`, + `SessionMessage` data models. +- `strands-py/src/strands/multiagent/{base,graph,swarm}.py` -- multi-agent + orchestrator state persistence. +- `strands-py/src/strands/experimental/checkpoint/checkpoint.py` and + `strands-py/src/strands/types/_snapshot.py` -- two adjacent, non-store + persistence mechanisms that matter for the versioning and fork questions. +- `team/designs/0014-storage.md` -- an internal, dated (2026-06-29), "Proposed" + design record for a future unified storage primitive that would replace the + interface studied here. + +## The storage model + +There is no log file anywhere in this store. The durable session is a +**row-set of small, independent JSON documents**, one file (file backend) or +one S3 object (S3 backend) per session/agent/message/multi-agent record, with +no envelope tying them together beyond the path/key hierarchy itself. + +`SessionManager`'s docstring states the intent directly: "A session manager is +in charge of persisting the conversation and state of an agent across its +interaction. Changes made to the agents conversation, state, or other +attributes should be persisted immediately after they are changed." +(`strands-py/src/strands/session/session_manager.py:34-37`). + +Both shipped backends document the same layout in their class docstrings +(`strands-py/src/strands/session/file_session_manager.py:31-43`, +`strands-py/src/strands/session/s3_session_manager.py:34-46`): + +```text +// +└── session_/ + ├── session.json # Session metadata + └── agents/ + └── agent_/ + ├── agent.json # Agent metadata + └── messages/ + ├── message_.json + └── message_.json +``` + +This docstring is incomplete on both files: `create_session` on both backends +also creates a `multi_agents/` directory/prefix +(`strands-py/src/strands/session/file_session_manager.py:172-173`, +inferred equivalently for S3 since `_get_multi_agent_path` builds +`multi_agents/multi_agent_/` keys at +`strands-py/src/strands/session/s3_session_manager.py:353-357`), which the +docstring never mentions. + +Everything under a session directory/prefix is **authoritative** -- there is +no derived cache, summary, index, or search structure of any kind in this +store. Every read returns exactly what was last written, verbatim +(`_read_file` / `_read_s3_object` simply deserialize JSON: +`strands-py/src/strands/session/file_session_manager.py:119-131`, +`strands-py/src/strands/session/s3_session_manager.py:142-154`). Ordering +within an agent's messages is **positional**, carried by an integer +`message_id` that doubles as part of the filename/key +(`message_.json`), not by a separate append-only sequence counter. + +Conceptual model: **session-as-row-set** -- a session is a directory (or key +prefix) of independent whole-object documents addressed by a fixed path +scheme, closer to a tiny per-session key-value store than to a transcript or +a log. This is a deliberate design consequence, not an oversight: because +each conversation turn's user/assistant/tool messages are each written as +their own whole file (see Write and append path), the store never needs a +true "append" primitive, which sidesteps the one operation an object store +cannot do natively. + +## Keying and identity + +`session_id` and `agent_id` are **caller-supplied strings**, not minted by the +store. `RepositorySessionManager.__init__` takes `session_id` as a +constructor argument and creates the session if one does not already exist +under that id -- an idempotent create-or-attach, not a fresh mint per call +(`strands-py/src/strands/session/repository_session_manager.py:31-59`). + +Both ids are validated identically, by a shared helper that rejects anything +containing a path separator: + +```python +# strands-py/src/strands/_identifier.py:14-30 +def validate(id_: str, type_: Identifier) -> str: + if os.path.basename(id_) != id_: + raise ValueError(f"{type_.value}_id={id_} | id cannot contain path separators") + return id_ +``` + +`Identifier` has exactly two members, `AGENT` and `SESSION` +(`strands-py/src/strands/_identifier.py:7-11`). Multi-agent ids reuse the +`AGENT` variant -- there is no third `MULTI_AGENT` identifier kind +(`strands-py/src/strands/session/file_session_manager.py:298`, +`strands-py/src/strands/session/s3_session_manager.py:356`). + +Key hierarchy (file backend): +`/session_/agents/agent_/messages/message_.json` +and `/session_/multi_agents/multi_agent_/multi_agent.json` +(`strands-py/src/strands/session/file_session_manager.py:74-117,295-299`). +Key hierarchy (S3 backend) is the identical shape as a flat key with `/` +separators and an optional caller-supplied `prefix` +(`strands-py/src/strands/session/s3_session_manager.py:93-140,353-357`). + +`message_id` is an integer **position index**, assigned client-side inside +`RepositorySessionManager`, not by the repository: + +```python +# strands-py/src/strands/session/repository_session_manager.py:77-86 +latest_agent_message = self._latest_agent_message[agent.agent_id] +if latest_agent_message: + next_index = latest_agent_message.message_id + 1 +else: + next_index = 0 +session_message = SessionMessage.from_message(message, next_index) +self._latest_agent_message[agent.agent_id] = session_message +self.session_repository.create_message(self.session_id, agent.agent_id, session_message) +``` + +**No listing or enumeration operation exists anywhere** in the interface or +either shipped implementation: neither `SessionManager` nor +`SessionRepository` declares a `list_sessions`/`list_agents` method, and a +repo-wide search of `strands-py/src/strands/session/*.py` for +`list_sessions` returns no matches. A caller must already know the +`session_id` (and `agent_id`) to address anything; there is no picker, no +directory scan surfaced through the SDK, and no cross-session query. (See +Listing, summaries, and search.) + +There is no workspace/cwd binding, and no relocation/rename concept: the +`Session` dataclass carries only `session_id`, `session_type`, `created_at`, +`updated_at` (`strands-py/src/strands/types/session.py:196-212`) -- no +working-directory or origin field exists to reconcile if a caller's working +directory moves. The id is a bare opaque string used verbatim as a directory +name / key prefix. + +## The store interface + +Strands is interface-first: there are two separate abstract contracts, layered. +`SessionManager` is a **hook-driven lifecycle interface** invoked by the +agent's hook registry; `SessionRepository` is the **CRUD interface** it calls +into. `RepositorySessionManager` is the one shipped `SessionManager`, +generic over any `SessionRepository`; `FileSessionManager` and +`S3SessionManager` are `SessionRepository` implementations that also inherit +`RepositorySessionManager`, so each is usable directly as a +`session_manager=` argument. + +### `SessionManager` (abstract, `strands-py/src/strands/session/session_manager.py`) + +| Method | Signature | Required? | Invoked by | +| --- | --- | --- | --- | +| `register_hooks` | `(self, registry: HookRegistry, **kwargs) -> None` | Concrete (not abstract) | Called once by the agent's hook system when the manager is attached; wires every callback below (`:40-62`). | +| `redact_latest_message` | `(self, redact_message: Message, agent: "Agent", **kwargs) -> None` | **Abstract** | Not hook-wired; called directly by guardrail/redaction code paths (`:65-72`). | +| `append_message` | `(self, message: Message, agent: "Agent", **kwargs) -> None` | **Abstract** | `MessageAddedEvent` (`:46`). | +| `sync_agent` | `(self, agent: "Agent", **kwargs) -> None` | **Abstract** | `MessageAddedEvent` (`:49`) and `AfterInvocationEvent` (`:52`). | +| `initialize` | `(self, agent: "Agent", **kwargs) -> None` | **Abstract** | `AgentInitializedEvent` (`:43`). | +| `sync_multi_agent` | `(self, source: "MultiAgentBase", **kwargs) -> None` | Optional -- base impl raises `NotImplementedError` (`:109-113`) | `AfterNodeCallEvent` (`:55`) and `AfterMultiAgentInvocationEvent` (`:56`). | +| `initialize_multi_agent` | `(self, source: "MultiAgentBase", **kwargs) -> None` | Optional -- base impl raises `NotImplementedError` (`:126-130`) | `MultiAgentInitializedEvent` (`:54`). | +| `initialize_bidi_agent` | `(self, agent: "BidiAgent", **kwargs) -> None` | Optional -- raises `NotImplementedError` (`:139-143`) | `BidiAgentInitializedEvent` (`:59`). | +| `append_bidi_message` | `(self, message: Message, agent: "BidiAgent", **kwargs) -> None` | Optional -- raises `NotImplementedError` (`:153-157`) | `BidiMessageAddedEvent` (`:60`). | +| `sync_bidi_agent` | `(self, agent: "BidiAgent", **kwargs) -> None` | Optional -- raises `NotImplementedError` (`:166-170`) | `BidiMessageAddedEvent` (`:61`) and `BidiAfterInvocationEvent` (`:62`). | + +`BidiAgent` lives under `strands.experimental.bidi` -- the bidi hooks are an +experimental, parallel lifecycle for (presumably voice/streaming) agents that +have no conversation manager, hence no compaction/`removed_message_count` +concept (`strands-py/src/strands/types/session.py:159-164`). + +### `SessionRepository` (abstract, `strands-py/src/strands/session/session_repository.py`) + +```python +class SessionRepository(ABC): + @abstractmethod + def create_session(self, session: Session, **kwargs: Any) -> Session: ... # :16-17 + @abstractmethod + def read_session(self, session_id: str, **kwargs: Any) -> Session | None: ... # :20-21 + @abstractmethod + def create_agent(self, session_id: str, session_agent: SessionAgent, **kwargs: Any) -> None: ... # :24-25 + @abstractmethod + def read_agent(self, session_id: str, agent_id: str, **kwargs: Any) -> SessionAgent | None: ... # :28-29 + @abstractmethod + def update_agent(self, session_id: str, session_agent: SessionAgent, **kwargs: Any) -> None: ... # :32-33 + @abstractmethod + def create_message(self, session_id: str, agent_id: str, session_message: SessionMessage, **kwargs: Any) -> None: ... # :36-37 + @abstractmethod + def read_message(self, session_id: str, agent_id: str, message_id: int, **kwargs: Any) -> SessionMessage | None: ... # :40-41 + @abstractmethod + def update_message(self, session_id: str, agent_id: str, session_message: SessionMessage, **kwargs: Any) -> None: ... # :44-48 + @abstractmethod + def list_messages(self, session_id: str, agent_id: str, limit: int | None = None, offset: int = 0, **kwargs: Any) -> list[SessionMessage]: ... # :51-54 + + def create_multi_agent(self, session_id: str, multi_agent: "MultiAgentBase", **kwargs: Any) -> None: + raise NotImplementedError("MultiAgent is not implemented for this repository") # :56-58 + def read_multi_agent(self, session_id: str, multi_agent_id: str, **kwargs: Any) -> dict[str, Any] | None: + raise NotImplementedError("MultiAgent is not implemented for this repository") # :60-62 + def update_multi_agent(self, session_id: str, multi_agent: "MultiAgentBase", **kwargs: Any) -> None: + raise NotImplementedError("MultiAgent is not implemented for this repository") # :64-66 +``` + +`update_message`'s docstring is explicit about intent: "A message is usually +only updated when some content is redacted due to a guardrail." +(`strands-py/src/strands/session/session_repository.py:44-48`). + +**There is no `delete_message`, `delete_agent`, or `delete_session` on this +ABC at all.** Both shipped backends implement a `delete_session` method +(`strands-py/src/strands/session/file_session_manager.py:191-197`, +`strands-py/src/strands/session/s3_session_manager.py:191-212`), but it is +not part of the abstract contract, and neither `SessionManager` nor +`RepositorySessionManager` ever calls it +(`repository_session_manager.py` has no reference to `delete_session`). A +custom `SessionRepository` (say, backed by DynamoDB) that only implements the +abstract methods would compile and run with no delete capability at all, and +nothing in the type system would catch that gap. Deletion is a +backend-specific convenience bolted onto the concrete classes, not a +guaranteed part of the pluggable interface -- an interface gap worth flagging +against Q11 (retention/deletion). + +## Write and append path (ordering, durability, concurrency, delivery) + +**Every write is a whole-object replace, never a byte-level append or a +partial update, on both backends.** "Appending a message" means writing an +entirely new, small JSON file/object at a new key; nothing in this codebase +ever opens an existing session/agent/message file and appends bytes to it. +This is why the object-store limitation ("S3 has no append") never surfaces +here -- the abstraction was shaped to avoid needing it. Concretely: + +- `create_message` (append) writes one file/object per message, keyed by + `message_.json`. File backend: + `strands-py/src/strands/session/file_session_manager.py:231-239`. S3 + backend: `strands-py/src/strands/session/s3_session_manager.py:241-246`. +- `update_agent` / `update_message` read the previous record, verify it + exists (else raise `SessionException`), preserve `created_at` from the + prior version, and overwrite the whole object with the new one. This logic + is duplicated near-verbatim between the two backends: file + (`strands-py/src/strands/session/file_session_manager.py:220-229,249-259`), + S3 (`strands-py/src/strands/session/s3_session_manager.py:229-239,256-266`). +- `create_multi_agent` / `update_multi_agent` write one whole JSON blob per + multi-agent id, same pattern on both backends + (`strands-py/src/strands/session/file_session_manager.py:301-326`, + `strands-py/src/strands/session/s3_session_manager.py:359-379`). + +**Ordering** is the integer `message_id` position index, computed and cached +in the caller's process memory (`self._latest_agent_message`, +`strands-py/src/strands/session/repository_session_manager.py:63-64,78-86`), +not by any server-assigned sequence or timestamp. There is no monotonic +clock or expected-version precondition anywhere in the append path. + +**Durability and atomicity per single write differ in mechanism but converge +on the same guarantee (no torn/partial object is ever observable):** + +- File backend: `_write_file` writes to a `tempfile.mkstemp()` file in the + same directory, then calls `os.replace(tmp_path, path)` -- an atomic rename + on POSIX -- with cleanup of the temp file on any exception + (`strands-py/src/strands/session/file_session_manager.py:133-161`). There + is **no `os.fsync`** of the file descriptor or the containing directory + anywhere in this path, so while a reader can never observe a half-written + file, durability across an OS/power-loss crash immediately after + `os.replace` is not guaranteed by this code (inference -- no fsync means + the rename could still be sitting in page cache). +- S3 backend: `_write_s3_object` issues a single `put_object` call + (`strands-py/src/strands/session/s3_session_manager.py:156-164`). S3 + guarantees per-object atomicity for a single PUT as a platform property (a + concurrent GET returns either the whole old or whole new object, never a + mix); the SDK code does not add, verify, or even comment on this guarantee + -- it simply relies on it implicitly by issuing one PUT per logical write. + +**Concurrency: neither backend has any locking, or compare-and-swap / +conditional-write precondition on any operation.** No file locks, no S3 +`If-Match`/`If-None-Match` conditional headers, no version/ETag checks +anywhere in `file_session_manager.py` or `s3_session_manager.py`. Both +backends are symmetric here -- the S3 backend is not "less safe" than the +file backend; neither is safe against concurrent writers. Two consequences: + +1. **`create_session`'s existence check is a check-then-act race on both + backends, identically shaped.** File backend: + `if os.path.exists(session_dir): raise SessionException(...)` followed by + `os.makedirs(..., exist_ok=True)` + (`strands-py/src/strands/session/file_session_manager.py:164-180`). S3 + backend: a `head_object` 404 probe followed by `put_object`, with no + `IfNoneMatch` conditional write + (`strands-py/src/strands/session/s3_session_manager.py:166-181`). Two + processes racing to create the same `session_id` can both pass the + existence check and both write `session.json`; the loser's write is + silently overwritten with no error surfaced to either caller. This is one + place the two backends deliver genuinely identical (mis)behavior. +2. **The `message_id` counter lives above the repository abstraction, in + `RepositorySessionManager`'s process memory, so two independent manager + instances attached to the same `session_id`/`agent_id` (e.g. two processes + resuming the same session) will independently compute the same + `next_index` from whatever they read at `initialize()` time, and can both + `create_message` at the same key with different content** -- a genuine + collision (not just a lost update of identical data), applying identically + to both backends since the counter logic is shared, not + backend-specific (`strands-py/src/strands/session/repository_session_manager.py:69-86`). + +**Delivery semantics and idempotence.** There is no retry/at-least-once +wrapper visible in either backend (a raised `ClientError`/`OSError` +propagates as a `SessionException`, uncaught). The one idempotence-adjacent +mechanism is `Message.tracking_id` -- a durable, stable UUIDv4 assigned to a +message by the agent (not the store), distinct from the positional +`message_id`: + +```python +# strands-py/src/strands/types/content.py:230-248 +class Message(TypedDict): + content: list[ContentBlock] + role: Role + tracking_id: NotRequired[str] # durable UUID identity, survives session save/restore + metadata: NotRequired[MessageMetadata] +``` + +```python +# strands-py/src/strands/types/content.py:263-275 +def _ensure_tracking_id(message: Message) -> str: + if not message.get("tracking_id"): + message["tracking_id"] = _generate_tracking_id() + return message["tracking_id"] +``` + +`tracking_id` is content identity (a UUID, stable across copy/redact/restore); +`message_id` is storage-position identity (an integer, reassigned if a +message is re-appended at a different offset). The store itself does not +dedup by `tracking_id` -- nothing in `file_session_manager.py` or +`s3_session_manager.py` reads or checks it; it is preserved as ordinary +message content and round-trips verbatim +(`strands-py/tests/strands/session/test_file_session_manager.py:218-236`, +test `test_message_durable_id_persists`, confirms the field survives a +create/read round-trip byte-for-byte). + +## Read and resume path + +Resume is a full, eager, ordered read; there is no cursor-based incremental +read, no cached "latest view" separate from the store itself. On +`AgentInitializedEvent`, `RepositorySessionManager.initialize` +(`strands-py/src/strands/session/repository_session_manager.py:169-243`): + +1. Reads the `SessionAgent` record via `read_agent` (skipped entirely for a + session known to be brand-new, `:180-184`). +2. If absent, treats this as a new agent: creates it, then writes every + message currently in `agent.messages` as an individual + `create_message` call with sequential indices (`:186-200`). +3. If present, restores `agent.state`, internal state (interrupt state, + model state), and the conversation manager's own state via + `restore_from_session`, which may hand back messages to prepend + (`:201-216`). +4. Calls `list_messages(..., offset=agent.conversation_manager.removed_message_count)` + -- the **only** place pagination/offset is used on the read path, and it + is driven by the conversation manager's compaction bookkeeping, not by + caller-supplied pagination (`:217-223`). +5. Unless the model is `stateful` (server-managed conversation, e.g. a + Responses-API-style model that holds its own history), rebuilds + `agent.messages` as `prepend_messages + [...loaded session messages...]`, + then runs `_fix_broken_tool_use` to repair orphaned/mismatched + `toolUse`/`toolResult` pairs left over from prior truncation + (`:226-241`, referencing + `https://github.com/strands-agents/harness-sdk/issues/859` in the code + comment at `:240`). + +`_fix_broken_tool_use` (`strands-py/src/strands/session/repository_session_manager.py:245-319`) +is itself evidence that this store's "resume" path has had to defensively +patch real-world corrupted histories: it drops a leading orphaned +`toolResult` with no preceding `toolUse` (`:262-269`), and for any assistant +message with `toolUse` blocks whose paired result message doesn't exactly +match by `toolUseId`, it rebuilds that result message from scratch, filling +gaps with synthesized error results (`:270-319`). + +**Everything materialized on resume is eager** -- the entire (offset-adjusted) +message list is loaded into memory as part of `initialize`, not lazily +per-turn. There is no lazy-loading or byte-range mechanism anywhere in this +store (contrast with, e.g., a claim-check pattern for oversized tool results +-- none exists here; message bodies are stored inline, whole, every time). + +`list_messages`'s `limit`/`offset` parameters exist on the interface and are +exercised by the compaction-offset call above, but there is no other +caller-facing pagination in the resume path itself -- a full resume always +reads the tail of the message list from the compaction offset forward, in +one call. + +## Listing, summaries, and search + +**None of the three exist.** There is no listing operation +(`list_sessions`/`list_agents`) anywhere in `SessionManager` or +`SessionRepository`; no metadata sidecar, summary, or read-model file is +written by either backend (no `session.json` field is a denormalized +summary of message content -- `Session` only carries `session_id`, +`session_type`, `created_at`, `updated_at`, +`strands-py/src/strands/types/session.py:196-202`); and there is no +full-text, vector, or any other search index. A caller who wants to enumerate +sessions must build that capability outside this SDK (e.g. by listing the +`storage_dir` directory or the S3 bucket's `session_` prefixes directly) -- +nothing in `file_session_manager.py` or `s3_session_manager.py` exposes such +a helper. + +## Entry and message structure and versioning + +### `Session`, `SessionAgent`, `SessionMessage` (`strands-py/src/strands/types/session.py`) + +```python +# :58-74 +@dataclass +class SessionMessage: + message: Message + message_id: int + redact_message: Message | None = None + created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) + updated_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) +``` + +`to_message()` returns `redact_message` in place of `message` when set +(`:86-94`) -- redaction is modeled as a **side-by-side replacement field**, +not an edit of the original content: the original `message` stays on disk, +`redact_message` is added alongside it, and reads transparently prefer the +redaction. Bytes values in either field are base64-encoded on write and +decoded on read via `encode_bytes_values`/`decode_bytes_values` +(`:28-55`). + +```python +# :107-124 +@dataclass +class SessionAgent: + agent_id: str + state: dict[str, Any] + conversation_manager_state: dict[str, Any] + _internal_state: dict[str, Any] = field(default_factory=dict) + created_at: str = field(default_factory=...) + updated_at: str = field(default_factory=...) +``` + +`_internal_state` carries `interrupt_state` and `model_state` +(`strands-py/src/strands/types/session.py:135-138`), restored via +`initialize_internal_state` (`:176-181`). + +```python +# :195-202 +@dataclass +class Session: + session_id: str + session_type: SessionType + created_at: str = field(default_factory=...) + updated_at: str = field(default_factory=...) +``` + +`SessionType` is a `str, Enum` with a **single member**, `AGENT` +(`strands-py/src/strands/types/session.py:18-25`) -- the enum's own docstring +anticipates growth ("As sessions are expanded to support new use cases like +multi-agent patterns, new types will be added here"), but at this commit only +one value exists, and nothing branches on it. + +### Schema evolution: no version field on any durable session type + +**None of `Session`, `SessionAgent`, or `SessionMessage` carries a +`schema_version` field, and `from_dict` on all three silently drops unknown +keys and defaults missing ones** (`strands-py/src/strands/types/session.py:96-100,166-170,204-207` +all filter `env.items()` down to `inspect.signature(cls).parameters` before +constructing). There is no migration function, no legacy-format sniffing, and +no version negotiation anywhere in `session/` or `types/session.py`. For a +product at this stage, that absence is itself the finding: forward +compatibility is handled entirely by "extra fields are ignored, missing +fields use dataclass defaults," which works for additive changes but has no +mechanism to reject or transform an incompatible shape. + +The **only two schema-version fields in the whole persistence surface belong +to mechanisms that are explicitly not the session store**: + +- `Checkpoint.schema_version` (always `"1.0"`, + `strands-py/src/strands/experimental/checkpoint/checkpoint.py:40,45-57`), + which `from_dict` uses to hard-reject a mismatched version by raising + `CheckpointException` (`:73-78`). The module docstring is explicit that + this is not conversation state: "It does **not** capture conversation + state -- pair with a `SessionManager` for cross-process state continuity." + (`:1-6`). +- `Snapshot.schema_version` (always `"1.0"`, + `strands-py/src/strands/types/_snapshot.py:33,40-47`), whose `validate()` + raises `SnapshotException` on any version other than `"1.0"` + (`:54-66`). `Snapshot` is a separate, opt-in, in-memory export/import + feature on `Agent` (`take_snapshot`/`load_snapshot`, + `strands-py/src/strands/agent/agent.py:1543-1617`) -- it is the caller's + responsibility to persist and restore the `Snapshot` object; the + `SessionManager`/`SessionRepository` machinery is not involved at all. + +So: the transient, explicitly-versioned mechanisms reject old data outright; +the durable session store has no version concept and silently tolerates +shape drift. + +## Compaction and history management + +Compaction is entirely a **conversation-manager concern layered on top of an +unbounded, never-pruned message store** -- the durable message files are +never deleted or rewritten by compaction on either backend. + +`ConversationManager` tracks `removed_message_count`, "the messages that have +been removed from the agents messages array. These represent messages +provided by the user or LLM that have been removed, not messages included by +the conversation manager through something like summarization." +(`strands-py/src/strands/agent/conversation_manager/conversation_manager.py:78-82,94`). +`get_state`/`restore_from_session` persist and restore only this counter (and, +for `SummarizingConversationManager`, an optional summary message) as part of +`SessionAgent.conversation_manager_state` +(`strands-py/src/strands/agent/conversation_manager/conversation_manager.py:159-177`, +`strands-py/src/strands/agent/conversation_manager/summarizing_conversation_manager.py:85-100`). +`SummarizingConversationManager.reduce_context` increments +`removed_message_count` by the number of turns folded into a summary +(`strands-py/src/strands/agent/conversation_manager/summarizing_conversation_manager.py:188-191`); +`SlidingWindowConversationManager` does the same for trimmed turns +(`strands-py/src/strands/agent/conversation_manager/sliding_window_conversation_manager.py:206,270`). + +On resume, `initialize` calls +`list_messages(..., offset=removed_message_count)` +(`strands-py/src/strands/session/repository_session_manager.py:219-223`) -- +compaction is implemented purely as **a read-time offset into the full, +untouched message file list**. No `SessionRepository` implementation has a +`delete_message` method (confirmed above), so **the underlying +`message_.json` files for every "compacted-away" turn remain on disk (or +in the S3 bucket) forever**, counted against nothing, cleaned up by nothing. +A session that summarizes 10,000 turns down to a 50-turn visible window still +holds 10,000 message files in storage. This is the single most consequential +"what bounds durable growth" finding in this dossier: **nothing bounds it** +(see Retention, deletion, and multi-host). + +## Rewind, checkpoints, and fork + +**No rewind, undo, or branch/fork operation exists in the session store +itself.** The two adjacent mechanisms that could be mistaken for one: + +- **`Checkpoint`** (`strands-py/src/strands/experimental/checkpoint/checkpoint.py`) + is a **mid-cycle pause marker**, not a history edit: "A `Checkpoint` is a + pause-point marker emitted at agent cycle boundaries. It captures the + position (which boundary fired) and the cycle index." (`:1-11`). It is + emitted only on tool-use cycles ("A turn with no tool calls emits no + checkpoint; use a `SessionManager` for durability of every turn.", `:25-26`) + and is surfaced via `AgentResult.checkpoint` + (`strands-py/src/strands/agent/agent_result.py:30-41`) for the caller to + pass back on resume -- it is explicitly paired with, not a replacement for, + the session store. +- **`Snapshot`** (`strands-py/src/strands/types/_snapshot.py`, + `strands-py/src/strands/agent/agent.py:1543-1617`) is the closest thing to + a fork primitive: `agent.take_snapshot(preset="session")` captures + `messages`, `state`, `conversation_manager_state`, `interrupt_state`, and + optionally `system_prompt`/`model_state` as one in-memory, versioned, + JSON-serializable object; `agent.load_snapshot(snapshot)` restores all of + it into a (potentially different) agent instance. This is genuinely + copy-plus-restore -- a fresh agent loaded from a snapshot is fully + independent of the source agent's `session_id` -- but it is **entirely + outside `SessionManager`/`SessionRepository`**: nothing in + `file_session_manager.py` or `s3_session_manager.py` knows about + `Snapshot`, there is no lineage metadata recorded anywhere (no + parent-snapshot pointer, no fork counter), and persisting the `Snapshot` + object anywhere durable is entirely the caller's problem. + +Redaction (`redact_latest_message` / +`SessionRepository.update_message`) is the one retroactive-looking operation +that *is* part of the store, and it is additive rather than destructive: the +original `message` field is preserved on disk; `redact_message` is written +alongside it and preferred by `to_message()` +(`strands-py/src/strands/types/session.py:86-94`). + +## Subagents and nested sessions + +**A child node in a multi-agent orchestration is never its own durable +session.** Both shipped multi-agent patterns -- `Graph` and `Swarm` -- actively +forbid a node's own `Agent` from carrying a session manager: + +```python +# strands-py/src/strands/multiagent/graph.py:294-298 +if isinstance(executor, Agent): + # Check for session persistence + if executor._session_manager is not None: + raise ValueError("Session persistence is not supported for Graph agents yet.") +``` + +```python +# strands-py/src/strands/multiagent/swarm.py:539-541 +if node._session_manager is not None: + raise ValueError("Session persistence is not supported for Swarm agents yet.") +``` + +(Note: this SDK ships exactly two multi-agent orchestration patterns -- +`Graph` and `Swarm`. A search of `strands-py/src/strands/multiagent/` for +"workflow" returns no matches; there is no third `Workflow` primitive at this +commit.) + +The only durable artifact tied to multi-agent execution is the +**orchestrator's own state**, stored as a single whole-JSON blob at +`multi_agents/multi_agent_/multi_agent.json`, via `MultiAgentBase`'s +`serialize_state`/`deserialize_state` (declared abstract-by-convention on the +base class -- the base implementation simply raises `NotImplementedError`, +`strands-py/src/strands/multiagent/base.py:329-335`) and persisted through +`SessionManager.sync_multi_agent`/`initialize_multi_agent`. Both `Graph` and +`Swarm` wire this to hooks fired **after every node completes** and **after +the whole orchestrator run**: + +```python +# strands-py/src/strands/session/session_manager.py:54-56 +registry.add_callback(MultiAgentInitializedEvent, lambda event: self.initialize_multi_agent(event.source)) +registry.add_callback(AfterNodeCallEvent, lambda event: self.sync_multi_agent(event.source)) +registry.add_callback(AfterMultiAgentInvocationEvent, lambda event: self.sync_multi_agent(event.source)) +``` + +`Graph.serialize_state` / `Swarm.serialize_state` +(`strands-py/src/strands/multiagent/graph.py:1265-1287`, +`strands-py/src/strands/multiagent/swarm.py:978-1009`) embed a +`node_results` map keyed by node id, each value a `NodeResult.to_dict()` +(`strands-py/src/strands/multiagent/base.py:87-105`). For a node whose +executor is an `Agent`, that nested value is an `AgentResult.to_dict()` +(`strands-py/src/strands/agent/agent_result.py:120-131`): + +```python +# strands-py/src/strands/agent/agent_result.py:120-131 +def to_dict(self) -> dict[str, Any]: + return { + "type": "agent_result", + "message": self.message, # only the LAST message, not the full conversation + "stop_reason": self.stop_reason, + "checkpoint": self.checkpoint.to_dict() if self.checkpoint else None, + } +``` + +**This is the durable parent-child link, and it is lossy by construction**: a +node's *final* message is embedded inside the parent orchestrator's single +JSON blob; the node's full internal conversation (every intermediate +user/assistant/tool-call turn it produced while executing) is never written +to durable storage anywhere, because the node's `Agent` is barred from having +a session manager at all. There is no nested session directory, no sibling +session, and no separate child transcript to cascade-delete, orphan, or +reconcile -- there is nothing durable to orphan in the first place beyond that +one final message per node. + +Crash behavior follows directly from the sync timing: because +`sync_multi_agent` fires only on `AfterNodeCallEvent` (node *completion*) and +`AfterMultiAgentInvocationEvent` (run completion), a crash while a node is +**still executing** loses that node's entire in-flight work -- nothing about +it was ever synced. On restart, `deserialize_state` +(`strands-py/src/strands/multiagent/graph.py:1289-1316`, +`strands-py/src/strands/multiagent/swarm.py:1011-1029`) either resets all +nodes to re-execute from the beginning (if no `next_nodes_to_execute` was +persisted -- the terminal/fresh case) or resumes from the last-synced set of +ready-to-execute nodes; either way, resumption re-runs the interrupted node +from scratch rather than replaying any partial progress, because no partial +progress was ever durable. + +## Retention, deletion, and multi-host + +**No TTL, lifecycle policy, or scheduled cleanup exists anywhere in this +store.** A search of `strands-py/src/strands/session/*.py` and +`strands-py/src/strands/types/session.py` for retention/TTL/lifecycle/cleanup +terms returns no matches. The store retains everything it is given, +indefinitely, until an explicit `delete_session` call. + +**Deletion is whole-session-only, is not part of the abstract contract** +(see The store interface), and is **not equally atomic on the two +backends** -- this is the one place file and S3 genuinely diverge in +behavior, not just in mechanism: + +- File backend: `delete_session` is a single `shutil.rmtree(session_dir)` + call (`strands-py/src/strands/session/file_session_manager.py:191-197`). + From the caller's perspective this is effectively all-or-nothing for a + local disk -- it either removes the whole tree or raises. +- S3 backend: `delete_session` pages through `list_objects_v2`, collects + every key under the session prefix, then issues `delete_objects` in + batches of up to 1000 keys in a loop + (`strands-py/src/strands/session/s3_session_manager.py:191-212`). **This + is not atomic**: if the process crashes or a batch call raises partway + through the loop, the session is left with some keys deleted and others + present, with no recorded resume point or partial-delete marker anywhere + in the code. + +There is no per-message or per-agent delete on either backend (confirmed +above), so deletion cannot be partial by design at that granularity -- only +by accident, in the S3 multi-batch case. + +**Multi-host / multi-process behavior is not a designed-for path.** Nothing +in either backend detects a crashed writer, coordinates across hosts, or +handles a network filesystem specially. The file backend assumes a local (or +at least POSIX-semantics) filesystem: `os.replace` is atomic on a genuine +local filesystem but its atomicity on a network mount depends on that mount's +semantics, which the code does not check or document (inference). The S3 +backend is inherently multi-host-capable as a side effect of being an HTTP +API, but the SDK does nothing to add coordination beyond what was described +under Write and append path -- no leader election, no lease, no lock object. + +## What this implies for our Session Store (our inference) + +Strands' interface is a useful negative example as much as a positive one. +Two things are worth taking directly, and three are worth treating as +warnings: + +**Worth adopting:** + +- **Design the store so it never needs a true append against a backend that + can't append.** Strands sidesteps S3's lack of an append primitive + entirely by keying one message to one whole object. If our event store + ever needs an object-store-backed tier, the same move -- one event per + object, keyed by sequence, rather than one growing log file -- removes the + entire "how do you append to S3" problem instead of solving it. +- **Separate positional identity (storage key) from content identity + (durable id).** `message_id` (position) versus `tracking_id` (UUID, + content-stable across copy/restore) is a clean value-object split we + should keep: our own stream position and our own event/entry id should + never be conflated into one field. + +**Worth treating as a warning:** + +- **A pluggable interface with optional methods that raise + `NotImplementedError` is a soft contract, not a hard one.** `sync_multi_agent`, + `initialize_multi_agent`, and the three bidi methods are all "abstract in + spirit, concrete in practice" -- a conformance test suite could still pass + against a repository that silently can't do half of what the interface + implies. Our Session Store's contract should keep genuinely optional + capabilities out of the same interface as required ones, or gate them + behind an explicit capability check rather than a raised exception. +- **No version field on the durable record is a real gap, not just an + early-stage gap.** Strands versions its two *transient* mechanisms + (`Checkpoint`, `Snapshot`) but not the actual durable `Session`/`SessionAgent`/ + `SessionMessage` types, and papers over drift by silently dropping unknown + fields. Our Session Store should put the version field exactly where + Strands put it in the wrong place: on the durable record, not the + in-memory pause marker. +- **"Compaction" that only changes what is read, never what is stored, is + not retention.** Strands' `removed_message_count` offset is a legitimate + compaction technique for the model-visible window, but by itself it + guarantees unbounded storage growth with no counterpart deletion + mechanism. If our Session Store adopts an offset-based compaction view, it + needs a paired retention/GC story from day one -- Strands does not have one + at this commit, and it shows in the "delete is not part of the abstract + interface" finding above. + +Separately, `team/designs/0014-storage.md` (repo-root, dated 2026-06-29, +status "Proposed") is the Strands team's own acknowledgment of a version of +this same critique: it describes the current per-subsystem interface model +(including the `SessionManager`/`SessionRepository` split studied here, +referred to there as a "`SnapshotStorage` interface (6 methods)", +`team/designs/0014-storage.md:17`) as one that "requires per-subsystem +migration" and proposes collapsing session storage, memory, context, and +transcripts onto one four-method `put`/`get`/`delete`/`list` primitive +(`team/designs/0014-storage.md:93-133,191-200`). At the pinned commit this is +still a proposal -- the code in `strands-py/src/strands/session/` matches the +older, richer, per-subsystem interface documented above, not the unified one. +This is independent, primary-source confirmation that Strands' own team +considers the interface-first, five-separate-abstractions design (of which +sessions are one) a cost worth re-architecting, which is a useful signal +about how much weight to put on any one product's current interface shape +when designing ours. + +## Open questions + +- **The TypeScript SDK (`strands-ts/src/session/`) was not read for this + dossier.** `strands-ts/test/integ/session-manager.test.node.ts` and + `strands-ts/src/session/__tests__/session-manager.test.ts` exist and imply + a parallel implementation; whether it shares the same on-disk/S3 layout, + the same lack of append/CAS, and the same multi-agent restrictions as the + Python SDK studied here is unverified. +- **Whether `os.replace`'s atomicity holds on the network filesystems some + deployments might put `storage_dir` on** (NFS, SMB) was not verified from + source; the code neither checks for nor documents this. +- **Whether S3's read-after-write / list-after-write consistency (a platform + property, not something asserted in this SDK's code) is relied upon + anywhere implicitly** -- e.g., whether a `list_messages` call immediately + following a batch of `create_message` writes could ever observe a stale + listing. Given S3's current strong consistency guarantees this is likely + moot in practice, but the SDK code itself makes no assertion about it + either way, so this is inference, not a sourced claim. +- **Whether the multi-agent session-persistence restriction + ("Session persistence is not supported for Graph/Swarm agents yet.") is + planned to be lifted**, and if so what the intended parent-child durability + model would be, is not stated anywhere in the source read for this + dossier -- the error strings' "yet" is suggestive but not a commitment. +- **Whether `team/designs/0014-storage.md`'s unified `Storage` proposal has + since landed** post-commit is unknown; at the pinned commit it is + explicitly "Proposed," not implemented, and the session code does not + reflect it. +- **Whether any production deployment guidance exists for choosing S3 over + file storage given the atomicity/consistency differences documented + above** (e.g., official docs recommending against S3 for high-concurrency + multi-writer scenarios) was not found in the source tree searched; if such + guidance exists it likely lives in hosted documentation outside this + repository clone. diff --git a/docs/research/session-store/products/aws-strands/vs-session-events.md b/docs/research/session-store/products/aws-strands/vs-session-events.md new file mode 100644 index 000000000..d08a81f60 --- /dev/null +++ b/docs/research/session-store/products/aws-strands/vs-session-events.md @@ -0,0 +1,513 @@ +# AWS Strands Agents compared to our session event catalog + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT_COMPARISON](../../RESEARCH_PROMPT_COMPARISON.md). +Stage-one dossier: [AWS Strands Agents](./index.md). +Compared against `proto/trogonai/session/sessions/v1alpha1/` and [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) on 2026-08-04. + +**Store maturity: 6/12** -- evolution scars 0/3 (no `schema_version` field, no +migration function, no legacy-format sniffing anywhere in `session/` or +`types/session.py`, per +the dossier's [Schema evolution: no version field on any durable session type](./index.md#schema-evolution-no-version-field-on-any-durable-session-type) section; +the two `schema_version` fields in the whole persistence surface belong to +adjacent, explicitly non-store mechanisms, `Checkpoint` and `Snapshot` +(the dossier's [Schema evolution: no version field on any durable session type](./index.md#schema-evolution-no-version-field-on-any-durable-session-type) section), and the team's own +`team/designs/0014-storage.md` proposes replacing the whole per-subsystem +interface rather than evolving it in place +(the dossier's [What this implies for our Session Store (our inference)](./index.md#what-this-implies-for-our-session-store-our-inference) section)), operational age 1/3 +(`_fix_broken_tool_use` is a real defensive read-path repair for corrupted +histories, referencing GitHub issue `strands-agents/harness-sdk#859` in the +code comment at +`strands-py/src/strands/session/repository_session_manager.py:240,245,405`, +and the dossier's [Read and resume path](./index.md#read-and-resume-path) +section places it on the restore path), but the dossier surfaces no +dated, field-confirmed corruption or growth incident the way Cline's +`cline/cline#9011` was confirmed with open/close dates), exposure 2/3 +(AWS-branded, `authors = [{name = "AWS", email = "opensource@amazon.com"}]` +(`strands-py/pyproject.toml:14-16`), Apache-2.0 (`LICENSE.APACHE`), with +parallel Python and TypeScript SDKs (`strands-py/`, `strands-ts/`) and a +multi-host-capable S3 backend as a shipped option (the dossier's +[Retention, deletion, and multi-host](./index.md#retention-deletion-and-multi-host) +section), but no adoption-scale evidence, no plugin +ecosystem, and only one referenced issue), design independence 3/3 (no +evidence in the dossier that this store was forked from another product's +persistence code; both backends are original to this SDK). At 6/12 this sits +on the edge of the "thin evidence" threshold rather than below it, so its +recommendations are weighted as one data point, not an industry norm, and are +called out as such below. + +## The one structural difference everything else follows from + +Strands' store has no append primitive at all, on either backend. "Appending +a message" means creating a brand-new whole JSON object (a file or an S3 key) +per message, positionally keyed by a client-computed integer (`message_id`) +that doubles as the storage address +(the dossier's [The storage model](./index.md#the-storage-model) and [Keying and identity](./index.md#keying-and-identity) sections). This is not a granularity choice the +way fx's turn-level commit or Cline's whole-document rewrite are; it is the +direct consequence of choosing an object store as the durable substrate. A +backend that has no append operation must decompose everything into a set of +small, independently addressable writes and reconstruct order from the keys +themselves, and the dossier is explicit that this is deliberate: "the store +never needs a true 'append' primitive, which sidesteps the one operation an +object store cannot do natively" (the dossier's [The storage model](./index.md#the-storage-model) section). + +Nothing about this store's mutation model resembles ours. Our design commits +at fact granularity too, but every commuting fact still goes through a real +append onto a shared logical stream, and every invariant-bearing fact is +guarded by a server-enforced `WRITE_PRECONDITION` +(`docs/adr/0035-session-store-decider-aggregate.md:181-192`). Strands has no +server-enforced guard anywhere, on either backend, for creation, for +positional ordering, or for deletion: "neither backend has any locking, or +compare-and-swap / conditional-write precondition on any operation. No file +locks, no S3 `If-Match`/`If-None-Match` conditional headers, no version/ETag +checks anywhere" (the dossier's [Write and append path](./index.md#write-and-append-path-ordering-durability-concurrency-delivery) section). That absence is not an +oversight parallel to ours being incomplete; it is coherent with the rest of +the design, because a fresh, uniquely-keyed whole-object write never needs a +guard against a concurrent editor the way an append or an in-place update +would -- unless two writers pick the *same* key, which nothing in either +backend prevents. + +Everything else documented in the dossier is a consequence of that one +missing primitive, not an independent design choice: the racy `create_session` +check-then-act on both backends (the dossier's [Write and append path](./index.md#write-and-append-path-ordering-durability-concurrency-delivery) section), the racy +`message_id` counter collision when two managers resume the same session +independently (the dossier's [Write and append path](./index.md#write-and-append-path-ordering-durability-concurrency-delivery) section), whole-session-only deletion +diverging in atomicity between the two backends +(the dossier's [Retention, deletion, and multi-host](./index.md#retention-deletion-and-multi-host) section), and the total absence of a version field on +any durable session type (the dossier's [Schema evolution: no version field on any durable session type](./index.md#schema-evolution-no-version-field-on-any-durable-session-type) section) all trace back to a +store that was shaped, correctly, to avoid needing a write-ordering primitive +at all -- and then never added one back for the operations (creation, +positional counters, deletion) that still needed one. + +## Mapping + +| Strands | Ours | Verdict | +| --- | --- | --- | +| `Session{session_id, session_type, created_at, updated_at}`, `SessionType` a `str, Enum` with one member `AGENT` (dossier: [Entry and message structure and versioning](./index.md#entry-and-message-structure-and-versioning)) | No `session_type` field; a session's kind is implicit in its `StoredSessionExecutionPlan` | Gap, minor -- deliberate on both sides: Strands' enum anticipates growth it hasn't needed yet, ours never modeled the concept | +| `session_id`/`agent_id`, caller-supplied strings, path-separator-validated, used verbatim as directory names/key prefixes (dossier: [Keying and identity](./index.md#keying-and-identity)) | `SessionId`, opaque, resolved through a `StreamSubjectResolver` to `session.sessions.events.`; a subject-token-unsafe id is mapped through a routing-key transform (`docs/adr/0035-session-store-decider-aggregate.md:98-106`) | Semantic mismatch -- Strands' id *is* the storage address; ours is never itself a filesystem or subject token | +| `message_id`, integer position, client-computed in `RepositorySessionManager`'s process memory, doubles as the storage key: `_get_message_path` interpolates it straight into `message_.json` and rejects a non-integer (`strands-py/src/strands/session/file_session_manager.py:100-117`) | `SessionOrdinal`, fold-derived, "derived by counting at fold time, never read from JetStream message metadata... never a self-position naming" (`proto/trogonai/session/sessions/v1alpha1/session_ordinal.proto:5-15`, `docs/adr/0035-session-store-decider-aggregate.md:140-168`) | Ours, decisively -- see structural difference above | +| `tracking_id`, durable UUID content identity, recorded but never read or checked by either backend for dedup (dossier: [Write and append path](./index.md#write-and-append-path-ordering-durability-concurrency-delivery)) | `CanonicalMessage.message_id`, stable message id, the join key for first-terminal-outcome-wins fold on `AssistantMessageCompleted`/`AssistantMessageFailed` (`proto/trogonai/session/sessions/v1alpha1/message.proto:14-28`, `docs/adr/0035-session-store-decider-aggregate.md:200-207`) | Ours, decisively -- the same field exists on both sides, but only ours is load-bearing for anything | +| `SessionAgent.state`/`_internal_state{interrupt_state, model_state}`, a durable JSON document that *is* the authoritative resume path: `initialize_internal_state` assigns `agent._interrupt_state` and `agent._model_state` straight out of it (`strands-py/src/strands/types/session.py:176-181`), called from the restore path at `strands-py/src/strands/session/repository_session_manager.py:209` | Aggregate snapshot, "an advisory cached fold of that log. Corruption or incompatibility falls back to earlier replay" (`docs/adr/0035-session-store-decider-aggregate.md:414-415`); harness recovery checkpoint, "an opaque artifact... It cannot replace event replay" (`:416-418`) | Semantic mismatch -- same problem (resume needs process state), opposite authority model: Strands' document is load-bearing, ours is explicitly disposable | +| `SessionAgent.conversation_manager_state{removed_message_count, optional summary}` (dossier: [Compaction and history management](./index.md#compaction-and-history-management)) | `Compacted{covers_from, covers_through, summary_content, tokens_before, tokens_after, model, usage}` (`proto/trogonai/session/sessions/v1alpha1/compacted.proto:19-38`) | Ours, decisively -- a range-addressed, self-sufficient marker vs. a bare offset counter with no boundary provenance | +| `SessionMessage.redact_message`, a second field written alongside the original on the *same* durable object, read-preferred by `to_message()` (dossier: [Entry and message structure and versioning](./index.md#entry-and-message-structure-and-versioning) and [Rewind, checkpoints, and fork](./index.md#rewind-checkpoints-and-fork)) | `RedactionApplied{redacted_event_ids, reason}`, a new event masking the targeted events at read time; original bytes never touched (`proto/trogonai/session/sessions/v1alpha1/redaction_applied.proto:5-19`) | Semantic mismatch, not equivalence -- same intent, opposite mechanism: Strands' redaction is the one in-place edit that exists anywhere in the system; ours performs zero edits, ever | +| `delete_session`: file backend `shutil.rmtree`; S3 backend paginated `delete_objects`, no resume marker (dossier: [Retention, deletion, and multi-host](./index.md#retention-deletion-and-multi-host)) | `SessionHidden` (visibility tombstone, no bytes deleted) + `ArtifactErased` (per-artifact, out-of-band byte destruction) + deferred crypto-shredding follow-up ADR (`proto/trogonai/session/sessions/v1alpha1/session_hidden.proto:5-16`, `.../artifact_erased.proto:5-17`, `docs/adr/0035-session-store-decider-aggregate.md:896-900`) | Semantic mismatch, and its own section below -- Strands attempts real erasure and can silently half-fail; we don't attempt erasure in `v1alpha1` at all, so we can't fail the same way, but we also haven't solved what Strands is trying to solve | +| No `list_sessions`/`list_agents` anywhere in `SessionManager` or `SessionRepository` (dossier: [Keying and identity](./index.md#keying-and-identity) and [Listing, summaries, and search](./index.md#listing-summaries-and-search)) | `get_session`, `list_sessions` as rebuildable KV projection queries (`docs/adr/0035-session-store-decider-aggregate.md:930-935`) | Ours, decisively | +| `Graph`/`Swarm` forbid a node's own `Agent` from holding a session manager at all; only the orchestrator's single `multi_agent.json` is durable, embedding only the *last* message per node via `AgentResult.to_dict()` (dossier: [Subagents and nested sessions](./index.md#subagents-and-nested-sessions)) | `DelegationDispatched`/`ParentLinked`/`CascadePolicy` give a child its own full durable session and stream (`proto/trogonai/session/sessions/v1alpha1/delegation_dispatched.proto:20-25`, `.../parent_linked.proto:19-27`, `docs/adr/0035-session-store-decider-aggregate.md:729-756`) | Ours, decisively -- see Subagent cascade below | +| No TTL/lifecycle/cleanup found anywhere; store retains everything until an explicit `delete_session` call (dossier: [Retention, deletion, and multi-host](./index.md#retention-deletion-and-multi-host)) | Keep-forever by design (`docs/adr/0035-session-store-decider-aggregate.md:857-863`), with `SessionHidden`/`RedactionApplied`/`ArtifactErased` as the explicit privacy contract plus optional reversible cold-tiering (`:911-921`) | Trade-off, not a plain win -- see Retention below | +| `Checkpoint.schema_version`/`Snapshot.schema_version` (both always `"1.0"`, hard-reject on mismatch), on two mechanisms the module docstrings say explicitly are *not* the session store (dossier: [Schema evolution: no version field on any durable session type](./index.md#schema-evolution-no-version-field-on-any-durable-session-type)); no version field on `Session`/`SessionAgent`/`SessionMessage` | No `schema_version` field on any session event either; "Schema evolution is additive (new optional fields, reserved retired numbers), never a per-event version branch" (`docs/adr/0035-session-store-decider-aggregate.md:378-379`) | Same absence, different mechanism underneath -- see recommendation 1 | +| `Checkpoint` (`experimental/checkpoint/checkpoint.py`): a mid-cycle pause marker, tool-cycle-only, explicitly paired with, not a replacement for, the session store (dossier: [Rewind, checkpoints, and fork](./index.md#rewind-checkpoints-and-fork)) | `Checkpoint` embedded in `CheckpointProduced`/`ExecutionAttemptStarted.restored_checkpoint`: attempt-scoped evidence with its own admission contract (digest, plan-digest equality, first-evidence-wins) (`proto/trogonai/session/sessions/v1alpha1/checkpoint.proto:8-38`, `docs/adr/0035-session-store-decider-aggregate.md:426-465`) | Semantic mismatch by name only -- both are called "Checkpoint" and both say "not a replacement for the transcript," but the two are not a like-for-like: Strands' fires only on tool-use cycles, ours is per-attempt and digest-verified | +| `Snapshot` (`types/_snapshot.py`, `agent.take_snapshot`/`load_snapshot`): opt-in, in-memory, versioned copy-plus-restore, entirely outside `SessionManager`/`SessionRepository`, no lineage metadata anywhere (dossier: [Rewind, checkpoints, and fork](./index.md#rewind-checkpoints-and-fork)) | `SessionForked`, an atomic `[SessionStarted, SessionForked]` in-stream creation batch recording `source_session_id` and `context_prefix_boundary` (`proto/trogonai/session/sessions/v1alpha1/session_forked.proto:7-27`, `docs/adr/0035-session-store-decider-aggregate.md:669-721`) | Ours, decisively -- fork is a first-class, in-band, lineage-recording domain event; Strands' nearest analogue is entirely out-of-band and unversioned in the store | +| `multi_agents/multi_agent_/multi_agent.json`, a single whole-blob orchestrator state keyed by `node_results` (dossier: [Subagents and nested sessions](./index.md#subagents-and-nested-sessions)) | No second denormalized blob; the parent-child graph folds from `DelegationDispatched`/`ParentLinked` facts across streams, and the audit trail folds from the child's own message/tool events | Ours, decisively -- no second copy of "what a node did" that can drift from the node's own real session | +| Command exit status not distinguished from any other tool outcome anywhere in the dossier | `CommandTermination{exit_code \| signal}` on `ToolCallCompleted.termination`, deliberately kept out of the provider-visible result (`proto/trogonai/session/sessions/v1alpha1/command_termination.proto:5-22`, `.../tool_call_completed.proto:16-35`) | Ours, decisively -- also independently confirmed closed by fx's item 2 | +| Required `WorkspaceRef`-equivalent: none. `Session` carries only `session_id`, `session_type`, `created_at`, `updated_at`; no cwd/origin field of any kind (dossier: [Keying and identity](./index.md#keying-and-identity)) | `SessionStarted.workspace`, a required `WorkspaceRef{workspace_id, uri, revision}` (`proto/trogonai/session/sessions/v1alpha1/session_started.proto:16-24`, `.../workspace.proto:13-22`) | Ours, decisively | + +## What we should consider changing + +Ordered most-consequential first. Given the 6/12 maturity score, none of these +is presented as an industry norm; each stands on Strands' own evidence alone. + +### 1. Name explicitly why the durable event catalog carries no `schema_version` field, and what happens the day a change cannot be additive + +**The change.** [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 3 states "Schema evolution is additive (new +optional fields, reserved retired numbers), never a per-event version branch" +(`docs/adr/0035-session-store-decider-aggregate.md:378-379`), but does not say +what the mechanism is the day an existing event's shape genuinely cannot +change additively. + +**Evidence anchor.** Strands, store maturity 6/12: `Session`, `SessionAgent`, +and `SessionMessage` carry no `schema_version` field, and `from_dict` on all +three "silently drops unknown keys and defaults missing ones" +(`strands-py/src/strands/types/session.py:96-100,166-170,204-207`, per +the dossier's [Schema evolution: no version field on any durable session type](./index.md#schema-evolution-no-version-field-on-any-durable-session-type) section) -- while two adjacent, explicitly +*non*-durable mechanisms in the same codebase, `Checkpoint.schema_version` and +`Snapshot.schema_version`, both hard-reject a version mismatch +(the dossier's [Schema evolution: no version field on any durable session type](./index.md#schema-evolution-no-version-field-on-any-durable-session-type) section). + +**Blast radius.** Additive -- a documentation clarification to facet 3, not a +schema change. I am not recommending a literal `schema_version` field be +added over facet 3's stated prohibition on per-event version branches; see +Why. + +**Why.** The naive fix -- copy Strands' `Checkpoint`/`Snapshot` pattern onto +session events -- would directly contradict facet 3's stated principle, so +recommending it outright would be recommending a decision reversal, not a +refinement. But Strands' actual failure mode is not "no version field," it is +"an incompatible shape change is undetectable and gets silently absorbed," +and our mechanism differs in kind, not just in degree: protobuf's structural +required-field presence, additive-only wire evolution, and the typed-decode- +and-reject boundary of decision 3 already reject a malformed or incompatible +payload loudly, where Strands' Python dataclass field-filtering absorbs it +silently. The version field itself is not the fix; the fix is already in +place. What remains genuinely open, and what Strands' internal inconsistency +is useful evidence for, is unstated: the ADR should say explicitly what the +concrete mechanism is for a truly non-additive change (a new event type added +to the oneof, a reserved-and-replaced field number, something else), so a +future implementer facing that day does not reach for Strands' pattern (a +field that silently tolerates drift) as the path of least resistance under +time pressure. + +**Cost.** None beyond the documentation; becomes a real cost only when a +genuinely non-additive change is actually needed and the mechanism has to be +invented on the spot instead of decided in advance. + +### 2. When the deferred erasure-grade-deletion follow-up ADR is written, model it as N atomic per-item facts, never a bulk destroy + +**The change.** [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 7 defers "legal or user-requested erasure +beyond masking -- per-session encryption and key destruction... to a named +follow-up ADR" +(`docs/adr/0035-session-store-decider-aggregate.md:896-900`). This +recommendation is about the shape that follow-up should take, not a change to +`v1alpha1` today. + +**Evidence anchor.** Strands, store maturity 6/12: the S3 `delete_session` +path pages through `list_objects_v2` and issues `delete_objects` in batches of +up to 1000 keys in a loop with no recorded resume point +(`strands-py/src/strands/session/s3_session_manager.py:191-212`, per +the dossier's [Retention, deletion, and multi-host](./index.md#retention-deletion-and-multi-host) section); the dossier is explicit that "if the process +crashes or a batch call raises partway through the loop, the session is left +with some keys deleted and others present, with no recorded resume point or +partial-delete marker anywhere in the code." + +**Blast radius.** Additive -- `ArtifactErased` already is a per-artifact, +`At`-guarded, one-fact-per-item event +(`proto/trogonai/session/sessions/v1alpha1/artifact_erased.proto:5-17`). The +recommendation is to extend that shape (for example, a per-item crypto- +shred-completed fact) when the follow-up ADR is written, rather than to +introduce a bulk multi-key destroy operation. + +**Why.** Strands' S3 backend is exactly the negative case our per-item +pattern already avoids: because every `ArtifactErased` is its own +`At`-guarded event on the log, a partial failure across many artifacts leaves +an exact, queryable record of which ones succeeded and which didn't -- a fold +over events already appended -- the opposite of Strands' silent gap, where +nothing in the code path can tell "fully erased" from "half erased" after a +crash. This is worth stating before the follow-up ADR is drafted, so a future +author reaching for a batch API, the obvious and more performant shape for +"erase everything about this session," does not reintroduce the exact failure +mode this dossier documents. + +**Cost.** A slower erasure operation for a session with many artifacts (N +appends instead of one bulk call), which is the direct trade for a +resumable, auditable partial-failure state. + +### 3. State explicitly whether cold-tier relocation to the JetStream Object Store needs any write precondition of its own + +**The change.** [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 7 permits, as an optional deployment choice, +copying "already-immutable old events... to the JetStream Object Store, +evicted from the hot stream, and restored on demand" +(`docs/adr/0035-session-store-decider-aggregate.md:915-921`), but does not +state whether that relocation needs a write-precondition of its own, given +that JetStream's Object Store, like S3, is not append-native. + +**Evidence anchor.** Strands, store maturity 6/12: the corpus's clearest +demonstration of what "object store, zero added concurrency discipline" +produces is two silent races the dossier confirms are identically shaped on +both backends -- `create_session`'s check-then-act +(`strands-py/src/strands/session/file_session_manager.py:164-180`, +`strands-py/src/strands/session/s3_session_manager.py:166-181`, per +the dossier's [Write and append path](./index.md#write-and-append-path-ordering-durability-concurrency-delivery) section) and the `message_id` counter collision across +two independently resuming managers +(`strands-py/src/strands/session/repository_session_manager.py:69-86`, per +the dossier's [Write and append path](./index.md#write-and-append-path-ordering-durability-concurrency-delivery) section). + +**Blast radius.** Additive -- a documentation clarification to facet 7's +Consequences/Non-Goals. If the audit this recommendation asks for turns up an +actual gap, the fix (a conditional-put on the tiering job) is Breaking, +cheap -- an implementation detail, no event shape changes. + +**Why.** Our tiering job is architecturally unlike Strands' scenario: it is a +single promotion process moving already-committed, already-ordered, immutable +hot-stream bytes, never two independent writers targeting the same tiered +key the way two Strands processes can independently resume the same +`session_id`. Strands' two races likely don't transfer to our tiering story -- +but "likely doesn't transfer" is exactly the kind of unstated assumption +Strands' own races show is worth writing down rather than assuming, since +both of Strands' races are silent (discovered only by reading the code, never +by a test failing) and cost nothing to rule out explicitly in advance. + +**Cost.** None beyond writing the sentence, unless the audit surfaces a real +gap in the tiering job's design. + +## What our design already does better + +- **Position identity is fold-derived and is never a storage key.** + `SessionOrdinal` is "derived by counting at fold time, never read from + JetStream message metadata" and "never writes its own predicted position + into its payload" + (`proto/trogonai/session/sessions/v1alpha1/session_ordinal.proto:5-15`, + `docs/adr/0035-session-store-decider-aggregate.md:140-168`). Strands' + `message_id` is exactly the opposite: client-computed in process memory and + used as the literal filename/key, which is precisely how two independent + managers resuming the same session collide on the same key with different + content (the dossier's [Write and append path](./index.md#write-and-append-path-ordering-durability-concurrency-delivery) section). +- **A real, server-enforced concurrency guard on invariant-bearing writes.** + `At(current_position)` rejects a stale writer at the broker for every + lifecycle and ledger transition + (`docs/adr/0035-session-store-decider-aggregate.md:186-192`). Strands has no + concurrency control anywhere on either backend -- "no file locks, no S3 + `If-Match`/`If-None-Match` conditional headers, no version/ETag checks" + (the dossier's [Write and append path](./index.md#write-and-append-path-ordering-durability-concurrency-delivery) section) -- so every write in Strands is the equivalent of our + `Any` bucket, with none of the invariants our `At`-guarded commands enforce + (one active attempt, mutually exclusive approve/deny, one terminal outcome + per operation) expressible at all. +- **Listing and search are first-class rebuildable projections.** `get_session` + and `list_sessions` fold from the log + (`docs/adr/0035-session-store-decider-aggregate.md:930-935`). Strands has no + listing operation anywhere in `SessionManager` or `SessionRepository`; a + caller must already know the `session_id` to address anything + (the dossier's [Keying and identity](./index.md#keying-and-identity) and [Listing, summaries, and search](./index.md#listing-summaries-and-search) sections). +- **Redaction never mutates a stored record.** `RedactionApplied` is a new + append naming event ids to mask at read time, automatically covering every + duplicate (shared deterministic event id) and every fork's inherited + context (read-by-reference) + (`proto/trogonai/session/sessions/v1alpha1/redaction_applied.proto:5-19`, + `docs/adr/0035-session-store-decider-aggregate.md:872-882`). Strands' + `redact_message` is a second field written onto the *same* durable object + in place -- the one edit that exists anywhere in this store -- and has no + propagation story at all, because Strands has no fork/inheritance-by- + reference concept to worry about in the first place. +- **Fork is a first-class, atomic, lineage-recording domain event.** + `SessionForked` records `source_session_id` and `context_prefix_boundary` in + the child's own creation batch + (`proto/trogonai/session/sessions/v1alpha1/session_forked.proto:7-27`). + Strands' nearest analogue, `Snapshot`, is "entirely outside + `SessionManager`/`SessionRepository`... there is no lineage metadata + recorded anywhere" (the dossier's [Rewind, checkpoints, and fork](./index.md#rewind-checkpoints-and-fork) section), and persisting it durably is + entirely the caller's problem. +- **A delegated child gets the same durability, resume, audit, and redaction + machinery as any other session.** `DelegationDispatched`/`ParentLinked` + make a child a real, independently resumable session + (`docs/adr/0035-session-store-decider-aggregate.md:729-756`). Strands + forbids a node's own `Agent` from holding a session manager at all + (the dossier's [Subagents and nested sessions](./index.md#subagents-and-nested-sessions) section) -- see Subagent cascade below. +- **A required, recorded workspace binding.** `SessionStarted.workspace` is a + required `WorkspaceRef` + (`proto/trogonai/session/sessions/v1alpha1/session_started.proto:16-24`). + Strands' `Session` dataclass has no cwd/origin field of any kind + (the dossier's [Keying and identity](./index.md#keying-and-identity) section). +- **Typed process-termination facts, kept separate from the provider-visible + transcript.** `CommandTermination{exit_code | signal}` belongs to + `ToolCallCompleted`, deliberately not to the result the model saw + (`proto/trogonai/session/sessions/v1alpha1/command_termination.proto:5-22`). + Nothing in the Strands dossier distinguishes a process exit status from any + other tool outcome. + +## Trade-offs, not gaps + +- **Eager, offset-bounded resume (Strands) vs. snapshot-bounded aggregate + replay (ours).** Strands' `initialize` calls + `list_messages(..., offset=removed_message_count)` + (the dossier's [Read and resume path](./index.md#read-and-resume-path) section) -- the *only* bound on resume cost is how much + compaction has actually fired; a very long, rarely-compacted run pays a + real, unbounded per-resume cost reading every remaining message file + individually. Our aggregate snapshot is a genuinely separate, disposable + artifact bounding replay cost independent of retention + (`docs/adr/0035-session-store-decider-aggregate.md:911-914`), but the same + open edge exists on our side for model-visible context compilation, which + is "bounded by the latest `Compacted` marker" and not by elapsed time since + it last fired -- an open question the Cline comparison already raised for + our design + (the [Cline comparison](../cline/vs-session-events.md#retention-on-an-unbounded-log)'s Retention on an unbounded log section), + not re-derived here. +- **Whole-object atomicity-by-avoidance (Strands) vs. classified + write-preconditions (ours).** Every Strands write is a `tempfile.mkstemp()` + + `os.replace` (file) or a single `put_object` (S3) -- atomicity-simple, + because a fresh whole-object write can never be observed torn + (the dossier's [Write and append path](./index.md#write-and-append-path-ordering-durability-concurrency-delivery) section), at the cost of no server-side ordering signal + anywhere. Ours buys real invariants on the guarded path (one active + attempt, mutually exclusive approve/deny) at the cost of the substrate + obligations facet 2 lists as prerequisites + (`docs/adr/0035-session-store-decider-aggregate.md:326-341`) actually + shipping before this store can go live. Strands' strategy is genuinely + simpler to implement correctly on day one; ours needs more machinery but + expresses invariants Strands' design structurally cannot, because Strands + has no invariant-bearing writes at all. +- **Field-mutation redaction (Strands) vs. event-referencing redaction + (ours).** Strands' `redact_message` touches exactly the one record in + question and nothing else, which is simple to reason about for a flat + per-message store with no forking. Ours requires the fold and every + projection to consistently honor a masking pass over id-referenced content + -- more moving parts, but the only shape that keeps working once + content-addressed dedup and fork-by-reference exist, which Strands has + neither of. + +## What not to copy + +- **A client-computed positional key used as the storage address.** + `message_id` is assigned in process memory and doubles as the filename/key + with no server check -- precisely how two independent managers resuming the + same session collide (the dossier's [Write and append path](./index.md#write-and-append-path-ordering-durability-concurrency-delivery) section). `SessionOrdinal` is fold-derived + and never a storage key at all, specifically so this cannot happen. +- **Optional interface methods that raise `NotImplementedError` instead of + being excluded from the contract.** `sync_multi_agent`, + `initialize_multi_agent`, the three bidi methods, and `delete_session`/ + `delete_message`/`delete_agent` living entirely outside the abstract + `SessionRepository` altogether (the dossier's [The store interface](./index.md#the-store-interface) and [What this implies for our Session Store (our inference)](./index.md#what-this-implies-for-our-session-store-our-inference) sections) mean a + conformance suite can pass against a backend that silently can't do half of + what the interface promises. We have one storage substrate, not a + pluggable interface, so this specific failure mode doesn't arise for us + today; it is the right warning to keep if a pluggable + `SessionRepository`-equivalent is ever proposed for our own store. +- **Bulk multi-key deletion with no resume marker.** Recommendation 2 above + exists specifically because of this pattern. +- **A durable type with no version field sitting beside two adjacent + transient mechanisms that have one.** The inconsistency itself, regardless + of which side is "right," is what to avoid: apply one policy -- versioned + and hard-checked, or additive-only and structurally enforced -- uniformly + across every persistence mechanism in the platform, not just the one an + implementer happened to design most recently. +- **Treating offset-based compaction as if it were retention.** + `removed_message_count` only changes what a resume reads; it deletes + nothing, ever, and there is no `delete_message` anywhere to close that gap + (the dossier's [Compaction and history management](./index.md#compaction-and-history-management) section). Decision 7 already keeps compaction and + retention/erasure as distinct, explicit concepts; Strands' single + overloaded mechanism is the cautionary shape not to repeat if either is + ever revisited. + +## The two gaps the industry has not closed + +### Subagent cascade + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6 already takes a position here: a child session is its +own logical stream, linked by facts on each side +(`DelegationDispatched`/`ParentLinked`), cascade policy is explicit and +recorded (`CascadePolicy`), rewind-invalidation is distinct from terminal +cascade, and acyclicity holds by construction +(`docs/adr/0035-session-store-decider-aggregate.md:723-791`). The question +here is whether Strands' evidence validates, refines, or challenges that +position, not whether we still need one. + +**What Strands does.** Both shipped multi-agent patterns actively forbid a +node's own `Agent` from carrying a session manager at all: + +```python +# strands-py/src/strands/multiagent/graph.py:294-298 +if isinstance(executor, Agent): + if executor._session_manager is not None: + raise ValueError("Session persistence is not supported for Graph agents yet.") +``` + +```python +# strands-py/src/strands/multiagent/swarm.py:539-541 +if node._session_manager is not None: + raise ValueError("Session persistence is not supported for Swarm agents yet.") +``` + +The only durable child-related artifact is the orchestrator's own +`multi_agents/multi_agent_/multi_agent.json`, and it embeds only the +*last* message of each finished node via `AgentResult.to_dict()` +(the dossier's [Subagents and nested sessions](./index.md#subagents-and-nested-sessions) section) -- "lossy by construction": a node's full internal +conversation is never written to durable storage anywhere, because the +node's `Agent` is barred from having a session manager at all. Crash behavior +follows directly from the sync timing: `sync_multi_agent` fires only on +`AfterNodeCallEvent` (node completion) and `AfterMultiAgentInvocationEvent` +(run completion), so a crash while a node is *still executing* loses that +node's entire in-flight work -- nothing about it was ever synced +(the dossier's [Subagents and nested sessions](./index.md#subagents-and-nested-sessions) section). On restart, the interrupted node re-runs from scratch, +because no partial progress was ever durable. + +**Does this validate, challenge, or refine decision 6?** It validates the +core premise and sharpens the actual bar decision 6 clears. Where Cline's +one-level-deep synchronous cascade showed "an incomplete cascade is dangerous +because it looks complete," Strands shows a step further: the industry's +other honest answer to "what happens to a child session's transcript" is not +an incomplete cascade at all, but *no transcript, on purpose, until this is +designed properly* -- a shipped, adopted vendor SDK voting with its feet that +decision 6's problem is hard enough to defer entirely rather than half-solve, +with the error string's "yet" as the only signal it is even on a roadmap. +This does not suggest decision 6's mechanism is wrong; it sharpens what +decision 6 has already cleared, since "does not attempt it" is itself a +competitive, currently-shipped answer. It also surfaces one narrow risk +decision 6's text does not name, though it is orthogonal to decision 6 +itself: Strands' crash-mid-node loss is a durability-within-a-single-child's- +execution problem, not a linking-already-durable-children problem, and our +design structurally avoids it for a different reason -- every child gets its +own full event-sourced stream where in-flight work is durable at fact +granularity the moment each event is appended (`UserMessageRecorded`, +`ToolCallCompleted`, and so on, on the *child's own* stream), unlike Strands +where the child's durable record doesn't exist at all until the node +finishes. This is the same argument the fx comparison already made about +fact-granular commit protecting the *parent* stream from an in-flight-turn +loss on crash +(the [fx comparison](../fx/vs-session-events.md#the-one-structural-difference-everything-else-follows-from)'s structural-difference section); +Strands extends it, unintentionally, to the *child* stream specifically, +which neither fx nor Cline's dossier tested because neither product's +subagent story loses *all* in-flight child work on every crash the way +Strands' does. + +### Retention on an unbounded log + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7 already takes a position here: keep-forever, +`SessionHidden` as a visibility tombstone (no bytes deleted), +`RedactionApplied` for read-time masking, `ArtifactErased` for out-of-band +artifact-byte destruction, aggregate snapshots that "bound replay, not +storage," optional reversible cold-tiering, and erasure-grade deletion +explicitly deferred to a named follow-up ADR +(`docs/adr/0035-session-store-decider-aggregate.md:855-921`). The question +here is whether Strands' evidence validates that design or exposes a cost the +ADR does not bound. + +**What Strands does.** No TTL, lifecycle policy, or scheduled cleanup exists +anywhere; a search of `session/*.py` and `types/session.py` for retention/TTL/ +lifecycle/cleanup terms returns no matches (the dossier's [Retention, deletion, and multi-host](./index.md#retention-deletion-and-multi-host) section). Compaction +(`removed_message_count`) is purely a read-time offset into an untouched +message list: "No `SessionRepository` implementation has a `delete_message` +method... the underlying `message_.json` files for every 'compacted-away' +turn remain on disk (or in the S3 bucket) forever." The dossier's own +example: "A session that summarizes 10,000 turns down to a 50-turn visible +window still holds 10,000 message files in storage" +(the dossier's [Compaction and history management](./index.md#compaction-and-history-management) section). Deletion exists only as `delete_session` -- whole-session, +backend-specific, not part of the abstract contract, never called by +`SessionManager` itself -- and it is the one place file and S3 genuinely +diverge in semantics: file backend `shutil.rmtree` (all-or-nothing for local +disk) vs. S3's paginated `delete_objects` loop, where "if the process crashes +or a batch call raises partway through the loop, the session is left with +some keys deleted and others present, with no recorded resume point or +partial-delete marker anywhere in the code" (the dossier's [Retention, deletion, and multi-host](./index.md#retention-deletion-and-multi-host) section). + +**Does this validate, challenge, or refine decision 7?** It validates the +core keep-forever-plus-explicit-privacy-contract shape and sharpens the +deletion question specifically. Both designs accept unbounded growth -- +decision 7 explicitly, Strands implicitly by never building a +`delete_message` path -- so the real divergence is entirely in what "delete" +means when someone finally asks for it. Strands attempts real erasure at the +one granularity it supports (whole session), and that attempt can silently +half-fail with no record; that is precisely the failure mode our deferred +erasure-grade-deletion follow-up ADR needs to avoid, which is why +recommendation 2 above proposes modeling it as N atomic per-item facts rather +than a bulk destroy. Where decision 7's compaction story does something +Strands' offset-only compaction cannot: our aggregate snapshot is a genuinely +separate, disposable artifact bounding replay cost independent of retention +policy, whereas Strands conflates "what the model sees" with the entire +resume mechanism, so a very long-running, rarely-compacted session pays a +real, unbounded per-resume cost reading every remaining message file +individually. This cost shape resembles Cline's field-confirmed growth +failure (`cline/cline#9011`) more than it resembles anything decision 7 +produces on its own terms -- but, marking inference as inference, the Strands +dossier does not report an issue confirming this specific resume-cost failure +in the field the way Cline's was field-confirmed with open/close dates. +Strands' own `_fix_broken_tool_use` defensive-repair code, referencing GitHub +issue `strands-agents/harness-sdk#859`, is field evidence of a related but +distinct failure -- broken tool-use/tool-result pairing after truncation or a +crash -- not of a growth-driven resume slowdown specifically. + +## Open questions for the ADR + +1. Does cold-tier relocation to the JetStream Object Store (facet 7) need any + write precondition of its own, or is the tiering job's single-writer + construction sufficient justification to state explicitly that none is + needed? +2. What is the concrete mechanism for a genuinely non-additive event-shape + change, given facet 3 forbids per-event version branches -- a new event + type added to the oneof, a reserved-and-replaced field number, or + something else -- and should that mechanism be named now rather than + invented under pressure the day it's actually needed? +3. When the deferred erasure-grade-deletion ADR is written, should it commit + now to a per-item atomic-fact shape (extending `ArtifactErased`) rather + than leave the door open to a bulk destroy operation later, once a batch + API looks like the obvious performance win? +4. Should decision 6 note explicitly that a child session's own + event-sourced stream, not just the parent-child link facts, is what + prevents a Strands-style total loss of in-flight node work on crash? This + is a real benefit of the current design; it is currently implicit rather + than stated. diff --git a/docs/research/session-store/products/claude-agent-sdk.md b/docs/research/session-store/products/claude-agent-sdk/index.md similarity index 96% rename from docs/research/session-store/products/claude-agent-sdk.md rename to docs/research/session-store/products/claude-agent-sdk/index.md index 7ea45cc1a..ce6039982 100644 --- a/docs/research/session-store/products/claude-agent-sdk.md +++ b/docs/research/session-store/products/claude-agent-sdk/index.md @@ -1,10 +1,18 @@ # 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). +Produced by running [RESEARCH_PROMPT](../../RESEARCH_PROMPT.md). Evidence snapshot retrieved 2026-07-23. Version-sensitive claims were checked against these authoritative anchors: +For a later immutable inspection of the complete TypeScript session surface, +see the [Claude Agent SDK 0.3.220 session type snapshot and platform +comparison](./session-types.md). +That pinned inspection narrows one inference below: `SessionStore` is +stream-shaped, but its local-first, best-effort mirror may drop batches. It +cannot be the authoritative platform Session log; any equation below is a +structural analogy only. + - 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, @@ -175,7 +183,7 @@ class SessionStore(Protocol): async def append(self, key: SessionKey, entries: list[SessionStoreEntry]) -> None: ... async def load(self, key: SessionKey) -> list[SessionStoreEntry] | None: ... - # Optional — omit or raise NotImplementedError + # 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: ... @@ -379,9 +387,9 @@ occasionally `custom-title` / `frame-link`. 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}, + entry: `{messageId, snapshot: {messageId, timestamp, trackedFileBackups}, isSnapshotUpdate}` where `trackedFileBackups` maps each tracked path to - `{backupFileName, version, backupTime}` — plus content blobs at + `{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 @@ -482,12 +490,13 @@ 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: +event-sourced Session Store structurally, but the SDK interface is only +stream-shaped and does not provide authoritative delivery: - `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. + random access, mutation, or in-place rewrite. This resembles our event stream + and full-replay read, but is not its authoritative write path. - 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 @@ -501,9 +510,10 @@ event-sourced Session Store, and the SDK interface is already stream-shaped: 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. + edits, exactly our design goal. +- Delivery may duplicate UUID-bearing entries, but final mirror failure can + drop a batch. Our backing needs idempotent append and independent proof of + completeness, not reliance on this transport. **Gaps we must close that this product leaves open.** There is no expected- position precondition on `append` (no optimistic-concurrency surface), and diff --git a/docs/research/session-store/products/claude-agent-sdk/session-types.md b/docs/research/session-store/products/claude-agent-sdk/session-types.md new file mode 100644 index 000000000..d0724e3ce --- /dev/null +++ b/docs/research/session-store/products/claude-agent-sdk/session-types.md @@ -0,0 +1,449 @@ +# Claude Agent SDK 0.3.220 session type snapshot and platform comparison + +Part of [Session Store Research](../../index.md). This is an immutable inspection +snapshot of the published `@anthropic-ai/claude-agent-sdk` npm tarball, not a +rolling description of the package. A later SDK release should get a new +snapshot or an explicitly dated addendum. + +## Snapshot identity + +The npm registry and tarball were inspected on 2026-08-03. Package facts in +this document come from the published declarations and bundled runtime. They +are separate from adapter recommendations, which are labeled as inference. + +This comparison does not define the platform Session Store. The platform owns +its Session schema and harness loop independently. Any later Claude integration +would translate at the edge and cannot introduce Claude identities, transcript +layouts, bridge positions, or resume semantics into the core contract. + +| Fact | Snapshot value | +| --- | --- | +| Package version | `0.3.220` | +| Bundled Claude Code version | `2.1.220` | +| Dist-tags | `latest` and `next` both `0.3.220`; no `beta` tag present | +| Registry metadata retrieval | `2026-08-03T21:30:09Z` | +| Compressed size | `1,144,946` bytes | +| Unpacked size | `4,258,606` bytes, 15 files | +| SHA-1 | `c59cf4fff0166d2a04b01470eabdd4a792add48d` | +| SHA-512 | `82573b49dc0f90e90bc3ca31c0ba3d3ca4dd2c91aa5bf3c8478bab59716846d5fd625970a33b0455ce53735f84bcb4a47ebb313c9a905aa3a0a6350e4177f5a0` | +| npm integrity | `sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA==` | + +Pinned sources: [npm registry metadata](https://registry.npmjs.org/@anthropic-ai%2fclaude-agent-sdk/0.3.220), +[`sdk.d.ts`](https://unpkg.com/@anthropic-ai/claude-agent-sdk@0.3.220/sdk.d.ts), +[`browser-sdk.d.ts`](https://unpkg.com/@anthropic-ai/claude-agent-sdk@0.3.220/browser-sdk.d.ts), +[`bridge.d.ts`](https://unpkg.com/@anthropic-ai/claude-agent-sdk@0.3.220/bridge.d.ts), and +[`sdk-tools.d.ts`](https://unpkg.com/@anthropic-ai/claude-agent-sdk@0.3.220/sdk-tools.d.ts). + +This snapshot complements the broader +[Claude Agent SDK and Claude Code dossier](./index.md), which covers +documented and observed storage behavior. This page is narrower: it freezes +the complete session-related TypeScript surface in this one release and tests +it against the platform Session contract in this checkout. + +## Authority boundary + +- [ADR#0024](../../../../adr/0024-agent-platform-stream-topology.md) is accepted. + It authoritatively requires Sessions to pin the Agent revision they start on + and gives the placement rule for ordered facts. +- [ADR#0025](../../../../adr/0025-agent-definition-data-ownership.md), + [ADR#0031](../../../../adr/0031-agent-implementation-and-session-plan.md), and + [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) are drafts. + So are [ADR#0032](../../../../adr/0032-model-route-and-credential-binding.md) + and [ADR#0043](../../../../adr/0043-agent-instructions-ownership-and-shape.md). + They express current design directions, not accepted architecture. +- Draft [ADR#0031](../../../../adr/0031-agent-implementation-and-session-plan.md)'s + platform-readable model fields are contested. Draft + [ADR#0032](../../../../adr/0032-model-route-and-credential-binding.md) says that + ownership precondition is currently unmet. Draft + [ADR#0043](../../../../adr/0043-agent-instructions-ownership-and-shape.md) keeps + instruction content and injection shape in runtime-owned settings. +- The + [`v1alpha1` Session event package](../../../../../proto/trogonai/session/sessions/v1alpha1/events.proto) + is real code in this checkout, but its own header says it depends on draft + ADRs and cannot promote to `v1` until those decisions and prerequisites are + settled. +- Every statement beginning with **Inference** below is a proposed integration + rule. It is not a fact about the SDK and not an accepted platform decision. + +## Export topology + +**Package fact.** The package has no unified `Session` class or interface. +Session behavior is split across execution, live messages, transcript +utilities, a storage adapter, browser transport, and bridge transport. + +| Package subpath | Declaration | Session relevance | +| --- | --- | --- | +| `.` | `sdk.d.ts` | `Options`, `Query`, `SDKMessage`, history operations, hooks, `SessionStore` | +| `./browser` | `browser-sdk.d.ts` | Restricted remote `query()` over WebSocket or SSE | +| `./bridge` | `bridge.d.ts` | Remote worker attachment, epoch, sequence, state, and delivery reporting | +| `./sdk-tools` | `sdk-tools.d.ts` | Tool schemas with subagent, workflow, cron, and remote-session fields | +| `./sdk-tools.js` | `sdk-tools.d.ts` | Alias of the same type-only surface | +| `./extract` | `extractFromBunfs.d.ts` | Package extraction helper, not a Session contract | + +`agentSdkTypes.d.ts` is shipped but is not an exported package subpath. It +re-exports the root declarations for the browser and bridge build. Root +`export declare` names are importable. Unexported `declare type` names remain +private even when an exported wrapper refers to them. + +## Conceptual model + +**Package fact.** A root session is a native transcript identity plus a live +`Query` process and control channel. History utilities operate on that +transcript; `SessionStore` mirrors it; browser assumes an external Session +service; bridge attaches a remote worker; tool schemas add adjacent tasks and +workflows. These surfaces share IDs in places, but no one exported object owns +their identity, durable state, execution, and lifecycle together. + +## Root execution surface + +### `Options` + +**Package fact.** All 63 `Options` fields are grouped below. The +grouping is ours; the names and types are from the tarball. + +| Concern | Fields | +| --- | --- | +| Process and cancellation | `abortController`, `cwd`, `additionalDirectories`, `env`, `executable`, `executableArgs`, `extraArgs`, `pathToClaudeCodeExecutable`, `spawnClaudeCodeProcess`, `stderr` | +| Native agent and tools | `agent`, `agents`, `allowedTools`, `disallowedTools`, `toolAliases`, `tools`, `toolConfig`, `canUseTool`, `skills`, `plugins`, `hooks`, `includeHookEvents` | +| Models and limits | `model`, `fallbackModel`, `thinking`, `effort`, `maxThinkingTokens`, `maxTurns`, `maxBudgetUsd`, `taskBudget`, `betas`, `outputFormat` | +| Session selection | `continue`, `resume`, `resumeSessionAt`, `forkSession`, `sessionId`, `title` | +| Persistence | `persistSession`, `sessionStore`, `sessionStoreFlush`, `loadTimeoutMs`, `enableFileCheckpointing` | +| Permissions and isolation | `permissionMode`, `planModeInstructions`, `allowDangerouslySkipPermissions`, `permissionPromptToolName`, `sandbox` | +| MCP and interaction | `mcpServers`, `strictMcpConfig`, `onElicitation`, `onUserDialog`, `supportedDialogKinds`, `promptSuggestions`, `agentProgressSummaries` | +| Streaming and configuration | `includePartialMessages`, `forwardSubagentText`, `systemPrompt`, `settings`, `managedSettings`, `settingSources` | +| Diagnostics | `debug`, `debugFile` | + +Session-specific semantics are important. `continue` chooses the newest +conversation for the working directory. `resume` selects an ID. +`resumeSessionAt` limits resume through an assistant-message ID. `forkSession` +changes a resume into a new native session. `sessionId` supplies a custom UUID +for a new or forked session. `persistSession: false` prevents later resume. +`title` applies only at creation. +The store key calls `projectKey` caller-defined, but root `Options` cannot set +it directly. Query-backed store use derives it from cwd. + +### `Query` + +**Package fact.** `query({prompt, options})` returns a `Query`, which is an +`AsyncGenerator` plus all 27 declared methods: + +| Concern | Methods | +| --- | --- | +| Execution | `interrupt`, `streamInput`, `close` | +| Live mutation | `setPermissionMode`, `setMcpPermissionModeOverride`, `setModel`, `setMaxThinkingTokens`, `applyFlagSettings` | +| Initialization and discovery | `initializationResult`, `reinitialize`, `supportedCommands`, `supportedModels`, `supportedAgents`, `accountInfo` | +| Context and usage | `getContextUsage`, `usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET` | +| Files and plugins | `readFile`, `rewindFiles`, `seedReadState`, `reloadPlugins`, `reloadSkills` | +| MCP | `mcpServerStatus`, `reconnectMcpServer`, `toggleMcpServer`, `setMcpServers` | +| Tasks | `stopTask`, `backgroundTasks` | + +`reinitialize()` is transport-gap recovery, not transcript replay. It sends a +fresh initialize request and can redeliver pending permission and dialog +requests by `request_id`. `startup()` returns a one-use `WarmQuery`, whose +`query()` hands back the normal `Query` after prewarming a subprocess. +`close()` terminates query process resources; it is not a domain +`SessionClosed` operation. + +**Inference.** Most live mutation methods cannot be exposed directly in a +verified platform Session. Model, permission, MCP, plugin, skill, tool, and +settings changes must be rejected or converted into explicit platform +commands whose policy preserves the pinned plan. + +## Root session management + +| Export | Package behavior | +| --- | --- | +| `listSessions`, `SDKSessionInfo` | Lists ID, summary, modified time, and optional size, title, first prompt, branch, cwd, tag, and creation time | +| `getSessionInfo` | Reads one session summary locally or through a store | +| `getSessionMessages`, `SessionMessage` | Returns the linked message chain with optional system messages, pagination after chain materialization | +| `listSubagents`, `getSubagentMessages` | Enumerates native subagent IDs and reads one native child transcript | +| `forkSession` | Copies through an optional message ID, remaps message UUIDs, preserves links, and omits undo history | +| `renameSession`, `tagSession` | Appends native metadata entries | +| `deleteSession` | Deletes local transcript state or calls optional `SessionStore.delete`; absent store deletion is a no-op | +| `importSessionToStore` | Replays local main and optional subagent transcripts to `append` in batches | +| `foldSessionSummary` | Folds opaque entries into SDK-owned summary data; set-once and last-write-wins fields differ | +| `InMemorySessionStore` | Full non-production implementation with test helpers | +| `SessionMutationOptions` | Selects local `dir` or `sessionStore` for rename, tag, delete, and fork | +| `SessionStartHookInput`, `SessionStartHookSpecificOutput` | Reports start source; can add context or input, title, watch paths, and skill reload | + +Named companions are `ListSessionsOptions`, `GetSessionInfoOptions`, +`GetSessionMessagesOptions`, `ListSubagentsOptions`, +`GetSubagentMessagesOptions`, `ImportSessionToStoreOptions`, +`ForkSessionOptions`, and `ForkSessionResult`. They cover selection, paging, +filters, import batching, and fork output. `SessionEndHookInput` carries +`ExitReason`; `SessionCronSummary` carries ID, schedule, recurrence, and prompt. +`SessionMessage` exposes only `type`, `uuid`, `session_id`, opaque `message`, +`parent_tool_use_id`, and `parent_agent_id`; history content remains unknown. + +There is no public replay command, replay cursor, expected-position token, +lease, lock, transaction, or Session lifecycle state machine in this surface. +`SDKUserMessageReplay` is an observed output shape, not a replay operation. + +## Complete `SDKMessage` inventory + +The public union contains exactly 39 named members. Some expand into several +wire states; `SDKResultMessage`, for example, has success and multiple error +subtypes. The treatment column is our adapter classification: + +- **D** means translate a stable domain fact into one or more typed platform + events after validation and correlation. +- **L** means publish as live telemetry or a projection signal, but do not + append it as a Session domain fact by itself. +- **P** means prohibit the autonomous native behavior in a verified Session. + A platform command may provide an allowed replacement. +- **A** means adapter-owned only. Raw `SessionStoreEntry` or JSONL, SDK summary + sidecars, native session, message, subagent IDs and subpaths, and bridge + sequence, epoch, and cursor support recovery or correlation, but are not + platform domain facts. + +| # | Variant and wire discriminator | Key payload | Treatment | +| ---: | --- | --- | --- | +| 1 | `SDKAssistantMessage`, `assistant` | provider message, native IDs, error, request, replacement and abort metadata | D, assemble before `AssistantMessageCompleted` or `AssistantMessageFailed` | +| 2 | `SDKUserMessage`, `user` | user or tool-result content, origin, priority, synthetic and query flags | D for new input or tool outcome, after origin and content classification | +| 3 | `SDKUserMessageReplay`, `user` plus `isReplay: true` | required native message and session IDs, optional attachments | A; consume only for native context reconstruction and never re-append | +| 4 | `SDKResultMessage`, `result` | success or typed query error, usage, cost, denials, terminal reason | D as turn or attempt outcome, never automatically `SessionClosed` | +| 5 | `SDKSystemMessage`, `system/init` | effective model, tools, cwd, permissions, MCP, skills, plugins, capabilities | D only after Ready verification against the pinned plan | +| 6 | `SDKPartialAssistantMessage`, `stream_event` | raw provider stream event and time to first token | L | +| 7 | `SDKCompactBoundaryMessage`, `system/compact_boundary` | trigger, token counts, preserved-message linkage | D when source boundaries and summary are made self-sufficient | +| 8 | `SDKStatusMessage`, `system/status` | compacting or requesting state and compact result | L | +| 9 | `SDKAPIRetryMessage`, `system/api_retry` | attempt, delay, status, error category | L | +| 10 | `SDKControlRequestProgressMessage`, `system/control_request_progress` | request ID and started or retry status | L | +| 11 | `SDKModelRefusalFallbackMessage`, `system/model_refusal_fallback` | original and fallback models, replacement lineage | P because autonomous model substitution violates the draft plan rule | +| 12 | `SDKModelRefusalNoFallbackMessage`, `system/model_refusal_no_fallback` | model, request, refusal detail | D as typed refusal outcome | +| 13 | `SDKLocalCommandOutputMessage`, `system/local_command_output` | display text | D only when it is model-visible or audit-relevant, otherwise L | +| 14 | `SDKHookStartedMessage`, `system/hook_started` | hook identity and event | L | +| 15 | `SDKHookProgressMessage`, `system/hook_progress` | hook output streams | L | +| 16 | `SDKHookResponseMessage`, `system/hook_response` | output, exit, success, error, or cancellation | D for decision-bearing outcome, otherwise L | +| 17 | `SDKPluginInstallMessage`, `system/plugin_install` | install state and optional failure | P for mid-Session dependency mutation | +| 18 | `SDKToolProgressMessage`, `tool_progress` | tool IDs, elapsed time, heartbeat and retry | L | +| 19 | `SDKAuthStatusMessage`, `auth_status` | in-progress output and error | L | +| 20 | `SDKTaskNotificationMessage`, `system/task_notification` | native task terminal status, output file, summary, usage | P until native tasks map one-for-one to platform operations or child Sessions | +| 21 | `SDKTaskStartedMessage`, `system/task_started` | task ID, type, description, prompt, subagent or workflow metadata | P until child or external delegation admission exists | +| 22 | `SDKTaskUpdatedMessage`, `system/task_updated` | mutable task status patch | P for hidden native task state | +| 23 | `SDKTaskProgressMessage`, `system/task_progress` | description, usage, last tool, summary | L only for an already admitted platform operation | +| 24 | `SDKBackgroundTasksChangedMessage`, `system/background_tasks_changed` | replace-set of process-local tasks | L only for admitted operations; otherwise P | +| 25 | `SDKThinkingTokensMessage`, `system/thinking_tokens` | approximate running estimate | L | +| 26 | `SDKSessionStateChangedMessage`, `system/session_state_changed` | `idle`, `running`, or `requires_action` | L as liveness, not durable lifecycle | +| 27 | `SDKWorkerShuttingDownMessage`, `system/worker_shutting_down` | host reason | L as live-tail signal, never Session terminal state | +| 28 | `SDKCommandsChangedMessage`, `system/commands_changed` | replacement command list | P for unplanned command-surface mutation | +| 29 | `SDKNotificationMessage`, `system/notification` | keyed text, priority, color, timeout | L | +| 30 | `SDKFilesPersistedEvent`, `system/files_persisted` | persisted and failed files | D after claim-check and workspace attribution | +| 31 | `SDKToolUseSummaryMessage`, `tool_use_summary` | summary and preceding tool IDs | L as a derived view | +| 32 | `SDKMemoryRecallMessage`, `system/memory_recall` | mode and recalled sources or content | D if it entered model context, with source normalization and claim-checks | +| 33 | `SDKRateLimitEvent`, `rate_limit_event` | current allowance and reset data | L | +| 34 | `SDKElicitationCompleteMessage`, `system/elicitation_complete` | MCP server and elicitation IDs | D when joined to an admitted tool operation | +| 35 | `SDKPermissionDeniedMessage`, `system/permission_denied` | tool, input linkage, typed and human reasons | D to `ToolCallDenied` without parsing reason text | +| 36 | `SDKPromptSuggestionMessage`, `prompt_suggestion` | predicted next prompt | L | +| 37 | `SDKMirrorErrorMessage`, `system/mirror_error` | failed store key and error | D as integrity failure if mirroring is enabled; production authority must not depend on it | +| 38 | `SDKInformationalMessage`, `system/informational` | text, level, optional stop flag | D when it changes execution or model-visible history, otherwise L | +| 39 | `SDKConversationResetMessage`, `conversation_reset` | new native conversation ID | P; map clear or reset intent to explicit platform rewind, fork, or new Session semantics | + +`SDKActiveGoalMessage` is exported and appears in the private `StdoutMessage` +union, but is absent from public `SDKMessage`. Runtime 0.3.220 nevertheless +forwards `active_goal` into `Query`. It is a version-specific 40th observable +Query payload and a declaration defect, not a stable `SDKMessage` member. Its +single goal condition, iteration state, timestamps, token baseline, and +optional reason have no exact platform event match. Keep it adapter or live +state, or add a distinct typed domain event only if durability is required. + +## `SessionStore` + +**Package fact.** `SessionStore` is an alpha mirror adapter, not a replacement +for Claude Code's local JSONL authority. + +| Type or method | Contract in 0.3.220 | +| --- | --- | +| `SessionKey` | `projectKey`, `sessionId`, and optional opaque `subpath` | +| `append` | Required, receives opaque JSON-safe batches after local write succeeds | +| `load` | Required, returns the complete transcript or `null` before resume | +| `listSessions` | Optional ID and adapter-clock modification time listing; required by store-backed `continue` | +| `listSessionSummaries` | Optional bulk SDK-owned summary sidecar listing | +| `delete` | Optional, main-key deletion must cascade to subkeys and summary | +| `listSubkeys` | Optional, required to restore subagent transcripts | +| `SessionStoreEntry` | Only `type`, optional `uuid`, optional `timestamp`, and opaque JSON fields are public | +| `SessionStoreFlush` | `batched` or `eager` | +| `SessionSummaryEntry` | Session ID, adapter-clock `mtime`, and opaque SDK-owned `data` | + +Runtime inspection confirms these constraints: `sessionStore` cannot combine +with `persistSession: false`; store-backed `continue` needs listing; file +checkpointing cannot combine with `sessionStore`; default load timeout is 60 +seconds; and `continue` chooses the newest `mtime`. + +The mirror retries rejected appends up to three total attempts. Timed-out +calls are not retried because they might still land. Final failure drops the +batch and emits `mirror_error` while the query continues. Adapters are expected +to deduplicate UUID-bearing entries. Entries without UUIDs are append-only. +Resume loads the entire transcript and materializes temporary local JSONL. +There is no range read, cursor, version, compare-and-swap token, or atomic +append precondition. +`foldSessionSummary` is pure, so the adapter must serialize or transaction/CAS +its own summary-sidecar read-fold-write. That responsibility does not add a +compare-and-swap token to the transcript contract. + +**Inference.** If a Claude integration is added later, it must treat +`SessionStoreEntry` as opaque product recovery material in an edge-owned store. +It cannot be the authoritative platform Session event stream, because +successful platform commands cannot depend on a best-effort secondary write +that may be dropped. + +## Shipped private control declarations + +**Package fact.** Public `SDKControlRequest` and `SDKControlResponse` wrappers +refer to non-exported request and response unions. Consumers can narrow the +objects at runtime, but cannot import the member type names. + +The private request union covers `interrupt`, `can_use_tool`, `initialize`, +`set_permission_mode`, `set_model`, `set_max_thinking_tokens`, +`rename_session`, `set_color`, `mcp_status`, `get_context_usage`, +`get_session_cost`, `list_models`, `get_usage`, `get_binary_version`, +`mcp_call`, `file_suggestions`, `hook_callback`, `mcp_message`, +`rewind_files`, `cancel_async_message`, `read_file`, `get_workspace_diff`, +`get_plan`, `seed_read_state`, `mcp_set_servers`, `register_repo_root`, +`reload_plugins`, `reload_skills`, `mcp_reconnect`, `mcp_toggle`, `stop_task`, +`background_tasks`, `apply_flag_settings`, `get_settings`, `elicitation`, and +`request_user_dialog`. Private success and error envelopes can also carry +pending permission and dialog requests during initialize recovery. + +The private `StdoutMessage` union adds `SDKActiveGoalMessage`, control request, +control response, control cancellation, and keepalive to public `SDKMessage`. +This proves that the typed public iterator is narrower than the shipped wire. + +**Inference.** A future Claude edge adapter needs an exhaustive control-channel +allowlist. +Read-only discovery may pass through. Permission asks must bind to the +platform tool operation. Model, settings, MCP topology, rewind, plugin, skill, +task, and rename mutations require platform command authority or rejection. + +## Browser, bridge, and tool-schema surfaces + +### `./browser` + +The browser export accepts exactly one transport: WebSocket, or preferred SSE. +Its query options contain prompt stream, abort, tool permission callback, +hooks, MCP servers, output schema, elicitation, dialogs, and prompt +suggestions. It does not expose root options for resume, continue, fork, +custom session ID, title, persistence, `SessionStore`, cwd, or model. SSE takes +an externally created session ID. **Inference:** browser mode presupposes an +external Session service and is a client transport, not our aggregate model. +The declaration example omits required `SSEOptions.sessionId`, another seam +consumers must not copy literally. + +### `./bridge` + +`BridgeSessionHandle` has a remote session ID, live SSE high-water sequence, +worker epoch, connection status, message and result writes, control forwarding, +transport reconnect, state and metadata reporting, delivery reporting, flush, +and close. Attach options add inbound messages, permission responses, +interrupt, live model, thinking, permission, and title controls. + +Bridge sequence resumes transport frames, not transcript history. Epoch fences +the active worker, but neither value is a platform Session ordinal or event +version. The declaration also says bridge alpha stability is a separate +versioning universe from root `query()`. + +### `./sdk-tools` + +These are tool schemas, not the root lifecycle API. Session-adjacent fields +include a remote agent `sessionUrl`; subagent fork model inheritance and +session permission inheritance; same-session workflow `resumeFromRunId`; +durable cron jobs that survive Sessions; workflow run IDs and remote session +URLs and `transcriptDir`; replayed notification timestamps; and `Monitor` +persistent mode, which runs until `TaskStop` or Session end. **Inference:** a +URL or transcript directory is not platform identity, and native fork, cron, +or monitor ownership cannot silently become platform Session semantics. + +## Platform Session in this checkout + +**Repository fact.** The current code has 57 Session proto files, 72 messages, +19 enums, and 41 concrete arms in +[`SessionEvent`](../../../../../proto/trogonai/session/sessions/v1alpha1/events.proto). +It has generated Rust exports, codecs, and local per-event semantic validation. +`validate_session_event` checks only facts one event can prove about itself; +cross-event joins and stream invariants are explicitly out of scope. Current +non-test code only re-exports it, while its call sites are the validation tests. +The catalog covers lifecycle, fork, rewind, compaction, conversation, tools, +artifacts, files, execution attempts, delegation, operation ledger, privacy, +system notices, todo state, and organization metadata. + +[`SessionStarted`](../../../../../proto/trogonai/session/sessions/v1alpha1/session_started.proto) +stores one +[`StoredSessionExecutionPlan`](../../../../../proto/trogonai/session/sessions/v1alpha1/execution_plan.proto), +currently opaque canonical `plan_bytes` plus a digest, and a workspace +reference. The concrete typed plan described by draft +[ADR#0031](../../../../adr/0031-agent-implementation-and-session-plan.md) is not +yet a proto contract in this package. + +**Repository fact.** No Session-specific commands, decider `initial_state`/ +`evolve`/`decide`, composed event store and subject resolver, projection, +snapshot policy, reconciler, Claude adapter, or adapter conformance suite +exists yet. Generic infrastructure does exist: the decider runtime supplies +write preconditions and snapshots, while its NATS crate supplies a JetStream +stream store, optimistic concurrency, snapshot storage, and projector +primitives. Draft +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) leaves their +Session-specific composition and its listed substrate corrections as follow-up +work. + +## Comparison matrix + +| Axis | SDK 0.3.220 | Platform Session direction | Deferred edge rule | +| --- | --- | --- | --- | +| Aggregate | No unified aggregate | One logical event-sourced aggregate per Session in draft [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) | Adapter must not equate `Query` or JSONL with aggregate state | +| Identity | cwd-derived project key, native UUID, optional subpath | Opaque Session ID and subject resolution | Keep any product identity binding at the edge, never reuse one ID domain as the other | +| Revision and plan | Model, tools, settings, plugins, and agents can be selected or mutated | Accepted revision pin; draft immutable execution plan | Freeze admitted native projection and reject unplanned mutation | +| Authority | Local JSONL first, optional best-effort mirror | Typed authoritative event log | Store native recovery bytes separately | +| Ordering | JSONL order, UUID links, bridge sequence | Logical `SessionOrdinal`; physical sequence is not domain identity | Keep bridge and transcript positions outside core Session ordering | +| Concurrency | Same native session can receive interleaved writers | Draft `NoStream`, `At`, and `Any` preconditions by command | Platform owns command concurrency and invariants | +| Idempotency | UUID dedupe recommendation; some entries lack UUID | Draft deterministic event IDs and operation ledger | Bind native IDs to stable platform operation IDs and request digests | +| Resume | Full transcript load and temporary JSONL | Harness recovery is platform-owned | Product resume stays an edge concern and cannot define core recovery fields | +| Replay | Output flag only | Read and fold of durable events | Never append replayed SDK messages again | +| Fork | Physical copy with remapped UUIDs and no undo history | Draft new Session with source-prefix reference | Implement platform fork first, then materialize a native transcript view | +| Rewind | Native file rewind and resume-at-message | Draft append-only `SessionRewound` plus read-time history invalidation | Convert explicit command, do not truncate platform history | +| Compaction | Native boundary plus preserved-message links | Draft self-sufficient in-stream `Compacted` marker | Capture summary, exact source range, prompt rules, and digest | +| Assistant stream | Partial and full provider messages plus result boundary | Coarse started, completed, or failed facts; no token-delta events | Stream deltas live, assemble one canonical durable outcome | +| Tool authorization | Callback and control request, plus auto-denial event | Typed request, approval or denial, execution, and operation ledger | Platform authorizes and dispatches before the native loop observes a result | +| Subagents | Native task IDs and subpath transcripts | Draft one child Session per admitted delegation | Intercept native spawn or disable it | +| External delegation | Remote task and session URL shapes | Draft typed external delegation operation | Record authenticated destination, authorization, request digest, and outcome | +| Liveness | `idle`, `running`, `requires_action`; task and worker signals | ExecutionAttempt and Session lifecycle facts | Keep transient state out of durable terminal fold | +| Listing | Local scan or optional summary sidecar | Rebuildable projection | Build platform picker from events, not SDK summaries | +| Deletion | Physical local delete or optional store delete | Draft keep-forever log, hide, redaction, artifact erasure | Do not route SDK delete to event-log deletion | +| Checkpoints | File-history blobs cannot use `SessionStore` | Platform harness checkpoint is independent | Keep product recovery artifacts outside the core checkpoint schema | +| Browser and bridge | External remote service, transport sequence, worker epoch | Aggregate identity, event ordinal, execution attempt | Treat as transport and hosting facts only | +| Privacy and tenancy | cwd-oriented scope, opaque transcript payload | Resolver and authorization decisions remain separate | Never encode tenant or authority from a filesystem key | + +## Deferred integration questions + +These questions matter only if Claude becomes an integration target. They are +not gaps in the platform-owned Session Store or harness design. + +1. Define an edge-owned binding among the platform Session, native UUID, + execution attempt, TurnId, project key, subpath, bridge identity, epoch, and + cursor. Hook `prompt_id` is not uniformly present on `SDKMessage`, so turn + correlation needs an explicit integration rule. +2. Specify an exhaustive native message and control translation contract, + including assembly, replay dedupe, refusal, clear, compact, and result + semantics. `SDKResultMessage` ends a turn or attempt, not a Session; + approvals arrive through callbacks or control, not the output iterator. +3. Build immutable native configuration projection and verification. Disable + fallback model selection, dynamic tools, hidden subagents, and mid-Session + dependency mutation unless a platform command explicitly authorizes them. +4. Persist opaque native transcript and recovery material outside typed + platform events, harness checkpoints, and projections. +5. Put tool, model, delegation, and external side effects behind stable + operation IDs, request digests, authorization, and reconciliation. +6. Define product restart behavior without turning native resume coordinates + into core Session fields. +7. Add conformance tests for multi-host resume, retry, duplicate delivery, + control re-delivery, crash windows, native fork materialization, rewind, + compaction, and child-session recovery. + +## Recommendation + +**Inference.** Build the platform Session Store and harness loop from the +platform's own domain first. This SDK snapshot is comparison material, not a +reason to shape the core schema around Claude sessions. + +If Claude support is chosen later, implement it as an edge translation with +its own identity, transcript, control, and recovery state. Its conformance tests +must prove that native behavior maps into existing platform commands and events +without adding product-specific fields to the Session contract. diff --git a/docs/research/session-store/products/cline/index.md b/docs/research/session-store/products/cline/index.md new file mode 100644 index 000000000..6b7bb1485 --- /dev/null +++ b/docs/research/session-store/products/cline/index.md @@ -0,0 +1,1160 @@ +# Cline: how session transcripts are stored and resumed + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT](../../RESEARCH_PROMPT.md). +Evidence snapshot retrieved 2026-08-04. Version-sensitive claims were checked +against these authoritative anchors: + +- Repo [cline/cline](https://github.com/cline/cline), pinned commit `5ec2d47b21b3a09aa7a094bfbbe0c7e8f7ddd3fa` + (committed 2026-08-03T20:01:37-07:00, `refactor(ui): extract agent prompt + queue (#12791)`). Every `path:line` citation below was read against this + exact commit. +- The repo is a monorepo, not the single-package extension repo that older + third-party summaries (DeepWiki-style write-ups, blog posts) describe. + Relevant subtrees: `apps/vscode` (the VS Code extension host), `apps/cli`, + `apps/cline-hub`, `sdk/packages/core` (published as `@cline/core`, the new + session/runtime engine), `sdk/packages/shared` (published as `@cline/shared`, + shared types/paths/db helpers), `sdk/packages/llms` (published as + `@cline/llms`, re-exports message types from `@cline/shared`). +- In-repo official user docs `docs/core-workflows/checkpoints.mdx` (same + commit) -- cited below specifically because it is **contradicted** by the + source it describes. +- GitHub issue [cline/cline#9011](https://github.com/cline/cline/issues/9011) + (opened 2026-02-01, closed 2026-07-03) -- a secondary source, cited only to + corroborate a growth-risk finding that is independently established from + source below. Labeled as secondary throughout. + +A note on method for this dossier specifically: pre-task hypotheses supplied +by the task brief (that `api_conversation_history.json`, `ui_messages.json`, +and `task_metadata.json` are the *current* dual-file format, and that +checkpoints are a "shadow git repository") both turn out to be **stale** +relative to this pinned commit. Cline has, since those third-party summaries +were written, grown a second, parallel persistence generation inside +`sdk/packages/core` that supersedes the classic VS Code extension's on-disk +format for anything created going forward. Both generations are documented +below; the dossier is explicit about which one is live. + +## The storage model + +Two persistence generations coexist on disk at this commit, serving different +purposes: + +**Generation 1 -- the classic per-task flat files.** Written by the pre-SDK +VS Code extension, under the legacy VS Code global-storage root +(`{extensionGlobalStoragePath}/tasks/{taskId}/`, historically; the SDK-era code +also mirrors the same layout under `~/.cline/data/tasks/{taskId}/` -- see +Keying and identity). `apps/vscode/src/core/storage/disk.ts:1-40` defines +`GlobalFileNames`, an object naming the files: `apiConversationHistory: +"api_conversation_history.json"`, `uiMessages: "ui_messages.json"`, +`contextHistory: "context_history.json"`, `taskMetadata: +"task_metadata.json"`, plus `openRouterModels.json`, `openRouterGenerations`, +`mcpSettings`, `clineRules`. Each is a **plain JSON array/object, rewritten in +full on every save** -- there is no append-only log at this generation. For +example `saveTaskMetadata` (`apps/vscode/src/core/storage/disk.ts:238-251`, +approx.) does `fs.writeFile(filePath, JSON.stringify(metadata))` -- a full-file +overwrite, not an append. + +At this pinned commit, **only `task_metadata.json` is still actively written** +by live code (`apps/vscode/src/core/context/context-tracking/ +FileContextTracker.ts`, see Retention below). `api_conversation_history.json` +and `ui_messages.json` are read-only: they are consumed for one-time migration +and cross-version fallback, not written by any code path found in this +checkout (see "Interop" and "Read and resume path" -- `legacy-state-reader.ts` +and `sdk-session-history-loader.ts`). The task brief's premise that these are +today's live dual-write pair does not hold; that was true of an earlier +single-package version of Cline, before the `sdk/packages/core` engine +existed. + +**Generation 2 -- the SDK session store (`@cline/core`).** This is the live +system for anything created today, used by `apps/vscode`, `apps/cli`, and +`apps/cline-hub` alike. A session ("task" in VS Code UI copy) is: + +- A **row** in either a SQLite table (`sessions`, see The store interface) or + a flat JSON index file (`sessions.index.json`) -- whichever backend adapter + is active. This row is the fast-lookup/listing projection. +- A **manifest file**, `{sessionsDir}/{sessionId}/{sessionId}.json`, a single + JSON object matching `SessionManifestSchema` + (`sdk/packages/core/src/session/models/session-manifest.ts:6-28`), containing + denormalized session config/status/metadata (including, per session, an + embedded checkpoint history -- see Rewind/checkpoints). +- A **messages file**, `{sessionsDir}/{sessionId}/{sessionId}.messages.json` + (path computed by `SessionArtifacts.sessionMessagesPath`, + `sdk/packages/core/src/services/session-artifacts.ts:75-80`), a single JSON + object `{version: 1, updated_at, agent, sessionId, taskType?, messages: + StoredMessageWithMetadata[], system_prompt?}` + (`buildMessagesFilePayload`, `sdk/packages/core/src/services/ + session-data.ts:304-327`) -- **the actual model-facing conversation + transcript**. This is the closest thing to a "the durable session" answer: + it is the only artifact that carries the full turn-by-turn content. +- An optional **compaction sidecar**, + `{sessionsDir}/{sessionId}/{sessionId}.compaction.json` + (`SessionArtifacts.sessionCompactionPath`, `session-artifacts.ts:82-87`), + validated by `SessionCompactionStateSchema` + (`sdk/packages/core/src/session/models/session-compaction.ts:25-34`). + +None of these four artifacts is an append-only log. The messages file, the +manifest file, and the compaction sidecar are each **read in full and +rewritten in full** on every persist +(`SessionManifestStore.persistSessionMessages`, +`sdk/packages/core/src/session/stores/session-manifest-store.ts:157-176`; +`writeSessionManifest`, same file, lines 71-78). The SQLite/file index row is +mutated in place with `UPDATE`/`INSERT OR REPLACE` +(`sdk/packages/core/src/session/services/session-service.ts:33-72`, +`sdk/packages/core/src/session/services/file-session-service.ts:114-118`). +This is a **mutable-document model, not an event log**: there is no +sequence-numbered append stream anywhere in this generation either. (This +directly matters for the platform's event-sourced Session Store design -- see +"What this implies".) + +Which is authoritative and which is derived, concretely: + +- **Authoritative**: the messages file (model-facing transcript content), the + manifest file (session config/status/metadata as of last write), and the + SQLite/file-index row (status, lineage, path pointers) are each the sole + source for their own slice of state -- none of the three is rebuildable from + either of the other two if lost, because each holds data the others do not + (e.g., only the row holds `statusLock`/PID; only the manifest holds + `checkpoint.history`; only the messages file holds message content). +- **Derived at read time, never persisted**: the UI-facing `ClineMessage[]` + transcript shown in the VS Code webview. It is computed from the persisted + model-facing messages by `sdkMessagesToClineMessages()` + (`apps/vscode/src/sdk/message-translator.ts:2133-2317`) on every + history/resume load -- see Entry/message structure. This is the answer to + the task brief's "dual representation" question: in the current + architecture there is only **one** persisted transcript type; the second, + UI-shaped type is a pure projection, not a second file. +- **Derived, self-healing, and explicitly documented as a fallback**: when + listing sessions, a directory scan of manifest files + (`listManifestHistoryRows`, `sdk/packages/core/src/runtime/host/ + history.ts:...`, see Listing) backstops the SQLite/file index if the index + under-returns rows. + +Best-fit conceptual model per the RESEARCH_PROMPT taxonomy: **session-as-row +plus session-as-document**. The row (SQLite or JSON index entry) is the +addressable, queryable, lock-carrying record; the manifest and messages files +are full-document sidecars keyed by the same id. There is no +session-as-append-only-log anywhere in this codebase at this commit -- every +persistence layer that could have been an event log (messages file, +manifest, compaction sidecar, index row) is instead read-modify-write-whole. + +## Keying and identity + +- **VS Code's "taskId" IS the SDK's "sessionId"** -- the same string is used as + both identifiers. Confirmed by `createHistoryItemFromSession(sessionId, + ...): HistoryItem { id: sessionId, ... }` + (`apps/vscode/src/sdk/cline-session-factory.ts`, function + `createHistoryItemFromSession`). +- **Root session ids** are minted as `${Date.now()}_${nanoid(5)}` when the + caller does not supply one: + `createRootSessionWithArtifacts()` + (`sdk/packages/core/src/session/services/persistence-service.ts:104-110`): + ```ts + const providedId = input.sessionId.trim(); + const sessionId = + providedId.length > 0 ? providedId : `${Date.now()}_${nanoid(5)}`; + ``` + This scheme is time-prefixed (millisecond epoch), so directory/session names + sort chronologically by string comparison. `sdk/packages/core/src/runtime/ + host/history.ts` corroborates this independently: session listing extracts a + 13+-digit recency token from session ids via a `/\d{13,}/g` regex + (`extractSessionRecencyToken`) purely to sort listings, implying the authors + rely on the epoch-prefix convention rather than trusting directory mtimes. +- **Subagent (child) session ids are deterministic, not random.** + `makeSubSessionId(rootSessionId, agentId)` + (`sdk/packages/core/src/session/models/session-graph.ts:9-17`): + ```ts + export function makeSubSessionId(rootSessionId: string, agentId: string): string { + const root = sanitizeSessionToken(rootSessionId); + const agent = sanitizeSessionToken(agentId); + const joined = `${root}__${agent}`; + return joined.length > 180 ? joined.slice(0, 180) : joined; + } + ``` + `sanitizeSessionToken` (same file, lines 5-7) replaces any character outside + `[a-zA-Z0-9._-]` with `_`. Because the id is a pure function of + `(rootSessionId, agentId)`, re-spawning the same named subagent under the + same root **reuses the same child session id and row** + (`TeamChildSessionManager.upsertSubagentSession`, + `sdk/packages/core/src/session/team/team-child-session-manager.ts:132-160`, + checks `existing = await this.adapter.getSession(sessionId)` before + deciding insert vs. update). Team-task sub-sessions get a related but + non-deterministic id: `makeTeamTaskSubSessionId` appends a `nanoid(6)` + (`session-graph.ts:19-26`): `` `${root}__teamtask__${agent}__${nanoid(6)}` ``. + Both id shapes are parsed back apart by `parseSubSessionId` / + `parseTeamTaskSubSessionId` (`session-graph.ts:28-67`) wherever a + sub-session's artifact directory needs to be resolved back to its root + (`childArtifactFileStem`, `sdk/packages/core/src/services/ + session-artifacts.ts:34-58`). +- **Lineage is relational, not path-nested.** A child session's link to its + parent is a set of plain columns/fields on its own `SessionRow` -- + `parentSessionId`, `parentAgentId`, `agentId`, `conversationId`, + `isSubagent` + (`sdk/packages/core/src/session/models/session-row.ts:6-34`) -- not a + parent-relative directory path. All child sessions for a given generation + still live in a **flat** `{sessionsDir}/{sessionId}/` directory next to + their root (subagent artifact paths are computed relative to the *root's* + directory: `childArtifactFileStem` resolves to `{rootSessionId}` and a + `fileStem`, then `subagentArtifactPaths` joins that file stem inside + `this.sessionArtifactsDir(rootSessionId)`, + `session-artifacts.ts:132-144`) -- so a subagent's messages file physically + lives inside its root session's directory as a sibling file + (`{rootSessionId}/{agentId}.messages.json`), while the subagent's own + manifest/row still exists as an independent session entity addressable by + its own deterministic id. +- **Listing is per-machine/global, not scoped to a project by construction.** + `resolveSessionDataDir()` (`sdk/packages/shared/src/storage/paths.ts`) + resolves to a single flat directory, `~/.cline/data/sessions/`, shared + across all cwds/workspaces; `cwd`/`workspaceRoot` are just columns on each + row (`sdk/packages/core/src/session/models/session-row.ts:18-19`), not part + of the storage path. Any project-scoping in the VS Code UI (e.g., only + showing tasks for the current workspace) is a query-time filter over this + flat row set, not a storage-layout property -- I did not read the specific + VS Code UI filter call site to confirm whether such a filter exists at + this commit; flagged under Open questions. +- **No relocation/rename reconciliation was found.** Because `cwd` is stored + as a plain string column and no other product's session store references + or mirrors that path, a moved-workspace scenario is not treated specially + anywhere in the persistence code read for this dossier (contrast with + other corpus entries, e.g. Grok Build's `RelocationJournal` -- Cline appears + to have no equivalent). +- **Path resolution is explicitly unified** across the classic and SDK code: + both `resolveDataDirFromEnv()` + (`apps/vscode/src/shared/storage/storage-context.ts`, resolves + `CLINE_DATA_DIR` → `${CLINE_DIR}/data` → `~/.cline/data`) and the SDK's + `resolveClineDataDir()` (`sdk/packages/shared/src/storage/paths.ts`) share + the same default and env-var precedence, with an explicit code comment + referencing an internal fixed bug ticket ("ENG-2332") about a prior + divergence between the two resolvers -- i.e., the two persistence + generations deliberately live under the same root directory today. + +## The store interface + +Cline's SDK session layer **does** expose a pluggable, first-class adapter +interface. Reproduced verbatim from +`sdk/packages/core/src/types/session.ts:105-128`: + +```ts +export interface SessionPersistenceAdapter { + ensureSessionsDir(): string; + upsertSession(row: SessionRow): Promise; + getSession(sessionId: string): Promise; + listSessions(options: { + limit: number; + parentSessionId?: string; + status?: string; + }): Promise; + updateSession( + input: PersistedSessionUpdateInput, + ): Promise<{ updated: boolean; statusLock: number }>; + deleteSession(sessionId: string, cascade: boolean): Promise; + enqueueSpawnRequest(input: { + rootSessionId: string; + parentAgentId: string; + task?: string; + systemPrompt?: string; + }): Promise; + claimSpawnRequest( + rootSessionId: string, + parentAgentId: string, + ): Promise; +} +``` + +All eight methods are required (no optional methods on this interface). The +companion input type, also verbatim (`sdk/packages/core/src/ +types/session.ts:89-103`): + +```ts +export interface PersistedSessionUpdateInput { + sessionId: string; + expectedStatusLock?: number; + status?: SessionStatus; + endedAt?: string | null; + exitCode?: number | null; + prompt?: string | null; + metadata?: Record | null; + title?: string | null; + parentSessionId?: string | null; + parentAgentId?: string | null; + agentId?: string | null; + conversationId?: string | null; + setRunning?: boolean; +} +``` + +Two concrete adapters implement this interface at this commit: + +1. `LocalSessionPersistenceAdapter` (SQLite-backed), defined inline in + `sdk/packages/core/src/session/services/session-service.ts:20-261`, wraps a + `SqliteSessionStore` (`sdk/packages/core/src/services/storage/ + sqlite-session-store.ts`) and issues raw SQL (`INSERT OR REPLACE INTO + sessions (...)`, `session-service.ts:35-40`) against a `sessions` table. +2. `FileSessionPersistenceAdapter` (flat-file-backed), defined inline in + `sdk/packages/core/src/session/services/file-session-service.ts:50-268`, + backed by a single JSON index file `sessions.index.json` + (`{version: 1, sessions: Record}`) plus a + `subagent-spawn-queue.json` file for the spawn-request queue methods. + +**Which one is actually used, resolved definitively:** +`createLocalBackend()` (`sdk/packages/core/src/runtime/host/host.ts:64-97`) +tries SQLite first and only falls back to the file adapter if SQLite +initialization throws: + +```ts +function createLocalBackend(options: ClineCoreOptions): SessionBackend { + try { + const store = new SqliteSessionStore(); + store.init(); + return new CoreSessionService(store, { ... }); + } catch (error) { + // Fallback to file-based session service if SQLite is unavailable. + options.telemetry?.capture({ + event: "session_backend_fallback", + properties: { requestedBackend: "sqlite", fallbackBackend: "file" }, + }); + ... + return new FileSessionService(undefined, { ... }); + } +} +``` +(`host.ts:64-97`, comment and telemetry event name as in source). So: SQLite +is the primary backend; the JSON-index adapter is a resilience fallback for +environments where the native SQLite binding cannot load, not a +user-selectable alternative. VS Code specifically forces +`backendMode: "local"` (`apps/vscode/src/sdk/vscode-session-host.ts:126`), +which routes through `createLocalRuntimeHost` → `createLocalBackend` above -- +i.e., VS Code never uses the "hub" or "remote" runtime-host modes that +`createRuntimeHost` (`host.ts:137-249`) also supports for other Cline +surfaces (CLI/hub daemon, enterprise remote). + +The SQLite schema (`sessions` table), reconstructed from the two adapters' +raw SQL (`session-service.ts:35-40`, `sqlite-session-store.ts:86-91`): +`session_id, source, pid, started_at, ended_at, exit_code, status, +status_lock, interactive, provider, model, cwd, workspace_root, team_name, +enable_tools, enable_spawn, enable_teams, parent_session_id, +parent_agent_id, agent_id, conversation_id, is_subagent, prompt, +metadata_json, transcript_path, hook_path, messages_path, updated_at` (28 +columns). The DB file itself: `sessionDbPath()` returns +`join(resolveDbDataDir(), "sessions.db")` +(`sdk/packages/core/src/services/storage/sqlite-session-store.ts:44-46`), +i.e. `~/.cline/data/db/sessions.db` by default. I did not read +`@cline/shared/db`'s `loadSqliteDb`/`ensureSessionSchema` implementation (a +different package than the ones the task pointed at), so the SQLite journal +mode (WAL vs. rollback journal, `busy_timeout`) is not confirmed -- flagged +under Open questions, and directly relevant to whether two concurrent VS +Code windows can safely write to the same DB file. + +`UnifiedSessionPersistenceService` +(`sdk/packages/core/src/session/services/persistence-service.ts:42-618`) is +the class that actually orchestrates an adapter plus a +`SessionManifestStore` plus a `TeamChildSessionManager`; `CoreSessionService` +and `FileSessionService` both extend it, injecting their respective adapter. +This is the effective "session store" callers (`apps/vscode/src/sdk/*`) +interact with; its public surface includes `createRootSessionWithArtifacts`, +`updateSessionStatus`, `updateSession`, `persistSessionMessages`, +`readSessionCompactionState`/`persistSessionCompactionState`, +`listSessions`, `reconcileDeadSessions`, `deleteSession`, and the +`TeamChildSessionManager`-delegated subagent methods +(`upsertSubagentSession`, `applyStatusToRunningChildSessions`, etc.) -- see +Write/append path and Subagents below for their individual contracts. + +## Write and append path (ordering, durability, concurrency, delivery) + +**Commit shape: full rewrite, not append, for message content.** +`SessionManifestStore.persistSessionMessages()` +(`sdk/packages/core/src/session/stores/session-manifest-store.ts:157-176`) +serializes the *entire* messages array on every call: +```ts +const payload = buildMessagesFilePayload({ updatedAt: nowIso(), context, messages, systemPrompt }); +const contents = `${JSON.stringify(payload, null, 2)}\n`; +mkdirSync(dirname(path), { recursive: true }); +writeFileSync(path, contents, "utf8"); +``` +There is no positional/line-append anywhere in this path -- the whole +conversation is re-serialized and rewritten to the same path on each turn. + +**Ordering** is array order within that JSON file, established purely by the +order messages are pushed into the in-memory array before the call; there is +no independent sequence number stamped on each entry by the store itself +(individual `MessageWithMetadata` entries carry an optional `ts` field, but +it is caller-supplied -- see Entry/message structure). + +**Durability/atomicity is inconsistent across the three artifacts written +per session, and this is a genuine finding, not paraphrase:** +- The messages file and the manifest file are written with a **plain + `writeFileSync`**, no temp-file-and-rename, no fsync: + `SessionManifestStore.persistSessionMessages` (as above, + `session-manifest-store.ts:174-175`) and + `SessionManifestStore.writeSessionManifest` + (`session-manifest-store.ts:71-78`, `writeFileSync(manifestPath, + JSON.stringify(...))`). A process kill mid-write can leave either file + truncated/corrupt on a POSIX filesystem that does not guarantee atomic + `write()` for the buffer size involved. +- The compaction sidecar, by contrast, **is** written atomically: + `persistSessionCompactionState` calls `writeFileAtomic` + (`session-manifest-store.ts:243-251`), defined in + `sdk/packages/core/src/session/stores/atomic-file.ts:23-53` -- open a + `{path}.{pid}.{uuid}.tmp` file with the `wx` flag, `writeFile`, `sync()` + (fsync), close, `rename()` to the final path, then a best-effort fsync of + the parent directory (`fsyncBestEffort`, `atomic-file.ts:5-21`). +- The file-index adapter's own bookkeeping (`sessions.index.json`, + `subagent-spawn-queue.json`) also uses a temp+rename pattern: + `atomicWriteJson()` (`file-session-service.ts:44-48`, `writeFileSync` to + `${path}.tmp` then `renameSync`) -- simpler than `atomic-file.ts` (no fsync + call before rename), but still crash-safer than a bare `writeFileSync`. +- So: **the SQLite row (or file index) and the compaction sidecar have some + torn-write protection; the manifest and -- critically -- the messages file, + which is where actual conversation content lives, do not.** + +**Three-way write with no cross-artifact transaction on session creation.** +`createRootSessionWithArtifacts()` +(`sdk/packages/core/src/session/services/persistence-service.ts:104-179`) +performs, in this order: (1) `adapter.upsertSession(...)` (row insert), (2) +`manifestStore.initializeMessagesFile(...)` (plain `writeFileSync` of an +empty messages payload), (3) `manifestStore.writeSessionManifest(...)` +(plain `writeFileSync` of the manifest). No code read in this dossier wraps +these three writes in a transaction, a write-ahead marker, or a rollback +path. If the process is killed between steps 1 and 2, the row exists with a +`messagesPath` pointing at a file that was never created; between 2 and 3, +the messages file exists but the manifest (which `readManifestFile` and +`listManifestHistoryRows` depend on for fallback listing) does not. +Reconciliation of this specific interleaving is not implemented anywhere I +found -- `reconcileDeadRunningSession` (below) only reconciles *status* +(dead PID → `failed`), not missing artifact files. This is left as an Open +question rather than asserted as a bug, since I did not find a reproduction +or issue confirming it happens in practice. + +**Concurrency model: optimistic concurrency control on the row, single +apparent writer per session in normal operation.** `SessionRow.statusLock` +(`sdk/packages/core/src/session/models/session-row.ts:14`) is an integer +version stamp. Both adapters implement compare-and-swap semantics keyed on +it: the SQLite adapter's `updateSession` issues `UPDATE ... WHERE session_id += ? AND status_lock = ?` (`session-service.ts:194-208`), returning +`updated: false` if the row's lock no longer matches; the file adapter does +the equivalent in-memory compare +(`file-session-service.ts:150-155`). Callers retry through +`withOccRetry(load, update, maxRetries)` +(`sdk/packages/core/src/services/session-data.ts:383-409`), with +`OCC_MAX_RETRIES = 4` +(`sdk/packages/core/src/session/services/persistence-service.ts:40`) used by +`updateSessionStatus` (`persistence-service.ts:181-215`) and `updateSession` +(`persistence-service.ts:217-285`, hand-rolled retry loop, same constant). No +evidence of multi-writer coordination beyond this per-row CAS was found; the +messages/manifest files themselves have no locking at all (see above), so +two concurrent writers to the *same* session (e.g. two VS Code windows +somehow sharing one taskId) could race on those files with no +compare-and-swap protection -- this is inferred from the absence of any +lock/version check in `persistSessionMessages`/`writeSessionManifest`, not +from a documented guarantee. + +**Delivery semantics**: best-effort, at-most-once from the store's +perspective -- there is no idempotence key on message-file writes (a +crashed write is simply lost, not retried or deduplicated by id on next +attempt). Row-level writes get "effectively exactly-once under a stable PID" +via the OCC retry loop, since a failed CAS is retried against the freshly +re-read row rather than blindly reapplied. + +## Read and resume path + +**Resume prefers the live/current-generation transcript, then falls back +to legacy files.** `SdkSessionHistoryLoader.loadInitialMessages()` +(`apps/vscode/src/sdk/sdk-session-history-loader.ts`, full file, 46 lines): +tries `sessionHost.readLiveMessages?.(taskId) ?? sessionHost.readMessages(taskId)` +first; only if that returns empty does it fall back to +`getSavedApiConversationHistory(taskId)` (the classic +`api_conversation_history.json` reader from +`apps/vscode/src/core/storage/disk.ts`). This is the concrete evidence that +classic per-task files are a **fallback source for tasks created before the +SDK migration**, not a currently-maintained parallel store. + +**No pagination, no cursor, no bound on transcript size at read time.** +`readPersistedMessagesFile(messagesPath)` +(`sdk/packages/core/src/runtime/host/runtime-host-support.ts:53-75`) reads +the *entire* messages file into memory with a single `readFile` + full +`JSON.parse`: +```ts +const raw = (await readFile(path, "utf8")).trim(); +const parsed = JSON.parse(raw) as unknown; +if (Array.isArray(parsed)) return parsed as LlmsProviders.Message[]; +if (parsed && typeof parsed === "object") { + const messages = (parsed as { messages?: unknown }).messages; + if (Array.isArray(messages)) return messages as LlmsProviders.Message[]; +} +``` +(defensively handles both a bare array and the `{messages: [...]}` envelope +shape, but always whole-file). There is no offset/limit parameter anywhere +in this function or its callers. Combined with the full-file-rewrite write +path above, this means both the read and write cost of a session scale +linearly with total transcript size, with no architectural cap -- directly +relevant to the Retention section below. + +**Resumed sessions reconstruct the UI transcript by deterministic +replay/translation of the persisted model messages, not by reading a second +persisted UI-transcript file.** `sdkMessagesToClineMessages()` +(`apps/vscode/src/sdk/message-translator.ts:2133-2317`) walks the persisted +`SdkMessageWithMetrics[]` array and re-derives a `ClineMessage[]`: text +blocks become `say: "text"`/`say: "user_feedback"` rows, `tool_use`/ +`tool_result` pairs are matched up and re-emitted via +`finalizePersistedToolUse`, and the transcript's **final** turn is +conditionally retagged into a synthetic completion row (`endFinalTurn()`, +lines 2175-2180, 2297-2299) -- gated on `finalTurnCompleted` (the caller- +supplied "did the last run end cleanly" flag, sourced from the session +row's terminal status) specifically so that a cancelled/failed/crashed +final turn is not mis-rendered as a successful completion. The function's +own comments document that this reconstruction is **lossy by design**: +"persisted transcripts carry no per-turn outcome... an earlier turn that +the user cancelled mid-response and then followed up on is indistinguishable +from one that ended cleanly -- retagging it would present an interrupted +response as a deliberate turn end" (comment above `endFinalTurn`, +`message-translator.ts:2167-2174`). Message `ts` values and ids are also +**re-minted** during this replay via a shared `MessageTranslatorState`/ +`MessageIdMinter` rather than preserved verbatim from the original stream +(`state.nextTs()` calls throughout, e.g. lines 2252, 2269, 2308). + +This closes the task brief's central "dual representation" question +directly: the classic architecture (pre-SDK) genuinely wrote two +independent files -- `api_conversation_history.json` (model-facing) and +`ui_messages.json` (display-facing) -- populated by two independent append +calls from the same in-process `Task` object as it streamed (that object no +longer exists in this checkout: `apps/vscode/src/sdk/task-proxy.ts:1-5` +explicitly states its `MessageStateHandler` "Mirrors the classic +`MessageStateHandlerEvents` from `src/core/task/message-state.ts`" -- past +tense, `origin/main` reference, i.e. describing code that has been removed +from this monorepo). In the **current** architecture there is exactly **one** +persisted transcript (`StoredMessageWithMetadata[]` in the messages file); +the UI-shaped `ClineMessage[]` is computed fresh on both the live-streaming +path (`message-translator.ts`'s event-driven functions, e.g. +`agentEventToMessages`) and the resume/history path +(`sdkMessagesToClineMessages`), sharing the same tool-name/shape mapping +logic by design (explicit comment: "Keep this in the live message +translator so history rendering and streaming rendering share the same SDK +tool → Cline UI mapping", `message-translator.ts:2130-2132`). The +model-facing array is therefore authoritative and the UI array is fully +derivable from it -- with the explicitly-documented caveat that the +derivation loses per-turn outcome fidelity for turns other than the final +one. + +**What is materialized eagerly vs. lazily on listing/resume**: session row +lookup is eager (index/SQLite read); the manifest's title field is read +lazily and cheaply (`readSessionManifestTitle`, +`session-manifest-store.ts:95-123`, explicitly reads only the file's +`metadata.title` key off the event loop rather than the full +Zod-validated manifest, "the session-listing hot path needs nothing from +the manifest except the title... skip the full `SessionManifestSchema` +validation" per its own doc comment); full message content, cost/token +aggregates, and provider/model inference are all **lazy**, computed only +when a specific session's history is actually opened +(`hydrateSessionHistory()`, `sdk/packages/core/src/runtime/host/ +history.ts`, infers missing title/provider/model/cost by reading the full +messages file on demand; `inferTitleFromMessages()` truncates to 50 chars). + +## Listing, summaries, and search + +Listing (`UnifiedSessionPersistenceService.listSessions(limit = 200)`, +`persistence-service.ts:510-535`) is a **query against the adapter** +(SQL `SELECT ... ORDER BY started_at DESC LIMIT ?` for SQLite, +`session-service.ts:99-109`, or an in-memory filter+sort+slice over the +whole `sessions.index.json` for the file adapter, +`file-session-service.ts:124-140`), **not** a directory scan, in the normal +path. Two self-healing/fallback behaviors sit around this: + +- Every call to `listSessions()` first runs `reconcileDeadSessions(scanLimit)` + (`persistence-service.ts:513`, `scanLimit = min(limit*5, 2000)`), which + queries all `idle`/`running`/`pending` rows and, for each, checks whether + its owning process is still alive via `process.kill(pid, 0)` + (`isPidAlive`, `persistence-service.ts:424-437`) -- a session whose PID is + dead is transitioned to `status: "failed"` with metadata + `{terminal_marker: "failed_external_process_exit", terminal_marker_at, + terminal_marker_pid, terminal_marker_source: "stale_session_reconciler"}` + (`reconcileDeadRunningSession`, `persistence-service.ts:439-508`), and its + manifest is rewritten to match (`buildManifestFromRow`, + `sdk/packages/core/src/services/session-data.ts:350-381`), and a JSONL + audit line is appended to a hooks log + (`appendStaleSessionHookLog`, `session-manifest-store.ts:258-279`, writes + to `${CLINE_HOOKS_LOG_PATH}` or `{hookLogDir}/hooks.jsonl`). So listing + doubles as the crash-detection mechanism -- there is no separate daemon or + watcher; staleness is discovered lazily, the next time anyone lists. +- A separate, directory-scan-based listing path, + `listManifestHistoryRows(limit)` + (`sdk/packages/core/src/runtime/host/history.ts`), enumerates + `{sessionsDir}/{sessionId}/{sessionId}.json` manifest files directly off + disk, Zod-validates each with `SessionManifestSchema.safeParse()`, and + silently drops any that fail validation. Per-session recency is derived by + regex-extracting a 13+-digit token from the session id + (`extractSessionRecencyToken`, `/\d{13,}/g`), not from file mtimes. This + path exists specifically as a fallback/cross-check for the adapter-backed + listing (`listSessionHistoryFromBackend`, same file, calls + `readPersistedMessagesFile` for read-only history rendering) -- evidence + that the manifest files are treated as a resilient ground truth + independent of whichever row-index backend is active, consistent with the + three artifacts each being independently authoritative for their own + fields (see The storage model). +- `listHostSessionRows()` (`history.ts`) filters subagents out of the + default listing via `isRootSessionRecord()` (`!isSubagent && + !parentSessionId`) -- child sessions do not appear in the top-level task + list by default. +- `shouldProjectLegacyRunningSessionAsIdle()` (`history.ts`) is a read-time + display workaround: a session stuck in `running`+`interactive` state is + projected as `idle` for display purposes without mutating the stored + status -- a targeted patch for some known stuck-state class the comment + does not fully explain; not investigated further here (Open questions). + +No dedicated full-text/vector search subsystem was found in the source read +for this dossier; listing/search over session content, if any exists in the +VS Code webview, would be a client-side filter over the already-loaded +`HistoryItem[]`/`ClineMessage[]` in memory, not a store-side indexed search -- +I did not exhaustively search the webview UI code for this, so treat as an +open question rather than a confirmed absence. + +## Entry/message structure and versioning + +Two distinct message types exist, precisely named, one persisted and one +derived: + +**Model-facing, persisted type**: `MessageWithMetadata`, defined in +`sdk/packages/shared/src/llms/messages.ts:131-156`, re-exported from +`@cline/llms` (`sdk/packages/llms/src/providers/messages.ts:4-17`) and +aliased in the core package as `StoredMessageWithMetadata` +(`sdk/packages/core/src/types/session.ts:79`: `export type +StoredMessageWithMetadata = LlmsProviders.MessageWithMetadata;`). Verbatim: + +```ts +export type MessageRole = "user" | "assistant"; + +export interface Message { + role: MessageRole; + content: string | ContentBlock[]; +} + +export interface MessageWithMetadata extends Message { + id?: string; + agent?: string; + sessionId?: string; + metadata?: Record; + modelInfo?: { id: string; provider: string; family?: string }; + metrics?: { + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + cost?: number; + }; + ts?: number; +} +``` +(`sdk/packages/shared/src/llms/messages.ts:12-156`). `ContentBlock` is a +tagged union of `TextContent`, `FileContent`, `ImageContent`, +`ToolUseContent`, `ToolResultContent`, `ThinkingContent`, +`RedactedThinkingContent` (same file, lines 17-116), each with a `type` +discriminant field and provider-agnostic shape (e.g. `ToolUseContent.id` + +`call_id?` to bridge Cline's internal id with a provider-native call id; +`ThinkingContent.signature`/`details`/`summary` for provider-specific +reasoning replay data). This is the **only** persisted message shape in the +current architecture (see The storage model / Read and resume path); the +messages file is `{version: 1, updated_at, agent, sessionId, taskType?, +messages: StoredMessageWithMetadata[], system_prompt?}` +(`buildMessagesFilePayload`, `sdk/packages/core/src/services/ +session-data.ts:304-327`). Fields are normalized before every persist by +`normalizeStoredMessageModelMetadata()` (`session-data.ts:64-111`), which +migrates a legacy flat `providerId`/`modelId` shape into the nested +`modelInfo` object and mints a stable `id` via `nanoid()` if missing -- +evidence of at least one prior on-disk shape for this same file that the +current code still tolerates on read. + +**Display-facing, derived (not persisted in the current architecture) +type**: `ClineMessage`, defined in +`apps/vscode/src/shared/ExtensionMessage.ts:174-203`. Verbatim: + +```ts +export interface ClineMessage { + ts: number + type: "ask" | "say" + ask?: ClineAsk + say?: ClineSay + text?: string + reasoning?: string + images?: string[] + files?: string[] + partial?: boolean + seq?: number + epoch?: number + commandCompleted?: boolean + lastCheckpointHash?: string + isCheckpointCheckedOut?: boolean + isOperationOutsideWorkspace?: boolean + conversationHistoryIndex?: number + conversationHistoryDeletedRange?: [number, number] + modelInfo?: ClineMessageModelInfo +} +``` +`ClineAsk` and `ClineSay` (`ExtensionMessage.ts:205-262`) are large string +literal unions naming every distinct webview row kind (`"followup"`, +`"tool"`, `"completion_result"`, `"checkpoint_created"`, `"compaction"`, +`"subagent"`, etc.) -- this is fundamentally a **UI event-row** type, not a +conversational-turn type: a single model turn can expand into several +`ClineMessage` rows (one per tool call, one per reasoning block, etc.), and +conversely legacy-classic code persisted this type directly to +`ui_messages.json` (`apps/vscode/src/core/storage/disk.ts` / +`legacy-state-reader.ts`'s `readUiMessages()`, which filters out +`REMOVED_LEGACY_SAY_TYPES = new Set(["error_retry", "api_req_retried"])` on +read -- a small, explicit backward-incompatible-value migration). In the +current architecture `ClineMessage[]` is never written to disk on its own; +it is produced fresh by `message-translator.ts`'s live event-to-message +functions while a session is streaming, and by +`sdkMessagesToClineMessages()` (above) when a session's history is +opened/resumed. `seq`/`epoch` fields on `ClineMessage` are explicitly +documented (inline comments, `ExtensionMessage.ts:184-195`) as freshness/ +identity fencing for the webview's convergent-replica message merge, "not +optional for classic/legacy" wording present, i.e. purely a runtime-replica +concern, unrelated to persistence. + +**Versioning**: the manifest schema carries an explicit `version: +z.literal(1)` field (`SessionManifestSchema`, +`sdk/packages/core/src/session/models/session-manifest.ts:6-28`); the +messages-file payload and the compaction sidecar likewise both stamp +`version: 1` (`buildMessagesFilePayload`, `session-data.ts:309`; +`SessionCompactionStateSchema`, `sdk/packages/core/src/session/models/ +session-compaction.ts:25-34`). All three are Zod schemas parsed on read +(`SessionManifestSchema.parse`/`.safeParse`, +`SessionCompactionStateSchema.parse`), so malformed or wrong-version files +fail closed (caught and treated as absent, not repaired). No `version: 2` +(or higher) branch, and no migration function keyed off this field, was +found anywhere in the checkout -- i.e. the format has not yet needed to +evolve since being introduced, or a future migration path simply does not +exist yet. Separately, `apps/vscode/src/core/storage/ +state-migrations.ts:65-67` contains a function stub, +`migrateTaskHistoryToFile`, whose entire body is `// TODO migrate to sdk +location` -- i.e., a planned migration from the classic +`state/taskHistory.json` format into the new SDK session store is +acknowledged in a TODO but **not implemented** at this pinned commit. The +rest of `state-migrations.ts` (lines 1-63+) handles unrelated VS Code +workspace-state → global-state key migrations, not session-format +migrations, and was confirmed not to be the "entry/message format +versioning" mechanism the task brief's named anchor suggested it might be. + +## Compaction and history management + +A session's compaction state is a **separate sidecar artifact**, not an +in-place rewrite of the messages file. `SessionCompactionStateSchema` +(`sdk/packages/core/src/session/models/session-compaction.ts:25-34`): +```ts +export const SessionCompactionStateSchema = z.object({ + version: z.literal(1), + updated_at: z.string().datetime(), + conversation_id: z.string().min(1).optional(), + source_message_count: z.number().int().nonnegative(), + source_prefix_hash: z.string().min(1).optional(), + source_last_message_key: z.string().min(1).optional(), + messages: z.array(MessageWithMetadataSchema), + system_prompt: z.string().optional(), +}); +``` +`persistSessionCompactionState`/`readSessionCompactionState` +(`UnifiedSessionPersistenceService`, `persistence-service.ts:326-341`, +delegating to `SessionManifestStore`, `session-manifest-store.ts:220-256`) +read/write this file at a path resolved from the manifest's +`compaction_path` field, falling back to the artifact-computed default path +(`resolveCompactionPath`, `session-manifest-store.ts:195-201`). The full +underlying messages file (`{sessionId}.messages.json`) is left untouched by +compaction; the compaction sidecar instead carries its own `messages` array +(the model-visible, shrunk view) plus a `source_prefix_hash`/ +`source_last_message_key` used to detect whether the durable transcript has +since diverged from what was compacted (`sourcePrefixHash`/ +`messageBoundaryKey`, `session-compaction.ts:86-113`, hashing role + +content + agent/session/metadata/modelInfo/metrics but explicitly +**excluding** `id`/`ts` from the hash -- the code comment explains this was +deliberate: hashing transport-identity fields "made projection fail for +semantically identical prefixes, so persistence was silently rejected every +turn", `session-compaction.ts:79-85` -- a concrete documented bug-and-fix in +the compaction consistency check itself). + +This means: durable record (full messages file) persists in full; the +model-visible view shrinks via a parallel, independently-versioned sidecar. +Compaction is therefore an **upstream/session-runtime concern that leaves an +artifact in the store**, not a store-internal operation on the messages +file itself -- the store's role is limited to holding the sidecar and +exposing read/write/delete for it (`deleteSessionCompactionState`, +`persistence-service.ts:339-341`, `rm(path, {force: true})`). + +I did not trace the runtime code that actually *decides when* to compact +(token-threshold triggers, manual `condense_task`/`summarize_task` UI +actions referenced by `ClineAsk`/`ClineSay` values like `"condense"`, +`"summarize_task"` in `ExtensionMessage.ts:220-221`) or how a resume +reconciles a compaction sidecar whose `source_prefix_hash` no longer matches +the current messages file -- flagged under Open questions. + +## Rewind, checkpoints, and fork + +**The official user-facing docs describe an architecture that does not +match the source at this commit.** `docs/core-workflows/checkpoints.mdx` +(same pinned commit) states under "How It Works": "Cline maintains a shadow +Git repository separate from your project's actual Git history. After each +tool use (file edits, commands, etc.), Cline commits the current state of +your files to this shadow repo." No `CheckpointTracker`/shadow-repository +class, no secondary `.git` directory creation, and no code implementing a +parallel repository was found anywhere in this checkout. This is a +significant, well-evidenced doc-vs-code discrepancy, not a matter of +interpretation -- the actual mechanism, detailed below, stores checkpoints +as **refs inside the user's own repository**. + +**Actual mechanism**: `sdk/packages/core/src/hooks/checkpoint-hooks.ts` +(full file, 477 lines) and `sdk/packages/core/src/session/ +checkpoint-restore.ts` (full file, 414 lines). Key facts: + +- A checkpoint is a private git ref, `refs/cline/checkpoints/{sessionId}/ + {runCount}`, created directly inside the user's working repository -- not a + separate repo, not a separate `.git` directory. +- The commit a checkpoint ref points at is produced via `git stash create` + (captures tracked-file changes without touching the working tree or + index), run through raw `execFile("git", [...])` calls -- **not** through + the `simple-git` npm dependency that is listed in the package's + dependencies but not used for this. +- Untracked files are folded in via a synthesized third parent commit: + `createUntrackedParentCommit()` builds a commit object from a temporary + index built with a scratch `GIT_INDEX_FILE` environment variable, so the + final checkpoint commit can represent "tracked changes + untracked files" + as a single addressable point even though `git stash create` alone only + covers tracked state. +- `CHECKPOINT_STASH_MESSAGE_PREFIX = "cline checkpoint session="` is stamped + into the stash commit message, used later to identify/verify checkpoint + commits defensively (`resolveCheckpointKind()`, + `checkpoint-restore.ts`, detects checkpoint "kind" -- `"stash"` vs. + `"commit"` -- for pre-`kind`-field checkpoints via parent count + this + message-prefix marker, i.e. a backward-compatibility shim for checkpoints + created before the `kind` field existed). +- Checkpoints are **keyed by `runCount`**, an integer "user turn" counter + that (per code comments) survives context compaction, via + `getUserRunSpan` -- i.e., the checkpoint boundary concept is turn-based, not + message-index-based, so it stays meaningful even after the messages array + is compacted. +- Checkpoint creation is **gated**: hooks only fire on root (non-subagent) + sessions, and only on the first iteration of a run + (`createCheckpointHooks`, returns `beforeRun`/`beforeModel` `AgentHooks` + callbacks with these gates inline) -- subagent/child sessions do not get + their own checkpoint timeline. +- **Checkpoint history is stored inside the session's manifest metadata, not + as separate files.** `CheckpointEntry {ref, createdAt, runCount, kind?: + "stash"|"commit"}` and `CheckpointMetadata {latest, history}` are read/ + written via `readSessionMetadata`/`writeSessionMetadata` callbacks that + operate on the session's `metadata` JSON blob + (`checkpoint-hooks.ts`). This is independently corroborated by the type + definitions in `sdk/packages/core/src/types/sessions.ts:33-44`: + ```ts + checkpoint?: { + latest?: { ref?: string; createdAt?: number; runCount?: number }; + history?: Array<{ ref?: string; createdAt?: number; runCount?: number }>; + }; + ``` + as a field of `SessionHistoryMetadata`. There is no `checkpoints/` + directory of files anywhere in this codebase -- the task brief's + hypothesis of a per-session checkpoints directory does not hold. +- **Restore** (`checkpoint-restore.ts`): `beginWorktreeRestoreTransaction(cwd)` + first takes a safety-net stash + private ref + (`refs/cline/restore-transactions/{transactionId}`) before any destructive + operation. `applyCheckpointToWorktree(cwd, checkpoint)` then does `git + reset --hard` to the checkpoint commit, conditionally `git clean -fd`, and + for stash-kind checkpoints a `git stash apply` to layer back the + originally-stashed working-tree state. `findCheckpointForRun()`/ + `trimMessagesToCheckpoint()`/`trimMessagesBeforeUserRun()` locate the + checkpoint for a given run and trim the in-memory/persisted message array + back to that point -- but explicitly **throw** if the target run has been + folded into a compacted summary (i.e., you cannot restore to a point + inside a compacted region; the compaction sidecar's boundary becomes a + hard floor for how far back a checkpoint restore can reach). +- The VS Code gRPC handler for checkpoint restore delegates entirely to the + SDK: `apps/vscode/src/core/controller/checkpoints/checkpointRestore.ts` + (24 lines total) forwards straight to `controller.restoreCheckpoint` + with **no legacy fallback branch** -- further confirming the old + shadow-git `CheckpointTracker` class is gone from the live code path, not + merely superseded-but-still-present. +- Cost model (as documented in code, not benchmarked by me): each checkpoint + is one `git stash create` + up to one synthetic commit-tree operation + against the user's real repository object database -- i.e., cost scales + with the size of the working-tree diff since the last checkpoint (git's + own object-store deduplication applies, since these are ordinary git + objects), not with total conversation length. The official docs' own + caveat -- "For very large repositories, checkpoints may use significant + storage and slow down Cline as it commits file snapshots after each tool + use" (`checkpoints.mdx`) -- is consistent with this being real git object + writes against the real repo, even though the "shadow repository" + framing around it is not accurate. + +**Fork**: no distinct "fork a session" operation was found. Restoring to a +checkpoint mutates the working tree and trims the message array of the +*same* session in place; it does not create a new session id or a +copy-with-lineage. I did not find evidence of session branching/forking as +a first-class store operation anywhere in this codebase. + +## Subagents and nested sessions + +Subagents (and team-task sub-runs) are **first-class sibling sessions**, +linked to their parent by relational fields, not nested inside the parent's +storage: + +- `SessionRow` carries `parentSessionId?`, `parentAgentId?`, `agentId?`, + `conversationId?`, `isSubagent: boolean` + (`sdk/packages/core/src/session/models/session-row.ts:24-28`) directly on + every row, root or child alike. +- A subagent session's id is deterministic -- + `makeSubSessionId(rootSessionId, agentId)` (see Keying and identity) -- + so re-invoking the same named agent under the same root updates the + existing child row rather than creating a new one + (`TeamChildSessionManager.upsertSubagentSession`, + `sdk/packages/core/src/session/team/team-child-session-manager.ts:132-160`, + explicit `existing = await this.adapter.getSession(sessionId)` check + before deciding insert vs. reuse). +- The child **inherits** (copies, at creation time -- not a live reference) + several fields from the parent row: `provider`, `model`, `cwd`, + `workspaceRoot`, `teamName`, `enableTools`, `enableSpawn`, `enableTeams` + (`buildSubsessionRow`, `team-child-session-manager.ts:71-113`). It gets + its **own** `messagesPath` -- a separate, isolated transcript file -- so + subagent conversation content is not commingled with the root's messages + file, even though the file itself lives as a sibling inside the root's + session directory (`{rootSessionId}/{agentId}.messages.json`, per + `subagentArtifactPaths`, `session-artifacts.ts:132-144`). +- Spawn requests are queued and claimed through the same + `SessionPersistenceAdapter` interface (`enqueueSpawnRequest`/ + `claimSpawnRequest`), backed by a `subagent_spawn_queue` SQL table + (`session-service.ts:223-260`) or the `subagent-spawn-queue.json` file + (`file-session-service.ts:34-38, 231-267`) -- an at-least-once producer/ + consumer queue with a `consumed_at` marker for idempotent claiming, scoped + per `(rootSessionId, parentAgentId)`. +- Status propagation cascades downward while running: on a status change to + a terminal state (e.g. `"cancelled"`), the parent's + `updateSessionStatus` explicitly propagates to children: + `await this.teamChildren.applyStatusToRunningChildSessions(sessionId, + "cancelled")` (`persistence-service.ts:205-211`). +- **On parent delete, children are explicitly cascade-deleted, not + orphaned.** `UnifiedSessionPersistenceService.deleteSession()` + (`persistence-service.ts:557-609`): for a non-subagent (root) session, it + queries all rows with `parentSessionId === id` (limit 2000), deletes the + parent row, deletes all matching child rows via + `adapter.deleteSession(id, true)` (cascade flag), and then -- for every + child -- deletes its checkpoint refs (`deleteCheckpointRefs(child.cwd, + child.sessionId)`), its messages file, its compaction state, and its + manifest file, finally removing the now-empty artifact directory + (`removeSessionDirIfEmpty`). This is an explicit, code-level guarantee + that Cline does not orphan subagent sessions on parent delete -- a direct + contrast with at least one other product in this corpus (Grok Build, + which the accepted dossier documents as orphaning children on parent + delete). +- **The cascade is one level deep, and only from a root.** The child query + sits inside `if (!row.isSubagent)` (`persistence-service.ts:566`), so + deleting a session that is *itself* a subagent never looks for its own + children, and the children it does find are deleted directly rather than + recursed into. Cline is safe from orphaning today only because the + parent-child graph is in practice one level deep; the guarantee is a + property of how deep the graph happens to get, not of the delete + algorithm. Read together with the absent depth cap noted below, this is a + latent orphan path rather than a present bug, and it is the sharpest + available evidence that a one-level cascade written against an assumed-flat + graph is not the same thing as cascade semantics. +- Nesting depth: no explicit maximum-depth guard was found in the + persistence layer itself (the task brief mentioned a `xai-grok-tools`-style + "subagent depth cap" existing in another product; I did not find an + equivalent constant/check in Cline's session-service/team code, though a + cap could plausibly live in tool-definition/agent-loop code not read for + this dossier -- flagged under Open questions rather than asserted absent). +- Team-task sub-sessions (as opposed to plain subagents) get a distinct, + non-deterministic id shape (`makeTeamTaskSubSessionId`, trailing + `nanoid(6)`) and are tagged `taskType: "team"` in their messages-file + envelope (`resolveMessagesFileContext`, + `sdk/packages/core/src/services/session-data.ts:279-302`, distinguishes + `agent: "lead" | "subagent" | "teammate"`) -- teammates are a third + category alongside root/subagent, gated separately in the message-file + context resolver. + +## Retention, deletion, and multi-host + +**No growth bound was found for the messages file, and this is the +direct, source-confirmed mechanism behind the task brief's ">10MB / freezes +the extension" concern.** Two independent pieces of evidence: + +1. In the still-active legacy write path, `FileContextTracker` + (`apps/vscode/src/core/context/context-tracking/ + FileContextTracker.ts`, full file, 280 lines) appends a new entry to + `metadata.files_in_context` on *every* file-read/edit/mention event + during a task (`addFileToFileContextTracker()`), with **no cap, trim, or + eviction of old entries**, then calls `saveTaskMetadata(taskId, + metadata)` -- a full JSON re-serialize-and-rewrite of the entire + `task_metadata.json` file + (`apps/vscode/src/core/storage/disk.ts`) -- on every single such event. + This is unbounded growth by construction: the file's size is + monotonically non-decreasing across a task's lifetime, and every event + pays the cost of rewriting the whole (growing) file. +2. In the current SDK messages file, both the write path + (`persistSessionMessages`, full-file `JSON.stringify`+`writeFileSync` on + every turn) and the read path (`readPersistedMessagesFile`, full-file + `readFile`+`JSON.parse`) scale linearly with total transcript size, with + no pagination, truncation, or size-based warning found anywhere in the + store layer (see Write/append path and Read/resume path above). + +**Corroborating secondary evidence (GitHub issue, not source -- cited only +to confirm this is user-visible, per the explicit task requirement):** +[cline/cline#9011](https://github.com/cline/cline/issues/9011), "When tasks +grow beyond ~5-10MB (measured by `api_conversation_history.json` + +`ui_messages.json`), clicking on them in RustRover/VS Code can cause the IDE +to become unresponsive or freeze indefinitely" (filed against Cline +v3.52.0, JetBrains plugin; opened 2026-02-01, closed 2026-07-03). The +reporter's own root-cause analysis (task size 10.5MB / 2016 UI messages / +590 API messages) matches the unbounded-full-file-JSON-parse mechanism +found in source above, though the specific files the reporter names +(`api_conversation_history.json`, `ui_messages.json`) are the **classic** +per-task files, not the SDK-era messages file -- i.e., this issue documents +the legacy-generation growth problem, and I have only source-level +(not issue-level) confirmation that the same unbounded-full-file pattern +also exists in the current SDK generation's messages file. A bot comment on +the issue additionally raises a **separate** contributing factor not +independently verified by me from source: a default 4 MiB gRPC message-size +limit in the extension's own webview/host IPC layer +(`src/standalone/protobus-service.ts` per the comment -- a path I did not +read, since it lives outside the areas the task pointed me at) -- +mentioned here for completeness but not verified, and explicitly a +secondary-source claim. + +**Retention/TTL/scheduled cleanup**: no time-based or size-based retention +policy (auto-archival, TTL, scheduled pruning) was found anywhere in the +persistence code read for this dossier. Deletion is entirely manual/ +explicit, driven by `deleteSession()` (see Subagents, above, for its +cascade behavior) -- I found no cron-like or startup-triggered pruning job; +the only "automatic" lifecycle transition found is the crash-detection +reconciler (`reconcileDeadSessions`, marks stale `running` sessions +`failed` -- a status change, not a deletion). + +**Delete cascade** (already detailed under Subagents): deleting a root +session deletes its row, its children's rows, and every artifact file +(messages, compaction state, manifest, checkpoint refs) for both the parent +and its children. There is no "no-op for append-only backends" case here +since nothing in this store is append-only. + +**Multi-host / multi-process behavior**: +- VS Code explicitly forces `backendMode: "local"` + (`apps/vscode/src/sdk/vscode-session-host.ts:126`) -- it never uses the + SDK's "hub" (`HubRuntimeHost`) or "remote" (`RemoteRuntimeHost`) runtime + modes that exist in `sdk/packages/core/src/runtime/host/host.ts:137-249` + for other Cline surfaces. Those modes exist in the codebase (and `auto` + mode will opportunistically discover/connect to a local hub daemon for + other consumers, `host.ts:195-246`) but are not reachable from the VS Code + extension at this commit. +- Crash detection is PID-liveness-based (`process.kill(pid, 0)`, + `persistence-service.ts:424-437`), which only detects the crash of the + process whose PID is recorded on the row -- it does not detect, e.g., a + second process on a different machine sharing a network filesystem. +- Separately, the **classic** in-memory `StateManager` singleton + (`apps/vscode/src/core/storage/StateManager.ts`, full file, 789 lines) + documents its own, unrelated multi-instance caveat: it is an + in-memory-cache-first store with a 500ms debounced disk-persistence + timer (`PERSISTENCE_DELAY_MS = 500`), and its own comments explicitly + state that other VS Code windows only observe another window's changes + after a restart -- this concerns global extension settings/state (API + keys, feature toggles), not session/task content, but is a genuine, + source-documented multi-window staleness gap adjacent to the session + store proper. +- The SQLite adapter's actual concurrency safety under concurrent writers + (WAL mode? `busy_timeout`?) was **not confirmed** -- `loadSqliteDb`/ + `ensureSessionSchema` live in `@cline/shared/db`, a module I did not read + in full for this dossier. Flagged under Open questions. + +## Interop with foreign session stores + +No evidence was found of Cline reading another *product's* native session +store (e.g., Claude Code, Codex, Aider). The only "foreign format" reading +found is **Cline reading its own prior generation's format**: +`apps/vscode/src/sdk/legacy-state-reader.ts` (full file, 309 lines) is +explicitly framed as replacing "classic `src/core/storage/disk.ts` reads +(see `origin/main`)... so the SDK adapter can surface tasks and settings +created before the SDK migration. All reads are non-throwing" -- i.e., this +is Cline importing/resuming its own earlier self, not a different agent +product. `readAllLegacyState()` (same file) is described in its own comment +as "the primary entry point for bootstrapping the SDK adapter from existing +on-disk data" -- a one-time/ongoing-fallback bootstrap, not a general +foreign-store import feature. This section is otherwise not applicable at +this commit as far as this research could establish. + +## What this implies for our Session Store (our inference) + +Cline's current architecture (the SDK generation) is best described as +**session-as-row plus session-as-document**, not an append-only event log: +every persisted artifact -- the SQLite/file-index row, the manifest, the +messages file, the compaction sidecar -- is a mutable document that gets +read in full and rewritten in full on each update, with no positional +append, no sequence-numbered event stream, and (for the two artifacts that +matter most, the messages file and the manifest) no atomic-write protection +at all. The one place Cline does something structurally closer to our +platform's event-sourced design is the row-level optimistic-concurrency +check (`statusLock` + `expectedStatusLock`, retried via `withOccRetry`) -- +that pattern (an expected-version precondition on update, bounded retries) +is directly reusable vocabulary for a Session Store `append`/`update` +contract, even though Cline applies it to a whole-row replace rather than +an appended event. + +The clearest structural lesson for us is the **dual-representation +resolution**: Cline used to persist two independently-authored transcripts +(model-facing, display-facing) and kept them in sync only because a single +long-lived in-process object streamed writes to both. That coupling was +fragile enough that the rewrite eliminated it entirely -- the current design +persists exactly one durable transcript (model-facing) and computes the +display transcript fresh, every time, from that one source, explicitly +documented as lossy for anything except the final turn. This is a strong +argument, independent of Cline's specific bugs, for our Session Store to +treat "the UI/display view" as a pure, versioned projection function over +the canonical event/message log rather than as a second thing that must be +kept consistent by discipline. + +The clearest structural warning for us is the retention story: an +unbounded, full-file-rewrite-per-turn transcript format is a proven, +user-visible failure mode (issue #9011) even in a mature, widely-used +product -- reinforcing that our Session Store's append/read paths must be +genuinely incremental (bounded per-operation cost independent of total +session size), not merely "JSON, versioned, and hope it stays small." + +## Open questions + +- Does the SQLite backend (`@cline/shared/db`'s `loadSqliteDb`/ + `ensureSessionSchema`, not read for this dossier) enable WAL mode or set a + busy timeout, and is concurrent access from two VS Code windows against + the same `sessions.db` actually safe? The SQLite adapter's row-level CAS + (`statusLock`) implies awareness of concurrent writers, but I found no + direct evidence of the underlying SQLite connection's concurrency + configuration. +- What happens, concretely, if a process is killed between the three writes + in `createRootSessionWithArtifacts()` (row insert → messages file → + manifest file), or mid-write to the messages/manifest file themselves + (both plain `writeFileSync`, no atomic rename)? I found no reconciliation + code for a missing/truncated messages or manifest file specifically (only + for a dead-PID *status*), but also found no reproduction/issue confirming + this occurs in practice -- left open rather than asserted as broken. + Answering this would require reading `readManifestFile`'s and any related + file's actual catch-and-recover behavior more exhaustively than time + allowed here, plus exercising the crash scenario directly. +- What actually triggers compaction (token thresholds? explicit user action + via the `"condense"`/`"summarize_task"` `ClineAsk` values?), and what + happens on resume when a compaction sidecar's `source_prefix_hash` no + longer matches the current messages file? `session-versioning-service.ts` + (named in earlier planning as a file to read) was not reached in this + research pass. +- Is there an explicit maximum subagent/team nesting depth enforced + anywhere (tool-definition layer, agent-loop layer)? None was found in the + session-persistence code specifically. +- Does `shouldProjectLegacyRunningSessionAsIdle()` + (`sdk/packages/core/src/runtime/host/history.ts`) correspond to a known, + named bug class, and is it still needed at this commit, or is it legacy + defensive code for a since-fixed issue? +- Is there any project/workspace-scoped filtering of the session list in + the VS Code UI, given that the underlying store (`~/.cline/data/ + sessions/`) is a single global flat namespace with `cwd` as just a row + column? Not confirmed either way from the persistence-layer code read. +- Does Cline have any file-content-level deduplication or diff-based + storage for the messages file (as opposed to full-content JSON), given + how large tool-result blobs (file reads, command output) could + contribute disproportionately to the ">10MB" growth pattern? Not + addressed by any code read for this dossier -- `ContentBlock`'s + `ToolResultContent.content` is stored as plain string/array content with + no reference/hash-based sharing observed. +- Full verbatim confirmation of PR #11480 ("feat(sdk): cap tool output + ingestion for bash and file reads", found via the issue-tracker search + above) as a fix for the growth problem was not performed -- I confirmed + only its title and that issue #9011 references related work via a linked + Linear ticket (CLINE-1255); I did not read the PR's diff to confirm it + actually caps messages-file growth versus something narrower (e.g., a + single tool call's output size before it ever reaches the transcript). diff --git a/docs/research/session-store/products/cline/vs-session-events.md b/docs/research/session-store/products/cline/vs-session-events.md new file mode 100644 index 000000000..a67f50258 --- /dev/null +++ b/docs/research/session-store/products/cline/vs-session-events.md @@ -0,0 +1,506 @@ +# Cline compared to our session event catalog + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT_COMPARISON](../../RESEARCH_PROMPT_COMPARISON.md). +Stage-one dossier: [Cline](./index.md). +Compared against `proto/trogonai/session/sessions/v1alpha1/` and [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) on 2026-08-04. + +**Store maturity: 10/12** -- evolution scars 2/3 (a real generational cut-over, +classic per-task flat files to the SDK's row-plus-document store, with +back-compat read paths still live -- `legacy-state-reader.ts`, +`normalizeStoredMessageModelMetadata` migrating flat `providerId`/`modelId` +onto nested `modelInfo` -- but the current generation's own schema has never +needed a version bump past `version: 1`, and `migrateTaskHistoryToFile` in +`state-migrations.ts:65-67` is an unimplemented stub, so only one full-cut +migration exists, not iterative in-place evolution), operational age 2/3 +(`cline/cline#9011`, opened 2026-02-01 and closed 2026-07-03, is a real, +source-corroborated growth failure -- a 10.5MB/2016-message task freezing the +JetBrains IDE -- but the dossier is explicit that issue-level confirmation +exists only for the legacy generation's named files +(`api_conversation_history.json`, `ui_messages.json`), not for the current SDK +generation, which is confirmed only at the source level), exposure 3/3 +(vendor-shipped across VS Code, CLI, and a hub surface, pluggable +`SessionPersistenceAdapter` with a SQLite-backed primary and a JSON-index +fallback), design independence 3/3 (no evidence in the dossier that the SDK +session store was forked from another product's persistence code; it reads +only its own prior generation). + +## The one structural difference everything else follows from + +Cline's SDK-generation store has no append operation anywhere. Every durable +artifact -- the session row, the manifest file +(`{sessionsDir}/{sessionId}/{sessionId}.json`, +`sdk/packages/core/src/session/models/session-manifest.ts:6-28`), the +messages file (`{sessionId}.messages.json`, +`sdk/packages/core/src/services/session-artifacts.ts:75-80`), and the +compaction sidecar (`{sessionId}.compaction.json`, +`sdk/packages/core/src/session/models/session-compaction.ts:25-34`) -- is a mutable +whole document, read in full and rewritten in full on every persist. +`persistSessionMessages()` re-serializes the entire `messages: []` array with +`JSON.stringify(payload, null, 2)` and a plain `writeFileSync` on every turn +(`sdk/packages/core/src/session/stores/session-manifest-store.ts:157-176`); there is +no positional append, no line-oriented log, no cursor. This is not a +difference of commit granularity the way fx's one-event-per-turn model is +(fx still appends, just coarsely); Cline's store has no granularity concept +at all, because "persist" always means "replace the whole document." + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) makes append-only mutation the only primitive session state ever +undergoes (decision 2: "rewind, revert, compaction, and hide are all new +appended events, never edits or deletes to old ones"), and ties every write to +a server-enforced `WRITE_PRECONDITION` (`NoStream` / `At(current_position)` / +`Any`) rather than a client-issued compare-and-swap. Cline's closest analogue +-- `statusLock`/`expectedStatusLock`, a version-stamped integer column CAS'd +via `UPDATE ... WHERE session_id = ? AND status_lock = ?` +(`sdk/packages/core/src/session/services/session-service.ts:194-208`), retried up to +`OCC_MAX_RETRIES = 4` (`persistence-service.ts:40`) -- only protects the row. +The messages file and the manifest, which carry the actual conversation +content, have no locking or versioning at all. + +Everything else in this comparison is downstream of that one fact: the +durability asymmetry (below), the unbounded growth confirmed by issue #9011 +(see the industry-gaps section), and the one-level-deep cascade (see the industry-gaps section) are +all consequences of a store built around "rewrite the document," not +"append the fact." + +## Mapping + +| Cline | Ours | Verdict | +| --- | --- | --- | +| `SessionRow.session_id` (SQLite `sessions` table / `sessions.index.json`; `taskId == sessionId`) | Opaque `SessionId`; one logical stream per session on subject `session.sessions.events.` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 1) | Equivalent identity concept | +| `SessionRow.status` + `status_lock` (28-column row, client-issued CAS, `session-service.ts:194-208`) | Lifecycle folded from `SessionStarted`/`SessionClosed`/`SessionCancelled`/`SessionFailed`/`SessionHidden`, guarded by JetStream `At(current_position)` (`Nats-Expected-Last-Subject-Sequence`, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2) | Ours, decisively -- no denormalized status-plus-version column that can drift from the log; the guard is enforced by the broker, not a client-issued `UPDATE ... WHERE` | +| `SessionRow.parentSessionId` / `parentAgentId` / `agentId` / `isSubagent` (`sdk/packages/core/src/session/models/session-row.ts:6-34`) | `DelegationDispatched{child_session_id, operation_id}` (`proto/trogonai/session/sessions/v1alpha1/delegation_dispatched.proto`) on the parent's stream, `ParentLinked{parent_session_id, operation_id, parent_dispatched_at}` (`parent_linked.proto`) on the child's | Ours -- one fact recorded once on each side ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6) vs. four plain columns on a mutable row that can be edited independently of any event | +| `SessionRow.cwd` / `workspace_root` | `SessionStarted.workspace`, a required `WorkspaceRef{workspace_id, uri, revision}` (`session_started.proto`, `workspace.proto`) | Ours, decisively -- see below | +| Deterministic subagent id `makeSubSessionId(rootSessionId, agentId)`; re-spawning a named subagent reuses its row (`session-graph.ts:9-17`, `TeamChildSessionManager.upsertSubagentSession`, `team-child-session-manager.ts:132-160`) | No deterministic child-id scheme in the catalog; `DispatchDelegation` always mints a fresh `child_session_id` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6) | Deliberate divergence -- see recommendation 2 | +| `SessionCompactionStateSchema` sidecar: `{source_message_count, source_prefix_hash, source_last_message_key, messages[]}` (`session-compaction.ts:25-34`) | `Compacted{covers_from, covers_through, summary_content, tokens_before, tokens_after}`, a single in-stream marker (`compacted.proto`) | Ours, decisively -- no second file whose hash can silently drift from the transcript it summarizes; see below | +| `CheckpointEntry{ref, createdAt, runCount, kind}` / `CheckpointMetadata{latest, history}` stored in manifest metadata; `ref` is a private git ref `refs/cline/checkpoints/{sessionId}/{runCount}` (`sdk/packages/core/src/types/sessions.ts:33-44`) | `Checkpoint{reference, checkpoint_type, digest, checkpoint_id, producing_execution_attempt_id, covers_through, session_execution_plan_digest}` inside `CheckpointProduced` / `ExecutionAttemptStarted.restored_checkpoint` (`checkpoint.proto`, `checkpoint_produced.proto`) | Semantic mismatch, not a plain equivalence -- see below | +| `runCount`, an integer keying checkpoint refs, surviving compaction (`getUserRunSpan`) | `turn_id`, stamped (not inferred) on `UserMessageRecorded`, all three `AssistantMessage*` events, and `ToolCallRequested/Started/Completed/Failed` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 3) | Ours -- same underlying concept (a turn survives compaction and re-identifies related facts), generalized past checkpoint-keying to every conversational and tool event | +| `MessageWithMetadata{role, content, modelInfo{id,provider,family?}, metrics{inputTokens,outputTokens,cacheReadTokens,cacheWriteTokens,cost?}, ts}` (`sdk/packages/shared/src/llms/messages.ts:131-156`) | `CanonicalMessage{message_id, role, content, model, usage, created_at}` (`message.proto`) | Equivalent | +| `ContentBlock` union: `TextContent, FileContent, ImageContent, ToolUseContent, ToolResultContent, ThinkingContent, RedactedThinkingContent` | `ContentBlock` oneof: `text, artifact_ref, ThinkingBlock, ToolUseBlock, ToolResultBlock, bytes redacted_thinking, ProviderBlock` (`message.proto`) | Equivalent; ours additionally keeps an unmodelled-provider-block escape hatch (`ProviderBlock`, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 3) Cline has no analogue for | +| `metrics{inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, cost}` with no finality marker | `TokenUsage{input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cost, completeness}` where `completeness` is `UsageCompleteness{FINAL, PARTIAL}` (`token_usage.proto`) | Ours, decisively -- Cline has no field distinguishing a mid-stream token reading from a final one; this is the same gap fx's stage-two comparison flagged (its item 6) and it now recurs independently in a second, unrelated product, which is corroborating evidence the distinction is worth having | +| Legacy `FileContextTracker.addFileToFileContextTracker()` appends to `metadata.files_in_context` on every file-read/edit/mention with no cap, rewriting all of `task_metadata.json` each time (`FileContextTracker.ts`) | `ResourceObservation` on `ToolCallCompleted.observed` (`repeated ResourceObservation`, `tool_call_completed.proto`, `resource_observation.proto`) | Ours, decisively -- the same information (what did this call put in context) is attached to the completing call itself rather than accreted forever in a separately-rewritten unbounded array; see the retention gap below for the one place this pattern can still bite us | +| `SessionRow.transcript_path` / `messages_path` / `hook_path` | No equivalent -- storage location is an implementation detail of a store adapter, not a domain fact | Ours, by design (nothing to record) | +| Spawn queue: `enqueueSpawnRequest`/`claimSpawnRequest` against `subagent_spawn_queue` (SQL) or `subagent-spawn-queue.json`, at-least-once, `consumed_at` marks completion | `OperationReserved{operation_id, request_digest, operation_kind}` / `OperationOutcomeRecorded{oneof succeeded, failed, cancelled, unknown}` (`operation_reserved.proto`, `operation_outcome_recorded.proto`), `OPERATION_KIND_CHILD_SESSION_DELEGATION` | Ours -- a typed outcome oneof including a non-terminal `unknown` state, vs. Cline's binary consumed/unconsumed marker with no modelled failure outcome | +| `reconcileDeadSessions()` / `isPidAlive()`, run as a side effect of `listSessions()`, transitions dead-PID rows to `status: "failed"` with `metadata{terminal_marker, terminal_marker_source: "stale_session_reconciler"}` (`persistence-service.ts:424-508`) | `SessionFailed{reason, detail}` (`session_failed.proto`), triggered per [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6 by "a liveness watchdog that concludes no further attempt will run" | Ours, mostly -- same concept (dead-process detection becomes a terminal fact), but Cline's version is opportunistic (runs only when something happens to list sessions); see recommendation 3 | +| `applyStatusToRunningChildSessions(sessionId, "cancelled")`: a direct, synchronous status push into the children's rows on parent terminal transition (`persistence-service.ts:205-211`) | A reconciler process manager reacting to `session.sessions.events.>`, appending an atomic `[ParentTerminated, SessionCancelled]` batch per child ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6) | Trade-off, not a plain win -- see the subagent cascade gap below | +| Official docs describe checkpoints as a "shadow Git repository" (`docs/core-workflows/checkpoints.mdx`); the source implements private refs inside the user's own repo instead | [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 3 explicitly keeps "harness recovery checkpoint," "aggregate snapshot," and "read-side checkpoint" as four records with separate authority precisely to avoid this kind of concept collapse | Not a store feature to compare -- a methodology point: our naming discipline exists to prevent exactly the doc/code drift Cline shipped | +| No redaction or byte-erasure concept found anywhere in the dossier | `RedactionApplied{redacted_event_ids, reason}` (`redaction_applied.proto`), `ArtifactErased{artifact_id, reason}` (`artifact_erased.proto`) | Ours, decisively | +| No retention/TTL policy found; deletion is manual only (`deleteSession()`) | `SessionHidden{reason}` (`session_hidden.proto`), a visibility tombstone, plus deferred crypto-shredding ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7) | Ours, partially -- neither side deletes bytes, but we at least have a typed tombstone and a masking story; genuine erasure is an open, named gap on both sides | + +## What we should consider changing + +### 1. Bound fanout in the parent-to-children lineage projection the reconciler dispatches against + +**The change.** [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6 has the terminal-cascade reconciler +discover children through "a parent-to-children lineage projection folded +from `DelegationDispatched`," with no stated bound on how many children that +projection returns for one parent, or on what happens once dispatch to all of +them is in flight. + +**Evidence anchor.** Cline, store maturity 10/12: +`deleteSession()` queries rows with `parentSessionId === id` capped at +`limit: 2000` before cascading the delete +(`sdk/packages/core/src/session/services/persistence-service.ts:557-609`). The dossier +treats this as a latent orphan path rather than a present bug precisely +because nothing bounds fanout today; a 2001st child is silently excluded from +the cascade. + +**Blast radius.** Additive if implemented as a paginated/cursor-following read +on the lineage projection; Breaking, cheap if implemented as a hard fanout +cap enforced in `decide` at `DispatchDelegation` time (new validation, +nothing persisted changes shape). + +**Why.** Decision 6's cascade is transitive by construction and costs +D sequential reconciler round-trips for a chain of *depth* D -- the ADR's own +consequence list names that cost explicitly. It says nothing about *width*: +a single parent with an unbounded number of live children is a different +risk axis, and an unbounded or mis-paginated lineage projection can silently +drop children from a wide cascade the exact way Cline's `limit: 2000` would +silently exclude the 2001st row from a delete. Given the reconciler is +already event-driven and idempotent, closing this is cheap insurance; leaving +it open makes decision 6's "transitive by construction" claim conditional on +an unstated width assumption, the same way Cline's one-level cascade turned +out to be conditional on an unstated depth assumption. + +**Cost.** A paginated lineage read (if the projection is a KV/document store, +this is a cursor, not a schema change) or an enforced cap on live children at +dispatch time, which is a real product decision (what should the cap be, and +what should happen when it's hit) that the ADR does not currently need to +make. + +### 2. Do not adopt deterministic, re-usable child session ids + +**The change under consideration, and why to reject it.** Cline's +`makeSubSessionId(rootSessionId, agentId)` is a pure function of parent and +agent name, so re-invoking a named subagent updates the same row instead of +minting a new one (`session-graph.ts:9-17`, +`TeamChildSessionManager.upsertSubagentSession`, +`team-child-session-manager.ts:132-160`). + +**Evidence anchor.** Cline, store maturity 10/12, same citations above. + +**Blast radius.** Breaking the decision -- [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6: "Acyclicity is +enforced by construction... `DispatchDelegation` always mints a fresh +`child_session_id`... A cycle would require an edge into a pre-existing +session, which this makes impossible." Making a child id a deterministic +function of `(parent, agent_name)` reintroduces exactly the case decision 6 +was written to rule out: dispatch into a stream that may already exist. + +**Why not to do this.** Cline's convenience buys a single durable "slot" per +named subagent, so a UI can show "this agent's latest run" without a query. +That convenience is real, but it is a *read-model* concern, not an identity +scheme: the same effect is available as a projection keyed by +`(parent_session_id, agent_name)` that resolves to the most recently +dispatched child, without touching how child ids are minted. Recording this +here is meant to stop it from being re-proposed on the grounds that Cline +does it and it seems ergonomic -- the ergonomics are real, the identity +mechanism that buys them is not compatible with decision 6's acyclicity +argument. + +**Cost of the alternative.** A new projection (`get_latest_child_for_agent` +or similar), not a schema or identity change. + +### 3. Make the liveness watchdog an explicit standing process, not a side effect of a read path + +**The change.** [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6 says "a liveness watchdog that concludes +no further attempt will run records a Session-level `SessionFailed`," but +does not say when or how often that watchdog runs. + +**Evidence anchor.** Cline, store maturity 10/12: `reconcileDeadSessions()` +and its `isPidAlive()` check run only as a side effect of `listSessions()` +being called (`persistence-service.ts:424-508, 510-535`) -- there is no +independent daemon; a crashed session with no active reader can sit in +`running` status indefinitely. + +**Blast radius.** Additive -- a clarifying note in [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) (Consequences +already names "new standing services" including reconciler/watchdog +processes; this makes explicit that the watchdog must be one of them, not an +incidental side effect of a query). + +**Why.** Cline's own dossier shows the failure mode directly: staleness is +discovered lazily, "the next time anyone lists," which means a crashed +session with no active reader can block whatever single-active-attempt +invariant depends on its terminal state for an unbounded time. Our design +already leans toward a standing watchdog (the ADR uses the present participle +"watchdog," implying an ongoing process), but nothing currently rules out an +implementation that regresses to Cline's pattern for expedience. + +**Cost.** A real standing service to build and operate (already acknowledged +in the ADR's Consequences); this recommendation is only about making explicit +that it must not be query-triggered, which costs nothing to write down. + +### 4. State whether the aggregate snapshot's own write needs an atomicity guarantee, and why or why not + +**The change.** [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8 / facet 3 describes the aggregate +snapshot as "an advisory cached fold of that log. Corruption or +incompatibility falls back to earlier replay" but does not state whether the +snapshot's own write path needs any atomicity contract (temp-write-then- +rename, fsync, or none of the above). + +**Evidence anchor.** Cline, store maturity 10/12: the compaction sidecar +gets a proper atomic write -- temp file with a `wx` flag, `writeFile`, `sync()` +fsync, close, `rename()`, best-effort parent-directory fsync +(`sdk/packages/core/src/session/stores/atomic-file.ts:23-53`) -- while the two +artifacts that actually hold conversation content, the messages file and the +manifest, use bare `writeFileSync` with no temp file and no fsync +(`session-manifest-store.ts:71-78, 157-176, 174-175`). Cline gave its +*least* load-bearing artifact the *most* durability. + +**Blast radius.** Additive -- this is a documentation/Non-Goals clarification, +not a schema change. + +**Why.** Cline's inconsistency is a real problem for Cline specifically +because its manifest and messages file have no independent source of truth +to fall back to if a torn write corrupts them; the compaction sidecar, which +got the careful atomic-write treatment, is the *recomputable* one. Our +situation inverts this: the event log is authoritative and the snapshot is +explicitly disposable on corruption, so a torn snapshot write is a +non-issue by the ADR's own design, provided the fallback path (replay from +the log) is actually exercised on read, not just described in prose. The +useful lesson from Cline is not "match their atomicity," it's "say +out loud, for each stored artifact, whether its atomicity matters, and why" -- +Cline never did that audit and got the priority backwards as a result. + +**Cost.** None beyond writing the sentence; it becomes real cost only if the +audit turns up a spot where the snapshot write path is assumed durable by an +implementer who didn't read this far into the ADR. + +### 5. Consider a cap or claim-check threshold on `ToolCallCompleted.observed` + +**The change.** `ToolCallCompleted.observed` is `repeated ResourceObservation` +(`tool_call_completed.proto`) with no stated bound on how many observations +one call can carry. + +**Evidence anchor.** Cline, store maturity 10/12 (this recommendation reuses +the growth evidence from the industry-gaps section below: `files_in_context` +unbounded growth and `cline/cline#9011`, both confirming that "many small +observations, never bounded" is a real failure mode in this problem space, +even though Cline's specific mechanism differs -- it accretes across +*many separate* full-document rewrites, where ours would accrete *within one +event*). + +**Blast radius.** Additive if implemented as a soft warning/metric; Breaking, +cheap if a hard cap forces large observation sets through `ArtifactRef` +instead of being inlined (the message already has that escape hatch -- +`ResourceObservation`'s digest-based outcome doesn't inline content, only a +`ByteRange` and a digest, so the risk is observation *count*, not observation +*size*, for a tool that touches very many resources in one call, e.g. a +repo-wide search). + +**Why.** This is the one place the append-only design does not automatically +avoid Cline's growth pattern: an append-only log still allows a single event +to grow unbounded if a repeated field inside it has no cap. It is worth +naming even though -- see the retention gap below -- nothing in the dossier +demonstrates this has actually happened to us or to Cline; it's inference +from the field's shape, not a confirmed failure. + +**Cost.** A cap requires deciding what "too many observations for one call" +means product-side; a soft warning costs only a metric. + +## What our design already does better + +- **Server-enforced OCC vs. client-issued CAS.** JetStream's + `At(current_position)` guard (`Nats-Expected-Last-Subject-Sequence`) rejects + a stale writer at the broker; Cline's `statusLock` CAS is a client-issued + `UPDATE ... WHERE status_lock = ?` retried up to four times + (`session-service.ts:194-208`, `persistence-service.ts:40`) that only + covers the row -- the messages file and manifest that hold the actual + content have no equivalent protection at all. +- **Content is claim-checked, not inlined.** `ArtifactRef{artifact_id, digest, + size_bytes, ...}` (`artifact.proto`) and `ResourceObservation`'s + digest-based outcome (`resource_observation.proto`) keep large content out + of the event log by reference; Cline's `MessageWithMetadata.content` and + `files_in_context` both inline full content into documents that get + rewritten whole on every touch. +- **Typed, per-entity terminal-outcome resolution.** `ToolCallCompleted` vs. + `ToolCallFailed`, and `AssistantMessageCompleted` vs. + `AssistantMessageFailed`, compete under an explicit first-terminal-outcome- + wins fold rule keyed by `tool_execution_id`/`message_id` + (`tool_call_completed.proto`, `tool_call_failed.proto`, + `assistant_message_completed.proto`, `assistant_message_failed.proto`; ADR + decision 4). Cline's dual-transcript resolution (below) shows what happens + without this: a non-final turn's cancel-vs-clean-end outcome is simply lost. +- **Redaction and erasure are named, typed events, not absent concepts.** + `RedactionApplied` and `ArtifactErased` have no analogue anywhere in the + Cline dossier. +- **Workspace binding is a required, recorded fact, not an inferred column.** + `SessionStarted.workspace` is a required `WorkspaceRef`; Cline's `cwd` is a + plain SQL column with no relocation/rename reconciliation found in the + dossier -- a session whose working directory moved on disk is simply not + handled. +- **Typed process-termination facts, kept separate from what the model saw.** + `CommandTermination{exit_code | signal}` belongs to `ToolCallCompleted`, + deliberately not to `ToolCallResult`, "so an exit status the model never + saw must not enter the replay shape" (`command_termination.proto`). Cline + has no equivalent typed separation between execution outcome and + provider-visible result. +- **Compaction is one self-sufficient in-stream marker.** `Compacted{ + covers_from, covers_through, summary_content}` (`compacted.proto`) needs no + second file. Cline's sidecar (`SessionCompactionStateSchema`) is a second + artifact whose `source_prefix_hash` had to be redefined mid-flight to + exclude `id`/`ts` after the team discovered hashing transport-identity + fields "made projection fail for semantically identical prefixes... so + persistence was silently rejected every turn" + (`session-compaction.ts:79-85`) -- a class of bug that cannot occur if there + is no second hash to keep in sync with the thing it summarizes. + +## Trade-offs, not gaps + +- **Synchronous same-transaction status push vs. eventually-consistent + reconciler cascade.** Cline's `applyStatusToRunningChildSessions` pushes a + parent's terminal status into every currently-running child row directly, + in the same code path, no round trip. Ours is deliberately eventually + consistent ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6, Consequences: "a deep collaboration chain + cascades in O(depth) reconciler round-trips; callers expecting synchronous + cascade are surprised"). Cline's approach is simpler and faster for a flat, + shallow graph; ours is correct at unbounded depth and survives a crash + mid-cascade, which Cline's direct push does not need to survive because it + assumes the graph stays flat. Neither is free: Cline pays with the orphan + risk in the industry-gaps section below; we pay with cascade latency. +- **Opportunistic crash detection vs. a dedicated watchdog.** Cline detects + a dead process only when someone lists sessions; a standing watchdog (ours) + costs an always-on process but detects failure without depending on read + traffic. Recommendation 3 above already surfaces this as worth making + explicit rather than leaving implicit. +- **`runCount` as a bare integer vs. `turn_id` as a stamped identifier.** + Both survive compaction and let a system re-identify facts belonging to the + same user-driven turn. Cline's is narrower (only checkpoints key off it); + ours is broader (stamped on every conversational and tool event) at the + cost of being one more field every producer must set correctly. + +## What not to copy + +- **Bare `writeFileSync` for the artifacts that matter most.** The messages + file and manifest -- the two documents that actually hold conversation + content -- get no temp-file-then-rename, no fsync + (`session-manifest-store.ts:71-78, 157-176`), while the compaction sidecar, + which is fully recomputable, gets the careful atomic write + (`atomic-file.ts:23-53`). Durability effort should track what is + irreplaceable if lost, not what happened to be built most recently. +- **Whole-document rewrite as the only write primitive.** Rewriting an + entire messages array on every turn is the direct cause of the growth + failure documented in `cline/cline#9011`. Our append-only log with + bounded-cost tail replay exists specifically so that a session's *history* + never has to be re-read and re-written in full to record one more fact. +- **Unbounded accretion into a single mutable field.** The legacy + `files_in_context` array (`FileContextTracker.ts`) grows forever with no + cap, no eviction, and a full-file rewrite on every addition. Recommendation + 5 above exists precisely because our own `ToolCallCompleted.observed` is a + `repeated` field that could, in principle, grow the same way inside a + single event if nothing bounds it. +- **Documenting a mechanism that isn't the one shipped.** Cline's own docs + describe checkpoints as a "shadow Git repository"; the source implements + private refs in the user's real repo instead. This is a process lesson, not + a store lesson: keep the ADR's terminology (harness recovery checkpoint, + aggregate snapshot, read-side checkpoint) as precise as it currently is, + specifically so no future doc describes one as if it were another. + +## The two gaps the industry has not closed + +### Subagent cascade + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6 already takes a position here: a child session is its +own logical stream, linked by facts on each side +(`DelegationDispatched`/`ParentLinked`); terminal cascade is driven by a +reconciler reacting to terminal markers on `session.sessions.events.>`, and +"cascade is transitive, because `SessionCancelled` is itself a terminal +marker the same reconciler reacts to; a chain of depth D still takes D +sequential reconciler round-trips." The question for this section is whether +Cline's evidence validates, challenges, or refines that position -- not +whether we still need one. + +**What Cline does.** `deleteSession()` cascades exactly one level: for a +non-subagent session it queries rows with `parentSessionId === id` (capped at +`limit: 2000`), deletes the parent row, deletes the matched child rows +directly, and for each deletes its checkpoint refs, messages file, +compaction state, and manifest file +(`sdk/packages/core/src/session/services/persistence-service.ts:557-609`). Critically, +that child query sits inside `if (!row.isSubagent)` +(`persistence-service.ts:566`): deleting a session that is *itself* a +subagent never looks for its own children. The children found by the query +are deleted directly, not recursed into. The dossier's own conclusion: +"Cline is safe from orphaning today only because the parent-child graph is in +practice one level deep; the guarantee is a property of how deep the graph +happens to get, not of the delete algorithm." No maximum-nesting-depth guard +was found anywhere in the persistence layer (flagged as an open question in +the dossier, since one could plausibly live in tool-definition or agent-loop +code not read for the dossier). Rewind has no separate cascade concept at +all: `findCheckpointForRun()`/`trimMessagesToCheckpoint()` simply throw if the +target run has been folded into a compacted summary, and status propagation +to *running* children on parent terminal transition is a direct, +synchronous row push (`applyStatusToRunningChildSessions`, +`persistence-service.ts:205-211`), not a reconciled, replayable cascade. + +**Does this validate, challenge, or refine decision 6?** It validates the +core design choice and sharpens one risk decision 6 does not yet name. +Cline is the clearest evidence in the corpus that "cascade that looks +complete" and "cascade that is actually transitive" are different claims: a +one-level cascade is invisible as a limitation for as long as the graph +happens to stay flat, and becomes an orphan path the moment it doesn't. +Decision 6's transitive-by-construction design (cascade *is* the reconciler +reacting to its own emitted terminal markers, recursively, rather than a +one-shot query for direct children) is exactly the structural fix for the +failure mode Cline's own dossier calls out in itself. Cline does not +challenge decision 6's shape; it does surface two things decision 6's text +does not yet cover, both of which are already written up as recommendation 1 +above: an explicit fanout bound on the lineage-discovery projection (Cline's +analogue is the silent `limit: 2000`, which excludes rather than fails loud), +and, separately, whether an explicit maximum nesting *depth* should be +enforced given that a deep chain now costs D sequential reconciler round +trips per decision 6's own Consequences -- Cline shows no position on depth +limits either (only that a depth cap "could plausibly live in tool-definition +or agent-loop code not read for this dossier"), so this is not evidence for +adding one, only evidence that the industry (this product included) has not +converged on an answer and it remains an explicit choice for the ADR owner, +not a borrowed norm. + +### Retention on an unbounded log + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7 already takes a position here: keep-forever, with +`SessionHidden` as a visibility tombstone (no bytes deleted), `RedactionApplied` +for read-time masking, `ArtifactErased` for out-of-band artifact-byte +destruction, and "aggregate snapshots bound replay, not storage" so that +resume cost is O(tail after the newest snapshot) "even as the log grows +forever." The question here is whether Cline's confirmed growth failure +validates that design or exposes a cost the ADR does not bound. + +**What Cline does.** Nothing bounds the size of a session's durable record. +Two independent mechanisms confirm this: the legacy +`FileContextTracker.addFileToFileContextTracker()` appends to +`metadata.files_in_context` on every file-read/edit/mention event with no +cap, trim, or eviction, rewriting all of `task_metadata.json` each time +(`FileContextTracker.ts`); and the current SDK-generation messages file is +both written (`persistSessionMessages()`, +`session-manifest-store.ts:157-176`) and read +(`readPersistedMessagesFile()`, `runtime-host-support.ts:53-75`) as a single +whole-file JSON blob with no offset, limit, or pagination anywhere. The +corroborating field evidence is `cline/cline#9011` (opened 2026-02-01, closed +2026-07-03): a task reaching roughly 10.5MB and 2016 UI messages / 590 API +messages caused the JetBrains IDE to become unresponsive or freeze +indefinitely when the task was opened. The dossier is careful that this +issue names the *legacy* files specifically, so it is source-confirmed for +the SDK generation and only source-confirmed, not issue-confirmed, that the +SDK generation's own full-file-rewrite pattern would fail the same way at +comparable size -- but the write and read paths scale identically (linear in +total session size, no bound), so the mechanism is the same even where the +field report is not. + +**Does this validate, challenge, or refine decision 7?** It validates the +structural fix and sharpens where the ADR's claim needs to be read narrowly. +Cline's failure mode is specifically the cost of a full linear read-and-parse +plus a full linear rewrite on every turn -- exactly what decision 7's +snapshot-bounded replay is designed to avoid, since our runtime "resumes from +the newest snapshot and replays only the tail," never the whole log, and +never rewrites the log to make room for new facts. On its own terms the +retention design does not reproduce Cline's failure mode: an append is O(1) +in total session size regardless of how large the log has grown, which is +the one property Cline's design lacks entirely. + +That said, two costs the ADR does not explicitly bound are worth naming +rather than assuming away, because they are the parts of our read path that +still scale with something: + +- **Model-visible context compilation.** [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8 says the + model-visible context is "compiled deterministically from the event log, + bounded by the latest `Compacted` marker." That bound is on how far *back* + the compilation reads, not on how much content sits between the last + compaction and the current turn -- a very long uncompacted run (compaction + never triggers, or is deferred) has no stated bound on this cost. This is + an agent-loop/compaction-policy concern under decision 4, not a store + defect, but the ADR does not currently say who is responsible for + guaranteeing compaction actually happens before this cost grows large. +- **`ToolCallCompleted.observed`, per recommendation 5.** A single event + can still grow unbounded if nothing caps how many `ResourceObservation` + entries one call accumulates. This is the one place a purely append-only + design does not automatically inherit Cline's protection-by-boundedness, + because the accretion happens *inside* one fact rather than *across* many + rewritten documents. + +Neither of these is confirmed as an actual failure anywhere in the corpus -- +they are inferences from the shape of the design, flagged per the "mark +inference as inference" rule, not evidence-backed gaps the way Cline's own +growth failure is. They are the honest edges of "does snapshot-bounded replay +avoid Cline's failure mode," not a claim that decision 7 is wrong. + +## Open questions for the ADR + +1. Should the parent-to-children lineage projection the terminal-cascade + reconciler reads from (decision 6) have an explicit fanout bound, and if + so, what should happen to a request that would exceed it -- reject the + dispatch, or degrade to a paginated/best-effort cascade? +2. Should there be an explicit maximum subagent nesting depth, given that + decision 6's cascade cost is O(depth) reconciler round-trips per event, and + neither Cline nor (per the dossier) any product it was checked against + enforces one? +3. Who is responsible for guaranteeing that `Compacted` markers are emitted + often enough that model-visible-context compilation (decision 8) never has + to walk an unboundedly long uncompacted tail -- the agent loop's + compaction-trigger policy, or a store-side backstop? +4. Should `ToolCallCompleted.observed` carry an explicit cap, or a documented + expectation that a tool touching very many resources reports a summary + `ArtifactRef` instead of one `ResourceObservation` per resource? +5. Does the aggregate snapshot's write path (decision 8) need any atomicity + guarantee of its own, or is "corruption falls back to replay" sufficient + justification to leave it unspecified -- and if the latter, should that + reasoning be stated in the ADR so a future implementer does not add + unneeded ceremony (or, conversely, skip needed ceremony assuming it's + already covered)? diff --git a/docs/research/session-store/products/codex-cli.md b/docs/research/session-store/products/codex-cli/index.md similarity index 90% rename from docs/research/session-store/products/codex-cli.md rename to docs/research/session-store/products/codex-cli/index.md index ea29cfffe..9e9ac532f 100644 --- a/docs/research/session-store/products/codex-cli.md +++ b/docs/research/session-store/products/codex-cli/index.md @@ -1,7 +1,7 @@ # Codex CLI: how session transcripts are stored and resumed Part of Session Store Research. -Produced by running [RESEARCH_PROMPT](../RESEARCH_PROMPT.md). +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 @@ -20,11 +20,11 @@ Authoritative anchors: > 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 +> 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 +> 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 @@ -48,7 +48,7 @@ own doc comment states it plainly (`codex-rs/rollout/src/recorder.rs:75-82`): /// $ jq -C . ~/.codex/sessions/rollout-2025-05-07T17-24-21-...-....jsonl ``` -Each line is a `RolloutLine` — a timestamp, an optional monotonic `ordinal`, and +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`): @@ -146,9 +146,9 @@ 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`:** +**Rollout log (authoritative), `RolloutRecorder` -- `codex-rs/rollout/src/recorder.rs`:** -- `RolloutRecorder::new(params)` — open a recorder. `params` is either +- `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 }` @@ -156,37 +156,37 @@ Reconstructed from the source, the effective contract is: 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 +- `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 +- `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 +- `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 +- `shutdown() -> io::Result<()>` -- drain then stop the writer task (`recorder.rs:1050-1072`). -- `get_rollout_history(path) -> InitialHistory` — full ordered read of a file +- `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 + -> 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`:** +**SQLite state runtime (derived), `StateDbHandle = Arc` -- `codex-rs/rollout/src/state_db.rs`:** -- `init(config) -> Option` / `try_init(config)` — open the +- `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 +- `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, +- `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 +- `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 +- `ctx.upsert_thread(&ThreadMetadata)` -- write one listing row (`state_db.rs:607`). The reader/lineage side is the `codex-thread-store` crate @@ -242,14 +242,14 @@ from either tier with the log as the tiebreaker. 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 + 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 + 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 @@ -292,16 +292,16 @@ from either tier with the log as the tiebreaker. `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 + `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. + 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 + 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`, @@ -318,7 +318,7 @@ from either tier with the log as the tiebreaker. `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 + metadata columns), not a separate FTS/vector engine -- no `CREATE VIRTUAL TABLE ... fts5` exists in the state migrations. ## Entry/message structure and versioning @@ -332,12 +332,12 @@ from either tier with the log as the tiebreaker. 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, + (`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 — + 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`), + 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`). @@ -347,11 +347,11 @@ from either tier with the log as the tiebreaker. 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 + 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 + `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 @@ -373,7 +373,7 @@ from either tier with the log as the tiebreaker. `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 + 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. @@ -391,13 +391,13 @@ from either tier with the log as the tiebreaker. 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 + 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 + `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 @@ -406,8 +406,8 @@ from either tier with the log as the tiebreaker. 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) + 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`). @@ -425,7 +425,7 @@ from either tier with the log as the tiebreaker. `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 + 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 @@ -484,8 +484,8 @@ optional per-turn/per-item read model with an explicit 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 +- **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 @@ -502,20 +502,20 @@ implemented on plain files rather than a database log. Salient lessons: 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 — + 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 +- **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 + 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** — + 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. diff --git a/docs/research/session-store/products/continue/index.md b/docs/research/session-store/products/continue/index.md new file mode 100644 index 000000000..cf7f6615a --- /dev/null +++ b/docs/research/session-store/products/continue/index.md @@ -0,0 +1,804 @@ +# Continue: how session transcripts are stored and resumed + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT](../../RESEARCH_PROMPT.md). +Evidence snapshot retrieved 2026-08-04. Version-sensitive claims were checked +against a local clone of +[continuedev/continue](https://github.com/continuedev/continue) pinned at +commit `5522c6f44ca0ac3528b37244818fbfa39b5af470` (committed 2026-07-20; +message "docs: remove Sign in link (login flow retired)"). Continue is +Apache-2.0. All `path:line` citations below are repo-root-relative paths at +that exact commit. The session subsystem spans: + +- `core/util/history.ts` -- the `HistoryManager` singleton: the only code that + reads or writes the on-disk session store. +- `core/util/paths.ts` -- path resolution (`getSessionFilePath`, + `getSessionsFolderPath`, `getSessionsListPath`) and the global `~/.continue` + directory layout. +- `core/index.d.ts` -- the `Session`, `BaseSessionMetadata`, `ChatHistoryItem`, + and `ChatMessage` types. +- `core/util/conversationCompaction.ts` -- the VS Code/JetBrains ("core") path + compaction implementation. +- `core/protocol/core.ts` -- the `ListHistoryOptions` type and the + `history/*` IPC message surface. +- `core/core.ts:304-332` -- the IPC handlers that are the sole callers of + `HistoryManager`'s public methods. +- `gui/src/redux/thunks/session.ts`, `gui/src/redux/slices/sessionSlice.ts` -- + the VS Code/JetBrains webview's session lifecycle (write triggers, + in-memory truncation/rewind). +- `extensions/cli/src/session.ts`, `extensions/cli/src/services/ + ChatHistoryService.ts`, `extensions/cli/src/compaction.ts`, + `extensions/cli/src/subagent/` -- the `cn` CLI, which reuses `HistoryManager` + for its file format but layers a second, independent write/resume/compaction + path on top of it. + +Continue ships three clients (a VS Code/JetBrains extension pair sharing one +webview "gui", and a standalone terminal CLI called `cn`) that all read and +write the same `~/.continue/sessions/` directory through the same +`HistoryManager`, but the CLI and the GUI diverge sharply in write cadence, +resume mechanics, and compaction semantics on top of that shared format. Both +divergences are load-bearing findings below. + +## The storage model + +A session is one JSON file plus one entry in a separate JSON list file, both +under a single global (not per-project) directory: + +```text +~/.continue/sessions/{sessionId}.json # the full transcript, one per session +~/.continue/sessions/sessions.json # a flat array of metadata for every session +``` + +(`~/.continue` is `CONTINUE_GLOBAL_DIR` if set, +`core/util/paths.ts:27-36`; the sessions folder is +`getSessionsFolderPath()`, `core/util/paths.ts:78-84`.) + +There is no append-only log anywhere in this store. Both files are mutable +documents that get fully parsed, modified in memory, and fully rewritten on +every write: + +- The per-session file (`{sessionId}.json`) holds the entire ordered + `ChatHistoryItem[]` transcript for that session as a single JSON document. + `HistoryManager.save()` writes it with a plain `fs.writeFileSync`, no + temp-file-and-rename, no lock (`core/util/history.ts:131-134`). +- `sessions.json` is a flat JSON array of `BaseSessionMetadata` -- a + denormalized summary of every session that exists -- read in full, + mutated in memory, and rewritten in full on every save or delete + (`core/util/history.ts:136-191`, `:69-84`). + +**Neither file is authoritative over the other in the way an index over a log +would be.** The per-session file is the only place the actual conversation +content lives; `sessions.json` is a hand-maintained, independently-written +cache of a few fields (title, workspace, message count) whose only purpose is +to avoid parsing every session file to render a picker. Losing a session file +loses the conversation. Losing `sessions.json` loses only the ability to +enumerate sessions cheaply -- the CLI's own resume path (below) proves that +sessions remain independently resolvable by directory listing even without +it. But nothing in the codebase treats `sessions.json` as rebuildable: there +is no scan-and-rebuild function anywhere in the repository (confirmed by +grepping every reference to `sessions.json`, `getSessionsListPath`, and +`getSessionsFolderPath` outside test files -- the only matches are +`core/util/history.ts`, `core/util/paths.ts`, and +`extensions/cli/src/session.ts`). The two files are written by +non-transactional, non-atomic, independent `fs` calls in sequence, and +whichever one a given code path forgets to update first is the one that goes +stale. That divergence-with-no-repair-path is this product's central +storage-model fact, detailed under "The index-drift failure mode" below. + +Conceptually this is **session-as-document, twice over**: one mutable +document per session, and one mutable document (`sessions.json`) that +denormalizes metadata from all of them. There is no event log, no positional +sequence number, and (confirmed by grepping the whole session-relevant +surface for `lock`, `atomicWrite`, `writeFileAtomic`, and `proper-lockfile`) +no locking or atomic-rename discipline of any kind protecting either file. + +## Keying and identity + +- A session's only identity is its `sessionId: string` + (`core/index.d.ts:279-290`), minted as a random `uuidv4()` -- not UUIDv7, + not time-ordered, not server-assigned. The GUI mints it in the `newSession` + reducer (`gui/src/redux/slices/sessionSlice.ts:709`, + `state.id = uuidv4()`); the CLI mints it in + `SessionManager.getCurrentSession()` and `createSession()` + (`extensions/cli/src/session.ts:96-98`, `:342`). Both are pure client-side + IDs; the store never assigns or renumbers them. +- The session file's on-disk name is derived directly from the id: + `{sessionsFolder}/{sessionId}.json` (`core/util/paths.ts:102-104`). There is + no subpath, no per-workspace subdirectory, and no subagent-suffix scheme -- + every session for every project on the machine lives flat in one directory. +- `workspaceDirectory: string` is stored as a field on `Session` and + `BaseSessionMetadata` (`core/index.d.ts:282`, `:296`), but it is **not** + part of the key -- it is denormalized data, populated from + `window.workspacePaths?.[0]` in the GUI + (`gui/src/redux/thunks/session.ts:253`) or `process.cwd()` in the CLI + (`extensions/cli/src/session.ts:103`, `:344`, `:567`). +- **Listing is global by default, not scoped.** `ListHistoryOptions` supports + an optional `workspaceDirectory` filter (`core/protocol/core.ts:57-61`), + and `HistoryManager.list()` implements it as a case-insensitive exact-string + match (`core/util/history.ts:41-49`). But neither shipped client ever + supplies it: the GUI's `refreshSessionMetadata` thunk calls + `ideMessenger.request("history/list", { limit, offset })` with no + `workspaceDirectory` key at all (`gui/src/redux/thunks/session.ts:41-45`), + and the CLI's `listSessions()` calls `historyManager.list({ limit })`, + same omission (`extensions/cli/src/session.ts:456`). The `cn ls` command + (`extensions/cli/src/commands/ls.ts:34`, `:56`) shows every session on the + machine, across every project, with no workspace filter applied anywhere in + that call chain. The GUI's History page filters only by a client-side + MiniSearch title index over the full `allSessionMetadata` array + (`gui/src/components/History/index.tsx:69-116`), never by workspace. The + only place `workspaceDirectory` filtering is actually exercised is the test + suite (`core/util/history.test.ts:110-155`). Workspace scoping exists in + the type and in `HistoryManager`, but is dead code on every production call + path found. +- **Relocation/rename is unhandled.** No code anywhere touches a session's + stored `workspaceDirectory` after creation; there is no listener for a + workspace being moved or renamed, and no normalization (symlink resolution, + trailing-slash handling) beyond `.toLowerCase()` in the comparison itself + (`core/util/history.ts:43`). Since production listing never filters by + workspace anyway, a moved workspace directory today has no visible effect -- + but if a caller ever did pass `workspaceDirectory` (as the tests do), a + renamed or moved project folder would silently and permanently drop that + project's sessions out of any workspace-scoped view; the session's file and + its `sessions.json` entry remain intact and it stays visible in every + *global* listing, so nothing is destroyed, but it becomes unreachable from + the one filtered view the field exists to support. + +## The store interface + +There is no pluggable store interface or SDK type -- `HistoryManager` +(`core/util/history.ts:24-193`) is a concrete class instantiated once as a +module-level singleton (`core/util/history.ts:195-197`, +`const historyManager = new HistoryManager()`) and imported directly by +`core/core.ts`. It is exposed to the two IDE extensions and the webview only +through a fixed IPC message surface (`core/protocol/core.ts:63-76`); the CLI +imports the same class directly as a library (`core/util/history.js`) rather +than through IPC. Reconstructing the effective interface from both call +paths: + +| Operation | Signature | Where | Behavior | +| --- | --- | --- | --- | +| `list` | `list(options: ListHistoryOptions): BaseSessionMetadata[]` | `core/util/history.ts:25-58` | Reads `sessions.json` in full, filters out legacy-format entries and (if malformed) fails silent to `[]`, reverses to newest-first, optionally filters by `workspaceDirectory` (case-insensitive exact match), then slices by `offset`/`limit`. Never touches the per-session files or checks they exist. | +| `load` | `load(sessionId: string): Session` | `:91-109` | Reads and `JSON.parse`s the single per-session file; on any error (missing file, bad JSON) logs and returns a synthesized empty `Session` with `history: []`, `title: NEW_SESSION_TITLE`, the given id -- never throws to the caller. | +| `save` | `save(session: Session): void` | `:111-192` | Full-document rewrite of the per-session file (with an explicit re-ordering of keys purely for readability), then a read-modify-write of `sessions.json`: update the matching metadata entry in place, or append a new one if the id isn't found. Recomputes `messageCount` from the full history on every call. Throws a decorated error if `sessions.json` is present but not valid JSON (unless it is empty/whitespace, which is treated as "no sessions yet" and reset to `[]`). | +| `delete` | `delete(sessionId: string): void` | `:60-85` | Throws if the per-session file doesn't exist; otherwise `fs.unlinkSync`s it first, then reads, filters, and rewrites `sessions.json`. If `sessions.json` is malformed, the filter step operates on `[]` (see index-drift below) and the rewrite silently replaces the whole index with an empty array. | +| `clearAll` | `clearAll(): void` | `:87-89` | `fs.rmSync` on the entire sessions folder, recursive and forced -- deletes every session file and the index together. The one operation where both files are guaranteed to change atomically from the caller's point of view (a single directory removal), though still not crash-atomic at the filesystem level. | + +The IPC surface that wraps these (`core/protocol/core.ts:70-76`, +implemented at `core/core.ts:304-332`) is a 1:1 pass-through, with one +addition: `history/list` re-slices the result to `msg.data?.limit ?? 100` +*again* after `HistoryManager.list()` has already applied its own optional +limit (`core/core.ts:307-308`). Since the GUI's `refreshSessionMetadata` +thunk passes no `limit`, `HistoryManager.list()` returns every session +unbounded, and this second slice is what actually caps the GUI's session +picker at 100 sessions, silently and independent of the `HistoryManager` +contract itself. + +`history/share` (`core/protocol/core.ts:74`, `core/core.ts:323-328`) is a +read-only export operation, not part of the store proper: it loads a session +and renders it to a timestamped Markdown file via +`core/util/historyUtils.ts:41-105` (`toMarkDown`/`shareSession`). + +The CLI does not use this IPC surface at all -- it imports `historyManager` +directly (`extensions/cli/src/session.ts:12`) and additionally implements its +own `loadSession()` (resume-by-most-recent) that **bypasses `HistoryManager` +entirely**: see "Read and resume path" below. + +## Write and append path (ordering, durability, concurrency, delivery) + +There is no append operation. Every write is a full-document rewrite of the +per-session file, ordering is simply array order in memory, and the delivery +model is at-most-once, best-effort, fire-and-forget: + +- **Ordering.** `ChatHistoryItem[]` order in the `Session.history` array *is* + conversation order; there is no sequence number, timestamp, or monotonic + id on individual entries (`core/index.d.ts:534-545` -- `ChatHistoryItem` has + no id or ordinal field at all). Whoever holds the in-memory array and calls + `save()` last wins. +- **Atomicity/durability.** `save()` calls `fs.writeFileSync` directly on the + session file with no temp-file-then-rename and no `fsync` discipline + (`core/util/history.ts:131-134`); a crash mid-write leaves a partially + written, likely unparseable JSON file, which `load()` will catch and + silently paper over with an empty session (`:91-109`) rather than surface + as corruption. The same is true for `sessions.json` + (`:178-181`, `:81-84`). No lock file, no `proper-lockfile`/`flock` + dependency, and no compare-and-swap of any kind was found anywhere in the + session write path (grepped for `lock`, `atomicWrite`, and + `writeFileAtomic` across `core/util/history.ts`, `core/util/paths.ts`, and + `extensions/cli/src/session.ts`: no hits). +- **Concurrency model.** Effectively single-writer-per-session by social + convention only, not enforcement: nothing prevents the VS Code extension + and a `cn` CLI process from writing the same `sessionId.json` (or the + shared `sessions.json`) concurrently; the last `fs.writeFileSync` wins and + the loser's in-memory state (and, for `sessions.json`, every entry not + present in the winner's snapshot) is silently discarded. There is no + optimistic-concurrency precondition (no expected-version, no ETag) on + `save()`. +- **Delivery semantics and write cadence differ sharply between the two + clients that share this store:** + - The **VS Code/JetBrains GUI** saves the whole session once per completed + LLM turn (`gui/src/redux/thunks/streamThunkWrapper.tsx:29-36`, guarded by + `!state.session.isInEdit`) via `saveCurrentSession` → + `dispatch(updateSession(...))` → `ideMessenger.request("history/save", + session)` (`gui/src/redux/thunks/session.ts:70-82`, `:186-262`), plus on + session-switch/new-session events (`gui/src/components/Layout.tsx:63-68`, + `:94-100`) and on tab close (`gui/src/components/TabBar/TabBar.tsx:162`). + - The **CLI** saves on *every single history mutation* -- every user + message, every assistant delta, every tool-call-state update -- because + `ChatHistoryService.setHistoryInternal()` calls `updateSessionHistory()` + unconditionally unless explicitly told not to + (`extensions/cli/src/services/ChatHistoryService.ts:44-71`, the `persist` + option defaults to persisting), and `updateSessionHistory()` → + `SessionManager.updateHistory()` → `saveSession()` → + `historyManager.save(...)` (`extensions/cli/src/session.ts:116-120`, + `:279-296`) is a full rewrite of the entire session file each time. A + long tool-heavy turn can therefore rewrite the whole transcript file many + times before the turn even completes. + - Both share the same `historyManager.save()` call underneath, but the CLI + additionally transforms the session before persisting it: + `getSessionPersistenceSnapshot()` (`extensions/cli/src/session.ts:211-235`) + strips every `system`-role message out of `history` and rewrites `user` + messages' `content` into an `editorState` field before the write. The GUI + performs no such transform -- it persists `session.history` as constructed + in Redux, including whatever is in it. The on-disk shape of a session + file therefore depends on which client last saved it. + - There is no retry, no queue, and no acknowledgment of a failed save + beyond a `console.log`/`logger.error` (`core/util/history.ts:100-101` for + load errors is analogous; the CLI's `saveSession()` catches and logs but + does not re-raise, `extensions/cli/src/session.ts:293-295`). A failed + write is simply lost; the in-memory state moves on regardless. + +### The index-drift failure mode + +`sessions.json` and the per-session `.json` files are written by separate, +non-atomic `fs` calls in `save()` and `delete()`, and nothing in the codebase +reconciles them if a process is killed between the two writes, if the two +files disagree, or if `sessions.json` becomes malformed. There is no repair, +fsck, or rebuild-from-directory-scan function anywhere in the repository. +Four concrete divergence paths, each independently reachable from the code +read above: + +1. **Orphan file, absent from the index.** `save()` writes the per-session + file first (`core/util/history.ts:131-134`) and only afterward reads, + updates, and rewrites `sessions.json` (`:136-191`). A crash, an + `ENOSPC`, or a thrown validation error between those two steps (the code + explicitly re-throws a decorated error if `sessions.json` fails to parse, + `:182-190`) leaves a fully-written, fully-valid session file that + `sessions.json` has never heard of. `HistoryManager.list()` -- the only + thing the GUI's history picker and `cn ls` consult -- never scans the + sessions directory, only `sessions.json` (`:26-30`), so this session is + permanently invisible to both, forever, unless something calls `save()` + on it again. +2. **Dangling index entry, absent file.** `delete()` unlinks the session + file first (`core/util/history.ts:66`) and only then reads and rewrites + `sessions.json` (`:69-84`). A crash between those two steps leaves a + `sessions.json` entry pointing at a file that no longer exists. The GUI + would still show that session's title in the picker; opening it calls + `load()`, which catches the missing-file error and returns a synthesized + *empty* session with the *requested* id but no history and the default + title (`:91-109`) -- silently, with only a `console.log`, no user-facing + error. The stale `sessions.json` entry is never cleaned up by this path; + it persists until something else (e.g. a fresh `save()` under that same + id) overwrites it. +3. **Whole-index wipeout on a corrupted `sessions.json`, triggered by any + delete.** `list()` and `delete()` both parse `sessions.json` through + `safeParseArray()` (`:12-22`), which catches any `JSON.parse` failure and + returns `undefined` -- silently, with only `console.warn`, not surfaced to + the caller. Both call sites then fall back with `?? []` + (`:32`, `:71-75`). For `list()` this just means a corrupted index reads + as "zero sessions" for that call. For `delete()` it is destructive: the + filtered (now-empty) array is written straight back to disk + (`:77-84`), replacing a merely-*malformed* `sessions.json` with a + *validly empty* one -- permanently discarding every other session's index + entry in the process of deleting one session, with no warning beyond a + console log the user will never see. Every session file on disk survives + this untouched; every one of them becomes invisible to `list()` until + individually re-saved. + `save()`, by contrast, does **not** use `safeParseArray` for this file -- it + does a plain `JSON.parse` in its own try/catch and re-throws a + user-facing error naming the file if the content is non-empty and + unparseable (`:138-151`, `:182-190`). So the same corrupted-index + condition is silent data loss through `delete()` and a loud, blocking + error through `save()` -- the three methods on one `HistoryManager` + disagree with each other about whether a broken index is a fatal + condition. +4. **Resume bypasses the index entirely, exposing orphans the picker + cannot see.** The CLI's own `--resume` path, `loadSession()` + (`extensions/cli/src/session.ts:301-332`), does not call + `historyManager.list()` or read `sessions.json` at all. It does a raw + `fs.readdirSync` over the sessions folder, filters out `sessions.json` + itself, sorts every remaining `.json` file by filesystem `mtime`, and + loads the newest (`:309-321`). This means an orphan session file from + failure mode (1) above -- invisible to `cn ls` and to the GUI picker -- is + nonetheless the one `cn --resume` will pick up if it happens to be the + most recently modified file, and a dangling index entry from failure mode + (2) has no effect on this path at all, because it never consults the + index. The two ways of finding "the session to work with" in this one + product (index-backed listing versus raw-directory-scan resume) can and + do disagree about which sessions exist. + +No test in `core/util/history.test.ts` (224 lines, read in full) exercises +any of the above; the suite covers the happy path (create, list, load, +update, delete, workspace filtering, and a 100-session scale check) but never +a missing file, a malformed `sessions.json`, or a crash between the two +writes. + +## Read and resume path + +- **Standard load** (`HistoryManager.load`, `core/util/history.ts:91-109`) is + a single synchronous full-file read and `JSON.parse` of the target + session's `.json` file -- no cursor, no incremental read, no pagination of + the transcript itself, and no size bound on it. The whole `ChatHistoryItem[]` + array is materialized into memory in one call, eagerly, every time a + session is opened. +- **The GUI's "resume last session"** (`loadLastSession`, + `gui/src/redux/thunks/session.ts:140-170`) resumes by `lastSessionId` + tracked purely in Redux state (`state.session.lastSessionId`, set in the + `newSession` reducer, `gui/src/redux/slices/sessionSlice.ts:687`) -- an + in-memory pointer with no persistence of its own. It calls `history/load` + for that id, with one retry after a 1s delay on failure. There is a + commented-out alternative in the same function that would have called + `history/list` with `limit: 1` to find the actual most-recent session + (`gui/src/redux/thunks/session.ts:145-150`, dead/disabled code) -- meaning + the *shipped* GUI resume path depends on the webview process having + survived with its Redux state intact, not on any durable "last session" + record in the store. +- **The CLI's `--resume`** (`loadSession()`, + `extensions/cli/src/session.ts:301-332`) is the opposite: it never + consults any "last session id" state at all, durable or otherwise. It + lists the sessions directory directly and picks the file with the newest + `mtime`, bypassing `sessions.json` and `HistoryManager.list()` completely + (see index-drift finding 4, above). +- **`cn ls` / the GUI history picker** (list, not resume) always loads + through `HistoryManager.list()`, which is a full parse of `sessions.json` + into memory followed by in-memory `Array.filter`/`slice` -- there is no + streaming or partial read of the index file itself, though the index only + holds metadata, not transcripts, so this is comparatively cheap. No + numbers on index size or list latency are quoted anywhere in the source or + comments (unlike, e.g., grok-build's documented ~12K-session cold-boot + cost) -- Continue's own scale story is untested beyond the 100-session unit + test (`core/util/history.test.ts:159-222`). +- Nothing here is materialized lazily: opening a session loads its entire + history array; there is no "load metadata now, load transcript body on + demand" split beyond the metadata/transcript file split itself. + +## Listing, summaries, and search + +- **Listing** is `HistoryManager.list()`: parse all of `sessions.json`, + filter out legacy-format entries (see next section), reverse + (`sessions.json` accumulates in creation order because new entries are + only ever `push`ed, never reordered -- see `save()`, + `core/util/history.ts:167-176` -- so reversing yields newest-first), + optionally filter by `workspaceDirectory` (dead in production, see + "Keying and identity"), then slice by `offset`/`limit` + (`core/util/history.ts:25-58`). This is the entire "index"; there is no + separate query engine, no SQL, no directory scan for the metadata path. +- **The metadata sidecar is `sessions.json` itself** -- `BaseSessionMetadata` + (`core/index.d.ts:292-298`): `sessionId`, `title`, `dateCreated` (set once, + at first `save()`, never updated -- `core/util/history.ts:171`), + `workspaceDirectory`, and an optional `messageCount` (recomputed from the + full history's assistant-role message count on every `save()`, + `:154-156`, `:161`). It is written at write time (inline in `save()`), not + computed lazily or rebuilt from the transcripts. It is a genuinely thin + read model compared to other products in this corpus -- no tags, no fork + lineage, no git metadata, no per-session token/cost totals in the index + itself (`SessionUsage`/cost data lives only inside the per-session file, + `core/index.d.ts:274-277`, `:289`). + The CLI extends this in memory only, never on disk: `ExtendedSessionMetadata` + (`extensions/cli/src/session.ts:22-26`) adds `firstUserMessage`, `isRemote`, + and `remoteId`, computed by re-reading each session file + (`getSessionMetadataWithPreview`, `:399-439`) at `cn ls` time -- an + extra full read of every session file on every `cn ls` invocation, on top + of the `sessions.json` read, specifically to extract a message preview that + `sessions.json` does not carry. +- `isRemote`/`remoteId` on `ExtendedSessionMetadata` and the + `getRemoteSessions()` function that would have populated them + (`extensions/cli/src/session.ts:444-446`) are vestigial: the function's + entire body is `return [];` with the comment "Remote sessions are no + longer available (Hub integration removed)." -- added by commit + `92b99cad9` ("feat: strip more hub config", 2026-03-23), which gutted a + Continue Hub-backed remote-session feature that had been added five months + earlier by commit `b12499cb0` ("feat: show remote sessions in cn ls + (#7694)", 2025-09-19, which is also where `SessionMetadata` was renamed to + today's `BaseSessionMetadata`, `core/index.d.ts` diff at that commit). The + type fields and the merge-with-local-sessions code in `listSessions()` + (`extensions/cli/src/session.ts:451-500`) remain in place; only the data + source was severed. +- **Search** is not a separate indexed subsystem at all. The GUI's History + page builds an in-memory MiniSearch index over session *titles only*, + rebuilt from `allSessionMetadata` on every change + (`gui/src/components/History/index.tsx:37-65`), combining exact, fuzzy, and + prefix matches with priority weighting (`:69-107`). It indexes nothing + from inside the transcripts and persists nothing -- it is rebuilt from + scratch in the renderer process every time the metadata list changes. + There is no FTS database, no vector index, and no bootstrap/incremental- + update story to describe, because there is no persisted search index. + +## Entry/message structure and versioning + +- The entry type is `ChatHistoryItem` (`core/index.d.ts:534-545`): + ```ts + export interface ChatHistoryItem { + message: ChatMessage; + contextItems: ContextItemWithId[]; + editorState?: any; + modifiers?: InputModifiers; + promptLogs?: PromptLog[]; + toolCallStates?: ToolCallState[]; + isGatheringContext?: boolean; + reasoning?: Reasoning; + appliedRules?: RuleMetadata[]; + conversationSummary?: string; + } + ``` + `ChatMessage` is a discriminated union on `role` + (`core/index.d.ts:440-445`): `UserChatMessage`, `AssistantChatMessage`, + `ThinkingChatMessage`, `SystemChatMessage`, `ToolResultChatMessage` + (`:374-438`), each carrying `content: MessageContent` (a string or + `MessagePart[]` of text/image parts, `:342-354`) and an open + `metadata?: Record` bag. `ToolCallState` + (`core/index.d.ts:516-525`) is the tool-execution envelope nested under an + assistant `ChatHistoryItem`: `toolCallId`, the `ToolCall` itself, + `status: ToolStatus` (a six-state enum, `:497-503`), `parsedArgs`, and an + `output?: ContextItem[]`. + There is no id, parent-pointer, or timestamp on `ChatHistoryItem` itself -- + the entry is not addressable independently of its array position, and the + store cannot dedup or link entries across a chain beyond that position. + Identity for the *store* is entirely at the session level + (`sessionId`); nothing below that granularity has a persisted key. +- `Session.history` (the entry list) is opaque to `HistoryManager` in the + sense that `save()`/`load()` never parse into individual entries -- they + serialize/deserialize the whole array as one JSON blob. The one place the + store *does* interpret an entry field is `save()`'s `messageCount` + computation, which filters `session.history` on + `item.message.role === "assistant"` to populate the metadata sidecar + (`core/util/history.ts:154-156`). +- **Format evolution is real and directly evidenced, but entirely + additive/optional-field, with no schema-version field anywhere on `Session` + or `ChatHistoryItem`.** Three concrete episodes, oldest first: + 1. **The Python-era legacy format, still sniffed for today.** Continue's + server was originally a Python process (`continuedev`); its session + metadata model was a Pydantic `SessionInfo(ContinueBaseModel)` with a + snake_case `session_id: str` field (first seen at commit `c25527926`, + "feat: successfully loading past sessions", 2023-08-06, in + `continuedev/src/continuedev/server/session_manager.py`, since removed + from the repository). When the server was rewritten in TypeScript, + `HistoryManager.list()` was given an explicit filter to skip any + `sessions.json` entry matching that shape -- + `typeof session.session_id !== "string"` (`core/util/history.ts:36`, + comment "Filter out old format") -- a check present as early as the + first TypeScript `HistoryManager` (commit `7edfd3d65`, "history", + 2023-12-09) and unchanged in every subsequent rename of the surrounding + types (`SessionInfo`→`SessionMetadata`→`BaseSessionMetadata`) through to + today. **Old-format entries are silently dropped from `list()` output + forever, not migrated.** Nothing rewrites `sessions.json` to purge them + (they simply never pass the filter again on the next `list()` call + either); nothing converts an old-format entry into a loadable + `BaseSessionMetadata`. A user who still had Python-era `sessions.json` + entries on upgrade would have found them permanently invisible, with no + migration path found in the source. + 2. **A short-lived, then-removed file-checkpoint feature.** Commit + `5a3206261` ("checkpoints working with undo", 2024-11-13) added + `Checkpoint { [filepath: string]: string }` -- a raw filepath-to-full- + file-content map, no diffing, hashing, or dedup -- as + `Session.checkpoints?: Checkpoint[]`. Two weeks later, commit + `438cba450` ("feat: update redux store schemas", 2024-11-27) moved it + from a session-level array to a per-turn field: + `ChatHistoryItem.checkpoint: Checkpoint` plus + `isBeforeCheckpoint: boolean`. Two days after that, commit `4efd66137` + ("feat: bugfixes on redux schema updates", 2024-11-29) stopped + `HistoryManager.save()` from forwarding the (by-then legacy) + session-level `checkpoints` field. Five months later, commit + `ff8a63a9e` ("remove checkpoints", 2025-04-30) deleted the `Checkpoint` + interface and both `ChatHistoryItem` fields outright. **As of the + pinned commit, Continue has no checkpoint, undo, or rewind concept + anywhere in its type definitions or store** -- this was built, + relocated once, and fully retired inside a six-month window, with no + replacement. + 3. **`MessageModes` grew from a two-value to a four-value enum in place, + with no migration.** The same 2024-11-27 commit (`438cba450`) + introduced `type MessageModes = "chat" | "edit"`; today's + `core/index.d.ts:495` defines `MessageModes = "chat" | "agent" | "plan" + | "background"`. `Session.mode` is optional + (`core/index.d.ts:284-285`), so old sessions simply lack the field -- + evolution here is purely additive/optional, with no code anywhere + translating an old `mode` value into a new one. +- Beyond these three episodes, the general evolution style is additive + optional fields (`toolCallStates?`, `reasoning?`, `appliedRules?`, + `conversationSummary?`, `mode?`, `chatModelTitle?`, `usage?` on `Session` + and `ChatHistoryItem`, `core/index.d.ts:279-290`, `:534-545`) that + `JSON.parse` simply leaves `undefined` on old data -- no `serde`-style + explicit-default annotations exist in TypeScript/JSON, but the effect is + the same: old session files continue to load, with newer optional fields + absent. There is no store-format version number anywhere (`Session`, + `BaseSessionMetadata`, and the `sessions.json` array itself all lack a + `version`/`schemaVersion` key), so there is no way for the loader to know + *which* format-evolution episode a given file predates other than the one + ad hoc `session_id` sniff. + +## Compaction and history management + +Continue has **two independent compaction implementations that diverge on +the one thing that matters most for this research question: whether the +durable record is preserved.** + +- **The core/GUI path** (`core/util/conversationCompaction.ts:19-112`, + triggered manually by `conversation/compact` + (`core/core.ts:622-642`) from the GUI's "compact" action + (`gui/src/util/compactConversation.ts:10-43`)) is **non-destructive**: it + loads the full session, generates a summary of history up to a chosen + index via the current chat model, and writes that summary string into the + *existing* `ChatHistoryItem.conversationSummary` field at that index + (`conversationCompaction.ts:99-111`) -- then calls `historyManager.save()` + on the *whole, unshortened* history array. Every message before and after + the compaction point remains in the persisted JSON file, permanently. + Compaction only affects what gets sent to the model: `constructMessages()` + scans backward for the most recent `conversationSummary`, and if found, + slices the array to everything *after* that index for the LLM prompt + (`gui/src/redux/util/constructMessages.ts:47-58`) -- a read-time + interpretation of an in-place marker, not a rewrite of the durable log. + Re-compacting at an already-summarized index explicitly excludes the old + summary from the search and is handled as "we're re-compacting" + (`conversationCompaction.ts:29-30`, `:34-38`). Deleting a compaction from + the GUI (`useDeleteCompaction`, + `gui/src/util/compactConversation.ts:45-58`) clears the marker client-side + and re-saves -- again a full-array rewrite, but the underlying messages + were never gone. +- **The CLI path** (`extensions/cli/src/compaction.ts`, + `extensions/cli/src/ui/hooks/useChat.compaction.ts`) is **destructive**. + `compactChatHistory()` (`compaction.ts:53-167`) generates a summary the + same way, but returns a `compactedHistory` that is *only* the (optional) + system message plus one new assistant message carrying the summary + (`compaction.ts:140-155`) -- every prior user/assistant/tool message is + gone from that return value. Both the manual command + (`useChat.compaction.ts:56-66`, `updateSessionHistory(result.compactedHistory)`) + and auto-compaction (triggered by `shouldAutoCompact()`'s token-threshold + check against context limit, `compaction.ts:266-315`, wired through + `extensions/cli/src/stream/streamChatResponse.autoCompaction.ts`) persist + this truncated array as a **full replacement** of `session.history` via + `ChatHistoryService`/`updateSessionHistory` → + `historyManager.save()`. The pre-compaction transcript is not written + anywhere else first -- no snapshot artifact, no external file, nothing. + Once the CLI auto-compacts or a user runs `/compact`, the discarded + messages are unrecoverable from the on-disk session file. +- Both paths share the same `conversationSummary`-tagging convention and the + same `findCompactionIndex`/`getHistoryForLLM` read-time-slicing helper + shape (`extensions/cli/src/compaction.ts:174-238` mirrors + `gui/src/redux/util/constructMessages.ts:47-58` and + `gui/src/redux/slices/sessionSlice.ts`'s `findCompactionIndex` + equivalent), but only the core/GUI path actually keeps the durable log + intact; the CLI path collapses it. This is an unresolved internal + inconsistency in the product, not a documented design choice -- no comment + in either file acknowledges the other implementation. +- Compaction is entirely an upstream/prompt-construction concern from the + store's point of view either way: `HistoryManager` has no compaction-aware + method, no compaction marker type, and no snapshot format of its own. It + only ever sees a `Session.history` array to fully persist, whatever shape + the caller hands it. +- There is no resume/replay behavior that crosses "the compaction boundary" + as a distinct concept in this codebase -- because the GUI path never + actually removes anything, there is nothing to replay past; because the + CLI path removes it destructively, there is nothing left to replay. + +## Rewind, checkpoints, and fork + +- **No fork exists.** Grepping the whole tree for fork/branch/clone-session + vocabulary (`forkSession`, `sessionFork`, `branchSession`) returns nothing. + There is no lineage field on `Session` or `BaseSessionMetadata` (no + `parentSessionId`, no `forkedFrom`), and no code path creates a new session + from an existing one's prefix. +- **Checkpoints existed and were removed** -- the full five-month history + (added → relocated → forwarding dropped → deleted) is documented above + under "Entry/message structure and versioning," episode 2. As of the + pinned commit there is no file-state or environment checkpoint tied to + turns anywhere in the type system or the store. +- **"Rewind" exists only as a destructive, in-place truncation of the + history array, in both clients, with no appended marker and no + possibility of un-rewinding:** + - GUI: editing/resubmitting an earlier message + (`submitEditorAndInitAtIndex`, `gui/src/redux/slices/sessionSlice.ts:357-410`) + and the analogous `truncateHistoryToMessage` reducer + (`:414-434`) both do `state.history = state.history.slice(0, index + + 1).concat({...new empty assistant message})` -- everything after the + edited/target message is simply gone from the in-memory array, which the + next `saveCurrentSession` will persist as the new, shorter, full + contents of the session file. + - CLI: `handleEditMessage()` + (`extensions/cli/src/ui/hooks/useChat.ts:753-787`) computes + `rewindedHistory = chatHistory.slice(0, messageIndex)` and immediately + calls `updateSessionHistory(rewindedHistory)` -- a full rewrite of the + session file to the truncated array -- before resubmitting the new + message content from that point (`:762-786`; the UI's own label for this + action is literally "Editing Message ... (will rewind to this point)", + `extensions/cli/src/ui/EditMessageSelector.tsx:277`). + - Neither implementation appends a marker or keeps the discarded tail + anywhere; the store has no way to distinguish "this session was always + this short" from "this session was truncated by an edit." There is no + file-state or environment checkpoint captured alongside either + operation (that entire concept was removed, per above), so a rewind in + Continue undoes conversation history only, never any file edits the + agent made along the way. + +## Subagents and nested sessions + +Only the CLI has a subagent concept; nothing under `core/` or `gui/` +references one. + +- A subagent is invoked as a built-in tool + (`extensions/cli/src/tools/subagent.ts:15-115`) whose `run()` calls + `executeSubAgent()` (`extensions/cli/src/subagent/executor.ts:58-213`). + `executeSubAgent` builds a **brand-new, in-memory-only** + `ChatHistoryItem[]` seeded with just the delegated prompt + (`executor.ts:114-122`), and explicitly disables persistence for the + duration: it monkey-patches `services.chatHistory.isReady` to return + `false` for the call (`executor.ts:109-112`, comment "Temporarily disable + ChatHistoryService to prevent it from interfering with child session"), + restoring it in a `finally` block (`:190-193`). The subagent's own turns + are **never passed to `HistoryManager.save()` or written to any file** -- + there is no child session file, no child entry in `sessions.json`, and no + subagent-specific directory anywhere under `~/.continue/sessions`. +- The only durable trace of a subagent run is its **final text output**, + captured from the last message in its throwaway history array + (`executor.ts:164-179`) and returned as the tool-call's result string, + which the parent's `ChatHistoryService.addToolResult()` then folds into + the *parent's* own persisted `ChatHistoryItem.toolCallStates` + (`extensions/cli/src/tools/subagent.ts:86-98`, `:103-113`). This matches + the "entries in the parent transcript" model from the research taxonomy, + but even more minimally than that phrase implies: it is one opaque string + inside a tool-result, not a structured child-transcript reference. +- `parentSessionId` is threaded into `SubAgentExecutionOptions` + (`executor.ts:14-19`) and is fetched from + `chatHistoryService.getSessionId()` at the call site + (`extensions/cli/src/tools/subagent.ts:75-84`), but it is **never read + inside `executeSubAgent`'s body** (confirmed: the destructuring at + `executor.ts:61` omits it, and no other reference to `options.parentSessionId` + exists in the file). There is, in other words, no durable parent-child + link recorded anywhere -- not even a pointer file -- despite the plumbing + suggesting one was intended. +- **Nesting depth is not bounded by any code found.** The subagent's own + `streamChatResponse()` call computes its tool list via the same + `getRequestTools()`/`getAllAvailableTools()` path the top-level agent uses + (`extensions/cli/src/stream/handleToolCalls.ts:172-189`), and nothing in + `subagent.ts`, `subagent/executor.ts`, or `subagent/get-agents.ts` excludes + the subagent tool itself from that list or tracks a recursion depth. This + is not a firm claim of unbounded recursion -- it is what the depth-limiting + code would look like if present, and none was found; treat it as an open + question rather than a confirmed unbounded-nesting design. +- Since nothing about a subagent is persisted beyond the folded-in output + string, there is nothing to cascade, orphan, or reconcile on parent + delete/rewind/crash -- a subagent that is still running when its parent + process exits simply stops with the process; there is no independent + subagent session for the store to have opinions about. + +## Retention, deletion, and multi-host + +- **No retention policy of any kind exists.** Grepping `history.ts`, + `paths.ts`, `session.ts`, and `conversationCompaction.ts` for + `ttl`/`retention`/`cleanup`/`prune`/`gc` (session-scoped) returns nothing. + Sessions live until a user explicitly deletes one (`history/delete`) or + clears everything (`history/clear` → `clearAll()`, + `core/util/history.ts:87-89`). There is no scheduled cleanup, no + size-based eviction, and no age-based expiry. +- **Delete is local, in-place, and (per the index-drift finding above) + order-dependent rather than transactional:** unlink the session file, + then rewrite `sessions.json` to drop the matching entry + (`core/util/history.ts:60-85`). There is no remote/writeback backend to be + "remote-first" about -- Continue's session store has no server-side + component; even the CLI's abortive "remote session" feature + (`getRemoteSessions()`, now hard-coded to `[]`) was about Hub-hosted agent + runs, not a durable-session backend for this store. +- **`clearAll()` is the one crash-safer path**, in the narrow sense that a + single `fs.rmSync(sessionsFolder, {recursive: true, force: true})` + removes both files' worth of state together rather than in two ordered + writes -- though it is still not atomic at the filesystem level (a crash + mid-`rmSync` on a large directory can leave a partial removal). +- **Multi-host/shared-filesystem support was not found and does not appear + to have been designed for.** Every read and write in this store is a + synchronous local `fs` call with no lock; there is no per-host suffixing, + no network-filesystem detection, and no remote-writeback path for session + data. This is an inference from absence rather than a documented + non-goal: nothing in the source or in adjacent docs discusses multi-host + session access, so treat "not supported" as "not found," not as a + confirmed design decision. +- Multi-*process*-on-one-host is a real, reachable hazard rather than a + theoretical one: the GUI (via the extension host) and a `cn` CLI process + can both be pointed at the same `CONTINUE_GLOBAL_DIR` and both write + `sessions.json` with no coordination whatsoever (see "Write and append + path" above); no crash-detection registry (e.g. a live-process pid file, + as seen in other products in this corpus) exists here. + +## What this implies for our Session Store (our inference) + +Continue's durable session is a **mutable JSON document per session, plus a +second, independently-mutable JSON document that denormalizes a picker's +worth of metadata about all of them** -- not an append-only log with derived +projections, and not even internally consistent about how to treat its own +index going stale. Read as a cautionary data point rather than a positive +model for our event-sourced Session Store, it argues for several things we +already lean toward: + +- **A denormalized listing index that is not rebuildable is a liability, + not just an incompleteness.** Continue's `sessions.json` cannot be + regenerated from the per-session files that remain the actual source of + truth for conversation content; there is no scan-and-rebuild function. + Our design should ensure any read-model/projection over the session log is + explicitly reconstructable from the log by construction (fold-from-events), + precisely so that a "list index vs. transcript" divergence like Continue's + is a non-event: the projection is always rebuildable, never a second + independent source of truth that can silently disagree with the first. +- **Silent, inconsistent failure handling on a corrupted index is worse than + either always failing loud or always being self-healing.** Continue's + three `HistoryManager` methods (`list`, `save`, `delete`) each treat a + malformed `sessions.json` differently -- two swallow it into an empty + array (one of which then persists that emptiness, destructively), one + throws a decorated error to the caller. A store built on an actual + event-sourced foundation removes the entire failure category: there is no + "index that can be malformed independently of the log," because the index + *is* a projection of the log and is invalidated/rebuilt from it rather + than hand-maintained by separate read-modify-write calls scattered across + the write path. +- **"Same store, different write cadence" is a real operational hazard + worth designing against explicitly.** Continue's GUI persists once per + completed turn; its CLI persists on every single history mutation inside + a turn. Both share one `HistoryManager`, but nothing in the interface + enforces or documents an expected write granularity, so the two clients + drifted into very different I/O profiles and (per the compaction finding) + even different retention semantics on top of the identical store + contract. Our store's write contract should make the append/commit + granularity explicit and singular, not something each caller is free to + reinvent. +- **Compaction should be a store-adjacent, explicitly-versioned decision, + not something two call sites can implement in mutually-destructive ways.** + Continue's own core/GUI path is a good instinct -- mark-and-slice-at-read- + time, keep the durable record whole -- but the fact that a second, + independently-written compaction implementation in the same product + destructively discards history proves that "non-destructive compaction" + has to be a property the store itself guarantees (e.g. compaction expressed + only as an event that a fold-time projection can interpret, never as a + caller-provided replacement array the store blindly persists), not a + convention two different code paths happen to follow. +- **A subagent whose entire transcript is discarded except for one output + string is the degenerate end of the "nested session" spectrum**, and + Continue's implementation goes out of its way to *prevent* its own + ChatHistoryService from touching a running subagent (the + `isReady = () => false` monkey-patch), rather than routing subagent turns + into a first-class child-session facility. This is a useful negative data + point for ADR 0031's child-Session direction: it shows what happens when a + product treats "subagent" purely as an ephemeral tool implementation + detail rather than a session at all -- no cascade/orphan questions to + answer, because there was never a durable child session to begin with. + Where our design intentionally differs (making the subagent a first-class + child Session) is precisely the gap this product declines to fill. + +## Open questions + +- No test, comment, or issue reference was found acknowledging the + index-drift scenarios documented above (orphan file, dangling entry, + index-wipe-on-corrupted-delete, resume-bypasses-index); it is unclear + whether the Continue team is aware of them or whether they have been + reported/observed in the wild. +- Whether the GUI's disabled "resume by history/list" code + (`gui/src/redux/thunks/session.ts:145-150`) was removed deliberately in + favor of the Redux `lastSessionId` approach, or is simply dead code left + over from a refactor, could not be determined from the diff history + available in this clone. +- Whether subagent nesting is actually reachable in practice (i.e. whether + the subagent tool is filtered out of a subagent's own tool list by some + mechanism not found in `get-agents.ts`/`handleToolCalls.ts`) is unresolved; + this dossier treats it as an open question rather than a confirmed + unbounded-recursion finding. +- No migration tooling, CLI flag, or documentation was found for converting + a Python-era (`session_id` snake_case) `sessions.json` entry into the + current format; whether any users still carry such entries, and whether + Continue considers that data permanently lost, is unverified from source + alone. +- Whether `CONTINUE_GLOBAL_DIR` is ever pointed at a network/shared + filesystem in practice (e.g. a team dev-container setup), and what would + happen to `sessions.json` under concurrent writers in that configuration, + was not addressed anywhere in the source or in comments; this dossier's + "not designed for multi-host" conclusion is an inference from absence, not + a documented non-goal. +- Whether JetBrains' extension (not read in this pass -- only the shared + `gui/` webview and `core/` were examined) introduces any additional write + path into `HistoryManager` beyond what `core/core.ts` exposes was not + checked; the IDE-specific host code outside `core/` and `gui/` was out of + scope for this dossier. diff --git a/docs/research/session-store/products/continue/vs-session-events.md b/docs/research/session-store/products/continue/vs-session-events.md new file mode 100644 index 000000000..db05003f3 --- /dev/null +++ b/docs/research/session-store/products/continue/vs-session-events.md @@ -0,0 +1,112 @@ +# Continue compared to our session event catalog + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT_COMPARISON](../../RESEARCH_PROMPT_COMPARISON.md). +Stage-one dossier: [Continue](./index.md). +Compared against `proto/trogonai/session/sessions/v1alpha1/` and [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) on 2026-08-04. + +**Store maturity: 9/12**: evolution scars 2/3 (three dated format-evolution episodes over 2+ years: a Python-era `session_id` snake_case sniff still live since commit `7edfd3d65` (2023-12-09) that silently drops old-format entries forever rather than migrating them, a `Checkpoint`/file-undo feature added in commit `5a3206261` (2024-11-13) and fully deleted in commit `ff8a63a9e` (2025-04-30) with no replacement, and `MessageModes` growing from 2 to 4 values with no migration; real scars, but undisciplined ones, since no `schema_version` field exists anywhere on `Session`, `BaseSessionMetadata`, or the array), operational age 1/3 (the store code is over two years old by its earliest touched commit, but the dossier found no issue, test, or comment anywhere confirming any of the four index-drift failure modes it documents actually surfaced in the field; the risk is inferred from reading `core/util/history.ts`, not from a confirmed production incident), exposure 3/3 (three shipped, Apache-2.0 clients: VS Code extension, JetBrains extension, and a standalone CLI, all reading and writing the same on-disk format), design independence 3/3 (`HistoryManager` in `core/util/history.ts:24-193` is Continue's own class with no evidence of being forked from another product's persistence layer). + +## The one structural difference everything else follows from + +Continue keeps one conceptual session as **two independently written, non-atomic, mutually unreconciled mutable documents**, and has no function anywhere in its codebase that rebuilds one from the other. The per-session transcript lives at `~/.continue/sessions/{sessionId}.json` and the flat metadata index lives at `~/.continue/sessions/sessions.json`; both are read in full and rewritten in full on every mutation, by the same `HistoryManager` class (`core/util/history.ts:24-193`), with no lock, no atomic rename, and no fsync anywhere in the write path (`core/util/history.ts:131-134`, per the dossier). Because the two files are written in sequence rather than as one transaction, a crash between the writes produces one of four concrete, permanent divergences documented in the dossier's index-drift section: an orphan session file with no index entry (save writes the session file first, `sessions.json` second); a dangling index entry with no backing file (delete unlinks the file first, rewrites the index second); a whole-index wipeout, because `delete()`'s `safeParseArray` swallows a `JSON.parse` failure on a corrupted `sessions.json` to `undefined` and falls back to `?? []`, then writes that empty array back over every other session's entry; and the CLI's `--resume`/`loadSession()` path (`extensions/cli/src/session.ts:301-332`), which bypasses `sessions.json` and `HistoryManager.list()` entirely in favor of a raw `fs.readdirSync` plus newest-`mtime` scan, so it can resume a session the picker cannot see and is immune to a dangling entry the picker cannot resume. None of these four paths self-heals; there is no reconciliation, checksum, or rebuild-index function anywhere in the codebase. This is a materially different failure shape from Cline's whole-document rewrite (a single document, atomically wrong or right per write) and from fx's turn-granular append: Continue's defining structural fact is **dual-source-of-truth divergence with no reconciliation path**, so once it happens it is permanent. + +Our design has no analog to this failure mode by construction: the typed event log (`proto/trogonai/session/sessions/v1alpha1/events.proto`, the 41-arm `SessionEvent` oneof) is the sole authoritative record, and every other artifact, the aggregate snapshot, the `SessionProjection` a `Projector::catch_up` builds, and the read-side checkpoint, is explicitly rebuildable from it ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 8). The comparison in the rest of this document is therefore less "what field maps to what" and more "what does a product look like when it never adopted the append-only-log-plus-rebuildable-projection discipline at all." + +## Mapping + +| Continue construct | Our equivalent | Notes | +| --- | --- | --- | +| `~/.continue/sessions/{sessionId}.json` (full mutable transcript, whole-file rewrite) | Typed event log, one append per fact, `SessionEvent` oneof (`proto/trogonai/session/sessions/v1alpha1/events.proto:58-115`) | Continue has no append primitive at all; every save is `JSON.stringify` of the entire array. | +| `~/.continue/sessions/sessions.json` (flat metadata index, independently rewritten) | `SessionProjection`, rebuilt by `Projector::catch_up` from the log ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 8) | Ours is declared rebuildable and never authoritative; Continue's is a second mutable document with no rebuild path. | +| `sessionId: string` (`uuidv4()`, minted client-side in the GUI reducer or the CLI) | `SessionStarted.session_id` (`session_started.proto:17`) | Both are opaque client-visible ids; ours is the creation fact of a logical stream (`WRITE_PRECONDITION = NoStream`), not a filename. | +| `BaseSessionMetadata.workspaceDirectory` (`core/index.d.ts:292-298`, denormalized, not part of the key, and the workspace filter is dead code on every production list path) | `WorkspaceRef` on `SessionStarted.workspace` (`session_started.proto:23`, `workspace.proto:13-22`), immutable for the life of the session | Ours carries `workspace_id`, `uri`, and `revision`, and is the projection surface used to filter listings; Continue's equivalent field exists but nothing enforces it is actually used to scope anything. | +| `ChatHistoryItem` (`core/index.d.ts:534-545`): no id, no parent pointer, no timestamp of its own; identity is entirely session-level | `UserMessageRecorded.message` / `CanonicalMessage` plus `turn_id` (`user_message_recorded.proto:9-21`); `AssistantMessageStarted`/`Completed`/`Failed` | Every one of our conversation events carries its own event id, its session's `SessionOrdinal`, and a stamped `turn_id`; Continue's entries have none of the three. | +| `HistoryManager.save/load/list/delete/clearAll` (`core/util/history.ts:25-192`) | Command surface implied by the Decider aggregate: `CreateSession`, append, `Projector::catch_up` for listing, `SessionHidden` for delete | Continue's five methods each have distinct, undocumented error-handling behavior (dossier); ours routes every mutation through one typed command path with one precondition discipline per event. | +| In-memory `lastSessionId` (Redux, `gui/src/redux/slices/sessionSlice.ts:687`, `gui/src/redux/thunks/session.ts:140-170`) as the only "last session" record | No durable "last active session" pointer found in the catalog; listing is a `SessionProjection` query | Continue's "resume last" is not durable across a full client restart of the GUI process; ours has no equivalent construct at all today (see Open questions). | +| CLI's raw `fs.readdirSync` + mtime scan (`extensions/cli/src/session.ts:301-332`), a second, disagreeing notion of "what sessions exist" | N/A: only one authoritative read path, the event log itself | This is the sharpest divergence in the mapping: Continue's own product has two mutually inconsistent answers to "what sessions exist," and ours has exactly one by construction. | +| `core/util/conversationCompaction.ts:19-112`: non-destructive, writes `conversationSummary` into the existing `ChatHistoryItem`, keeps the full array | `Compacted` (`compacted.proto:19-38`): `summary_id`, `summary_content`, `covers_from`/`covers_through` (`SessionOrdinal`), `trigger`, covered events retained on the keep-forever log | Same non-destructive intent, but ours is one typed event with an explicit covered range and an explicit `CompactionTrigger`, not an in-place field mutation on a mutable array entry. | +| `extensions/cli/src/compaction.ts:53-167`: `compactChatHistory()`, destructive, returns a truncated array (system message + one summary), persisted as a full replacement | No equivalent; `Compacted` never removes or rewrites prior events ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 4) | This CLI path is the one place Continue actually loses transcript data on compaction; our design structurally cannot do this, since `Compacted` is additive and the covered events remain on-stream. | +| Rewind: `submitEditorAndInitAtIndex`/`truncateHistoryToMessage` (GUI, `gui/src/redux/slices/sessionSlice.ts:357-434`) and `handleEditMessage()` (CLI, `extensions/cli/src/ui/hooks/useChat.ts:753-787`): destructive in-place array truncation, no marker, no un-rewind | `SessionRewound` (`session_rewound.proto:16-22`): `keep_through` (`SessionOrdinal`), `reason`; a new appended event, never an edit | Continue's rewind is a physical delete; ours is a fact recorded on an append-only log, so the rewound range is still inspectable and a fork can still reference it. | +| Subagents: CLI-only, brand-new in-memory-only `ChatHistoryItem[]`, deliberately monkey-patches `services.chatHistory.isReady = () => false` for the call's duration (`extensions/cli/src/subagent/executor.ts:109-112`, restored at `:190-193`); only durable trace is the finished text folded into the parent's `toolCallStates` | `DelegationDispatched` (parent, `delegation_dispatched.proto:20-25`) + atomic `[SessionStarted, ParentLinked]` (child, `parent_linked.proto:19-27`), a full first-class sibling `Session` with its own log | Continue's CLI does not merely lack cascade semantics, it declines to create a durable child session at all; see "Subagent cascade" below. | +| `parentSessionId` plumbed into `SubAgentExecutionOptions` but never read inside `executeSubAgent`'s body (destructuring at `executor.ts:61` omits it) | `ParentLinked.parent_session_id` (`parent_linked.proto:21`), the child-side fact naming its parent | Continue has the field name in an options type and no code path that consumes it; ours is a required, folded field on the child's own creation event. | +| No retention policy of any kind (grepped `ttl`/`retention`/`cleanup`/`prune`/`gc`, zero hits per dossier); `clearAll()` is a single `fs.rmSync`, still not atomic at the filesystem level | `SessionHidden` (`session_hidden.proto:13-16`, visibility tombstone, no byte deletion), `RedactionApplied` (`redaction_applied.proto:13-19`, read-time masking of named event ids), `ArtifactErased` (`artifact_erased.proto:12-17`, out-of-band artifact-byte destruction) | Continue has no positive retention story at all, deliberate or accidental; ours has three distinct typed mechanisms, none of which physically truncates the log ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 7). | +| No fork of any kind found (grepped, zero hits per dossier) | `SessionForked` (`session_forked.proto:17-27`): `source_session_id`, `context_prefix_boundary` (`SessionOrdinal`), `ForkReason`, atomic with `SessionStarted` under `NoStream` | A capability Continue's own dossier confirms does not exist in the product at all. | +| Artifacts: none, tool output and context items are inlined directly in `ChatHistoryItem.contextItems`/`toolCallStates` | `ArtifactRef`/`ArtifactMetadata` claim-check by sha256 `Digest` (`artifact.proto:14-34`, `proto/trogonai/session/sessions/v1alpha1/digest.proto:9-14`), `ArtifactRecorded` (`artifact_recorded.proto:9-12`) | Continue inlines everything into the same mutable JSON document it writes wholesale on every turn; we never inline artifact bytes into the event stream. | +| Reads/tool observations: not modeled as a distinct concept; whatever a tool call returns lives in `ToolCallState` | `ResourceObservation` on `ToolCallCompleted.observed` (`tool_call_completed.proto:32-35`, `resource_observation.proto:26-58`): a `content_digest` or `absent` outcome, a `range`, a `complete` flag | We deliberately do not give reads their own event type (reads outnumber writes by more than an order of magnitude per `resource_observation.proto:15-16`); Continue has no comparable concept of "what a call observed" at all, only "what a call returned." | + +**Reverse direction, what we record that Continue does not, and whether the omission looks deliberate.** `turn_id` stamping across conversation and tool events, `SessionOrdinal` as a fold-derived logical position distinct from any physical offset, the operation-ledger pair (`OperationReserved`/`OperationOutcomeRecorded`), `CascadePolicy` as an explicit recorded fact, and `TodoUpdated`/`SystemNoticeRecorded` all have no counterpart anywhere in the dossier. None of these omissions look deliberate; Continue's store was not designed as an event log, so there was never a decision point at which "should we stamp a turn boundary" or "should cascade be an explicit enum" would have come up. They are absent because the underlying data model (a flat array of message-shaped objects) has no place to put them, not because Continue considered and rejected them. + +## What we should consider changing + +### 1. Add an operational reconciliation job that diffs the live `SessionProjection` against a full replay, not just a lazy `catch_up` + +**The change.** [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 8 defines `Projector::catch_up` as folding the stream into a `SessionProjection` and states projections are "rebuildable, never source of truth." That is a schema-level guarantee, not an operational one: nothing in the ADR or the catalog currently commits to *routinely exercising* the rebuild path before a consumer notices a stale or diverged projection. Add an explicit, scheduled reconciliation job (or CI-style property test running against production topology) that recomputes a `SessionProjection` from scratch and diffs it against the cached/live one, alerting on divergence. + +**Evidence anchor.** Continue (store maturity 9/12): across an 804-line dossier and a direct grep, there is no reconciliation, checksum, or rebuild-index function anywhere in the codebase (see the four index-drift failure modes above, drawn from the dossier's [Write and append path](./index.md#write-and-append-path-ordering-durability-concurrency-delivery) section; `core/util/history.ts` is 197 lines and contains no `flock`, `fsync`, or atomic-rename call at all). None of the four has ever been observed to self-heal; each is permanent once it occurs, and none has a confirmed field report either, which is itself telling: a divergence with no repair path and no detection mechanism can persist indefinitely without anyone learning it happened. + +**Blast radius.** Additive. This is an operational job and a projection-consistency test, not a schema or event-type change; it adds no new proto field. + +**Why.** Our architecture is immune to Continue's *specific* failure (two independently-written mutable files) because the log is append-only and the projection is declared rebuildable. But "rebuildable in principle" and "actually verified to match the log in practice" are different claims, and Continue's dossier is direct evidence of what it costs to have neither: not a bug report, an absence of one, because nothing was ever positioned to catch the class of bug at all. The cheapest way to keep the gap we already closed by design from reopening operationally is to routinely exercise the rebuild path against the thing it's supposed to match. + +**Cost.** A scheduled job (or a synthetic-load property test) plus alerting; no write-path cost, no new failure mode beyond "the reconciliation job itself needs to be monitored." + +### 2. Decide, explicitly, whether a durable "last active session" pointer belongs in the catalog + +**The change.** Continue's only notion of "the session to resume" is Redux's in-memory `lastSessionId` (`gui/src/redux/slices/sessionSlice.ts:687`), which does not survive a GUI process restart, and the CLI's separate raw-directory-mtime scan, which answers a related but different question ("newest file on disk") without consulting any durable pointer at all. Our catalog has no analog to either: there is no event or projection field for "the session this actor was last working in." Decide whether that is a deliberate omission (resume-target selection is a client/UI concern layered entirely on top of `SessionProjection` listing, never a store concept) or a gap the ADR should close with a lightweight, additive fact. + +**Evidence anchor.** Continue (9/12): two competing, disagreeing implementations of "what to resume" in the same shipped product (`gui/src/redux/thunks/session.ts:140-170` vs `extensions/cli/src/session.ts:301-332`), neither durable in the way the rest of the store is. + +**Blast radius.** Additive, if adopted at all: a new commuting happened-fact (`WRITE_PRECONDITION = Any`, following the pattern of `SessionRenamed`) recording "actor X's most-recently-touched session," scoped outside any individual session's own stream. Doing nothing is also a valid outcome of this open question; it is listed as a recommendation because the ADR does not currently take a position either way, not because the answer is obviously yes. + +**Why.** Continue shows what happens when this is left to accumulate as ad hoc, inconsistent client state instead of being decided once: two different answers to the same question, one of them not durable. We do not have that problem today because nothing yet claims to answer the question at all; better to decide now, while it is still a green-field choice, than after multiple clients have each grown their own incompatible notion of "resume". + +**Cost.** If adopted: a new event type or field, a new projection, and a decision about whether it is per-actor, per-workspace, or global; if rejected, only the cost of writing the decision down so it does not get re-litigated. + +## What our design already does better + +- **A single authoritative record instead of two independently mutable ones.** The entire "one structural difference" section above is, from our side, a description of a failure mode [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 8 already forecloses: the log is authoritative, every other artifact is declared rebuildable, and nothing is ever the second copy of a fact with its own independent write path. +- **Non-destructive compaction is a single typed event, not two incompatible implementations.** `Compacted` (`compacted.proto:19-38`) is one shape with an explicit covered range; Continue ships two, a non-destructive core/GUI path (`core/util/conversationCompaction.ts:19-112`) and a destructive CLI path (`extensions/cli/src/compaction.ts:53-167`) that actually discards the pre-compaction transcript, an inconsistency the dossier calls "an unresolved internal inconsistency in the product, not a documented design choice." +- **Rewind is a fact, not a deletion.** `SessionRewound` (`session_rewound.proto:16-22`) appends `keep_through`; nothing is removed. Continue's rewind, in both clients, is an in-place array truncation with no marker and no way back (`gui/src/redux/slices/sessionSlice.ts:357-434`, `extensions/cli/src/ui/hooks/useChat.ts:753-787`). +- **Turn identity is stamped, not inferred.** `turn_id` (`user_message_recorded.proto:12-20`) is stamped at write time precisely because a fold cannot reliably reconstruct a turn boundary from commuting, `Any`-precondition appends. Continue's `ChatHistoryItem` has no turn concept at all; a turn's boundary is implicit in array adjacency, which is exactly the kind of positional fact that a whole-array rewrite risks disturbing. +- **Artifacts are claim-checked, never inlined.** `ArtifactRef`/`ArtifactMetadata` (`artifact.proto:14-72`) keep the event log free of large byte payloads; Continue inlines tool output and context items directly into the same mutable document it rewrites wholesale on every save, so a large artifact makes every subsequent write more expensive, not just the one that produced it. +- **Fork exists at all, and mints a new identity that cannot be confused with its source.** `SessionForked` (`session_forked.proto:17-27`) is unanimous with the rest of the industry corpus (synthesis convergence 5); Continue's dossier found zero evidence of fork anywhere in the product. + +## Trade-offs, not gaps + +- **Whole-document simplicity vs. append-only discipline.** Continue's "read the whole file, mutate the whole array in memory, write the whole file back" model is genuinely simpler to reason about locally, no ordinal, no precondition classification, no oneof to extend, and for a single-user, single-writer, JSON-file-sized session it mostly works. We pay for durability, replay, and multi-consumer correctness with a heavier write path (typed events, `SessionOrdinal`, per-event precondition classification). Continue's failure modes are the cost of the simplicity, not proof the simplicity was a mistake for a product at its scale. +- **In-memory "last session" vs. a durable pointer.** Continue's `lastSessionId` living only in Redux state is a legitimate choice for a GUI extension whose process lifetime is tied to the editor window; a durable pointer would be over-engineering for that specific product. It becomes a defect only in the CLI, which has a genuinely durable process boundary (terminal sessions start and end) and still doesn't have one. This cuts both ways: it argues we should decide the "last session" question (recommendation 2) for our own multi-surface product, without implying Continue was wrong to skip it for its GUI surface specifically. + +## What not to copy + +- **Two independently written mutable documents for one conceptual session, with no atomic write and no reconciliation.** The transcript file and `sessions.json` can and do disagree, permanently, once they diverge (see the four failure modes above). Nothing in our design should ever introduce a second mutable "index" file that isn't rebuildable from the log. +- **Two mutually inconsistent implementations of the same operation (compaction) in one codebase.** One destructive, one not, with no shared contract between them. A single typed event with an explicit, unambiguous covered range (as `Compacted` already is) is the right shape precisely because it cannot fork into two behaviors by accident. +- **A privileged, undocumented second read path that disagrees with the primary index.** The CLI's raw `fs.readdirSync` + mtime scan for `--resume` answers a related but different question than `sessions.json`-backed listing, silently. If we ever need a "fast path" read for performance, it must be provably consistent with (or explicitly labelled as an approximation of) the authoritative projection, never a quietly independent source of truth. +- **Monkey-patching your own persistence layer to keep it from observing a running operation.** The CLI subagent path explicitly sets `services.chatHistory.isReady = () => false` for the duration of a subagent call specifically to stop its own store from writing (`extensions/cli/src/subagent/executor.ts:109-112`). Treating "don't let the store see this" as a feature, rather than modeling the operation as a first-class thing the store legitimately should see, is the opposite of the sibling-session design in decision 6. +- **Silent, undocumented format-compat sniffing with a data-dropping fallback.** The Python-era `session_id` snake_case sniff (since commit `7edfd3d65`) silently drops old-format entries with no migration and no warning. Any format evolution in our catalog should either fail loudly or carry data forward; it must never resolve an unrecognized shape by quietly discarding it. +- **Destructive, in-place, unmarked rewind.** Both of Continue's clients truncate the message array on edit-and-resend with nothing recorded and no way to recover the discarded tail. `SessionRewound` exists precisely so this never has to be true for us. + +## The two gaps the industry has not closed + +### Subagent cascade + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6 already takes a position: a delegated child is a first-class `Session` with its own log, created via an atomic `[SessionStarted, ParentLinked]` batch under `NoStream` after the parent's `DelegationDispatched` acks (parent-first dispatch, `delegation_dispatched.proto:20-25`, `parent_linked.proto:19-27`), acyclic by construction because a fresh `child_session_id` is always minted, with an explicit, recorded `CascadePolicy` (`cascade_policy.proto:8-16`) governing what happens to the child when the parent reaches a terminal state, and a distinct, separately-named fact for rewind-triggered invalidation (`ParentHistoryInvalidated`, `parent_history_invalidated.proto:17-27`) versus terminal-cascade (`ParentTerminated`, `parent_terminated.proto:11-19`). The question is whether Continue's evidence validates, refines, or challenges that position. + +Continue's CLI is the only place in the product with any subagent concept at all (`extensions/cli/src/tools/subagent.ts:15-115` → `executor.ts:58-213`), and its answer is the degenerate opposite end of the spectrum from decision 6: a subagent is not a session, durable or otherwise. It is a brand-new, in-memory-only `ChatHistoryItem[]` that is deliberately kept away from the persistence layer for its entire lifetime, `executor.ts:109-112` monkey-patches `services.chatHistory.isReady` to `false` specifically "to prevent it from interfering with child session" (per the code comment cited in the dossier) and restores it in a `finally` block at `:190-193`. The only durable trace a subagent run leaves is its final text output, folded into the parent's `ChatHistoryItem.toolCallStates` via `addToolResult()`. The `parentSessionId` field is plumbed as far as `SubAgentExecutionOptions`'s type signature and then never read inside `executeSubAgent`'s body (the destructuring at `executor.ts:61` omits it), so even the naming convention of "this belongs to a parent" is vestigial. There is consequently no cascade question to ask of Continue at all, no crash-recovery question, no orphan question, because there is no durable child to orphan, crash-recover, or cascade against; a crash mid-subagent-call loses the entire in-progress child transcript with nothing left to inspect, resume, or steer, and this could not be otherwise resumed even in principle since the state never touches disk. + +This validates decision 6 rather than challenging it: Continue is direct evidence of what a product looks like when it declines to build the thing decision 6 builds. Every other product in the synthesis corpus that has subagents at all converges on "sibling stream/session linked by a parent pointer" (synthesis convergence 6); Continue's CLI is the one data point that shows what happens when even that minimal convergence is skipped, total loss of durability, inspectability, and resumability for the delegated work, in exchange for a marginally simpler implementation (no persistence-layer coordination needed during the call). Decision 6's `CascadePolicy` enum and explicit `ParentTerminated`/`ParentHistoryInvalidated` distinction are a strictly more capable design than "no durable child exists," not a solution to a problem Continue's approach avoided; Continue simply moved the cost from "undefined cascade behavior" (the industry-wide unresolved gap named in the synthesis) to "no behavior to define," which is worse, not better, for anyone who wants to see, resume, or audit what a subagent actually did. + +### Retention on an unbounded log + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7 already takes a position: the log is keep-forever and never truncated; `SessionHidden` is a visibility tombstone with no byte deletion, replacing the older `SessionDeleted` naming precisely so it stops promising erasure the log does not perform; `RedactionApplied` masks named event ids at read time while leaving the original bytes on the log; `ArtifactErased` separates out-of-line artifact-byte destruction from log retention entirely; and true erasure-grade/crypto-shredding is explicitly deferred to a follow-up ADR, with an optional reversible cold-tiering path to JetStream Object Store. The question is whether Continue's evidence validates, refines, or challenges that position. + +Continue has no retention story of any kind: the dossier grepped `ttl`/`retention`/`cleanup`/`prune`/`gc` across the codebase and found zero hits, no scheduled cleanup, no size-based eviction, no age-based expiry. `clearAll()` is the single crash-safer deletion path (one `fs.rmSync`), and it is still not atomic at the filesystem level. Delete is local, in-place, and order-dependent rather than transactional, which is precisely how the whole-index-wipeout failure mode above happens: `delete()`'s `safeParseArray` swallows a `JSON.parse` failure on a corrupted `sessions.json` to `undefined`, falls back to `?? []`, and then writes that empty array back over every other session's index entry, so a single corrupted read during an unrelated delete can silently discard the entire session index. This is a stronger, more concrete failure than "the log grows forever": it is "an ordinary delete operation can destroy unrelated data because there was never an append-only discipline to make deletion safe in the first place." + +This is weaker evidence than the subagent case, and should be weighted accordingly: unlike some other products in the corpus, Continue's dossier documents no confirmed, field-reported growth or corruption issue tied to this, only architectural risk read directly out of the code (full-file read/write with no pagination, "untested beyond the 100-session unit test" per the dossier). Marked as **inference, not a confirmed incident**: whether any Continue user has actually hit the whole-index-wipeout path in production is one of the dossier's own Open Questions, unresolved. With that caveat, Continue still refines decision 7 rather than challenging it, and in a specific direction: decision 7's `SessionHidden`/`RedactionApplied`/`ArtifactErased` split exists because a plain, un-typed "delete" operation is dangerous once more than one record could be affected by it, and Continue's whole-index-wipeout is a demonstration of exactly that danger materializing in a product that treats delete as a single untyped, order-dependent filesystem operation rather than a set of distinct, explicitly named facts. It does not argue for anything decision 7 doesn't already have; it argues decision 7's insistence on splitting "hide," "redact," and "erase" into separate typed events, none of which is allowed to be a blind array/file rewrite, is doing real, load-bearing work that a less disciplined store visibly lacks. + +## Open questions for the ADR + +- Should [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) (or a companion operational runbook) commit to a scheduled reconciliation job that diffs a full-replay `SessionProjection` against the live one, given that Continue is evidence that "rebuildable in principle" and "actually verified to match" are different guarantees, and that a divergence with no detection mechanism can persist indefinitely unnoticed? (See recommendation 1.) +- Does the catalog need a durable "most recently active session" fact at all, or is resume-target selection entirely a client-side concern layered on `SessionProjection` listing? Continue's two disagreeing implementations (in-memory Redux state vs. raw directory scan) are evidence the question needs an explicit owner, not evidence for either answer. (See recommendation 2.) +- Continue's CLI subagent path shows a real product declining to give delegated work any durable session at all, specifically to keep it out of its own persistence layer's way during the call. Does decision 6's parent-first `[SessionStarted, ParentLinked]` dispatch impose any latency or coordination cost during a delegation call that a caller might be tempted to work around the way Continue's CLI worked around its own store, and if so, should the ADR say more about why that workaround is the wrong trade-off? +- Continue's dossier could not confirm whether its documented whole-index-wipeout or orphan-file failure modes have ever actually occurred in the field, only that the code permits them. Should the ADR's Consequences section note that a similar unknown, "does our reconciliation/rebuild path actually get exercised before a real divergence would be user-visible", remains open for our own design until recommendation 1 is acted on or explicitly rejected? diff --git a/docs/research/session-store/products/crush/index.md b/docs/research/session-store/products/crush/index.md new file mode 100644 index 000000000..286983fd4 --- /dev/null +++ b/docs/research/session-store/products/crush/index.md @@ -0,0 +1,980 @@ +# Crush: how session transcripts are stored and resumed + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT](../../RESEARCH_PROMPT.md). +Evidence snapshot retrieved 2026-08-04. Version-sensitive claims were checked +against these authoritative anchors: + +- Repo: `github.com/charmbracelet/crush`, cloned locally, pinned at commit + `fcfad839bbeff6530249c5e77f872eee2c7cb90e` ("Merge pull request #3489 from + charmbracelet/server-404s", committed 2026-08-03). `go.mod` declares + `module github.com/charmbracelet/crush` / `go 1.26.5`. +- `internal/db/migrations/*.sql` -- 7 goose migrations, the schema ledger. +- `internal/db/models.go`, `internal/db/sessions.sql.go`, `internal/db/messages.sql.go` (and siblings `files.sql.go`, `read_files.sql.go`) -- sqlc-generated data-access layer. +- `internal/session/session.go`, `internal/message/message.go`, `internal/message/content.go`, `internal/history/file.go`, `internal/filetracker/service.go` -- the hand-written service layer that gives the generated rows their meaning. +- `internal/db/connect.go`, `internal/db/connect_ncruces.go`, `internal/db/connect_modernc.go`, `internal/db/datadirlock.go` -- connection lifecycle, pragmas, and locking. +- `internal/agent/agent.go`, `internal/agent/coordinator.go`, `internal/agent/tools/edit.go` -- compaction and subagent orchestration, and file-history write call sites. +- `internal/server/server.go`, `internal/proto/session.go`, `internal/cmd/session.go`, `internal/cmd/stats.go` -- the REST/CLI surfaces layered on top of the same SQLite file. + +**License note (required):** Crush is licensed under **FSL-1.1-MIT** +(Functional Source License 1.1, MIT Future License), Copyright 2025-2026 +Charmbracelet, Inc. (`LICENSE.md:1-9`). This is a source-available license, +**not** an OSI-approved open-source license, while the version stays inside +its window. `LICENSE.md:87-92` ("Grant of Future License") grants an +irrevocable MIT license effective "on the second anniversary of the date we +make the Software available," applied per released version -- the tail of the +file (`LICENSE.md:114-116` in this checkout) carries a standing MIT block +already covering an earlier, now-converted release window +("Copyright (c) 2025-03-21 - 2025-05-30 Kujtim Hoxha"). Anything cited from +this dossier as open-source precedent should carry this caveat: the code +inspected here is source-available under FSL terms today, converting to MIT +on a rolling two-year delay, not freely re-licensable at the time of this +snapshot. + +## The storage model + +Crush's durable session state is a **single SQLite database file per +project**, `crush.db`, opened at `filepath.Join(dataDir, "crush.db")` +(`internal/db/connect.go:93`). There is no external log format, no JSONL, and +no blob store: every session, message, and file version is a row in one of +four tables defined across 7 goose migrations +(`internal/db/migrations/20250424200609_initial.sql`, +`.../20250515105448_add_summary_message_id.sql`, +`.../20250624000000_add_created_at_indexes.sql`, +`.../20250627000000_add_provider_to_messages.sql`, +`.../20250810000000_add_is_summary_message.sql`, +`.../20250812000000_add_todos_to_sessions.sql`, +`.../20260127000000_add_read_files_table.sql`). + +The initial migration defines the core schema +(`internal/db/migrations/20250424200609_initial.sql:1-98`): + +```sql +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + parent_session_id TEXT, + title TEXT NOT NULL, + message_count INTEGER NOT NULL DEFAULT 0 CHECK (message_count >= 0), + prompt_tokens INTEGER NOT NULL DEFAULT 0 CHECK (prompt_tokens >= 0), + completion_tokens INTEGER NOT NULL DEFAULT 0 CHECK (completion_tokens>= 0), + cost REAL NOT NULL DEFAULT 0.0 CHECK (cost >= 0.0), + updated_at INTEGER NOT NULL, + created_at INTEGER NOT NULL +); +-- ... +CREATE TABLE IF NOT EXISTS files ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + path TEXT NOT NULL, + content TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE, + UNIQUE(path, session_id, version) +); +-- ... +CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + parts TEXT NOT NULL default '[]', + model TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + finished_at INTEGER, + FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE +); +``` + +(`internal/db/migrations/20250424200609_initial.sql:4-14` sessions, +`:24-34` files, `:47-57` messages.) A later migration adds a fourth table, +`read_files`, a freshness sidecar keyed by `(path, session_id)` +(`internal/db/migrations/20260127000000_add_read_files_table.sql:3-9`). + +Three different mutability regimes coexist under one roof, and none of them +is a pure append-only log: + +- **`sessions`** is a classic mutable document row: one row per session, + updated in place (title, token/cost counters, `summary_message_id`, + `todos`) via full-row `UPDATE` statements + (`internal/db/sql/sessions.sql:43-53`, `:55-63`). Denormalized counters + (`message_count`) are maintained by `AFTER INSERT`/`AFTER DELETE` triggers + on `messages`, not recomputed at read time + (`internal/db/migrations/20250424200609_initial.sql:68-82`). +- **`messages`** is row-per-message, but each row's `parts` column is a JSON + array that gets **wholesale-overwritten** on every update + (`internal/message/message.go:403-411`, the `UpdateMessage` call) -- this is + a mutable-document-per-row model, not an append of deltas. The collection + of message rows *is* ordered append-like (`ORDER BY created_at ASC` in + `internal/db/sql/messages.sql`, referenced via `ListMessagesBySession` in + `internal/db/querier.go:39`), so the session-as-transcript is a sequence of + mutable rows rather than a sequence of immutable log entries. +- **`files`** is the one genuinely append-only structure: every edit inserts + a *new* row with an incremented `version`, and the schema's + `UNIQUE(path, session_id, version)` constraint + (`internal/db/migrations/20250424200609_initial.sql:33`) plus the + `history.Service` code path (`internal/history/file.go:58-135`) never + update an existing file-version row in place. + +Nothing here is a rebuildable projection in the fx/Zed sense of "derived from +an authoritative log": the `sessions`, `messages`, and `files` tables are all +independently authoritative for their own facts, cross-linked only by +`session_id` foreign keys. There is no separate index/search/cache layer +that could be dropped and rebuilt from a canonical log -- SQLite itself, via +its own B-tree indexes, is the only "index" in the system +(`CREATE INDEX ... idx_messages_session_id`, +`internal/db/migrations/20250424200609_initial.sql:59`, and similar). + +Best-fit conceptual model: **session-as-row**, with a **message-collection** +of mutable per-message rows, and a wholly separate **file-version log** +keyed by path. It does not match session-as-transcript-file, +session-as-directory, or session-as-append-only-log cleanly; it is closest to +"session-as-row" extended with two child collections (messages, file +versions) that have different mutability characters from each other. + +## Keying and identity + +- **Top-level session ID**: `uuid.New().String()` (google/uuid v4), minted + client-side in `internal/session/session.go:98` inside `service.Create`. + The scheme carries no ordering information; `sessions.created_at` / + `updated_at` (Unix-epoch integer columns) carry ordering instead. +- **Task/subagent session ID**: the *tool-call ID itself* is reused as the + session's primary key -- `CreateTaskSession(ctx, toolCallID, parentSessionID, title)` + sets `ID: toolCallID` directly (`internal/session/session.go:110-122`). + `parent_session_id` is set to the caller's session ID + (`internal/session/session.go:113`). +- **Title-generation session ID**: a derived string, `"title-" + parentSessionID` + (`internal/session/session.go:124-136`) -- a deterministic, collidable + composite key (calling `CreateTitleSession` twice for the same parent hits + the `sessions.id` primary key and fails/upserts via `INSERT`, not observed + guarded further in this file). +- **Composite agent-tool session key**: `CreateAgentToolSessionID(messageID, toolCallID)` + returns `fmt.Sprintf("%s$$%s", messageID, toolCallID)` + (`internal/session/session.go:351-353`), parsed back by + `ParseAgentToolSessionID` (`:356-362`, splits on `"$$"`) and tested by + `IsAgentToolSession` (`:365-368`). This is the ID actually passed as + `agentToolSessionID` into `CreateTaskSession` from the coordinator + (`internal/agent/coordinator.go:1404-1406`), so a running sub-agent's + session ID **is** `toolCallID` (per `CreateTaskSession`'s own `ID: toolCallID` + assignment) while the `$$`-joined string is a separate addressing scheme + used to derive that `toolCallID`/`messageID` pairing -- both mechanisms sit + side by side in the same file rather than being layered. +- **CLI short-ID / git-style addressing**: `session.HashID(id)` XXH3-hashes + the UUID and hex-encodes it (`internal/session/session.go:27-32`). + `resolveSessionID` (`internal/cmd/session.go:217-258`) first tries a direct + `Get` by full ID, then falls back to an **O(n) scan of every session** + returned by `List`, hashing each and matching on exact-or-prefix, with + git-style ambiguity disambiguation printing all matches + (`internal/cmd/session.go:229-256`) if more than one session shares a + prefix. +- **Listing scope**: `ListSessions` is a plain `SELECT * FROM sessions WHERE + parent_session_id is NULL ORDER BY updated_at DESC` + (`internal/db/sql/sessions.sql:37-41`) -- scoped to whichever `crush.db` the + process has open, i.e. implicitly scoped **per project**, since each + project gets its own data directory (see below). There is no + `workspace_id`/`project_id` column anywhere in the schema; the database + file boundary *is* the project boundary. Subagent/task/title sessions + (those with a non-null `parent_session_id`) are excluded from this and + from every stats query (`internal/db/sql/stats.sql:1-9, 21-27`, all filter + `WHERE parent_session_id IS NULL`). +- **Project scoping and relocation**: the data directory is resolved by + `internal/config/load.go:556-559` via + `fsext.LookupClosestBounded(workingDir, projectBoundary(workingDir), defaultDataDirectory)` + (looks for an existing `.crush` bounded by the project root), falling back + to creating `filepath.Join(workingDir, defaultDataDirectory)` + (`defaultDataDirectory = ".crush"`, `internal/config/config.go:24`). There + is no rename/relocation reconciliation logic found: if a project directory + is physically moved as a whole, its `.crush/crush.db` moves with it and is + found again at the new path; if a project is *re-created* at a new path + without moving the old `.crush`, a fresh, empty `crush.db` is created there + instead -- **inference**, not directly asserted by any code comment. +- **Cross-project enumeration**: the one place multiple projects' databases + are read in a single operation is `crush stats`. + `crawlForStats` walks a root directory looking for files literally named + `crush.db` (`internal/cmd/stats.go:243-244, 261`), then + `gatherStatsFromProjects` / `gatherStatsFromDBPaths` + (`internal/cmd/stats.go:314, 339`) open each with `db.ConnectReadOnly` + (no migrations run, `internal/db/connect.go:219-239`) and + `mergeStats` (`internal/cmd/stats.go:388`) combines the read-only query + results. This is an analytics-only aggregation path, not a session + list/resume path -- session listing itself never spans more than one + `crush.db`. + +## The store interface + +Crush has no pluggable session-store adapter/trait -- the store is internal. +Per the Method section's guidance for non-pluggable stores, the effective +interface is reconstructed at two layers. + +**Layer 1 -- sqlc-generated `Querier`** (`internal/db/querier.go:11-49`), +reproduced verbatim (it is the actual Go interface implemented by +`*db.Queries`, `internal/db/querier.go:51`): + +```go +type Querier interface { + CreateFile(ctx context.Context, arg CreateFileParams) (File, error) + CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) + CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) + DeleteFile(ctx context.Context, id string) error + DeleteMessage(ctx context.Context, id string) error + DeleteSession(ctx context.Context, id string) error + DeleteSessionFiles(ctx context.Context, sessionID string) error + DeleteSessionMessages(ctx context.Context, sessionID string) error + GetAverageResponseTime(ctx context.Context) (int64, error) + GetFile(ctx context.Context, id string) (File, error) + GetFileByPathAndSession(ctx context.Context, arg GetFileByPathAndSessionParams) (File, error) + GetFileRead(ctx context.Context, arg GetFileReadParams) (ReadFile, error) + GetHourDayHeatmap(ctx context.Context) ([]GetHourDayHeatmapRow, error) + GetLastSession(ctx context.Context) (Session, error) + GetMessage(ctx context.Context, id string) (Message, error) + GetRecentActivity(ctx context.Context) ([]GetRecentActivityRow, error) + GetSessionByID(ctx context.Context, id string) (Session, error) + GetToolUsage(ctx context.Context) ([]GetToolUsageRow, error) + GetTotalStats(ctx context.Context) (GetTotalStatsRow, error) + GetUsageByDay(ctx context.Context) ([]GetUsageByDayRow, error) + GetUsageByDayOfWeek(ctx context.Context) ([]GetUsageByDayOfWeekRow, error) + GetUsageByHour(ctx context.Context) ([]GetUsageByHourRow, error) + GetUsageByModel(ctx context.Context) ([]GetUsageByModelRow, error) + ListAllUserMessages(ctx context.Context) ([]Message, error) + ListFilesByPath(ctx context.Context, path string) ([]File, error) + ListFilesBySession(ctx context.Context, sessionID string) ([]File, error) + ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error) + ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error) + ListNewFiles(ctx context.Context) ([]File, error) + ListSessionReadFiles(ctx context.Context, sessionID string) ([]ReadFile, error) + ListSessions(ctx context.Context) ([]Session, error) + ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error) + RecordFileRead(ctx context.Context, arg RecordFileReadParams) error + RenameSession(ctx context.Context, arg RenameSessionParams) error + UpdateMessage(ctx context.Context, arg UpdateMessageParams) error + UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error) + UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) error +} +``` + +Note `ListNewFiles` (`internal/db/querier.go:40`) queries a `WHERE is_new = 1` +predicate (`internal/db/sql/files.sql:58-62`, generated into +`internal/db/files.sql.go:244-249`) against a column that **does not exist** +in any of the 7 migrations -- see Open Questions. + +**Layer 2 -- hand-written service interfaces**, which is what application +code (agent, tools, CLI, REST handlers) actually calls: + +- `session.Service` (`internal/session/session.go:65-82`): `Create`, + `CreateTitleSession`, `CreateTaskSession`, `Get`, `GetLast`, `List`, `Save`, + `UpdateTitleAndUsage`, `Rename`, `Delete`, plus + `CreateAgentToolSessionID`/`ParseAgentToolSessionID`/`IsAgentToolSession`. +- `message.Service` (`internal/message/message.go:46-68`): `Create`, + `Update`, `Get`, `List`, `ListUserMessages`, `ListAllUserMessages`, + `Delete`, `DeleteSessionMessages`, `Flush`, `FlushAll` -- with an explicit + documented consistency contract (quoted in full under Write and append + path below). +- `history.Service` (`internal/history/file.go:29-42`): `Create`, + `CreateVersion`, `Get`, `GetByPathAndSession`, `ListBySession`, + `ListLatestSessionFiles`, `Delete`, `DeleteSessionFiles`. +- `filetracker.Service` (`internal/filetracker/service.go:16-26`, thin + wrapper over `read_files`): `RecordRead`, `LastReadTime`, `ListReadFiles`. + +**Layer 3 -- external REST surface**, the one place a *different process* can +be a "caller" against the same store: `internal/server/server.go:171-202` +registers `GET/POST /v1/workspaces/{id}/sessions`, +`GET/PUT/DELETE /v1/workspaces/{id}/sessions/{sid}`, `.../history`, +`.../messages`, `.../messages/user`, `.../filetracker/files`, and an +`agent/sessions/{sid}` subresource (get, `/cancel`, `/prompts/queued`, +`/prompts/list`, `/prompts/clear`, `/summarize`, `/shell`). Transport is a +local Unix domain socket / Windows named pipe +(`maxUnixSocketPathLen = 104`, `internal/server/server.go:25`; `net.Listener` +field, `:97`; `listen(s.network, s.Addr)`, `:249`) -- not a remote/TCP path. +`internal/proto/session.go:1-37` defines the REST-layer `Session` DTO, with +two fields computed on read rather than persisted: `IsBusy` and +`AttachedClients` (`internal/proto/session.go:5-15`, doc comments explain +both are derived from in-memory workspace/coordinator state, not columns). + +## Write and append path (ordering, durability, concurrency, delivery) + +**Ordering.** `sessions.updated_at`/`created_at` and `messages.created_at` +are Unix-epoch integer columns set via SQLite's own `strftime('%s', 'now')` +(`internal/db/sql/sessions.sql:22-23`) -- server-assigned wall-clock +timestamps, not a monotonic sequence number. Message read order is +`ORDER BY created_at ASC` (implied by `ListMessagesBySession`, backing +`internal/message/message.go:456-469`); file-version order is `version` +ascending/descending depending on query (`internal/db/sql/files.sql`, +`ListFilesByPath` is read DESC per `internal/history/file.go:78` +comment "Files are ordered by version DESC, created_at DESC"). + +**Commit style differs by table.** + +- `sessions`: full-row `UPDATE` (`internal/db/sql/sessions.sql:43-53` + `UpdateSession`; `:55-63` `UpdateSessionTitleAndUsage`, which increments + counters additively: `prompt_tokens = prompt_tokens + ?` etc., rather than + overwriting them -- a partial compare-free increment, still not a CAS). + `session.service.Save` documents itself as unsafe for concurrent + read-modify-write of the whole row (`internal/session/session.go:223-224` + comment: "safer than fetching, modifying, and saving the entire session") + and both `UpdateTitleAndUsage` and `Rename` exist specifically to avoid + that race for their narrower fields. +- `messages`: full-column overwrite of `parts` via `UpdateMessage` + (`internal/message/message.go:403-409`), but writes are **coalesced** + through an in-memory debounce buffer, not issued per keystroke of a + streaming response. `message.Service`'s doc comment + (`internal/message/message.go:31-45`) states the contract explicitly: + + ```go + // Service is the public interface to the message store. + // + // [Service.Update] is eventually consistent: it accepts new state into + // an in-memory buffer and writes it to SQLite plus publishes a + // [pubsub.UpdatedEvent] on the next debounce tick (default + // [defaultUpdateDebounce]) or on the next terminal-state update, + // whichever comes first. Terminal-state updates — those that finish + // the message, add or finish a tool call, or end a reasoning section — + // flush synchronously before [Service.Update] returns. + // + // Callers that need stronger ordering (e.g. tests, shutdown, + // session-switch reads) must use [Service.Flush] or [Service.FlushAll] + // before reading via [Service.Get] / [Service.List]. Without an + // explicit flush, a read can race the debounce timer and miss the + // most recent in-memory state. + ``` + + `defaultUpdateDebounce = 33 * time.Millisecond` + (`internal/message/message.go:21`). `shouldFlushNow` + (`internal/message/message.go:418-446`) forces a synchronous flush when a + message finishes, a tool call is added/finishes, or reasoning finishes -- + otherwise deltas coalesce for up to one debounce window. This is the + closest thing in Crush to a write-behind cache in front of SQLite. +- `files`: pure insert-only, one new row per version, via `CreateFile` + inside a transaction (`internal/history/file.go:93-131`). Never an + `UPDATE` on an existing file-version row. +- `read_files`: upsert, `INSERT ... ON CONFLICT(path, session_id) DO UPDATE + SET read_at = excluded.read_at` (`internal/db/sql/read_files.sql:1-11`) -- + a genuinely mutable single row per (session, path). + +**Durability/atomicity.** Every connection sets, at open time +(`internal/db/connect.go:18-27`): + +```go +pragmas = map[string]string{ + "foreign_keys": "ON", + "journal_mode": "WAL", + "page_size": "4096", + "temp_store": "MEMORY", + "cache_size": "-8000", + "synchronous": "NORMAL", + "secure_delete": "ON", + "busy_timeout": "30000", +} +``` + +Two build-tag-gated driver backends apply these identically but by different +mechanisms: `internal/db/connect_ncruces.go:23-43` (the `ncruces/go-sqlite3`, +CGO-free WASM-based driver, used for a narrower CPU-arch set) execs each +`PRAGMA name = value;` inside the connection-init callback and opens with DSN +`_txlock=immediate`; `internal/db/connect_modernc.go:20-46` (`modernc.org/sqlite`, +pure Go, used for the broader/default arch set per its build-tag list at +`internal/db/connect_modernc.go:1`) instead passes each pragma as a +`_pragma=name(value)` DSN query parameter, also with `_txlock=immediate`. Both +comments explain `_txlock=immediate` the same way: "Use BEGIN IMMEDIATE so +writers acquire the reserved lock up front, preventing deferred-to-writer +upgrade deadlocks." `foreign_keys: "ON"` matters directly for the cascade +question below -- SQLite ignores `FOREIGN KEY` clauses unless this pragma is +set per-connection, and Crush does set it on every connection, both driver +backends. + +`conn.SetMaxOpenConns(1)` (`internal/db/connect.go:142`) serializes *all* +access through a single `database/sql` connection, with an explicit comment +citing a past incident: "allowing multiple pool connections to interleave +writes/checkpoints (especially under concurrent sub-agents) has caused +WAL/header desync resulting in SQLITE_NOTADB (26) on the next open" +(`internal/db/connect.go:137-141`). This single-connection choice is itself +the primary concurrency-control mechanism in the whole system -- it turns +SQLite's file-level write serialization into full statement-level +serialization within one process. + +Cross-*process* concurrency for the same data directory has a second, +independent mechanism: an OS-level advisory `flock` on `{dataDir}/crush.lock` +(`internal/db/datadirlock.go:51-80`), acquired via `lock.TryFile` +non-blocking, deliberately never unlinked (`internal/db/datadirlock.go:73-78` +explains the flock-is-keyed-by-inode-not-path hazard this avoids). This lock +is **opt-in per `Connect` call** via `WithDataDirLock(true)` +(`internal/db/connect.go:69-76`), and the only call site enabling it is the +server/workspace-bootstrap path, `internal/backend/backend.go:426`: +`db.Connect(b.ctx, cfg.Config().Options.DataDirectory, db.WithDataDirLock(true))`. +Ordinary local single-process TUI/CLI usage does not take this lock -- it +relies solely on `SetMaxOpenConns(1)` plus SQLite's own file locking. An +escape hatch, `CRUSH_SKIP_DATADIR_LOCK`, bypasses acquisition entirely +(`internal/db/datadirlock.go:83-86`). + +**Transactions.** Explicit `BeginTx`/`Commit`/`Rollback` wrapping is used in +exactly two places: `session.service.Delete`'s three-statement cascade +(`internal/session/session.go:138-169`, quoted under Subagents below) and +`history.service.createWithVersion`'s retry loop +(`internal/history/file.go:84-135`). Everything else (`message.Service`'s +writes, `session.Save`/`Rename`/`UpdateTitleAndUsage`) is a single SQL +statement relying on SQLite's own per-statement atomicity, with no +app-level transaction wrapper. + +**Concurrency model / expected-version precondition.** There is **no +optimistic-concurrency/expected-version precondition** anywhere in the +session, message, or history service layers -- confirmed by an explicit grep +across `internal/session/*.go`, `internal/message/*.go`, `internal/history/*.go` +for `expected_version`/CAS/compare-and-swap patterns, with zero matches +outside the one UNIQUE-constraint-retry loop below. The closest thing to +optimistic concurrency is in `history.service.createWithVersion` +(`internal/history/file.go:84-135`): + +```go +func (s *service) createWithVersion(ctx context.Context, sessionID, path, content string, version int64) (File, error) { + const maxRetries = 3 + var file File + var err error + for attempt := range maxRetries { + tx, txErr := s.db.BeginTx(ctx, nil) + ... + qtx := s.q.WithTx(tx) + dbFile, txErr := qtx.CreateFile(ctx, db.CreateFileParams{ + ID: uuid.New().String(), SessionID: sessionID, Path: path, + Content: content, Version: version, + }) + if txErr != nil { + tx.Rollback() + if strings.Contains(txErr.Error(), "UNIQUE constraint failed") { + if attempt < maxRetries-1 { + version++ + continue + } + } + return File{}, txErr + } + if txErr = tx.Commit(); txErr != nil { ... } + file = s.fromDBItem(dbFile) + s.Publish(pubsub.CreatedEvent, file) + return file, nil + } + return file, err +} +``` + +This is retry-on-conflict via the `UNIQUE(path, session_id, version)` +constraint, auto-incrementing the version up to 3 attempts -- not a +caller-supplied expected-version precondition (the caller never states what +version it expects to be writing over). + +**Delivery semantics.** Best-effort within the process: `message.Service` +documents itself as eventually consistent (quoted above); `pubsub.Broker` +events are fire-and-forget except for `PublishMustDeliver` used for terminal +message events (`internal/message/message.go:378-382`). There is no +client-supplied idempotence key / dedup-by-entry-id anywhere -- every write +path mints a fresh `uuid.New().String()` server-side +(`internal/message/message.go:177`, `internal/history/file.go:103`, +`internal/session/session.go:98`). + +## Read and resume path + +Resume always reads the durable SQLite file directly -- there is no separate +resume-time cache or filesystem snapshot. `session.service.Get` / +`session.service.List` (`internal/session/session.go:171-179, 252-263`) are +thin wrappers over `GetSessionByID`/`ListSessions`. Message history is +reconstructed by a **full ordered `SELECT`** of every row for the session, +`ListMessagesBySession` (backing `internal/message/message.go:456-469`), +with **no pagination, cursor, or offset** anywhere in the read path -- cost +scales linearly with the number of messages ever created in that session, no +stated bound found. + +The agent-level read path, `getSessionMessages` +(`internal/agent/agent.go:1692-1711`), is the one place a *bound* is applied, +and it is applied **after** the full read, in memory: + +```go +func (a *sessionAgent) getSessionMessages(ctx context.Context, session session.Session) ([]message.Message, error) { + msgs, err := a.messages.List(ctx, session.ID) + if err != nil { + return nil, fmt.Errorf("failed to list messages: %w", err) + } + if session.SummaryMessageID != "" { + summaryMsgIndex := -1 + for i, msg := range msgs { + if msg.ID == session.SummaryMessageID { + summaryMsgIndex = i + break + } + } + if summaryMsgIndex != -1 { + msgs = msgs[summaryMsgIndex:] + msgs[0].Role = message.User + } + } + return msgs, nil +} +``` + +Because `message.Service.Update` buffers state in memory with up to a +33ms debounce (`internal/message/message.go:21`), a read immediately after a +write can race the debounce timer; the service's own doc comment +(`internal/message/message.go:41-45`) says callers needing a +guaranteed-fresh read (explicitly including "session-switch reads") must call +`Flush`/`FlushAll` first. `FlushAll`'s own doc comment +(`internal/message/message.go:280-284`) confirms it exists specifically for +"shutdown and session-switch paths." + +There is no lazy/eager split beyond this: everything returned by `List` is +materialized eagerly into memory as `message.Message` structs +(`internal/message/message.go:456-469`); nothing is fetched lazily +per-field. + +## Listing, summaries, and search + +`session list` / the sessions picker reads the full `sessions` table for the +current project's `crush.db` (`WHERE parent_session_id is NULL ORDER BY +updated_at DESC`, `internal/db/sql/sessions.sql:37-41`) -- no pagination +found, cost bounded by the number of top-level sessions in that one +project's database, not globally. + +There is a metadata sidecar, but it is **denormalized in place on the +`sessions` row itself**, not a separate projection table: `message_count` is +maintained by `AFTER INSERT`/`AFTER DELETE` triggers +(`internal/db/migrations/20250424200609_initial.sql:68-82`), while +`prompt_tokens`/`completion_tokens`/`cost`/`title`/`summary_message_id`/`todos` +are maintained by explicit application writes +(`UpdateSessionTitleAndUsage`, `internal/db/sql/sessions.sql:55-63`; `Save`, +`internal/session/session.go:191-221`). Consistency with the underlying +`messages`/`files` rows is maintained by the write paths themselves (the +trigger fires in the same transaction as the `INSERT`/`DELETE`); there is no +separate rebuild/reconciliation job found. + +**No search subsystem exists.** No FTS table, no vector index, and no +external search service were found anywhere in `internal/db` or +`internal/message` (targeted greps for `fts`, `MATCH`, `embedding`, `vector` +all returned no relevant hits in the schema/service code). The only +"search"-like operation is the CLI's exact/prefix XXH3-hash match over an +in-memory list, `resolveSessionID` (`internal/cmd/session.go:217-258`) -- a +git-style short-ID resolver, not content search. + +Cross-project aggregation is limited to the `crush stats` command +(`internal/cmd/stats.go:243-244, 261, 314, 339, 388`), which crawls the +filesystem for files literally named `crush.db`, opens each read-only via +`db.ConnectReadOnly` (no migrations, `internal/db/connect.go:219-239`), and +merges the analytics query results. This is the sole place more than one +project database is read in a single operation, and it is analytics-only -- +it does not feed the session list/resume UI. + +## Entry/message structure and versioning + +The entry type is **not** in `internal/db` -- as flagged going in, it lives in +`internal/message/message.go` and `internal/message/content.go`, decoded out +of the `messages.parts` JSON TEXT column. + +`ContentPart` is a closed interface with a marker method +(`internal/message/content.go:51-53`): + +```go +type ContentPart interface { + isPart() +} +``` + +Eight concrete implementations, with full field lists +(`internal/message/content.go:55-146`): + +```go +type ReasoningContent struct { + Thinking string + Signature string + ThoughtSignature string // Used for google + ToolID string // Used for openrouter google models + ResponsesData *openai.ResponsesReasoningMetadata + StartedAt int64 + FinishedAt int64 +} +type TextContent struct { Text string } +type ImageURLContent struct { URL, Detail string } +type BinaryContent struct { Path, MIMEType string; Data []byte } +type ToolCall struct { + ID, Name, Input string + ProviderExecuted bool + Finished bool +} +type ToolResult struct { + ToolCallID, Name, Content, Data, MIMEType, Metadata string + IsError bool +} +type Finish struct { + Reason FinishReason + Time int64 + Message, Details string +} +// ShellCommand stores a bang-mode shell command and its output as a +// distinct content part so it can be reconstructed on session restore. +type ShellCommand struct { + Command string + Output string + ExitCode int +} +``` + +The JSON envelope is a hand-rolled tagged union, not a generic +`serde`/reflection scheme +(`internal/message/message.go:519-535, 537-646`): + +```go +type partType string + +const ( + reasoningType partType = "reasoning" + textType partType = "text" + imageURLType partType = "image_url" + binaryType partType = "binary" + toolCallType partType = "tool_call" + toolResultType partType = "tool_result" + finishType partType = "finish" + shellCommandType partType = "shell_command" +) + +type partWrapper struct { + Type partType `json:"type"` + Data ContentPart `json:"data"` +} +``` + +`marshalParts` type-switches on the concrete Go type to pick the tag +(`internal/message/message.go:537-570`); `unmarshalParts` does a two-pass +decode -- first into `[]json.RawMessage`, then per-element into an untyped +`{Type, Data json.RawMessage}` struct, then a `switch wrapper.Type` to +unmarshal `Data` into the matching concrete struct +(`internal/message/message.go:572-646`). There is **no schema-version field** +anywhere in this envelope or in the `messages` row -- additive fields +(`provider`, `is_summary_message`) were added as plain `ALTER TABLE ... ADD +COLUMN` migrations instead +(`internal/db/migrations/20250627000000_add_provider_to_messages.sql`, +`internal/db/migrations/20250810000000_add_is_summary_message.sql`), and the +`Message` Go struct picks up new fields via new `sql.NullString`/`int64` +columns on the generated `db.Message` (`internal/db/models.go:21-32`), not +via any envelope version discriminator. If the JSON shape of an existing +`ContentPart` type ever changed incompatibly, there is no sniffing/migration +mechanism visible in this tree to reinterpret old rows -- this is flagged +under Open Questions. + +**Storage-level format evolution** is entirely goose's migration ledger: +7 one-way `-- +goose Up` migrations, each also carrying a `-- +goose Down` +block, but the app only ever calls `goose.Up(conn, "migrations")` +(`internal/db/connect.go:163`) -- no code path found that invokes +`goose.Down`. Whether an edited-in-place migration file would be detected +(goose tracks applied version numbers in its own `goose_db_version` table, +which is a dependency's behavior, not code in this tree) was not verified +against goose's own source and is left as an inference boundary, unlike +Zed's `sqlez`, which strict-diffs migration text itself. + +## Compaction and history management + +Compaction is a **marker pattern that never deletes durable history** -- the +full session's `messages` rows are never truncated or rewritten by +summarization. + +`sessionAgent.Summarize` (`internal/agent/agent.go:1332-1471`) creates a +brand-new message row flagged as a summary +(`internal/agent/agent.go:1372-1377`): + +```go +summaryMessage, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{ + Role: message.Assistant, + Model: largeModel.ModelCfg.Model, + Provider: largeModel.ModelCfg.Provider, + IsSummaryMessage: true, +}) +``` + +then, once the summary content finishes streaming, the session row's +`summary_message_id` pointer is set to that message's ID and the session is +saved (`internal/session/session.go:191-221` `Save`, invoked from +`internal/agent/agent.go` later in `Summarize`). `messages.is_summary_message` +(`internal/db/migrations/20250810000000_add_is_summary_message.sql`) and +`sessions.summary_message_id` +(`internal/db/migrations/20250515105448_add_summary_message_id.sql`) are the +two on-disk artifacts this leaves. + +The read-side truncation is entirely `getSessionMessages` +(`internal/agent/agent.go:1692-1711`, quoted in full under Read and resume +path): it always lists the *complete* message history first, then, only if +`session.SummaryMessageID != ""`, slices the in-memory result down to +`msgs[summaryMsgIndex:]` and relabels that first (summary) message's `Role` +to `message.User` before handing it to the model as the new conversation +root. Any other reader of `messages.List` (REST `.../history`, `.../messages`, +the CLI, `crush stats`) sees the full, untruncated row set -- the compaction +boundary is a **model-context-builder concern**, applied at one specific +call site, not a store-level concern. This is functionally the same pattern +documented for Zed's `Message::Compaction` marker: durable history persists, +only the model-visible window shrinks, applied at exactly one read call site +rather than store-wide. + +## Rewind, checkpoints, and fork + +No session-level rewind/undo/branch verb exists. A targeted search across +`internal/` for `rewind`, `checkpoint`, `fork`/`Fork` turned up only UI ASCII +art and one WAL-checkpoint code comment (`internal/db/connect.go:139`, +referring to SQLite's own WAL-checkpoint mechanism, unrelated to +session/turn checkpoints) -- no session-retroactive-edit feature was found. + +What Crush has instead is **per-file version history tied to tool calls**, +not a session-level checkpoint/restore mechanism. Every file-mutating tool +(`internal/agent/tools/edit.go`, `multiedit.go`, `write.go`, +`lsp_rename.go`, `lsp_replace_symbol.go`) calls into `history.Service` to +record full file content per edit. `commitFileChange` +(`internal/agent/tools/edit.go:246-269`) is the representative call site: + +```go +func commitFileChange(edit editContext, sessionID, filePath, oldContent, newContent string) error { + if err := os.WriteFile(filePath, []byte(newContent), 0o644); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID) + if err != nil { + _, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent) + if err != nil { + return fmt.Errorf("error creating file history: %w", err) + } + } + if file.Content != oldContent { + // User manually changed the content; store an intermediate version. + if _, err := edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent); err != nil { + slog.Error("Error creating file history version", "error", err) + } + } + if _, err := edit.files.CreateVersion(edit.ctx, sessionID, filePath, newContent); err != nil { + slog.Error("Error creating file history version", "error", err) + } + edit.filetracker.RecordRead(edit.ctx, sessionID, filePath) + return nil +} +``` + +Each edit can write **one or two full-content rows** -- an intermediate +"old content" version whenever on-disk content has drifted from the last +recorded version (out-of-band user edits), plus always a new row for the +post-edit content. Content is stored **whole, not diffed, not +content-addressed, not deduplicated** -- `files.content TEXT NOT NULL` +(`internal/db/migrations/20250424200609_initial.sql:28`) holds the entire +file on every version. This is the same anti-pattern flagged in the fx +reference dossier (`previous_content` full-pre-image inlining): N edits to +one file cost roughly N (or 2N) full copies inside `crush.db`, unbounded by +anything but the file's own size times edit count. + +Nothing reads an old version back onto disk automatically: `history.Service` +exposes only `Get`/`GetByPathAndSession`/`ListBySession`/ +`ListLatestSessionFiles` as reads (`internal/history/file.go:29-42`), and a +grep of every caller of those methods +(`internal/workspace/app_workspace.go:294`, `internal/backend/session.go:91`, +`internal/agent/tools/edit.go:251`, `internal/agent/tools/write.go:143`) +shows them used only to *compare against* current on-disk content before +writing a new version -- no "restore file to version N" tool/command was +found. Versions are recorded but, in this codebase, apparently +write-only/diagnostic rather than restorable -- flagged under Open Questions +since a UI-only restore action (outside the greppable Go source, e.g. a TUI +key binding calling an undocumented path) cannot be ruled out from source +alone. + +Fork (branch a session from a point) has no code path found anywhere. + +## Subagents and nested sessions + +Subagent/task sessions are **first-class sibling rows** in the same +`sessions` table, linked to their parent only by the un-typed +`parent_session_id TEXT` column +(`internal/db/migrations/20250424200609_initial.sql:6`) -- there is no +separate "nested session" table or embedded-transcript structure. A child +session's messages/files are isolated in their own rows (own `session_id`), +not inherited or merged into the parent's transcript. + +**The central, surprising finding**: `parent_session_id` carries **no +foreign-key constraint anywhere** in any of the 7 migrations -- unlike +`files.session_id`, `messages.session_id`, and `read_files.session_id`, +which all declare `FOREIGN KEY (session_id) REFERENCES sessions (id) ON +DELETE CASCADE` +(`internal/db/migrations/20250424200609_initial.sql:32, 56`; +`internal/db/migrations/20260127000000_add_read_files_table.sql:7`). +`parent_session_id` is declared as a bare, unconstrained `TEXT` column +(`internal/db/migrations/20250424200609_initial.sql:6`) in the initial +migration and never gains a constraint in any later migration. Meanwhile, +`PRAGMA foreign_keys = "ON"` **is** set on every connection +(`internal/db/connect.go:19`, applied identically via both driver backends, +`internal/db/connect_ncruces.go:28-37`, `internal/db/connect_modernc.go:29-39`) +-- so the FKs that *do* exist (on `files`, `messages`, `read_files`) are +genuinely enforced by SQLite at runtime. The parent/child session +relationship itself, however, is simply never expressed in DDL, so there is +nothing for `foreign_keys=ON` to enforce on it. + +Consistent with that, the application-level delete path does **not** cascade +to child sessions either. `session.service.Delete` +(`internal/session/session.go:138-169`): + +```go +func (s *service) Delete(ctx context.Context, id string) error { + tx, err := s.db.BeginTx(ctx, nil) + ... + qtx := s.q.WithTx(tx) + dbSession, err := qtx.GetSessionByID(ctx, id) + ... + if err = qtx.DeleteSessionMessages(ctx, dbSession.ID); err != nil { ... } + if err = qtx.DeleteSessionFiles(ctx, dbSession.ID); err != nil { ... } + if err = qtx.DeleteSession(ctx, dbSession.ID); err != nil { ... } + if err = tx.Commit(); err != nil { ... } + ... +} +``` + +This deletes only the target session's own `messages` and `files` rows (via +their real `ON DELETE CASCADE` FKs firing as a backstop, plus the explicit +`DeleteSessionMessages`/`DeleteSessionFiles` calls before it) and the +session row itself -- there is no query anywhere for +`WHERE parent_session_id = ?` inside `Delete`, and no recursive walk of +children. **Net conclusion: deleting a parent session in Crush orphans any +child (task/title/agent-tool) sessions -- their rows, messages, and file +versions survive untouched, unreferenced, and undiscoverable through normal +listing** (since `ListSessions` filters `parent_session_id IS NULL`, +`internal/db/sql/sessions.sql:40`, an orphan with a now-dangling +`parent_session_id` is invisible to `session list` but still occupies rows +in `crush.db` indefinitely). No reconciliation/orphan-sweep job was found +anywhere in the codebase. + +Nesting depth (subagent spawning further subagents) is **not hardcoded**. +`buildTools` decides whether a given agent's own tool set includes the task +tool that can spawn a further subagent, gated purely by config +(`internal/agent/coordinator.go:681-683`): + +```go +func (c *coordinator) buildTools(ctx context.Context, agent config.Agent, isSubAgent bool) ([]fantasy.AgentTool, error) { + var allTools []fantasy.AgentTool + if slices.Contains(agent.AllowedTools, AgentToolName) { + agentTool, err := c.agentTool(ctx) + ... + } +``` + +Whether recursion is actually bounded therefore depends entirely on whether +a given `agent.AllowedTools` config includes `AgentToolName` for sub-agents +-- there is no `MAX_SUBAGENT_DEPTH`-style constant found (unlike Zed's +hardcoded `u8 = 1`). `runSubAgent` +(`internal/agent/coordinator.go:1402-1408`) creates the child session via +`CreateAgentToolSessionID` + `CreateTaskSession`, and +`updateParentSessionCost` (`internal/agent/coordinator.go:1487-1503`) rolls +the child's `Cost` into the parent's via an unguarded +read-modify-write-and-save (`parentSession.Cost += childSession.Cost`, +`internal/agent/coordinator.go:1497`) -- not wrapped in the same transaction +as anything else, relying entirely on the single-connection serialization +(`SetMaxOpenConns(1)`) to avoid lost updates from truly concurrent +subagents, rather than an atomic `UPDATE ... SET cost = cost + ?` +(`UpdateSessionTitleAndUsage`, which *does* do the increment-in-SQL version, +`internal/db/sql/sessions.sql:55-63`, is not the function used for cost +rollup here). Flagged as a potential race under Open Questions. + +On crash: nothing beyond normal SQLite crash recovery was found. There is no +special-cased "in-flight subagent" cleanup at startup; a killed process's +child sessions simply remain rows with whatever state they last flushed. + +## Retention, deletion, and multi-host + +**Retention/TTL**: none found. No scheduled cleanup job, no TTL column, no +lifecycle policy anywhere in `internal/`. Deletion is exclusively +user/CLI/REST-initiated (`sessionDeleteCmd`, +`DELETE /v1/workspaces/{id}/sessions/{sid}`). + +**Delete cascade**: within one session, `session.service.Delete` +(`internal/session/session.go:138-169`, quoted above) deletes messages and +files for that session inside one transaction, then the session row -- +real cascade for the session's own children (messages/files), but, as +established above, **no cascade to child sessions**. + +**Multi-host / multi-process**: multi-host is **not a first-class remote +path**. The "server" mode (`internal/server`, `internal/backend`) is a +local-machine IPC surface over a Unix domain socket / named pipe +(`internal/server/server.go:25, 97, 249`), letting multiple *local* clients +(e.g. multiple TUI/CLI invocations) share one running process's connection +to one `crush.db`. There is no network-filesystem or remote-writeback +handling found -- the OS-level `flock` (`internal/db/datadirlock.go:51-80`) +that gates concurrent access assumes a single local filesystem and a single +host; it is a same-machine, cross-*process* guard (default off, opt-in only +for the server bootstrap path, `internal/backend/backend.go:426`), not a +distributed-lock or leader-election mechanism. Crash detection is limited to +the informational (non-authoritative) `dataDirOwnerInfo` JSON payload +written into `crush.lock` (`internal/db/datadirlock.go:26-33, 88-98`) -- the +comment at `internal/db/datadirlock.go:73-78` is explicit that "the +authoritative state of ownership is the operating system flock on the file +descriptor," not the JSON payload, and a stale lock file left by a crashed +process's still-open fd is reclaimed the moment the kernel closes that fd, +with no separate app-level liveness check. + +## Interop with foreign session stores + +None found. No code path in this tree reads, imports, or converts session +data from another product's native store (Claude Code, Amazon Q, opencode, +etc.) -- targeted greps for common competitor session-file names/formats +inside `internal/` returned no hits. This section is otherwise not +applicable. + +## What this implies for our Session Store (our inference) + +**Our inference**: a stored session in Crush is "a row in `sessions` plus +whatever rows in `messages`/`files`/`read_files` reference its `id`" -- there +is no single authoritative append-only log underneath; instead there are +three independently-authoritative, differently-mutable stores (a +mutable session document, a collection of mutable-per-row messages, and a +genuinely append-only per-path file-version log) unified only by foreign +keys and a shared connection. It sits closer to a classic mutable-row RDBMS +model than to an event-sourced design: there is no single ordered log a +projection could be rebuilt from, updates are last-write-wins with no +expected-version precondition anywhere, and the one place durable history is +deliberately preserved across a destructive-looking operation (compaction) +does so via an in-place marker + read-time slice rather than an append-only +event. + +For our event-sourced Session Store, the two most transferable/cautionary +data points are: (1) the `parent_session_id`-without-FK finding shows that an +unenforced, DDL-invisible parent pointer silently produces permanent orphan +rows on delete -- our design should make the parent-child cascade/orphan +policy an explicit, enforced part of the schema (or an explicit, tested +reconciliation job) rather than an implicit convention; and (2) the +full-content, non-deduplicated file-version store is a concrete +cautionary example (alongside fx's `previous_content`) of unbounded storage +growth from turn-level file snapshots -- worth costing out explicitly if our +design considers storing file state per tool call. + +## Open questions + +- **`ListNewFiles`/`is_new` schema mismatch**: `internal/db/sql/files.sql:58-62` + and generated `internal/db/files.sql.go:244-249` reference a + `files.is_new` column that does not exist in any of the 7 migrations. + Confirmed dormant, not a live bug: the app only ever constructs queries via + `db.New(conn)` (`internal/db/db.go:20-22`), never `db.Prepare()` + (`internal/db/db.go:24-138`, which eagerly prepares every statement + including `ListNewFiles` at `internal/db/db.go:111-113` and would fail + loudly on this column), and `ListNewFiles` itself is never called anywhere + in application code. Left unresolved: why this column was ever generated + without a corresponding migration, and whether it is stale + work-in-progress or a since-abandoned feature. +- **`updateParentSessionCost` race**: `internal/agent/coordinator.go:1487-1503` + does an unguarded read-modify-write-and-save of `parentSession.Cost`, not + an atomic SQL increment. Whether this is actually racy in practice depends + on invariants not fully traced here (e.g., whether the coordinator + serializes subagent completions before calling this, beyond the blanket + `SetMaxOpenConns(1)` DB-level serialization) -- not fully verified. +- **File-version restore**: versions are recorded (`internal/history/file.go`) + and diffed-against-for-drift-detection (`internal/agent/tools/edit.go:258-263`), + but no "restore file to version N" code path was found via grep of every + caller of the history read methods. Cannot rule out a TUI-only interaction + not visible to a source-level grep. +- **Goose migration-ratchet strictness**: whether goose (a third-party + dependency, not vendored in this tree) detects an edited-in-place migration + file the way Zed's own `sqlez` does was not verified against goose's + source -- flagged as an inference boundary, not a checked claim. +- **Title-session ID collision**: `CreateTitleSession` mints a deterministic + `"title-" + parentSessionID` key (`internal/session/session.go:124-136`) + with no guard shown against calling it twice for the same parent within + this file -- behavior on a second call (unique-constraint error vs. silent + reuse) was not traced into `CreateSession`'s SQL (`INSERT`, no + `ON CONFLICT`, `internal/db/sql/sessions.sql:1-24`), so a second call would + presumably error; whether any caller guards against this was not verified. +- **Project relocation semantics**: whether moving/renaming a project + directory (as opposed to moving it as a whole with `.crush` inside it) has + any explicit reconciliation logic beyond `fsext.LookupClosestBounded` + (`internal/config/load.go:556-559`) was not exhaustively traced through + `internal/fsext`. diff --git a/docs/research/session-store/products/crush/vs-session-events.md b/docs/research/session-store/products/crush/vs-session-events.md new file mode 100644 index 000000000..afe2a8903 --- /dev/null +++ b/docs/research/session-store/products/crush/vs-session-events.md @@ -0,0 +1,543 @@ +# Crush compared to our session event catalog + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT_COMPARISON](../../RESEARCH_PROMPT_COMPARISON.md). +Stage-one dossier: [Crush](./index.md). +Compared against `proto/trogonai/session/sessions/v1alpha1/` and [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) on 2026-08-04. + +**Store maturity: 9/12**: evolution scars 2/3 (7 goose migrations add columns +across time and carry existing rows forward via plain `ALTER TABLE ... ADD +COLUMN` statements, for example `internal/db/migrations/20250810000000_add_is_summary_message.sql` +and `internal/db/migrations/20250627000000_add_provider_to_messages.sql`; +capped below 3/3 because the `messages.parts` JSON envelope itself carries no +schema-version field and no sniffing/back-compat read path, a gap the dossier +flags against itself), operational age 2/3 (a documented production incident +shaped the concurrency design: `internal/db/connect.go:137-141` cites +"WAL/header desync resulting in SQLITE_NOTADB (26) on the next open" as the +reason `SetMaxOpenConns(1)` exists; no first-commit date or issue-tracker +citation was available in the dossier to establish total field time), exposure +2/3 (a vendor-shipped Charmbracelet CLI/TUI product with a local REST/IPC +surface for multi-client-same-host use, but the dossier is explicit that this +is "not a first-class remote path," with no multi-host or network-filesystem +handling found anywhere), design independence 3/3 (an original Charmbracelet +schema and service layer, not inherited from an upstream fork). + +## The one structural difference everything else follows from + +Crush persists **current state**, not **history**. A stored session is a +mutable `sessions` row that *is* the state (title, token/cost counters, +`summary_message_id`, `todos`, all updated in place via full-row `UPDATE`, +`internal/db/sql/sessions.sql:43-53, 55-63`), plus a collection of `messages` +rows each individually mutable in place (`parts` is wholesale-overwritten by +`UpdateMessage`, `internal/message/message.go:403-411`), plus exactly one +genuinely append-only child collection, the per-path `files` version log. The +dossier's own conclusion is direct on this point: "there is no single +authoritative append-only log underneath; instead there are three +independently-authoritative, differently-mutable stores... unified only by +foreign keys and a shared connection." Crush sits in the cross-product +[synthesis](../../synthesis.md)'s "session-as-row" category alongside Goose and +Hermes, the two products that synthesis names as "the least event-sourced of +the products studied." + +We persist facts and derive state by folding them. `UserMessageRecorded`, +`AssistantMessageCompleted`, `ToolCallCompleted`, and every other arm of the +`SessionEvent` oneof are immutable once appended; the session's current title, +cost, summary boundary, and cascade state are never stored anywhere as a +mutable field, they are the output of folding the stream at read time +([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 2, facet 8). + +This is a different, more fundamental axis than the one the +[fx comparison](../fx/vs-session-events.md) leads with. fx's structural +difference is a question of commit granularity (turn versus fact) inside an +append-only design fx and we both already share. Crush's is a question of +whether an append-only design exists at all, and it does not, below the +`files` table. + +Nearly everything else in this comparison is a consequence of that one +choice, not an independent difference: + +- **No expected-version precondition anywhere** (confirmed by the dossier's + own explicit grep for CAS/compare-and-swap patterns across + `internal/session`, `internal/message`, `internal/history`) follows from + there being no single log whose position a caller could stake a claim + against. The closest analog, `history.service.createWithVersion`'s + retry-on-`UNIQUE`-constraint loop (`internal/history/file.go:84-135`), + auto-increments a version on conflict rather than checking one the caller + supplied, which is retry-on-collision, not optimistic concurrency. +- **The client-buffered, debounce-then-flush write path** for messages + (`internal/message/message.go:21`, `:31-45`) exists because a message row + is a thing you overwrite, not a thing you append to. A durable fact never + needs a "flush before reading" contract, because it was already durable the + instant it was appended. +- **The central cascade finding**, `parent_session_id`'s missing foreign key, + is a symptom of the same model: a relationship between two mutable rows is + exactly as durable as someone remembering to maintain it, whereas a + relationship recorded as an event (`DelegationDispatched` / `ParentLinked`) + is a fact a reconciler can rediscover and repair after a crash. +- **Retention has no in-between state.** There is no masked-but-retained + status because there is no log to redact, only rows to delete or keep; + "delete" in Crush can only mean a physical SQL `DELETE`, never a visibility + tombstone over an otherwise-preserved fact. + +## Mapping + +| Crush | Ours | Verdict | +| --- | --- | --- | +| `sessions` row (title, `message_count`, `prompt_tokens`, `completion_tokens`, `cost`, `summary_message_id`, `todos`), mutated via full-row `UPDATE` | Fold of `SessionStarted` + `TokenUsage`-bearing events + `SessionRenamed` + `TodoUpdated` + `Compacted` over the session stream | Ours | +| `uuid.New().String()` session id (`internal/session/session.go:98`) | Opaque `session_id` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) does not mandate a UUID version) | Equivalent | +| `parent_session_id TEXT`, no FK, no application-level cascade (`internal/db/migrations/20250424200609_initial.sql:6`) | `DelegationDispatched` + `ParentLinked` + `CascadePolicy`, reconciler-enforced | Ours, decisively (see gap section) | +| Tool-call-ID reused as subagent session id (`CreateTaskSession`, `internal/session/session.go:110-122`) | `child_session_id` is always a freshly minted id in a distinct namespace from `tool_call_id` (`DelegationDispatched.child_session_id`) | Ours (see What not to copy) | +| `"title-" + parentSessionID` deterministic composite session id (`internal/session/session.go:124-136`) | No equivalent; nothing in our catalog derives one entity's id from another's | Ours (see What not to copy) | +| `CreateAgentToolSessionID(messageID, toolCallID)` → `"%s$$%s"` composite string (`internal/session/session.go:351-362`) | No equivalent; `turn_id` and `tool_call_id` are already distinct typed correlators | Ours | +| `messages` row, `parts` JSON array wholesale-overwritten via `UpdateMessage` (`internal/message/message.go:403-411`) | `UserMessageRecorded` / `AssistantMessageStarted` / `AssistantMessageCompleted` / `AssistantMessageFailed`, each an immutable append | Ours, decisively | +| `ContentPart` union: `ReasoningContent`, `TextContent`, `ImageURLContent`, `BinaryContent`, `ToolCall`, `ToolResult`, `Finish`, `ShellCommand` (`internal/message/content.go:55-146`) | `ContentBlock` oneof (`ThinkingBlock`, `ToolUseBlock`, `ToolResultBlock`, `ProviderBlock`) on `CanonicalMessage` | Mostly equivalent; `ShellCommand` as a distinct message-content type has no analog (see Open questions) | +| `Finish{Reason, Time, Message, Details string}` | `AssistantMessageFailed` / `SessionCancelled.reason` / `SessionFailed.reason`, each a typed enum plus a free-text detail | Ours (typed reason vs. an untyped `Details` string) | +| `files` row: `session_id, path, content TEXT, version`, `UNIQUE(path, session_id, version)`, `ON DELETE CASCADE` (`internal/db/migrations/20250424200609_initial.sql:24-34`) | `FileChanged{path, change_kind, before_ref, after_ref, tool_call_id, turn_id, diff}`, content-addressed via `ArtifactRef`/`Digest` | Ours, decisively | +| `read_files` upsert of `read_at` per `(path, session_id)` (`internal/db/sql/read_files.sql:1-11`) | `ResourceObservation{uri, content_digest \| absent, range, complete}` on `ToolCallCompleted.observed` | Ours (digest and coverage, not just a timestamp; see already-does-better) | +| `sessions.summary_message_id` + `messages.is_summary_message`, truncation applied only inside `getSessionMessages` (`internal/agent/agent.go:1692-1711`) | `Compacted{summary_id, summary_content, covers_from, covers_through, trigger, guidance, tokens_before, tokens_after, model, usage}` | Ours, decisively stronger (see already-does-better) | +| No session-level rewind/checkpoint/fork of any kind (confirmed by a targeted grep for `rewind`, `checkpoint`, `fork`/`Fork`) | `SessionRewound{keep_through}`, `Checkpoint`/`CheckpointProduced`, `SessionForked` | Gap in Crush, not in us | +| Per-file version rows tied to tool calls, write-only in this codebase (no "restore to version N" caller found) | `Checkpoint.covers_through` restored via `ExecutionAttemptStarted.restored_checkpoint`, digest-verified | Ours | +| `crush stats`: read-only, filesystem-crawled, cross-project aggregation (`internal/cmd/stats.go:243-388`) | Not modeled; listing/search/analytics are rebuildable projections outside the event catalog ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 8) | Neutral, out of scope on both sides | +| No FTS/search subsystem found | Not modeled in the catalog either (facet 8) | Neutral | +| `sessions.updated_at`/`created_at`, Unix-epoch wall-clock integers, no monotonic sequence | `SessionOrdinal`, fold-derived logical position | Ours, decisively | +| No optimistic-concurrency/expected-version precondition anywhere (confirmed by explicit grep in the dossier) | `WRITE_PRECONDITION` (`NoStream` / `At` / `Any` classification, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 2) | Ours, decisively | +| `SetMaxOpenConns(1)` plus an opt-in, same-host advisory `flock` (`internal/db/datadirlock.go:51-80`, enabled only on the server-bootstrap path) | Per-subject expected-sequence enforcement, server-side, unconditional | Ours | +| No client-supplied idempotency key anywhere; every write mints a fresh server-side UUID | `OperationReserved.request_digest`, deterministic event ids (facet 2, facet 3) | Ours, decisively | +| `session.service.Delete`: real transactional SQL `DELETE` of the session's own `messages`/`files`, no walk of children (`internal/session/session.go:138-169`) | `SessionHidden` (visibility tombstone only) + `RedactionApplied` + `ArtifactErased`; the log itself is never truncated | Semantic mismatch, not a strict ranking either way (see Retention gap section) | +| Provider-specific fields folded directly into the canonical struct: `ReasoningContent.ThoughtSignature // Used for google`, `ReasoningContent.ToolID // Used for openrouter google models` (`internal/message/content.go:55-146`) | `ProviderBlock` (write-verbatim, read-never) and `ThinkingBlock.signature` as a contained escape hatch | Ours (see What not to copy) | +| `messages.model`, `messages.provider` only; no temperature/effort/thinking-budget recorded anywhere | `ModelSettings{max_output_tokens, temperature, top_p, thinking_budget_tokens, stop_sequences, raw_settings}` on `AssistantMessageStarted` | Ours, decisively | +| Project scope = one `crush.db` per project directory, resolved via `fsext.LookupClosestBounded` (`internal/config/load.go:556-559`); no `workspace_id` column exists | `WorkspaceRef` inline on `SessionStarted` inside one shared store | Trade-off (see below) | +| `crush.lock` OS advisory flock plus a `dataDirOwnerInfo` JSON crash-liveness payload (informational only; `internal/db/datadirlock.go:26-33, 73-78, 88-98`) | Server-enforced `WRITE_PRECONDITION`, no client-side lock file at all | Ours | + +## What we should consider changing + +Ordered most-consequential first. + +### 1. Add an explicit invariant: no session id may ever be derived from another entity's id + +**The change.** State, as a testable rule in [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) (a natural home is +alongside facet 2's identity/dedup contract, or facet 6's "always mints a +fresh `child_session_id`"), that a `session_id` must never be generated +deterministically from, or reused directly as, any other entity's id +(a `tool_call_id`, a `message_id`), and validate it at the command boundary +(facet 3 already validates commands generally). + +**Evidence anchor.** Crush (9/12), `internal/session/session.go:110-122`: +`CreateTaskSession` sets `ID: toolCallID` directly, so a running subagent's +session id *is* a tool-call id from a different lifecycle entirely; and +`internal/session/session.go:124-136`: `CreateTitleSession` mints the +deterministic, collidable key `"title-" + parentSessionID`, with the +dossier's own Open Questions noting that a second call for the same parent +was "not traced into `CreateSession`'s SQL," leaving the collision behavior +unverified even by the people who wrote it. + +**Blast radius.** Additive. [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 6 already mints a fresh +`child_session_id` for every real delegation in practice (`DispatchDelegation` +always mints one); this makes that practice an explicit, documented, and +validated invariant rather than an implicit consequence of how the one +existing call site happens to be written. + +**Why.** An id conflated across entity types is harmless until the two +id-spaces need to be told apart: a lookup service, a REST path, an audit +query, or a future cascade rule keyed on "is this actually a session id" all +break the moment a tool-call id and a session id can collide or be mistaken +for one another. Crush's own dossier shows the ambiguity is already live, not +hypothetical. + +**Cost.** Essentially none: a rule to document and check, not a new field or +event. + +### 2. Add a typed "child session purpose" alongside `CascadePolicy` + +**The change.** Add an enum (or reuse/extend `OperationKind`) to +`DelegationDispatched`/`ParentLinked` distinguishing *why* a child session was +created, separate from *what happens to it* on parent termination +(`CascadePolicy` already answers the latter). + +**Evidence anchor.** Crush (9/12) mints child-session-shaped rows for at +least three distinct purposes through the identical, unenforced mechanism: +a genuine subagent task (`CreateTaskSession`, `internal/session/session.go:110-122`), +a one-off title-generation utility call (`CreateTitleSession`, +`:124-136`), and an `agent-tool` session addressed by the `$$`-composite id +(`CreateAgentToolSessionID`, `:351-368`, invoked from +`internal/agent/coordinator.go:1404-1406`). All three inherit the same +(absent) cascade discipline because nothing distinguishes them at the type +level. + +**Blast radius.** Additive: a new enum field, existing consumers ignore it. + +**Why.** `CascadePolicy` answers "what happens to the child when the parent +ends," but not "why does this child exist," and those two questions plausibly +want different default answers. An ephemeral, single-completion utility child +(Crush's title-generation case) arguably never needs the same lifecycle +ceremony as a genuine multi-turn subagent; without a typed purpose, that +distinction can only live in caller convention, which is exactly the pattern +that let Crush's three cases silently converge on one unenforced mechanism. + +**Cost.** One more field to keep populated correctly at every delegation call +site; unused, it is dead weight on the event. + +### 3. Do not add a physical parent-cost rollup field; if usage aggregation is wanted, make it a projection fold + +**The change.** Explicitly reject, in the ADR, a written event or mutable +field that rolls a child session's cost/usage up into its parent. If a +"total cost including subagents" view is needed, derive it by folding the +lineage projection (`DelegationDispatched` → child streams → +`OperationOutcomeRecorded`), never by storing a mutated total anywhere. + +**Evidence anchor.** Crush (9/12), `internal/agent/coordinator.go:1487-1503`: +`updateParentSessionCost` performs `parentSession.Cost += childSession.Cost`, +an unguarded read-modify-write-and-save, not the atomic +`UPDATE ... SET cost = cost + ?` pattern Crush itself uses elsewhere +(`UpdateSessionTitleAndUsage`, `internal/db/sql/sessions.sql:55-63`). The +dossier flags this as a possible race left unverified, relying entirely on +`SetMaxOpenConns(1)` rather than an atomic increment or a fold. + +**Why not to do this.** Any write-side rollup of a child's cost onto the +parent invites exactly Crush's race, two concurrent children completing under +commuting, `Any`-classified facts with no compare-and-swap, and duplicates +data [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 8 already assigns to rebuildable projections. A stored +rollup is a second source of truth for a number a fold can always recompute +correctly. + +**Blast radius.** Additive if adopted as "no such field, ever." Breaking the +decision (facet 8) if a future proposal instead adds a written parent-side +usage-rollup event, since that would put a derivable aggregate back into the +log as a second source of truth. + +**Cost.** None if simply not built; a projection to maintain if a rollup read +model is later wanted. + +### 4. Name the compaction fold as a shared, testable obligation, not an implementation detail of one call site + +**The change.** Make explicit, as a documented (and ideally test-enforced) +rule under [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 4/facet 8, that every projection reconstructing +model-visible context must derive it from the same shared "fold from the +newest `Compacted` marker forward" function, rather than each reader +re-implementing the covers_from/covers_through walk independently. + +**Evidence anchor.** Crush (9/12), `internal/agent/agent.go:1692-1711` +(`getSessionMessages`, quoted in the stage-one dossier): the truncation +implied by `summary_message_id` is applied at exactly one call site. The +dossier is explicit that "any other reader of `messages.List` (REST +`.../history`, `.../messages`, the CLI, `crush stats`) sees the full, +untruncated row set." + +**Why.** This works for Crush today only because there happens to be exactly +one reader that needs the truncated view. The moment a second such reader is +added and does not know to reimplement the same slice logic, it silently sees +more history than intended, an easy, quiet bug. Our `covers_from`/`covers_through` +design is already structurally better (a range on `SessionOrdinal`, not an +index into an in-memory list), but that advantage is only real if every +consumer actually goes through one shared fold rather than each hand-rolling +the "skip to the newest marker" logic the way Crush's single call site does. + +**Blast radius.** Additive: an implementation/testing discipline, not a +schema change. + +**Cost.** A shared library obligation and a corresponding test, not a wire +cost. + +## What our design already does better + +**Compaction: a range with provenance, not a bare pointer, applied by policy +rather than by convention.** Crush's marker pattern agrees with ours in the +way that matters most: neither ever deletes or rewrites the durable message +history to compact it. `sessions.summary_message_id` plus +`messages.is_summary_message` (`internal/db/migrations/20250515105448_add_summary_message_id.sql`, +`internal/db/migrations/20250810000000_add_is_summary_message.sql`) is +structurally the same "in-stream marker, read-time truncation" idea as our +`Compacted` event, which is exactly the pattern [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 4 says +"corrects the platform compactor crate, which overwrote the stored message +list wholesale." Where ours is stronger: `covers_from`/`covers_through` are +`SessionOrdinal`-typed *ranges*, stable across restore and migration, versus +Crush's single `summary_message_id`, an index resolved by a linear scan of an +in-memory list (`internal/agent/agent.go:1696-1502`); and `Compacted` records +`trigger`, `guidance`, `tokens_before`/`tokens_after`, `model`, and `usage`, +none of which `is_summary_message` captures at all. As recommendation 4 +above notes, Crush's version also shows the risk of leaving the truncation +rule as one call site's implementation detail rather than a decision-level +fold obligation. + +**Content-addressed file storage instead of full-content-per-version rows.** +`files.content TEXT NOT NULL` (`internal/db/migrations/20250424200609_initial.sql:28`) +stores the entire file on every recorded version, never diffed, never +deduplicated; the dossier notes "N edits to one file cost roughly N (or 2N) +full copies inside `crush.db`, unbounded by anything but the file's own size +times edit count." This is a second, independent confirmation of the exact +anti-pattern the [fx comparison](../fx/vs-session-events.md) already +flagged (`previous_content` full-pre-image inlining). Our `before_ref`/ +`after_ref` `ArtifactRef` pair with `Digest` deduplicates identical content +globally and keeps the event itself small. + +**`ResourceObservation` answers what Crush's `read_files` table cannot.** +Crush's freshness sidecar records only `read_at`, a timestamp, per +`(path, session_id)` (`internal/db/sql/read_files.sql:1-11`). It cannot answer +"what did the agent actually see," "how much of the file," or "was it +re-read after an external change," because it carries no digest and no byte +range. Crush has to pay for a coarse version of that last question a +different, more expensive way: `commitFileChange` +(`internal/agent/tools/edit.go:246-269`) inserts an entire extra +"intermediate" content row purely to detect that on-disk content drifted from +the last recorded version. Our `ResourceObservation{content_digest, range, +complete}` on `ToolCallCompleted.observed` gets the same drift signal from a +digest already computed for audit purposes, at no extra storage cost, and +answers the audit and coverage questions Crush's timestamp-only sidecar +cannot. + +**Real optimistic concurrency instead of a retry-on-collision loop.** The +dossier confirms, by explicit grep, that there is no expected-version +precondition anywhere in Crush's session, message, or history service layers. +Its closest analog, `history.service.createWithVersion`'s three-attempt +retry on a `UNIQUE` constraint violation (`internal/history/file.go:84-135`), +auto-increments a version the caller never stated an expectation about; it is +retry-on-collision, not a caller-supplied compare-and-swap. Our +`WRITE_PRECONDITION` (`NoStream`/`At`/`Any`) is a real, server-enforced +precondition on every invariant-bearing transition. + +**Durable facts instead of a debounce-buffered mutable row.** Crush's +`message.Service.Update` explicitly documents itself as eventually +consistent, buffering state in memory for up to a 33ms debounce window before +it is durable, and requires callers doing a "session-switch read" to call +`Flush`/`FlushAll` first or risk missing the most recent state +(`internal/message/message.go:31-45`, quoted in the dossier). A fact in our +catalog is durable the instant its append acknowledges; there is no class of +"the write technically happened but the read raced it" bug for us to guard +against with a manual flush discipline. + +**Typed cancellation, failure, and cascade causes instead of a free-text +`Details` field.** Crush's `Finish` content part carries `Message, Details +string` (`internal/message/content.go:55-146`); our `SessionCancellationReason`, +`SessionFailureReason`, and `ParentTerminalCause` are all typed enums a +projection can switch on directly. + +**Real, typed cascade machinery instead of confirmed silent orphaning.** See +the Subagent cascade section below. + +## Trade-offs, not gaps + +**Real erasure now, versus audit-forever with erasure deferred.** Crush's +`session.service.Delete` performs an actual transactional SQL `DELETE` of a +session's own messages, files, and row: for a targeted session with no +children, that is genuine byte-level erasure, today. Our `SessionHidden` is +explicitly only a visibility tombstone; `ArtifactErased` only removes +out-of-line artifact bytes; erasure-grade deletion (crypto-shredding) is +named in [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 7 as deferred to a follow-up ADR. Crush buys true, +immediate erasure for the single-session case at the cost of zero audit or +rewind history and, as the cascade section below shows, badly broken erasure +semantics the moment children exist. We buy full audit and rewind history and +a cascade design built to avoid Crush's exact failure mode, at the cost of +not yet shipping a real "erase my data" guarantee. Neither is a strict +improvement on the other; a product that must honor a "delete my data" request +today, and cannot wait for the deferred follow-up ADR, would find Crush's +answer closer to what it needs, provided Crush's cascade gap were fixed. + +**Database-file-per-project isolation, versus a queryable shared store.** +Crush's project boundary is the `.crush/crush.db` file boundary itself; there +is no `workspace_id` column anywhere, so cross-project leakage is +structurally impossible by construction, not by access-control code. The +cost is visible in `crush stats` (`internal/cmd/stats.go:243-388`), a +bolted-on, read-only, migration-skipping filesystem crawl needed just to +aggregate across projects. Our `WorkspaceRef` on `SessionStarted` makes the +workspace binding queryable inside one shared store, at the cost of workspace +isolation being an access-control and projection concern enforced in code, +not guaranteed by an OS file boundary. + +**Same-transaction trigger-maintained counters, versus fold-derived read +models.** Crush's `message_count` is maintained by an `AFTER INSERT`/`AFTER +DELETE` SQL trigger firing in the same transaction as the write it summarizes +(`internal/db/migrations/20250424200609_initial.sql:68-82`), so, unlike a +counter updated by a separate application step, it genuinely cannot drift. +This is not obviously worse than folding for a store built on a single +transactional SQL database. It does not generalize to us: a session's events +and any cross-fact "counter" have no shared transaction to piggyback on in a +one-subject-per-append design, so folding is the only sound option for us, +not merely the more elegant one. + +## What not to copy + +- **Deriving one entity's id from another entity's id.** Tool-call-id-as- + session-id (`CreateTaskSession`, `internal/session/session.go:110-122`) and + the deterministic `"title-" + parentSessionID` composite key + (`internal/session/session.go:124-136`) both create ambiguous identity + across what should be separate id-spaces (see recommendation 1). +- **Provider-specific fields folded directly into a canonical struct.** + `ReasoningContent.ThoughtSignature // Used for google` and + `ReasoningContent.ToolID // Used for openrouter google models` + (`internal/message/content.go:55-146`) bake per-provider bleed straight + into the canonical content type, exactly the leaky-abstraction pattern our + contained `ProviderBlock`/`ThinkingBlock.signature` escape hatch is + designed to avoid. +- **Full-content, non-deduplicated version rows for file history.** + `files.content TEXT NOT NULL` stores whole files on every version with no + dedup; unbounded growth by construction (see already-does-better). +- **An unenforced, DDL-invisible parent-child relationship as the sole + cascade mechanism.** The central finding of this comparison; see the gap + section below. +- **A client-buffered write path whose correctness depends on callers + remembering to flush.** `message.Service`'s debounce-then-flush contract + substitutes a manual discipline ("session-switch reads" must call + `FlushAll`) for a durability guarantee. +- **An unguarded read-modify-write-and-save for a derived numeric rollup.** + `parentSession.Cost += childSession.Cost` (`internal/agent/coordinator.go:1497`) + relies entirely on a single global connection lock rather than an atomic + operation or a fold (see recommendation 3). + +## The two gaps the industry has not closed + +### Subagent cascade + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6 already takes a detailed position: dispatch is +parent-first with crash-safe reconciler repair (`DelegationDispatched` on the +parent, then an atomic `[SessionStarted, ParentLinked]` batch on the child +under `NoStream`); the graph is acyclic by construction, not by a runtime +check; rewind invalidation (`ParentHistoryInvalidated`) is explicitly +distinct from terminal cascade (`ParentTerminated`, carrying a typed +`ParentTerminalCause`); terminal cascade is driven by a reconciler +[processor](../../../glossary/processor) subscribed to +`session.sessions.events.>`, discovering children through the parent-to- +children lineage projection folded from `DelegationDispatched`, and is +transitive across a chain of depth D in D sequential reconciler round-trips; +`CascadePolicy` makes the child's fate on parent-terminal an explicit, +recorded choice (`CASCADE_ON_PARENT_TERMINAL` default, `INDEPENDENT` for an +intentional, recorded orphan); and a cross-stream atomic delete was +considered and rejected as unavailable ("a single `decide` names exactly one +`StreamId`, and JetStream offers no atomic write across subjects"). + +Crush's evidence: `parent_session_id TEXT` carries no foreign-key constraint +in any of the 7 migrations (`internal/db/migrations/20250424200609_initial.sql:6`), +unlike `files.session_id`, `messages.session_id`, and `read_files.session_id`, +which all declare `ON DELETE CASCADE` and are genuinely enforced, because +`PRAGMA foreign_keys = "ON"` is set on every connection +(`internal/db/connect.go:19`). `session.service.Delete` +(`internal/session/session.go:138-169`) does not query +`WHERE parent_session_id = ?` and does not walk children. `ListSessions` +filters `WHERE parent_session_id IS NULL` (`internal/db/sql/sessions.sql:40`), +so an orphaned child is not merely unlinked, it is invisible to every normal +listing surface while its rows persist indefinitely. No reconciliation or +orphan-sweep job was found anywhere in the codebase. + +**Does this validate, refine, or challenge decision 6?** It validates it, +strongly, and sharpens the argument for it. The cross-product +[synthesis](../../synthesis.md) already observes that "every product that has +subagents has the same unresolved gap" (#7); Crush is a sharper instance of +that gap than most, because it is not simply a case of nobody having +implemented cascade. Crush's schema *does* enforce cascade, correctly, for +every other parent-child relationship in the same database, with the +enforcement pragma switched on. The one relationship that most needed it, a +session's own children, was simply never given a constraint, in a codebase +otherwise disciplined enough to cascade-delete consistently. That is direct +evidence that "we will remember to walk the children" is not a safe design +even for a team careful enough to get every other foreign key right, which is +exactly why decision 6 makes cascade a typed, event-sourced, reconciler-driven +fact (`CascadePolicy` plus a lineage projection) rather than trusting a DDL +constraint or an application-level recursive delete. It also validates +rejecting the cross-stream-transaction alternative on grounds beyond +unavailability: Crush shows that even where a cross-table cascade *is* +trivially available (a plain SQL foreign key, in a single shared database), +it still was not used. A harder-to-forget mechanism, not merely an available +one, is the actual requirement. + +Where Crush's answer is worse: total invisibility. An orphan in Crush is +silently unreachable through every normal surface and persists forever with +no sweep; our design's default `CASCADE_POLICY_CASCADE_ON_PARENT_TERMINAL` +actively terminates a child rather than merely making it theoretically +discoverable. One open point this evidence sharpens: [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md)'s own +Consequences section lists "a scheduled orphan-closure sweep" as a "new +standing service" still to be built. Crush is a concrete demonstration that a +*designed* answer with no operational sweep yet running and tested produces +exactly Crush's failure mode until the sweep exists, is running, and is +verified against this exact case, not merely documented as planned. + +### Retention on an unbounded log + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7 already takes a detailed position: the log is never +truncated or purged, full stop; `SessionHidden` replaces the old +`SessionDeleted` as a visibility tombstone with a typed reason +(`SESSION_HIDDEN_REASON_USER_REQUESTED`, `SESSION_HIDDEN_REASON_RETENTION_POLICY`) +that removes a session from default surfaces and still cascades as a terminal +marker, but deletes no bytes; `RedactionApplied` masks targeted events' +content at read time while the original bytes remain, and, because +redelivered duplicates share one deterministic event id, redaction by event +id automatically covers duplicates and every fork's inherited context; +`ArtifactErased` separates out-of-band artifact-byte destruction from +event-log retention; erasure-grade deletion (crypto-shredding) is explicitly +named as deferred to a follow-up ADR, not silently dropped; this explicitly +supersedes [ADR#0029](../../../../adr/0029-decider-retention-and-truncation-watermark.md)'s purge for session streams; and optional, reversible +cold-storage tiering is available if a deployment needs to bound the hot +stream. + +Crush's evidence: no retention or TTL policy of any kind was found anywhere +in `internal/`, no scheduled cleanup job, no TTL column; deletion is +exclusively user/CLI/REST-initiated. But when Crush *does* delete, it is a +real transactional SQL `DELETE` of the session's own messages, files, and row +(`internal/session/session.go:138-169`), not a tombstone. Crush has no +in-between state at all: a session is either fully present or fully, +physically gone (for its own rows; see the cascade section for what happens +to children). Growth is genuinely unbounded on the read path: `files.content +TEXT NOT NULL` stores whole file content on every version with no dedup, and +`ListMessagesBySession` has, in the dossier's words, "no pagination, cursor, +or offset anywhere in the read path... cost scales linearly with the number +of messages ever created in that session, no stated bound found." The dossier +does not cite any issue report showing this became a user-visible problem in +practice; that absence is the dossier's own limit (a source read, not an +issue-tracker search), not a claim that no such problem exists, and I am +carrying that uncertainty forward rather than hardening it into "unproven." + +**Does this validate, refine, or challenge decision 7?** It is a genuine +trade-off, not a one-sided validation, and the honest reading cuts both ways. +On unbounded growth, Crush adds a third data point supporting the +synthesis's observation (#7) that "the two purest event-sourced designs are +also the two with zero retention story": Crush, while not itself +event-sourced, still ships with no automatic retention at all, so the +absence of a retention story is not unique to event-sourced designs, it is +the industry default regardless of storage model. That part validates +decision 7's premise that retention must be designed deliberately, because +nothing in any of these patterns forces it. On deletion *semantics* +specifically, decision 7 is refined, not validated, by this evidence: for a +single session with no children, Crush's binary hard-delete is a strictly +stronger guarantee, real, immediate, byte-level erasure, than what +`v1alpha1` currently offers, where `SessionHidden` promises only masking and +real erasure is explicitly deferred to a named follow-up ADR. A product that +must honor a "delete my data" request today, and cannot wait for that +follow-up ADR to ship, would find Crush's simpler answer closer to what it +needs for the no-children case, even though Crush's answer collapses +entirely the moment a child session exists (the cascade gap above). The +recommendation this evidence supports is not to abandon keep-forever, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) +already gives good reasons in the Alternatives Considered section for +rejecting truncation, but to keep treating the deferred erasure-grade +follow-up ADR as a named, prioritized gap rather than letting "we chose +keep-forever deliberately" read as "erasure is solved." + +What Crush does not have, that decision 7 does: no `SessionHidden`-equivalent +distinction between "hidden from listing" and "physically deleted" (deletion +always means physical `DELETE`); no `RedactionApplied`-equivalent partial, +targeted content masking of specific events, only whole-row deletion; no +`ArtifactErased`-equivalent independent artifact-lifecycle, file-version rows +are deleted wholesale with the session, never erasable individually by +artifact id. + +## Open questions for the ADR + +- Should `DelegationDispatched`/`ParentLinked` carry a typed "purpose" in + addition to `CascadePolicy`, so an ephemeral utility child (a one-off + completion, in Crush's case title generation) is distinguishable at the + type level from a genuine multi-turn subagent, rather than only by + convention at the call site (recommendation 2)? +- Should the ADR add an explicit, testable invariant that no session id may + ever be derived from, or reused from, another entity's id (recommendation + 1)? +- Is parent-cost/usage rollup in scope for the session event catalog at all, + or strictly a downstream projection concern over the lineage plus + `OperationOutcomeRecorded` usage fields, with no written event ever + representing it (recommendation 3)? +- Does a user-issued, inline command run directly in the conversation + (Crush's bang-mode `ShellCommand` content part, distinct from a + model-requested tool call) need its own representation in our catalog, or + is it always modeled as an ordinary tool call attributed to a human actor + rather than the model? +- Should the orphan-closure sweep named in [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md)'s Consequences section + carry an explicit test derived directly from Crush's failure mode (an + orphan invisible to listing yet permanently persisted), given that + "designed" and "implemented and verified against this exact case" are + different guarantees? diff --git a/docs/research/session-store/products/fx.md b/docs/research/session-store/products/fx/index.md similarity index 99% rename from docs/research/session-store/products/fx.md rename to docs/research/session-store/products/fx/index.md index cfecc593a..0a23bdd5d 100644 --- a/docs/research/session-store/products/fx.md +++ b/docs/research/session-store/products/fx/index.md @@ -1,9 +1,9 @@ # fx (Vercel): how session transcripts are stored and resumed Part of Session Store Research. -Produced by running [RESEARCH_PROMPT](../RESEARCH_PROMPT.md). +Produced by running [RESEARCH_PROMPT](../../RESEARCH_PROMPT.md). Mapped field-by-field onto our own catalog in -[fx compared to our session event catalog](./fx-vs-session-events.md). +[fx compared to our session event catalog](./vs-session-events.md). Evidence snapshot retrieved 2026-08-01. fx ships as a closed-source native binary, so there is no repository or commit to cite and no source quotes are possible. Version-sensitive claims were checked against these anchors: @@ -357,7 +357,7 @@ Two versions ship simultaneously and mean different things [observed]: sides except those two keys) settles this; an earlier revision of this dossier claimed all five fields were dropped, which the diff refutes. The projection is documented as a beta read contract in the - [session detail JSON reference](./fx-session-detail-json-reference.md). + [session detail JSON reference](./session-detail-json-reference.md). ```json {"schema_version":3, @@ -528,7 +528,7 @@ checkpoints, against 2 turn commits. and outcomes `recovered`, `recovered_with_unverified_artifacts`, `indeterminate`), `workspace`. The `session_detail` shape is documented as a beta read contract in the -[session detail JSON reference](./fx-session-detail-json-reference.md), +[session detail JSON reference](./session-detail-json-reference.md), including an error envelope (`{"kind":"session","error":…,"code":…}`) this dossier had not catalogued. @@ -674,7 +674,7 @@ has no structured exit code or signal), omits the session-level metadata the store holds (workspace, model, effort, token totals, title, preview), and the top-level response carries no `schema_version` of its own, only the nested execution object's. The projection is at least a documented beta contract now -(see the [session detail JSON reference](./fx-session-detail-json-reference.md)), +(see the [session detail JSON reference](./session-detail-json-reference.md)), but the emitted JSON can also contain unescaped control characters in strings, which strict JSON parsers reject [observed]. If we expose a read API over the Session Store, its projection needs to be a documented contract versioned at diff --git a/docs/research/session-store/products/fx-session-detail-json-reference.md b/docs/research/session-store/products/fx/session-detail-json-reference.md similarity index 100% rename from docs/research/session-store/products/fx-session-detail-json-reference.md rename to docs/research/session-store/products/fx/session-detail-json-reference.md diff --git a/docs/research/session-store/products/fx-vs-session-events.md b/docs/research/session-store/products/fx/vs-session-events.md similarity index 98% rename from docs/research/session-store/products/fx-vs-session-events.md rename to docs/research/session-store/products/fx/vs-session-events.md index 9f14b849f..8013b857c 100644 --- a/docs/research/session-store/products/fx-vs-session-events.md +++ b/docs/research/session-store/products/fx/vs-session-events.md @@ -1,16 +1,16 @@ # fx compared to our session event catalog Part of Session Store Research. This maps the reconstructed fx storage -format ([dossier](./fx.md)) onto `trogonai.session.sessions.v1alpha1` +format ([dossier](./index.md)) onto `trogonai.session.sessions.v1alpha1` and separates three things: where fx carries structure we do not, where our model is stronger, and where the difference is a trade-off rather than a gap. Sources are the fx dossier (evidence tags `[observed]` / `[literal]` carry over unchanged), the documented CLI read contract in the -[fx session detail JSON reference](./fx-session-detail-json-reference.md), +[fx session detail JSON reference](./session-detail-json-reference.md), and the 57 `.proto` files under `proto/trogonai/session/sessions/v1alpha1/`. Where a conclusion here differs -from an accepted record in the [ADR index](../../../adr/index.md), the ADR is +from an accepted record in the [ADR index](../../../../adr/index.md), the ADR is authoritative. This document proposes; it does not decide. ## The one structural difference everything else follows from @@ -39,7 +39,7 @@ and we get their effect for free: The corollary is the cost: fx pays one large write per turn and loses an in-flight turn on crash, while we pay N small writes and can always resume mid-turn. That is settled by -[ADR#0035](../../../adr/0035-session-store-decider-aggregate.md) and is not +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) and is not reopened here. The rest of this document is about the fields where fx is carrying real @@ -91,7 +91,7 @@ Ordered by how much is lost today, not by implementation cost. Status note: items 1, 2, 3, 5, 6, 7, 8, and the `untruncated_size_bytes` part of item 10 have since been implemented in `v1alpha1` and folded into -[ADR#0035](../../../adr/0035-session-store-decider-aggregate.md) facet 3, and +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 3, and the mapping table above reflects that ("Closed in `v1alpha1`" rows). Item 4 was implemented as `ResourceObservation` on `ToolCallCompleted` rather than as a `FileRead` event, for the reason given in that section. Items 9 and the rest of @@ -386,7 +386,7 @@ model is fine, a rollup in the event is a second source of truth. `command_output_replay` and `command_process_presentation`, so the public JSON hides exit codes and replay tapes but keeps handles, diffs, and the file ledger, and the projection is now a documented beta contract, the - [session detail JSON reference](./fx-session-detail-json-reference.md). What + [session detail JSON reference](./session-detail-json-reference.md). What survives of the criticism: the top-level response carries no `schema_version` of its own (only the nested execution object does), it omits the session-level metadata the store holds (workspace, model, token diff --git a/docs/research/session-store/products/gemini-cli.md b/docs/research/session-store/products/gemini-cli/index.md similarity index 91% rename from docs/research/session-store/products/gemini-cli.md rename to docs/research/session-store/products/gemini-cli/index.md index e44361fa0..ec90fc2d6 100644 --- a/docs/research/session-store/products/gemini-cli.md +++ b/docs/research/session-store/products/gemini-cli/index.md @@ -1,7 +1,7 @@ # Gemini CLI: how session transcripts are stored and resumed Part of Session Store Research. -Produced by running [RESEARCH_PROMPT](../RESEARCH_PROMPT.md). +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 @@ -23,13 +23,13 @@ Authoritative anchors: > 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 +> 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 +> 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 +> 3. **Legacy `Logger`** -- a global `logs.json` cross-session prompt history and > `checkpoint-.json` full-history snapshots for `/chat save`/`/restore` > (`core/logger.ts`). @@ -58,20 +58,20 @@ 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, +- **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`, +- **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 } }`, +- **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 +- **Rewind markers** -- `{ "$rewindTo": "" }`, interpreted at replay as "drop this message and everything after it" (`chatRecordingService.ts:76-78`, `172-202`, `855-875`). @@ -107,7 +107,7 @@ semantics are "latest full copy per id wins," not immutable events. 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 + (`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 @@ -125,34 +125,34 @@ 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. +- `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` — +- `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 +- `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 +- `recordThought(thought)` / `recordMessageTokens(usage)` -- queue thoughts and fold token usage into the last `gemini` message (`:660-697`). -- `saveSummary(summary)` / `recordDirectories(dirs)` — `$set` metadata updates +- `saveSummary(summary)` / `recordDirectories(dirs)` -- `$set` metadata updates (`:770-786`). -- `rewindTo(messageId) -> ConversationRecord | null` — truncate the in-memory +- `rewindTo(messageId) -> ConversationRecord | null` -- truncate the in-memory messages and append a `$rewindTo` marker (`:855-875`). -- `updateMessagesFromHistory(history)` — reconcile the recorded messages against +- `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 +- `getConversation() / getConversationFilePath()` -- read the cached projection (`:788-795`). - `deleteSession(idOrBasename)` / `deleteCurrentSessionAsync()` / - `deleteCurrentSessionIfNotResumableAsync()` — delete files (`:804-849`). + `deleteCurrentSessionIfNotResumableAsync()` -- delete files (`:804-849`). -Module-level readers: `loadConversationRecord(filePath, options?)` — the replay +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 +`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**. @@ -173,7 +173,7 @@ checkpoint semantics are applied by the loader replaying the log**. 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 +- **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 @@ -222,7 +222,7 @@ checkpoint semantics are applied by the loader replaying the log**. `/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" + 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. @@ -262,7 +262,7 @@ checkpoint semantics are applied by the loader replaying the log**. 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 + 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 @@ -292,7 +292,7 @@ checkpoint semantics are applied by the loader replaying the log**. 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 + (`: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 @@ -355,8 +355,8 @@ checkpoint semantics are applied by the loader replaying the log**. `~/.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 + 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 @@ -373,7 +373,7 @@ 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 +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 @@ -386,11 +386,11 @@ shape our design targets, but implemented loosely — the message semantics are 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 + 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 + 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 @@ -401,13 +401,13 @@ shape our design targets, but implemented loosely — the message semantics are 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 +- **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 + 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 + compaction/retention** of the JSONL itself -- rewound and superseded lines persist in the file indefinitely, so the file grows even as the projection shrinks. diff --git a/docs/research/session-store/products/google-adk/index.md b/docs/research/session-store/products/google-adk/index.md new file mode 100644 index 000000000..f34abdeba --- /dev/null +++ b/docs/research/session-store/products/google-adk/index.md @@ -0,0 +1,949 @@ +# Google ADK (Agent Development Kit, Python): how session transcripts are stored and resumed + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT](../../RESEARCH_PROMPT.md). +Evidence snapshot retrieved 2026-08-04. Apache-2.0. Source: +`google/adk-python`, pinned at commit `cbedafd9e4c18d462dc571e1bb079177a496ef51`. All +`path:line` citations below are repo-root-relative to that clone (e.g. +`src/google/adk/sessions/session.py:39`), not to this docs repo. + +- `src/google/adk/sessions/session.py` (the `Session` pydantic model) +- `src/google/adk/sessions/base_session_service.py` (the pluggable contract, + `BaseSessionService`) +- `src/google/adk/sessions/in_memory_session_service.py`, + `src/google/adk/sessions/database_session_service.py`, + `src/google/adk/sessions/sqlite_session_service.py`, + `src/google/adk/sessions/vertex_ai_session_service.py` (the four shipped + backends) +- `src/google/adk/sessions/schemas/v0.py`, `src/google/adk/sessions/schemas/v1.py`, + `src/google/adk/sessions/schemas/shared.py` (SQL schema generations) +- `src/google/adk/sessions/migration/` (schema migration tooling and its README) +- `src/google/adk/sessions/state.py`, `src/google/adk/sessions/_session_util.py` + (state scoping) +- `src/google/adk/events/event.py`, `src/google/adk/events/event_actions.py`, + `src/google/adk/events/_rewind_events.py` (the entry type and rewind/compaction + markers) +- `src/google/adk/apps/compaction.py` (compaction policy, operates on the + session's event list, calls back into `BaseSessionService.append_event`) +- `src/google/adk/runners.py` (the only caller of `rewind_before_invocation_id`) +- `src/google/adk/tools/agent_tool.py`, `src/google/adk/agents/context.py` + (the two distinct subagent storage models) + +ADK is a framework with a pluggable store abstraction, not a single +application, so the interface itself is the primary finding. Where a +conclusion here would differ from an accepted record in this repo's own ADR +index, note that only `BaseSessionService` and its four shipped +implementations were examined; no ADK server/UI code (e.g. `adk web`) was +read beyond what these files import. + +## The storage model + +The durable session is a **mutable, four-part relational/document record**, +not an append-only log at the storage layer -- although the runtime always +mutates it by appending, never rewriting. The `Session` pydantic model is the +literal contract: + +```python +class Session(BaseModel): + id: str + app_name: str + user_id: str + state: dict[str, Any] = Field(default_factory=dict) + events: list[Event] = Field(default_factory=list) + last_update_time: float = 0.0 + _storage_update_marker: str | None = PrivateAttr(default=None) +``` + +(`src/google/adk/sessions/session.py:28-73`, field names verbatim). `state` is +the *merged* view returned to callers (app + user + session scopes folded +together, see "Entry/message structure and versioning" below); `events` is the +ordered transcript; `last_update_time` and the private +`_storage_update_marker` are concurrency-control fields, not domain data +(`src/google/adk/sessions/session.py:64-73`). + +What is authoritative differs **by backend**, which is the core divergence +this dossier documents: + +- **`InMemorySessionService`**: a session is a `Session` object living in a + nested dict `dict[app_name][user_id][session_id] -> Session` + (`src/google/adk/sessions/in_memory_session_service.py:71`). There is no + external representation at all; the object graph *is* the store. Explicitly + documented as unsuitable for production + (`src/google/adk/sessions/in_memory_session_service.py:64-66`: "It is not + suitable for multi-threaded production environments. Use it for testing and + development only."). +- **`DatabaseSessionService`** (SQLAlchemy, any dialect): four/five tables -- + `sessions`, `events`, `app_states`, `user_states`, `adk_internal_metadata` + (`src/google/adk/sessions/schemas/v1.py:55-288`). The session row and the + event rows are both authoritative; `events` rows are individually inserted + (append), while the `sessions.state` column is periodically **overwritten in + place** by an UPDATE (`src/google/adk/sessions/database_session_service.py:931-932`, + `.../932: storage_session.state.update(...)`). +- **`SqliteSessionService`** (hand-rolled `aiosqlite`, distinct from + `DatabaseSessionService`): same four-table shape, created via raw SQL + strings (`src/google/adk/sessions/sqlite_session_service.py:48-96`), with + state columns updated via SQLite's `json_patch()` function + (`src/google/adk/sessions/sqlite_session_service.py:552-596`), which is an + atomic partial-document merge rather than a full overwrite. +- **`VertexAiSessionService`**: the Vertex AI Agent Engine Sessions API is the + store; ADK holds no local copy. The `raw_event` field + (`src/google/adk/sessions/vertex_ai_session_service.py:463-467`) is ADK's own + full-fidelity envelope smuggled into the remote API's `event_metadata`/ + `custom_metadata` extension points, so the "real" record ADK relies on for + fidelity is itself a derived/optional field of the remote schema, with a + documented fallback path when the SDK rejects it + (`src/google/adk/sessions/vertex_ai_session_service.py:488-494`). + +State and events are **not the same kind of record**. `events` is +append-typed (new `StorageEvent`/`Event` rows are only ever inserted, never +edited -- see "Write and append path"). `state` (app, user, and session scopes) +is a **mutable folded document**, updated via full-value overwrite +(SQLAlchemy backends) or `json_patch` (SQLite backend) every time a +`state_delta` lands. There is no way to recover a prior `state` value once +overwritten except by replaying the `state_delta`s recorded on past `events` +rows -- which makes `events` the only true append-only source of truth, and +`state` a **rebuildable-in-principle but not-actually-rebuilt** projection: no +backend reconstructs `state` from `events` on read; it always reads the +folded column/attribute directly. + +Conceptual model: **session-as-document (state) plus session-as-log +(events), both under one `Session` id**, with the log being the only part +that is genuinely append-only across all four backends. + +## Keying and identity + +A session's key is the triple `(app_name, user_id, session_id)`. This triple +is the primary key of the `sessions` table in both SQL backends +(`src/google/adk/sessions/schemas/v1.py:75-85`, +`src/google/adk/sessions/sqlite_session_service.py:66-76`) and the three +nesting levels of the in-memory dict +(`src/google/adk/sessions/in_memory_session_service.py:71`). There is no +single opaque "session id" that stands alone; `app_name` and `user_id` are +load-bearing parts of identity, not decoration. + +`session_id` minting: **client-supplied or server-generated UUID4**, never +ordering-encoding. `create_session`'s `session_id` parameter is optional +(`src/google/adk/sessions/base_session_service.py:60-80`); when absent, all +three local backends call `platform_uuid.new_uuid()` +(`src/google/adk/sessions/in_memory_session_service.py:135`, +`src/google/adk/sessions/database_session_service.py:590`, +`src/google/adk/sessions/sqlite_session_service.py:188`), which by default +returns `str(uuid.uuid4())` (`src/google/adk/platform/uuid.py:24`) -- a +context-var-overridable provider, but the shipped default is a random UUID4 +with no timestamp or lexical ordering. `VertexAiSessionService` instead lets +the remote Agent Engine API assign the id and extracts it from the response +resource name (`src/google/adk/sessions/vertex_ai_session_service.py:208-209`). +A user-supplied `session_id` is validated as URL-path-safe +(`^[A-Za-z0-9_-]+$`) only in the Vertex backend +(`src/google/adk/sessions/vertex_ai_session_service.py:51,77-85`); the other +backends accept any non-empty string (after `.strip()`). + +Listing is scoped by `(app_name, user_id)`, with `user_id` optional to widen +to all users of that app (`src/google/adk/sessions/base_session_service.py:94-96`: +"Lists all the sessions for a user... If not provided, lists all sessions for +all users."). There is no cross-`app_name` enumeration in any backend; every +`list_sessions` implementation filters on `app_name` first +(`src/google/adk/sessions/database_session_service.py:745-749`, +`src/google/adk/sessions/sqlite_session_service.py:326-337`). + +Relocation/rename: **not a concept in ADK.** There is no workspace/cwd +component in the key, and no rename or move operation exists in +`BaseSessionService`. The Vertex backend supports one identity-adjacent +operation: a full resource name (`reasoningEngines/.../sessions/`) can be +passed in place of a bare id and is normalized down to the short id, with the +reasoning-engine segment checked for a mismatch +(`src/google/adk/sessions/vertex_ai_session_service.py:54-74`). + +## The store interface + +ADK exposes a genuinely pluggable abstract base class, +`google.adk.sessions.base_session_service.BaseSessionService` +(`src/google/adk/sessions/base_session_service.py:54-210`). This is captured +**verbatim** below, method by method, because per the research brief this is +the centerpiece of the dossier. + +### Abstract methods (every backend must implement) + +```python +@abc.abstractmethod +async def create_session( + self, + *, + app_name: str, + user_id: str, + state: Optional[dict[str, Any]] = None, + session_id: Optional[str] = None, +) -> Session: + """Creates a new session.""" + +@abc.abstractmethod +async def get_session( + self, + *, + app_name: str, + user_id: str, + session_id: str, + config: Optional[GetSessionConfig] = None, +) -> Optional[Session]: + """Gets a session.""" + +@abc.abstractmethod +async def list_sessions( + self, *, app_name: str, user_id: Optional[str] = None +) -> ListSessionsResponse: + """Lists all the sessions for a user.""" + +@abc.abstractmethod +async def delete_session( + self, *, app_name: str, user_id: str, session_id: str +) -> None: + """Deletes a session.""" +``` + +(`src/google/adk/sessions/base_session_service.py:60-112`, signatures and +docstrings verbatim.) + +### Concrete (non-abstract) methods, overridable but shipped with a default + +```python +async def get_user_state( + self, *, app_name: str, user_id: str +) -> dict[str, Any]: + """... Raises NotImplementedError when the concrete BaseSessionService + implementation does not support reading user state independently of a + session. ...""" + raise NotImplementedError(...) + +async def append_event(self, session: Session, event: Event) -> Event: + """Appends an event to a session object.""" + if event.partial: + return event + self._apply_temp_state(session, event) + event = self._trim_temp_delta_state(event) + self._update_session_state(session, event) + session.events.append(event) + return event + +async def flush(self) -> None: + """Flushes any buffered events. + For non-buffering implementations, this can be a no-op.""" + pass +``` + +(`src/google/adk/sessions/base_session_service.py:114-172`, bodies verbatim.) +`get_user_state` is optional and explicitly may raise `NotImplementedError`; +callers are told to fall back to `list_sessions` + `get_session` per-session +merge (`src/google/adk/sessions/base_session_service.py:141-152`). +`append_event`'s **default body only mutates the in-memory `Session` object +passed in -- it does not persist anything.** Every concrete backend overrides +`append_event` to add the actual write path, then (with the sole exception of +`VertexAiSessionService`, see below) still calls +`super().append_event(...)` to keep the caller's in-memory object consistent +(`src/google/adk/sessions/database_session_service.py:956-957`, +`src/google/adk/sessions/sqlite_session_service.py:487-488`, +`src/google/adk/sessions/vertex_ai_session_service.py:392-393`). `flush()` has +no overrides anywhere in the sessions package -- grep confirms it is defined +exactly once, in the base class +(`src/google/adk/sessions/base_session_service.py:167`) -- so it is a +guaranteed no-op today; no shipped backend buffers writes. + +### Supporting types (verbatim) + +```python +class GetSessionConfig(BaseModel): + """The configuration of getting a session. + Attributes: + num_recent_events: ... if None, the filter is not applied; if greater + than 0, returns at most given number of recent events; if 0, no + events are returned. + after_timestamp: ... if None, the filter is not applied; otherwise, + returns events with timestamp >= the given time. + """ + num_recent_events: Optional[int] = None + after_timestamp: Optional[float] = None + +class ListSessionsResponse(BaseModel): + """The response of listing sessions. + The events and states are not set within each Session object.""" + sessions: list[Session] = Field(default_factory=list) +``` + +(`src/google/adk/sessions/base_session_service.py:29-51`.) Note the +`ListSessionsResponse` docstring's claim that "states are not set" is +**inaccurate for three of the four backends**: `InMemorySessionService`, +`DatabaseSessionService`, and `SqliteSessionService` all call their internal +`_merge_state`/merge helper inside `list_sessions` and populate `state` on +every returned `Session`, clearing only `events` +(`src/google/adk/sessions/in_memory_session_service.py:274-283`, +`src/google/adk/sessions/database_session_service.py:784-794`, +`src/google/adk/sessions/sqlite_session_service.py:357-371`). Only +`VertexAiSessionService.list_sessions` matches the docstring's stated +"no events" behavior for `state` in that it can leave `state` empty when the +API returns none, but even there `state` is populated when present +(`src/google/adk/sessions/vertex_ai_session_service.py:322-329`). This is a +doc/implementation mismatch in the upstream project, not a store-model +divergence, but it matters for any caller relying on the docstring for a +"list is metadata-only" contract. + +### Operation contract summary + +| Operation | Abstract? | Inputs | When invoked | +| --- | --- | --- | --- | +| `create_session` | yes | `app_name, user_id, state?, session_id?` | Runner-side "get or create" at invocation start; direct API/CLI calls. | +| `get_session` | yes | `app_name, user_id, session_id, config?` | Resume; every `Runner.run_async` invocation reloads or receives a session. | +| `list_sessions` | yes | `app_name, user_id?` | Session pickers, `adk web`/CLI listing. | +| `delete_session` | yes | `app_name, user_id, session_id` | Explicit retirement only; no automatic caller found in this package. | +| `get_user_state` | no (may raise `NotImplementedError`) | `app_name, user_id` | Optional fast-path read of user-scoped state without loading a session. | +| `append_event` | no (base is in-memory-only; every backend overrides) | `session: Session, event: Event` | Every turn/tool-call/compaction/rewind event; the sole write path onto a session's transcript and state. | +| `flush` | no (no-op default, unoverridden) | none | Declared for buffering implementations; no such implementation ships. | + +## Write and append path + +- **Append, not rewrite, for events; overwrite-or-patch for state.** Every + backend's `append_event` inserts one new event row/object and separately + mutates the state document (see "The storage model"). No backend rewrites + or removes prior event rows during append. +- **Ordering.** There is no explicit monotonic sequence number field on + `Event` -- ordering is positional (`events.append(event)` / + insertion order) for `InMemorySessionService` + (`src/google/adk/sessions/in_memory_session_service.py:352,164`), and + **timestamp, with an id tiebreak**, for both SQL-backed services on read: + `.order_by(schema.StorageEvent.timestamp.desc(), schema.StorageEvent.id.desc())` + (`src/google/adk/sessions/database_session_service.py:697-699`) and the + equivalent `ORDER BY timestamp DESC, id DESC` + (`src/google/adk/sessions/sqlite_session_service.py:285`). The database + comment explains why the tiebreak exists: "Without it the database is free + to return tied events in a different order on every read, so a replayed + conversation shuffles and `num_recent_events` truncates at an arbitrary + point in the tie" (`src/google/adk/sessions/database_session_service.py:693-696`). + `Event.timestamp` is a client-generated `float` via `platform_time.get_time()` + at construction (`src/google/adk/events/event.py:155`), not a + server-assigned monotonic counter, so **clock skew across writers can + reorder events** unless ids happen to tiebreak consistently (ids are UUID4, + so the tiebreak is arbitrary-but-stable, not causally meaningful). +- **Durability/atomicity.** `DatabaseSessionService` wraps each append in one + SQLAlchemy transaction (`sql_session.commit()` at + `src/google/adk/sessions/database_session_service.py:951`) with guaranteed + rollback on any exception via an `asynccontextmanager` + (`src/google/adk/sessions/database_session_service.py:411-429`). + `SqliteSessionService` similarly batches the state upsert(s) and the event + insert into one connection before a single `db.commit()` + (`src/google/adk/sessions/sqlite_session_service.py:456-482`). Neither does + torn-write detection or repair; durability is delegated entirely to the + underlying database engine's transaction guarantees. +- **Concurrency model differs sharply by backend -- this is the most + consequential divergence in this dossier:** + - `InMemorySessionService`: no locking at all. Concurrent `append_event` + calls on the same process race on plain Python dict/list mutation; + the class docstring says outright it "is not suitable for + multi-threaded production environments" + (`src/google/adk/sessions/in_memory_session_service.py:64-66`). + - `DatabaseSessionService`: **two independent concurrency layers.** First, + an in-process `asyncio.Lock` per `(app_name, user_id, session_id)` key + serializes concurrent `append_event` calls **within one process** + (`src/google/adk/sessions/database_session_service.py:446-476`, + `_with_session_lock`). Second, and only for MySQL/MariaDB/PostgreSQL + dialects, `SELECT ... FOR UPDATE` row-level locking is used on the + session row and (conditionally) on the app/user state rows + (`src/google/adk/sessions/database_session_service.py:431-436,865-866,881,895`; + `_supports_row_level_locking` explicitly excludes SQLite). On top of + that, an **optimistic-concurrency staleness check** compares a + `_storage_update_marker` (a microsecond-precision ISO timestamp string, + `get_update_marker()`, `src/google/adk/sessions/schemas/v1.py:141-146`) + captured at load time against the current DB row, raising + `ValueError(_STALE_SESSION_ERROR_MESSAGE)` on mismatch + (`src/google/adk/sessions/database_session_service.py:904-924`, + message at `:75-78`: "The session has been modified in storage since it + was loaded. Please reload the session before appending more events."). + A marker-less (e.g. manually constructed) `Session` falls back to a + timestamp comparison plus a query for whether the DB's latest event id + still matches the in-memory tail + (`_session_matches_storage_revision`, + `src/google/adk/sessions/database_session_service.py:540-571`). + - `SqliteSessionService`: **no per-process lock**, and a **cruder + staleness check** than `DatabaseSessionService` -- it compares only + `storage_update_time > session.last_update_time` and raises a bare + `ValueError` (not a typed exception) if the session looks stale + (`src/google/adk/sessions/sqlite_session_service.py:405-420`). Its state + upserts use SQLite's `json_patch()` inside `ON CONFLICT ... DO UPDATE`, + which is atomic per-statement but has no equivalent to + `DatabaseSessionService`'s row-level lock or savepoint-guarded + concurrent-insert handling. + - `VertexAiSessionService`: concurrency is delegated entirely to the + remote Agent Engine API; ADK adds no local locking, no staleness check, + and no expected-version precondition on its own `append_event` call + (`src/google/adk/sessions/vertex_ai_session_service.py:390-495`). + So: **optimistic concurrency with an expected-version precondition exists + only in `DatabaseSessionService`**, and is materially weaker in + `SqliteSessionService`, and **entirely absent** in the in-memory and Vertex + backends. A store abstraction that is "the same interface" across these + four backends hides a real behavioral cliff on concurrent append. +- **Delivery semantics.** No backend implements retry, idempotence keying, or + at-least-once redelivery around `append_event` itself; a raised exception + (stale-session `ValueError`, `SessionNotFoundError`, a DB error) propagates + to the caller with no automatic resend. `Event.id` (UUID4, assigned in + `Event.model_post_init`, `src/google/adk/events/event.py:282-286`) is a + primary-key column in both SQL schemas + (`src/google/adk/sessions/schemas/v1.py:180-182`), so a client-side retry + that reuses the same `Event` object would collide on the id rather than + duplicate -- an accidental dedup property, not a designed idempotence + mechanism. +- **`partial` events are never persisted.** Every backend's `append_event` + (and the base class's) checks `if event.partial: return event` and returns + without writing (`src/google/adk/sessions/base_session_service.py:156-157`, + and the same guard re-implemented in each backend, e.g. + `src/google/adk/sessions/in_memory_session_service.py:323-324`, + `src/google/adk/sessions/sqlite_session_service.py:394-395`). Streaming/ + partial model output is therefore transient-only in the interface's own + terms, never a durability concern. + +## Read and resume path + +- **Resume is a full ordered read of the durable store on every + `get_session` call**, not a cached/local-first read -- there is no + filesystem or process-local cache layer anywhere in this package; the + in-memory backend's "cache" is simply the entire store. +- **Pagination/bounding**: `GetSessionConfig.num_recent_events` and + `after_timestamp` are the only bounding controls + (`src/google/adk/sessions/base_session_service.py:29-43`). `num_recent_events` + is a `LIMIT`-style tail bound (SQL backends push it into the query, e.g. + `stmt.limit(config.num_recent_events)`, + `src/google/adk/sessions/database_session_service.py:701-702`); passing `0` + is special-cased to **skip the events query entirely** + (`src/google/adk/sessions/database_session_service.py:678-680`: "Existence/ + metadata-only read; skip the events query entirely."). There is no + cursor/offset pagination -- a caller cannot page through a transcript in + chunks; it is "all events", "last N", or "events after timestamp T". +- **Eager materialization.** Every backend returns the full requested event + list and the full merged state in one call; nothing is lazily loaded within + a `Session` object once returned. `InMemorySessionService` slices the + Python list in-process (`src/google/adk/sessions/in_memory_session_service.py:204-219`); + the SQL backends run one query for events and separate `sql_session.get` + calls (single-row primary-key lookups, not table scans) for app/user state + (`src/google/adk/sessions/database_session_service.py:709-715`, + `src/google/adk/sessions/sqlite_session_service.py:297-299`). +- **`VertexAiSessionService.get_session`** fetches the session resource and + the event list **in parallel** via `asyncio.gather` + (`src/google/adk/sessions/vertex_ai_session_service.py:257-263`), and + deliberately does not try to reconcile clock skew between the two calls -- + the comment explains why: "Preserve the entire event stream that Vertex + returns rather than trying to discard events written milliseconds after + the session resource was updated. Clock skew between those writes can + otherwise drop tool_result events and permanently break the replayed + conversation." (`src/google/adk/sessions/vertex_ai_session_service.py:285-288`). +- **State merge on read** (all three local backends): session-scoped state is + read from its own column/attribute, then app-scoped and user-scoped state + are merged in under `app:`/`user:` prefixes + (`src/google/adk/sessions/database_session_service.py:245-256`, `_merge_state`; + identical logic re-implemented in + `src/google/adk/sessions/sqlite_session_service.py:630-641` and + `src/google/adk/sessions/in_memory_session_service.py:224-246`). This merge + happens on every read, not once at write time -- there is no denormalized + "final state" cached anywhere. + +## Listing, summaries, and search + +- **Listing is a direct table/dict scan filtered by `(app_name, user_id?)`**, + not an index or search subsystem. `DatabaseSessionService.list_sessions` + issues one `SELECT` on `sessions` filtered by `app_name` (and `user_id` if + given), a state lookup, and a full result-set iteration to build responses + (`src/google/adk/sessions/database_session_service.py:737-795`). + `SqliteSessionService` is equivalent + (`src/google/adk/sessions/sqlite_session_service.py:320-372`). No stated + cost numbers or scale limits appear anywhere in this package -- no + pagination parameters exist on `list_sessions` at all + (`src/google/adk/sessions/base_session_service.py:93-96`), so cost is + whatever a full per-app (optionally per-user) table scan costs on the + underlying engine. +- **No metadata sidecar.** There is no separate summary/preview record + maintained at write time in any backend; `list_sessions` derives its + response by reading the same `sessions`/`app_states`/`user_states` rows + used elsewhere, with `events` explicitly zeroed + (`src/google/adk/sessions/in_memory_session_service.py:274-277`, + `.../281-283`). See "The store interface" above for the docstring/ + implementation mismatch on whether `state` is included. +- **No search subsystem of any kind was found** -- no FTS, no vector index, no + content index. Session content is only reachable through + `get_session`/`list_sessions`'s exact-key or `app_name`/`user_id`-scoped + reads. + +## Entry/message structure and versioning + +The unit of the durable log is `Event` (`src/google/adk/events/event.py:91`), +a `pydantic.BaseModel` subclass of `LlmResponse` +(`src/google/adk/events/event.py:29,91`), with `extra='ignore'` and a +camelCase alias generator for wire compatibility +(`src/google/adk/events/event.py:98-104`). Fields relevant to session storage +(verbatim names, `src/google/adk/events/event.py:107-155`): + +```python +class Event(LlmResponse): + invocation_id: str = '' + author: str = '' + actions: EventActions = Field(default_factory=EventActions) + output: Any | None = None + node_info: NodeInfo = Field(default_factory=NodeInfo) + long_running_tool_ids: set[str] | None = None + branch: str | None = None + isolation_scope: str | None = None + id: str = '' + timestamp: float = Field(default_factory=lambda: platform_time.get_time()) +``` + +`id` is never client-assigned before append -- it self-generates in +`model_post_init` via `Event.new_id()` (`str(uuid.uuid4())` through the +platform indirection) if empty +(`src/google/adk/events/event.py:282-286,315-317`). `branch` is the +dot-separated ancestor chain used to hide sibling sub-agent conversations from +each other (`src/google/adk/events/event.py:127-135`: "Branch is used when +multiple sub-agent shouldn't see their peer agents' conversation history."); +its hierarchical grammar (`name@run_id` segments) is implemented in +`_BranchPath` (`src/google/adk/events/_branch_path.py:20-151`). `isolation_scope` +is a second, narrower filter explicitly marked internal-and-unstable ("DO NOT +USE THIS FIELD DIRECTLY... may change without notice", +`src/google/adk/events/event.py:146-149`), currently used to scope Task-API +delegate agents to only their own function-call id's events. + +`EventActions` (`src/google/adk/events/event_actions.py:78-202`) is the +mutation/annotation envelope carried by every event -- this is what makes an +`Event` more than a transcript line: + +```python +class EventActions(BaseModel): + skip_summarization: Optional[bool] = None + state_delta: dict[str, Any] = Field(default_factory=dict) + artifact_delta: dict[str, int] = Field(default_factory=dict) + transfer_to_agent: Optional[str] = None + escalate: Optional[bool] = None + requested_auth_configs: dict[str, AuthConfig] = Field(default_factory=dict) + requested_tool_confirmations: dict[str, ToolConfirmation] = Field(default_factory=dict) + compaction: Optional[EventCompaction] = None + end_of_agent: Optional[bool] = None + agent_state: Optional[dict[str, Any]] = None + rewind_before_invocation_id: Optional[str] = None + route: Optional[Union[bool, int, str, list[Union[bool, int, str]]]] = None + render_ui_widgets: Optional[list[UiWidget]] = None + set_model_response: Optional[Any] = None +``` + +(`src/google/adk/events/event_actions.py:88-202`, field names/types verbatim.) +`state_delta` is the only field the store interface itself interprets (see +next paragraph); every other field is opaque to `BaseSessionService` and +passed through verbatim. `compaction` and `rewind_before_invocation_id` are +markers interpreted at replay time by application code, not by the store -- +see "Compaction and history management" and "Rewind, checkpoints, and fork". + +**State scoping is prefix-based and interpreted by the store, not opaque.** +`State` (`src/google/adk/sessions/state.py:61-136`) declares three prefixes: + +```python +class State: + APP_PREFIX = "app:" + USER_PREFIX = "user:" + TEMP_PREFIX = "temp:" +``` + +(`src/google/adk/sessions/state.py:64-66`.) `_session_util.extract_state_delta` +buckets a flat `state_delta` dict into `{"app": ..., "user": ..., "session": +...}` by stripping these prefixes, and **silently drops `temp:`-prefixed +keys** from the persisted buckets (`src/google/adk/sessions/_session_util.py:41-58`: +the loop only routes into `app`, `user`, or the `else` `session` bucket for +non-`temp:` keys). This is confirmed at the call site: +`BaseSessionService.append_event` applies `temp:` deltas to the in-memory +`Session.state` *before* trimming them from the event +(`_apply_temp_state`, `src/google/adk/sessions/base_session_service.py:174-186`), +then strips them from the event before it is persisted +(`_trim_temp_delta_state`, `src/google/adk/sessions/base_session_service.py:187-202`, +docstring: "This prevents temp-scoped state from being persisted, while the +in-memory session state... retains the values for the duration of the current +invocation."). + +So, durability by scope: + +| Scope | Persisted? | Shared across sessions? | Storage location | +| --- | --- | --- | --- | +| unprefixed (session) | yes | no -- one session only | `sessions.state` column/attribute | +| `app:` | yes | yes -- all sessions of that `app_name` | `app_states` table/dict, keyed by `app_name` alone | +| `user:` | yes | yes -- all sessions of that `(app_name, user_id)` | `user_states` table/dict, keyed by `(app_name, user_id)` | +| `temp:` | **no** | no -- process/invocation-local only | never leaves the in-memory `Session.state`/event; explicitly trimmed before persistence | + +`get_user_state` (`src/google/adk/sessions/base_session_service.py:114-152`) +exists specifically so a caller can read `user:`-scoped state **without** +first loading any session, described as avoiding "an expensive +`list_sessions` call just to access user-scoped data" +(`src/google/adk/sessions/base_session_service.py:125-128`) -- a direct +acknowledgment that `list_sessions` is the fallback path for reading shared +state, and that fallback is a full scan (see "Listing" above). + +**Versioning and evolution.** Two independent version axes exist: + +1. **SQL schema version**, tracked in the `adk_internal_metadata` table + (`src/google/adk/sessions/schemas/v1.py:55-67`, `StorageMetadata`). + `_schema_check_utils.get_db_schema_version_from_connection` inspects the + live database: if `adk_internal_metadata` exists, it trusts that row; if + not, it sniffs the `events` table's columns -- presence of an `actions` + column with no `event_data` column means the legacy v0 (pickle) schema + (`src/google/adk/sessions/migration/_schema_check_utils.py:70-89`). This is + detect-then-branch, not a hard version gate: `DatabaseSessionService` + keeps parallel v0/v1 SQLAlchemy model classes + (`_SchemaClasses`, `src/google/adk/sessions/database_session_service.py:259-276`) + and every read/write path branches on `self._db_schema_version` to select + which model class to bind, so **v0 databases keep working without + migration**, at reduced fidelity (v0 used Python `pickle` for + `EventActions`, v1 uses JSON -- `src/google/adk/sessions/schemas/v0.py:14-24`). +2. **Wire schema of `Event`/`Session` themselves**, which is bare pydantic + `model_dump`/`model_validate` with `exclude_none=True` + (e.g. `src/google/adk/sessions/schemas/v1.py:233`, + `src/google/adk/sessions/sqlite_session_service.py:468`) -- additive fields + with defaults are the only evolution mechanism visible; there is no + explicit per-`Event` schema-version field. + +**Migration is a one-way, external, dump-and-reload ratchet**, not an +in-place `ALTER TABLE`. `migration_runner.upgrade(source_db_url, +dest_db_url, ...)` refuses in-place migration outright +(`src/google/adk/sessions/migration/migration_runner.py:76-80`: "In-place +migration is not supported... migrations always read from a source and write +to a destination.") and chains migration steps through **temporary SQLite +files** for multi-hop upgrades, even between two non-SQLite databases +(`src/google/adk/sessions/migration/migration_runner.py:110-118`, docstring at +`:56-58`). The only registered step today is v0 (pickle) → v1 (JSON) +(`MIGRATIONS = {SCHEMA_VERSION_0_PICKLE: (SCHEMA_VERSION_1_JSON, +migrate_from_sqlalchemy_pickle.migrate)}`, +`src/google/adk/sessions/migration/migration_runner.py:34-39`). That migration +uses a **restricted unpickler** with an explicit allowlist of ~35 safe +classes (`_ALLOWED_PICKLE_GLOBALS`, +`src/google/adk/sessions/migration/migrate_from_sqlalchemy_pickle.py:41-100`) +and only falls back to the unrestricted `pickle.loads` if the caller +explicitly opts in with `allow_unsafe_unpickling=True` +(`src/google/adk/sessions/migration/migrate_from_sqlalchemy_pickle.py:120-126`), +i.e. legacy pickle payloads are treated as untrusted input during migration. A +second, SQLite-specific script +(`src/google/adk/sessions/migration/migrate_from_sqlalchemy_sqlite.py`) +migrates a SQLAlchemy-backed SQLite database to `SqliteSessionService`'s +hand-rolled schema, a **third schema lineage** distinct from both +`schemas/v0.py` and `schemas/v1.py`. The migration process document +(`src/google/adk/sessions/migration/README.md`) formalizes the deprecation +policy: a new schema version must keep `DatabaseSessionService` "backward- +compatible with the previous schema for a few releases (at least 2)" +before the old branch is removed (`.../README.md:106-109,123-129`). + +## Compaction and history management + +Compaction is an **application/runtime concern that writes back through the +same `append_event` store call**, not a store-level operation -- the store has +no compaction API of its own. `src/google/adk/apps/compaction.py` implements +two policies, both of which produce an ordinary `Event` whose +`actions.compaction` is an `EventCompaction` marker +(`start_timestamp, end_timestamp, compacted_content`, +`src/google/adk/events/event_actions.py:58-75`) and then call +`session_service.append_event(session=session, event=compaction_event)` +(`src/google/adk/apps/compaction.py:421-422`) exactly like any other event. +The durable event list is **never shortened or rewritten** by compaction -- +the compaction event is appended alongside the events it summarizes, and +"which events are still live" is a pure function computed at read time. + +The two policies: + +- **Token-threshold compaction** (`_run_compaction_for_token_threshold_config`, + `src/google/adk/apps/compaction.py:372-426`): triggers once the latest + observed/estimated prompt token count exceeds `config.token_threshold`, + selects the oldest events beyond `event_retention_size`, and seeds the + new summary with the **previous compaction's own summary content** so + summaries chain (`src/google/adk/apps/compaction.py:275-289`). +- **Sliding-window compaction** (`_run_compaction_for_sliding_window`, + `src/google/adk/apps/compaction.py:448-649`): triggers on an invocation + count (`compaction_interval`), with an `overlap_size` of prior invocations + re-included in each new summary for continuity -- documented with a worked + 4-invocation example in the function's own docstring + (`src/google/adk/apps/compaction.py:489-527`). + +Both policies route through `_longest_self_contained_prefix` +(`src/google/adk/apps/compaction.py:312-333`), which refuses to compact past +an "open" function-call/tool-confirmation/auth-request obligation that has no +matching response yet -- compaction cannot split a call from its response. + +**Rewind interacts with compaction through one shared function, +`_apply_rewinds`** (`src/google/adk/events/_rewind_events.py:22-55`), which +both the LLM prompt-content builder and both compaction policies call before +doing anything else (`src/google/adk/apps/compaction.py:394,544`, with an +explicit comment at `:390-393` and `:540-543` that this must stay consistent +across both call sites "otherwise rewound content can leak back into prompts +through a compaction summary"). This is the resume/replay behavior that +crosses both a rewind boundary and a compaction boundary: **the durable event +list is never edited; every reader must independently fold both markers +before building a prompt or a summary.** + +## Rewind, checkpoints, and fork + +**Rewind is an appended marker, never a destructive edit** -- the single +strongest confirmation of the append-only-log character of `events` in this +codebase. `Runner.rewind_async` +(`src/google/adk/runners.py:1329-1378`) locates the target invocation's first +event by linear scan, computes a `state_delta` that reverses every state +change made since that point (`_compute_state_delta_for_rewind`, +`src/google/adk/runners.py:1380-1405`, walking forward and diffing against +current state, setting reversed keys to `None` to signal removal) and an +equivalent artifact delta, then constructs and appends one new event: + +```python +rewind_event = Event( + invocation_id=new_invocation_context_id(), + author='user', + actions=EventActions( + rewind_before_invocation_id=rewind_before_invocation_id, + state_delta=state_delta, + artifact_delta=artifact_delta, + ), +) +await self.session_service.append_event(session=session, event=rewind_event) +``` + +(`src/google/adk/runners.py:1366-1378`, verbatim). No event is deleted, no +`Event` is mutated in place -- `_apply_rewinds` +(`src/google/adk/events/_rewind_events.py:22-55`) is a pure backward-scan +projection that any reader must apply to compute "which events are live." +`rewind_before_invocation_id` on `EventActions` is documented as being "only +set for rewind event" (`src/google/adk/events/event_actions.py:192-193`). + +**"Checkpoints" in ADK are not storage checkpoints but workflow-resume +state.** `EventActions.agent_state` is documented as "The agent state at the +current event, used for checkpoint and resume... should only be set by ADK +workflow" (`src/google/adk/events/event_actions.py:166-168`) -- this is +in-band workflow-node state riding on the ordinary event stream, not a +separate checkpoint artifact or file. No distinct checkpoint file/table +exists in the sessions package. + +**Fork does not exist as a session-store operation.** No `fork`/`branch`/ +`clone` verb appears on `BaseSessionService` or any backend. The closest +concept is the `branch` string field on `Event` +(`src/google/adk/events/event.py:127-135`) and `isolation_scope` +(`:136-149`), which are **filters over one shared event list**, not separate +storage: a sub-agent's events live in the *same* session's `events` array, +tagged with a longer `branch` path so readers can hide sibling agents' +history from each other. There is no copy-on-fork and no lineage record +beyond that string tag. + +## Subagents and nested sessions + +ADK ships **two structurally different subagent storage models**, and which +one a given sub-agent uses depends entirely on how it is attached -- this is +the divergence the research brief specifically asked to be documented. + +**1. In-session branch scoping** (`sub_agents=[...]`, and single-turn +`AgentTool` usage via `tool_context.run_node`): the sub-agent's events are +appended to the **same** `Session.events` list as the parent, distinguished +only by a longer `branch` path built with +`_BranchPath.create_sub_branch(base_branch, name=self.agent.name, +run_id=fc_id)` (`src/google/adk/tools/agent_tool.py:385-390`, in +`_SingleTurnAgentTool.run_async`) and executed via +`tool_context.run_node(self.agent, node_input=node_input, +override_branch=tool_branch, use_sub_branch=False)` +(`src/google/adk/tools/agent_tool.py:392-398`). `run_node` +(`src/google/adk/agents/context.py:424-479`) documents this explicitly: "The +dynamically executed node becomes a child run of the current node in the +workflow" -- it is a child *run*, not a child *session*. There is no separate +`Session` object, no separate store row, and no parent-delete cascade concern +because there is nothing separate to cascade to: deleting the parent session +deletes these child events too, trivially, because they were never anything +but rows/entries in the parent's own event list. Nesting depth is bounded +only by however deep `branch` paths can recurse (dot-separated segments, +`src/google/adk/events/_branch_path.py:20-42`), i.e. not bounded by the store. + +**2. Fully separate, throwaway session** (multi-turn `AgentTool` usage, ADK's +documented-but-discouraged direct wrapping path): `AgentTool.run_async` +constructs a **brand-new `Runner` with a brand-new +`InMemorySessionService()`** for every single tool call +(`src/google/adk/tools/agent_tool.py:225-271`, `runner = Runner(..., +session_service=InMemorySessionService(), ...)`), creates a fresh session in +it (`runner.session_service.create_session(...)`, +`src/google/adk/tools/agent_tool.py:284-288`), runs the sub-agent to +completion, forwards only `state_delta` back into the parent's +`tool_context.state` (`src/google/adk/tools/agent_tool.py:299-301`), and then +calls `await runner.close()` (`:310`). **The child session and its entire +event transcript are never persisted anywhere beyond that in-memory +`Runner`'s lifetime** -- they are discarded as soon as the tool call returns. +There is no durable parent-child link at all in this path: the parent +session's `events` gains only the tool-call/tool-result pair produced by the +*parent's* agent framework around the call, not the child's own transcript. +The `AgentTool` class docstring itself flags this path as discouraged in +favor of the single-turn/branch model: "Direct usage of `AgentTool` is +discouraged. See the single-turn mode guide for details." +(`src/google/adk/tools/agent_tool.py:125-126`). + +So: on **parent delete**, model (1) cascades trivially (it was never +separate); model (2) has nothing to cascade because nothing was durable in +the first place. On **parent rewind**, model (1)'s sub-agent events are +subject to the same `rewind_before_invocation_id` fold as any other event in +the shared list; model (2) is entirely out of scope of rewind because its +events never entered the durable store. On **crash mid-subagent-call**, +model (1) leaves whatever partial events had already been appended to the +shared session (consistent with the per-event durability story above); model +(2) loses the entire child run, since it lived only in an in-memory service +that is never flushed to durable storage -- a crash there is indistinguishable +from a normal tool-call failure from the parent session's point of view. + +## Retention, deletion, and multi-host + +- **No TTL or scheduled cleanup lives in this package** for the in-memory, + database, or SQLite backends -- `delete_session` is the only removal path, + and nothing calls it automatically anywhere searched in + `src/google/adk/sessions/` or `src/google/adk/runners.py`. + `VertexAiSessionService.create_session` does accept caller-supplied `ttl` + or `expire_time` keyword arguments passed straight through to the remote + API's session-create config + (`src/google/adk/sessions/vertex_ai_session_service.py:179-200`), which + makes retention a remote-service feature for that one backend, not an ADK + concern. +- **Delete behavior differs by backend in exactly the way the storage model + predicts.** `DatabaseSessionService.delete_session` issues one `DELETE` + against `sessions` filtered by the full key + (`src/google/adk/sessions/database_session_service.py:797-810`); cascading + removal of that session's `events` rows is enforced by the schema's + `ForeignKeyConstraint(..., ondelete="CASCADE")` + (`src/google/adk/sessions/schemas/v1.py:208-213`) plus the SQLAlchemy + relationship's own `cascade="all, delete-orphan"` + (`src/google/adk/sessions/schemas/v1.py:98-103`) -- belt-and-suspenders, + ORM-level and DB-level cascade both present. `SqliteSessionService` relies + on the same `ON DELETE CASCADE` declared in its raw schema SQL + (`src/google/adk/sessions/sqlite_session_service.py:87-90`), enabled only + because `PRAGMA foreign_keys = ON` is set on every connection + (`src/google/adk/sessions/sqlite_session_service.py:46,498`; SQLite ignores + `ON DELETE CASCADE` without this pragma). `InMemorySessionService.delete_session` + is a plain dict `.pop()` (`src/google/adk/sessions/in_memory_session_service.py:313`). + `VertexAiSessionService.delete_session` additionally **enforces ownership + before deleting** by fetching the session first and comparing `user_id`, + with a code comment explaining why: "Enforce ownership: delete_session + otherwise ignores user_id entirely." + (`src/google/adk/sessions/vertex_ai_session_service.py:346-359`) -- the + remote API's delete-by-name call has no `user_id` parameter of its own, so + ADK adds the check client-side. +- **App-scoped and user-scoped state are never deleted by + `delete_session`.** Deleting one session removes only that session's row + and its events; `app_states`/`user_states` rows persist and remain visible + to any other session sharing that `app_name`/`(app_name, user_id)` -- an + intentional consequence of the scoping model (see "Entry/message + structure and versioning"), but a real orphan-accumulation risk with no + visible cleanup path in this package. +- **Multi-host is not a first-class path for the local backends.** + `InMemorySessionService` is explicitly single-process + (`src/google/adk/sessions/in_memory_session_service.py:64-66`). + `SqliteSessionService` opens a fresh `aiosqlite.connect()` per operation + (`src/google/adk/sessions/sqlite_session_service.py:492-502`) with no + cross-process lock beyond whatever SQLite's own file locking provides -- + no explicit multi-host handling is present. `DatabaseSessionService` is the + one backend designed for shared, multi-writer access, via its + connection-pooled `AsyncEngine`, per-dialect row-level locking, and + optimistic-concurrency marker (see "Write and append path") -- this is + effectively ADK's answer to multi-host, achieved by pushing the problem + onto a real database rather than solving it in the session-store layer + itself. `VertexAiSessionService` is multi-host by construction, since the + Agent Engine service itself is the single shared backend. + +## Interop with foreign session stores + +None found. No code in `src/google/adk/sessions/` reads, discovers, imports, +or resumes another product's native session store. The `migration/` +directory's scripts migrate **ADK's own** prior schema generations +(SQLAlchemy-pickle-v0, SQLAlchemy-JSON-v1, and the separate hand-rolled +SQLite schema) into current ADK schemas -- this is intra-product schema +migration, not interop with a foreign product's session format. + +## What this implies for our Session Store (our inference) + +**Inference.** ADK's "stored session" is not one durable design but a +contract (`BaseSessionService`) that four backends satisfy with materially +different guarantees underneath -- closest to session-as-document (mutable +`state`, keyed by `(app_name, user_id, session_id)`) with an append-only +`events` list riding alongside it, rather than a pure event-sourced log with +state as a derived projection. State is written *directly*, not derived from +folding `events`; only `events` is genuinely append-only, and even that +guarantee is enforced by each backend independently rather than being a +property of the interface itself. The interface's true unifying value is +narrower than it looks: it guarantees the same four abstract *methods* exist +everywhere, but explicitly does **not** guarantee the same *concurrency*, +*durability*, or *subagent nesting* semantics across backends -- those vary +by backend in ways a caller must know about to write correct code (e.g. the +different `ValueError` vs typed-exception staleness signal between +`DatabaseSessionService` and `SqliteSessionService`, or the entirely-absent +child-session durability in multi-turn `AgentTool`). + +Three points worth carrying into our own Session Store design: + +- **A verbatim, testable store contract is valuable precisely because it + is thin.** `BaseSessionService` is four abstract methods plus two optional + ones. Keeping the mandatory surface this small is what let ADK ship four + backends with wildly different internals (in-memory dict, two independent + SQL schema generations, a hand-rolled SQLite path, and a remote API) behind + one interface without the interface itself needing to encode locking or + durability policy. Our own interface boundary should resist the temptation + to standardize concurrency semantics in the abstract contract -- that + belongs to each backend's own documented guarantees, made explicit rather + than implied by a shared method signature. +- **State-as-document plus events-as-log, within one aggregate id, is a + real, shipped pattern worth naming precisely because it is *not* what our + ADR direction favors.** ADK's `state` column is mutated directly and is + the one thing in this codebase that is *not* rebuildable from the event + log (no backend fold-replays `events` to reconstruct `state`; it always + reads the live column). If our Session Store commits to state-as-projection, + ADK is a concrete example of the alternative and its cost: state can drift + from what the event history would replay to, silently, because nothing + ever checks the two against each other. +- **Rewind-as-appended-marker plus a single shared fold function + (`_apply_rewinds`) that every reader must call is a clean, cheap pattern** + worth adopting directly: it keeps the log append-only, makes "what's live" + a pure function of the log rather than a stored flag, and -- critically -- + ADK's own code comments flag the fragility of this approach: two call + sites (prompt-building and compaction) must independently agree to call + the same fold function, and a divergence between them was explicitly + called out as a correctness risk in the source comments + (`src/google/adk/apps/compaction.py:390-393,540-543`). Our design should + make that fold a single, mandatory step in the read path rather than + something every consumer must remember to call. + +## Open questions + +- No `adk web`/server code, CLI (`adk migrate session` other than the + `migration_runner`/README references), or authentication/authorization + layer around session access was read; this dossier is scoped to + `src/google/adk/sessions/` and its direct dependents in `events/`, + `apps/compaction.py`, and `runners.py`. + `src/google/adk/sessions/migration/README.md:115-121` references an `adk + migrate session` CLI command and a `cli_tools_click.py`, neither of which + was located or read; how that CLI wires to `migration_runner.upgrade` is + unverified. +- Whether any first-party or common third-party `BaseSessionService` + implementations exist beyond the four in this package (e.g. Redis, + Firestore) was not investigated; the scope note in the task restricted + research to in-memory, database/SQL, and Vertex AI. +- The exact wire schema and versioning of the Vertex AI Agent Engine + Sessions API itself (server-side) is outside this repository and was not + examined; only ADK's client-side encoding/decoding + (`_from_api_event`, `raw_event` fallback) was read. + `src/google/adk/sessions/vertex_ai_session_service.py:558-573` prioritizes + `raw_event` when present and falls back to legacy top-level fields -- how + long that legacy path must be kept, and whether the remote API itself + enforces any schema version, is unknown from this repo alone. + `docs/upgrading_from_1_22_0.md`, referenced from + `src/google/adk/sessions/schemas/v0.py:19-20`, was not read; it may contain + additional migration guidance for users on the v0 schema. + `src/google/adk/sessions/migration/README.md` is itself the process + document for *adding* a new schema version, not evidence that a v2 schema + exists yet -- none does at this commit. +- Whether `DatabaseSessionService`'s in-process `asyncio.Lock` per session + key (`_with_session_lock`) is intended as the *only* safety net for + single-process multi-task concurrency, or whether row-level locking alone + was meant to be sufficient across all supported dialects, is not stated in + comments; SQLite's exclusion from row-level locking + (`_supports_row_level_locking`, + `src/google/adk/sessions/database_session_service.py:431-436`) combined + with the process-local lock suggests SQLite concurrency safety depends + entirely on single-process usage, but this was not confirmed by a test or + doc. + Whether `SqliteSessionService`'s bare `ValueError` staleness check + (versus `DatabaseSessionService`'s `_STALE_SESSION_ERROR_MESSAGE` + constant and marker-based check) is an intentional simplification or an + oversight is not stated anywhere in the source. +- Full-text of `docs/upgrading_from_1_22_0.md` and any migration guide for + the SQLite-specific schema lineage were not located in this clone under + the paths searched; if they exist elsewhere in the repository, they were + not consulted. diff --git a/docs/research/session-store/products/google-adk/vs-session-events.md b/docs/research/session-store/products/google-adk/vs-session-events.md new file mode 100644 index 000000000..37ea78387 --- /dev/null +++ b/docs/research/session-store/products/google-adk/vs-session-events.md @@ -0,0 +1,556 @@ +# Google ADK compared to our session event catalog + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT_COMPARISON](../../RESEARCH_PROMPT_COMPARISON.md). +Stage-one dossier: [Google ADK](./index.md). +Compared against `proto/trogonai/session/sessions/v1alpha1/` and [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) on 2026-08-04. + +All ADK `path:line` citations below are repo-root-relative to the pinned clone +the dossier used (commit `cbedafd9e4c18d462dc571e1bb079177a496ef51`), exactly +as the dossier itself states, never to this docs repo. All of our own +citations are repo-root-relative to this repository. + +**Store maturity: 10/12** -- evolution scars 3/3 (a real generational schema +cut-over is shipped and still supported: `adk_internal_metadata` tracks a +schema-version row, `_schema_check_utils.get_db_schema_version_from_connection` +detect-then-branches to a legacy pickle format when that table is absent +(`src/google/adk/sessions/migration/_schema_check_utils.py:70-89`), +`DatabaseSessionService` keeps parallel v0/v1 SQLAlchemy model classes live so +old databases keep working without migration +(`src/google/adk/sessions/database_session_service.py:259-276`), and a working +migration tool with a restricted, allowlisted unpickler +(`src/google/adk/sessions/migration/migrate_from_sqlalchemy_pickle.py:41-126`) +plus a written policy requiring at least two releases of back-compat before an +old branch is removed (`src/google/adk/sessions/migration/README.md:106-129`)), +operational age 1/3 (the dossier cites no issue tracker report of corruption, +growth, or lock contention anywhere in this package, and its own Open +Questions section says outright that whether `SqliteSessionService`'s cruder +staleness check is "an intentional simplification or an oversight is not +stated anywhere in the source" -- the only evidence of real field age is the +existence of live v0 databases needing migration, not a corroborated failure +report; this axis is comparatively thin evidence and should be weighted +accordingly), exposure 3/3 (four shipped backends including +`VertexAiSessionService`, a paid Google Cloud Agent Engine API, plus +explicit multi-host handling for the database backend via per-dialect +row-level locking and a connection-pooled `AsyncEngine` +(`src/google/adk/sessions/database_session_service.py:431-436`)), design +independence 3/3 (no evidence the store was forked from another product; the +dossier's own scope note says only `BaseSessionService` and its four backends +were read, and the `migration/` directory migrates ADK's own prior schema +generations, not a foreign format). + +## The one structural difference everything else follows from + +ADK's durable session is **session-as-document plus session-as-log under one +id, where only the log half is genuinely append-only.** The `Session` pydantic +model carries `state: dict[str, Any]` and `events: list[Event]` as siblings +(`src/google/adk/sessions/session.py:28-73`). `events` rows are only ever +inserted across all four backends. `state`, by contrast, is written *directly* +on every `state_delta`: a full-value overwrite in `DatabaseSessionService` +(`storage_session.state.update(...)`, +`src/google/adk/sessions/database_session_service.py:931-932`) or an atomic +`json_patch()` merge in `SqliteSessionService` +(`src/google/adk/sessions/sqlite_session_service.py:552-596`). No backend ever +folds `events` to reconstruct `state`; every read returns the live +column/attribute as-is. The dossier's own conclusion: `state` is +"rebuildable-in-principle but not-actually-rebuilt" (dossier, "The storage +model" section), so nothing stops it from silently drifting from what +replaying `events` would produce. + +This is the exact question [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8 answers the other way: "No +read model is authoritative. A `Projector::catch_up` folds the stream into a +`SessionProjection`..." and "the aggregate snapshot is an advisory cached fold +of that log. Corruption or incompatibility falls back to earlier replay" +([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md), facets +3 and 8). ADK is a concrete, shipped counter-example of what happens when a +"projection" is allowed a direct write path of its own instead of being purely +fold-derived: it is not a hypothetical risk decision 8 is guarding against in +the abstract, it is the load-bearing state store of a widely distributed +framework, doing exactly the thing decision 8 forecloses. Everything else in +this comparison -- the rewind marker needing a computed reversal payload +(below), the concurrency cliff varying by backend, the three subagent +storage shapes -- traces back to ADK treating `state` as a second, independently +mutable source of truth rather than a value that is always re-derivable from +`events` alone. + +## Mapping + +| ADK | Ours | Verdict | +| --- | --- | --- | +| `Session{id, app_name, user_id}` compound primary key (`src/google/adk/sessions/session.py:39-49`, `schemas/v1.py:75-85`) | Opaque `SessionId` addressing one subject `session.sessions.events.` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 1) | Semantic mismatch: ADK bakes tenant/app/user scoping directly into the primary key; we keep identity opaque and defer multi-tenant scoping to draft [ADR#0027](../../../../adr/0027-decider-multi-tenancy-primitive.md) | +| `Session.state` (mutable folded document, directly overwritten or `json_patch`'d) | No equivalent as an authoritative record; the closest concept is the aggregate snapshot, always an "advisory cached fold," never independently written ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8) | Ours, decisively -- see structural difference above | +| `Session.events: list[Event]` | Session event stream on `session.sessions.events.` | Equivalent shape, different typing discipline: `Event` has `extra='ignore'` and no schema validation at the storage boundary; every one of our events is schema-validated protobuf at append ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 3) | +| `EventActions.state_delta` (patched into `state` at every append) | No equivalent field; a change to session-scoped data is the fact itself (`TodoUpdated`, `FileChanged`, etc.), never a patch applied to a folded document | Ours, deliberately -- there is no document to patch, so there is nothing for a delta field to reconcile against | +| `EventActions.rewind_before_invocation_id` plus a computed reversing `state_delta` (`_compute_state_delta_for_rewind`, `src/google/adk/runners.py:1380-1405`, walks forward and diffs against current state to compute what to null out) | `SessionRewound{session_id, keep_through: SessionOrdinal, reason: RewindReason}` (`proto/trogonai/session/sessions/v1alpha1/session_rewound.proto`) | Ours, decisively -- no reversal payload to compute or persist; `keep_through` alone is sufficient because state is never a folded document that needs "reversing" in the first place | +| `_apply_rewinds` (`src/google/adk/events/_rewind_events.py:22-55`), a shared pure function every reader must remember to call | Model-visible context "compiled deterministically from the event log bounded by the latest `Compacted` marker" ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8) | Ours, structurally, but not yet stated as the *sole* mandatory entry point for a rewind-aware read -- see recommendation 1 | +| `Event.branch` / `_BranchPath` (in-session subagent scoping by a dot-separated path string, `src/google/adk/events/_branch_path.py:20-151`) | No equivalent; every delegation gets its own logical stream (`DelegationDispatched`, `ParentLinked`, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6) | Trade-off, not a gap -- see below | +| `EventActions.agent_state` ("checkpoint and resume... should only be set by ADK workflow," in-band on an ordinary event) | `Checkpoint` embedded in `CheckpointProduced` / `ExecutionAttemptStarted.restored_checkpoint`, a distinct, digest-verified, out-of-line artifact with its own admission contract (`proto/trogonai/session/sessions/v1alpha1/checkpoint.proto`, `checkpoint_produced.proto`; [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 3) | Semantic mismatch: ADK's "checkpoint" is workflow state riding on the ordinary transcript; ours is a separately-authorized record with its own `covers_through`, `checkpoint_id`, and plan-digest equality checks | +| `GetSessionConfig.num_recent_events` / `after_timestamp`, a query-time `LIMIT`/timestamp filter with no cursor pagination (`src/google/adk/sessions/base_session_service.py:29-43`) | Resume from the newest aggregate snapshot, replay only the tail after it ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8) | Ours, decisively -- bounded by snapshot cadence, not an ad hoc query-time limit the caller must remember to pass | +| `_storage_update_marker` + SQLAlchemy staleness check, present only in `DatabaseSessionService`, cruder in `SqliteSessionService`, absent in `InMemorySessionService`/`VertexAiSessionService` | `WRITE_PRECONDITION = At(current_position)` enforced server-side by JetStream for every invariant-bearing command by default ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2) | Ours, decisively -- see recommendation 5 | +| `adk_internal_metadata` schema-version row + `_schema_check_utils` detect-then-branch, two live parallel model-class generations (pickle v0, JSON v1) | No per-event schema-version field; evolution is additive only, "never a per-event version branch" ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 3) | Trade-off -- see below | +| `migration_runner.upgrade`, one-way dump-and-reload with a restricted unpickler and a written 2+-release back-compat policy | No migration event or tooling in the catalog today | Gap, already tracked in the [fx comparison](../fx/vs-session-events.md#9-migrations-are-not-journaled), item 9; ADK is corroborating evidence -- see recommendation 4 | +| `app_states` / `user_states` tables, `app:`/`user:`-prefixed keys, never touched by `delete_session` | No equivalent scope; all state is fold-derived per-session, nothing shared cross-session in `v1alpha1` | Ours, by absence of the feature -- but see the retention section and open question 6 | +| `DatabaseSessionService.delete_session` (real SQL `DELETE`, `ON DELETE CASCADE` to `events`, `schemas/v1.py:208-213`) | `SessionHidden`, a visibility tombstone; no bytes are ever deleted ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7) | Semantic mismatch: ADK's "delete" removes bytes in the DB backends; ours never does -- the closest ADK concept to our keep-forever contract is that `app_states`/`user_states` also survive `delete_session`, but as an unintentional orphan, not a deliberate design | +| `VertexAiSessionService` `ttl` / `expire_time` passthrough to the remote API | No TTL concept; retention is keep-forever plus explicit `SessionHidden`/`RedactionApplied`/`ArtifactErased` facts ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7) | Trade-off -- ADK delegates retention entirely to one vendor-hosted backend rather than deciding it at the store layer | +| Three subagent storage shapes: in-session branch scoping, in-session `isolation_scope`-filtered Task-API delegates, and a fully separate throwaway `InMemorySessionService` per `AgentTool` call | One model: every delegation is `DelegationDispatched` + `ParentLinked` into a genuinely separate, durable child stream ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6) | Ours, decisively for the throwaway case; trade-off for the two in-session cases -- see the subagent cascade section | +| `Event.timestamp` (client-generated `float`) with `id` (UUID4) as an arbitrary-but-stable tiebreak for read ordering (`database_session_service.py:697-699`) | `SessionOrdinal`, fold-derived, never a physical sequence or client clock ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2) | Ours, decisively -- no clock-skew reordering risk for any durable cross-reference | +| `Event.id`, self-assigned client-side UUID4, deduplicating only by accidental primary-key collision on retry | `Event.id`, deterministically derived UUIDv5 over `(subject, command type, idempotency key, batch index)` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2) | Ours, decisively -- a designed idempotency contract, not an accidental one | +| `ListSessionsResponse` docstring claims "states not set," contradicted by three of four backends actually setting `state` (`in_memory_session_service.py:274-283`, `database_session_service.py:784-794`, `sqlite_session_service.py:357-371`) | `SessionProjection`, one documented read-model contract per aggregate -- the projection value type *is* the read contract ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8) | Ours, decisively -- no doc/implementation drift is possible when there is exactly one authoritative contract instead of a docstring three of four backends silently violate | + +## What we should consider changing + +Ordered by how much is at stake, not by implementation cost. + +### 1. Name the model-visible-context compiler as the sole mandatory fold point for every rewind- and compaction-aware read + +**The change.** [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8 says the model-visible context is +"compiled deterministically from the event log bounded by the latest +`Compacted` marker," which already centralizes compaction-aware folding in +one place. The decision text does not equally name `SessionRewound.keep_through` +as bounded by that same single compiler, nor does it forbid a future reader +(a search projection, a cost rollup, a UI preview) from writing its own ad hoc +check for "is this event still live after the latest rewind." + +**Evidence anchor.** Google ADK, store maturity 10/12: `_apply_rewinds` +(`src/google/adk/events/_rewind_events.py:22-55`) is a shared pure fold +function that the prompt-content builder and *both* compaction policies must +independently call before doing anything else -- and ADK's own source comments +flag the fragility directly: the calls must "stay consistent across both call +sites... otherwise rewound content can leak back into prompts through a +compaction summary" (`src/google/adk/apps/compaction.py:390-393,540-543`). + +**Blast radius.** Additive -- a clarifying sentence in +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 8 +naming the context compiler as the sole entry point for both `Compacted` and +`SessionRewound.keep_through` bounds. No proto or schema change. + +**Why.** Decision 8 already structurally avoids ADK's mistake by compiling the +model-visible context as one named projection rather than leaving every +consumer to write its own fold. But "already structurally avoids it" is an +architectural accident until the ADR says so explicitly; ADK's own comments +are direct evidence of what happens when a correctness-critical fold is a +convention rather than an enforced single path -- two call sites, written by +the same team, in the same file family, still needed a comment reminding +themselves to stay in sync. Our design should not rely on every future +projection author independently rediscovering that reminder. + +**Cost.** None beyond the sentence; it becomes a real cost only if an +implementation audit later finds a projection that has already grown its own +ad hoc liveness check. + +### 2. State explicitly that a derived read model must never gain an independent write path + +**The change.** [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8 calls the aggregate snapshot "an advisory +cached fold of that log," and decision 3 calls it one of "four records with +separate authority," but neither passage explicitly forecloses a future +implementer adding a direct-write fast path to a snapshot or projection under +performance pressure. + +**Evidence anchor.** Google ADK, store maturity 10/12: `Session.state` is +exactly this shortcut, taken by every one of ADK's local backends -- +`storage_session.state.update(...)` +(`src/google/adk/sessions/database_session_service.py:931-932`) and +`json_patch()` +(`src/google/adk/sessions/sqlite_session_service.py:552-596`) -- and "no +backend fold-replays `events` to reconstruct `state`... makes `events` the +only true append-only source of truth, and `state` a +rebuildable-in-principle but not-actually-rebuilt projection" (dossier, "The +storage model"). + +**Blast radius.** Additive -- a Non-Goal or explicit constraint statement in +decision 8; no schema change. + +**Why.** This is already implied by decision 8's language, but ADK proves the +implication is not obvious enough to survive contact with a real +implementation: a widely distributed, actively maintained framework made +exactly this trade for its own load-bearing state, apparently without +anyone treating it as a violation of the store's own stated principles. +Naming the constraint explicitly, with ADK cited as the cautionary evidence, +turns "we wouldn't do that" into a documented rule a future PR under deadline +pressure has to argue against rather than quietly work around. + +**Cost.** None now; real cost only if a future change actually needs the rule +enforced against a proposed shortcut. + +### 3. Resolve whether a lightweight, non-`DispatchDelegation` subagent path is already fully covered + +**The change.** Confirm, in the ADR, whether a single-turn subagent +invocation that does not need independent resumability or an independent audit +boundary is already fully expressible as an ordinary +`ToolCallRequested`/`ToolCallCompleted` pair with no `DispatchDelegation` at +all -- or whether decision 6 should name this explicitly as a second, cheaper +delegation shape alongside the full child-session model. + +**Evidence anchor.** Google ADK, store maturity 10/12: its branch-scoped +subagent model has "no separate `Session` object, no separate store row, and +no parent-delete cascade concern because there is nothing separate to cascade +to" (dossier, "Subagents and nested sessions," citing +`src/google/adk/tools/agent_tool.py:385-398`, +`src/google/adk/agents/context.py:424-479`) -- a genuinely cheap answer for the +case where a subagent doesn't need its own durable identity. Its +*discouraged* throwaway `AgentTool` model shows the opposite outcome: "the +child session and its entire event transcript are never persisted anywhere +beyond that in-memory `Runner`'s lifetime... discarded as soon as the tool +call returns" (`src/google/adk/tools/agent_tool.py:225-310`), a path ADK's own +docstring calls out as discouraged (`agent_tool.py:125-126`). + +**Blast radius.** Additive as a clarifying question and, if the answer is +"yes, ordinary tool calls already cover it," a documentation-only conclusion. +Breaking the decision only if the ADR later decides decision 6 needs a new, +officially sanctioned lightweight delegation shape distinct from both an +ordinary tool call and a full `DispatchDelegation`. + +**Why.** This is not a gap to close by copying ADK's branch model; it is worth +resolving explicitly because ADK's own two-tier split shows both outcomes of +skipping child-session identity for a subagent: cheap and safe when the +subagent genuinely doesn't need independent durability (branch scoping), and +a silent, crash-invisible data-loss risk when it does (the discouraged +`AgentTool` path -- "a crash there is indistinguishable from a normal tool-call +failure from the parent session's point of view," per the dossier). Naming, +in the ADR, which invocations are expected to go through `DispatchDelegation` +versus an ordinary tool call closes an ambiguity ADK's own maintainers +apparently found easy to get wrong, since they had to write a docstring +discouraging their own worse tier rather than removing it. + +**Cost.** None if the answer is "already covered" -- this recommendation's +whole value is foreclosing a future implementer from reinventing ADK's +discouraged pattern out of a mistaken belief that every subagent call needs +full delegation machinery. + +### 4. Adopt an explicit schema-version marker and a written back-compat policy at the `v1alpha1` → `v1` promotion + +**The change.** Already tracked as open in the +[fx comparison](../fx/vs-session-events.md#9-migrations-are-not-journaled), +item 9: record a schema migration as an auditable event, with a +version marker and digests either side, rather than leaving it an operational +memory. + +**Evidence anchor.** Google ADK, store maturity 10/12, is corroborating +evidence from an entirely unrelated product: a real, shipped schema-version +row (`adk_internal_metadata`, +`src/google/adk/sessions/schemas/v1.py:55-67`), detect-then-branch sniffing +for databases predating that row +(`src/google/adk/sessions/migration/_schema_check_utils.py:70-89`), and a +written deprecation policy requiring "backward-compatible with the previous +schema for a few releases (at least 2)" +(`src/google/adk/sessions/migration/README.md:106-129`) before an old branch +is removed. + +**Blast radius.** Breaking, cheap when it lands -- a new event type plus a +version field, no rewrite of existing events. + +**Why.** ADK doesn't change the fx recommendation's reasoning, it strengthens +its urgency: this is now two independently built products (fx, ADK) that both +found it necessary to treat schema evolution as a first-class, auditable +operation rather than an implicit one, for the same reason -- a database or +log that silently contains two schema generations needs a way to tell them +apart that survives longer than institutional memory. + +**Cost.** One message type plus the discipline of stamping it at the actual +`v1` cutover; no cost until that promotion happens. + +### 5. State who must satisfy the `NoStream`/`At`/`Any` guarantee if a non-JetStream backend or tenant binding is ever introduced + +**The change.** [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2's per-command precondition classification +is a substrate-level guarantee today: "the runtime resolves the append guard +to `At(current_position))`... unless an aggregate opts out." Nothing in the +ADR states who is responsible for re-verifying that guarantee if draft +[ADR#0027](../../../../adr/0027-decider-multi-tenancy-primitive.md)'s +`TenantBinding::Dedicated` or a future storage tier is ever backed by +something other than NATS JetStream. + +**Evidence anchor.** Google ADK, store maturity 10/12: "optimistic concurrency +with an expected-version precondition exists only in `DatabaseSessionService`, +and is materially weaker in `SqliteSessionService`, and entirely absent in the +in-memory and Vertex backends. A store abstraction that is 'the same +interface' across these four backends hides a real behavioral cliff on +concurrent append" (dossier, "Write and append path"). `SqliteSessionService`'s +staleness check is a bare, untyped `ValueError` compared to +`DatabaseSessionService`'s marker-based check with a named constant +(`src/google/adk/sessions/sqlite_session_service.py:405-420` vs. +`src/google/adk/sessions/database_session_service.py:904-924`). + +**Blast radius.** Additive -- a Non-Goal or explicit obligation statement; no +schema change today, since we have exactly one backend (NATS JetStream) and +decision 2's guarantee already holds for it by default. + +**Why.** ADK's four backends satisfy the same method signatures +(`BaseSessionService`) while silently disagreeing about what happens under +concurrent append -- the interface promised more uniformity than it actually +delivered. Our situation is stronger today only because we have one substrate; +the moment a second one is entertained, the same risk ADK demonstrates +becomes live for us too, and nothing currently written down says whose job it +is to prove the new backend still enforces `At(current_position)` the way +JetStream does today. + +**Cost.** None until a second backend is actually proposed; at that point, the +cost is a conformance test suite for the precondition contract, not a schema +change. + +## What our design already does better + +- **Real, substrate-level optimistic concurrency by default, not a + per-backend afterthought.** [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2: "When a command declares no + `WRITE_PRECONDITION`, the runtime resolves the append guard to + `At(current_position)`... this is already satisfied on this substrate for + free, unless an aggregate opts out." ADK's equivalent exists in exactly one + of four backends, is cruder in a sibling, and is entirely absent in two + (dossier, "Write and append path"). +- **Rewind needs no computed reversal payload.** ADK's rewind must walk + forward and diff to compute a reversing `state_delta` + (`_compute_state_delta_for_rewind`, `src/google/adk/runners.py:1380-1405`) + because `state` is a folded document that has to be told how to un-happen. + Our `SessionRewound.keep_through` (`session_rewound.proto`) needs no + reversal computation at all: nothing is folded past the boundary, so there + is nothing to reverse. +- **A designed idempotency contract, not an accidental one.** ADK's `Event.id` + is a client-assigned UUID4 whose only protection against a retried append is + an incidental primary-key collision (dossier, "Write and append path"). Our + `Event.id` is deterministically derived from `(subject, command type, + idempotency key, batch index)` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2) -- retries are safe by + construction, not by accident. +- **Fold-derived ordinals, immune to clock skew.** ADK orders events by + client-generated `Event.timestamp` with a UUID tiebreak on ties, which the + database layer's own code comment explains exists only because "the + database is free to return tied events in a different order on every read" + (`src/google/adk/sessions/database_session_service.py:693-696`) -- a real + admission that client clocks can reorder a replayed conversation. Our + `SessionOrdinal` is fold-derived and never a physical or client-supplied + value ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2). +- **Delegation always buys a genuinely durable, resumable, auditable child.** + ADK's discouraged `AgentTool` path can lose an entire subagent run to a + crash with no trace at all -- "indistinguishable from a normal tool-call + failure from the parent session's point of view" (dossier, "Subagents and + nested sessions"). Every one of our delegations is `DelegationDispatched` + before child creation, with crash-safe reconciler repair ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision + 6): there is no path in our design where a dispatched child can vanish + without a trace. +- **One documented read-model contract, not a docstring three of four + backends contradict.** ADK's `ListSessionsResponse` docstring claims "states + are not set," which `InMemorySessionService`, `DatabaseSessionService`, and + `SqliteSessionService` all violate (dossier, "The store interface"). Our + `SessionProjection` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8) *is* the documented read contract; + there is no separate prose description of behavior for an implementation to + drift away from. +- **Redaction and erasure are named, typed facts.** `RedactionApplied` and + `ArtifactErased` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7) have no analogue anywhere in the ADK + dossier -- no redaction, masking, or byte-erasure concept was found in + `src/google/adk/sessions/` at all. +- **Cascade policy is a recorded, typed fact per child, not an emergent + property of storage choice.** `CascadePolicy` (`cascade_policy.proto`) makes + "does this child survive its parent's terminal state" an explicit, + per-delegation decision. ADK has no equivalent concept for either of its + in-session subagent models, and its throwaway model has no cascade story + because it has no durable child to cascade to. + +## Trade-offs, not gaps + +- **Branch-scoped, same-stream subagents vs. always-separate child streams.** + ADK's branch model (`Event.branch`, a dot-separated ancestor path filtering + one shared event list) costs nothing extra per subagent invocation: no new + stream, no dispatch saga, no cascade reconciliation. Ours always pays for a + full `DelegationDispatched`/`ParentLinked` saga ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6), even + for a subagent call that never needs independent resumability. ADK's + approach buys cheapness at the cost of no independent identity -- a sibling + agent's history must be manually hidden by string-prefix filtering, and + nothing about the branch is independently resumable, auditable, or + redactable on its own terms. Ours buys the opposite: every delegation, + however small, is a first-class citizen with its own durability and + redaction story, at the cost of dispatch/cascade machinery for every one. + Recommendation 3 above exists to make this an explicit choice rather than an + unexamined default in either direction. +- **Schema-version branching vs. additive-only evolution.** ADK's + `adk_internal_metadata` plus parallel v0/v1 model classes let it express a + genuinely breaking payload change (Python pickle to JSON) without minting a + new event type, at the cost of maintaining two full parallel schema + generations in the codebase for "at least 2" releases + (`src/google/adk/sessions/migration/README.md:123-129`). [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 3 + is additive-only, "never a per-event version branch" -- we cannot express + that kind of breaking change without a new event type, but we never carry + two live schema generations of the same event type at once. +- **State merged at every read vs. context compiled at every read.** ADK's + `_merge_state` folds session, `app:`, and `user:` scoped state together on + every `get_session` call (`src/google/adk/sessions/database_session_service.py:245-256`) + -- cheap, because it is a merge over a handful of already-mutable buckets, + never a fold over history. Our model-visible context is compiled from the + event log bounded by the latest snapshot/`Compacted` marker on every read + too ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8) -- potentially more expensive per read, but it is + the only way to guarantee the result cannot silently disagree with the log, + which is exactly the guarantee ADK's `state` column does not have. + +## What not to copy + +- **`Session.state` as a directly-mutated document with no fold-check against + `events`.** The direct antithesis of [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8. Nothing in ADK's + four backends ever compares `state` against what replaying `events` would + produce, so the two can silently disagree with no detection mechanism at + all. +- **The throwaway `AgentTool` subagent path.** A crash mid-call discards the + entire child transcript with no trace, "indistinguishable from a normal + tool-call failure" -- and ADK's own maintainers discourage it in their own + docstring rather than removing it. This is the single clearest cautionary + example in this dossier of what happens when a delegation is not given + durable identity before it starts running. +- **A correctness-critical fold relied on by convention across independent + call sites.** `_apply_rewinds` needing a source comment reminding two call + sites to stay in sync is a real, self-diagnosed fragility, even though the + underlying rewind-as-appended-marker shape is sound and matches ours. + Recommendation 1 exists specifically to avoid inheriting this pattern. +- **A documented read contract that the implementation silently + contradicts.** `ListSessionsResponse`'s docstring claims "states are not + set," which three of four backends violate. This echoes the fx + comparison's "underversioned public projection" lesson: our + `SessionProjection` read contract must never be allowed to drift from a + written description of it the way this docstring did. +- **A compound identity key that bakes in scoping.** `(app_name, user_id, + session_id)` as a literal composite primary key is workable for ADK, but it + is exactly the coupling we are deliberately avoiding by keeping `SessionId` + opaque and deferring tenant/scope binding to draft + [ADR#0027](../../../../adr/0027-decider-multi-tenancy-primitive.md) rather + than baking it into identity now. + +## The two gaps the industry has not closed + +### Subagent cascade + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6 already takes a position: a child session is its own +logical stream, linked by facts on each side (`DelegationDispatched`, +`ParentLinked`), dispatch is parent-first with crash-safe reconciler repair, +rewind invalidation is distinct from terminal cascade, and terminal cascade is +"transitive by construction" through a reconciler reacting to its own emitted +terminal markers. The question here is whether ADK's evidence validates, +challenges, or refines that position, not whether we still need one. + +**What ADK does when a parent is deleted, rewound, or crashes with a live +child.** ADK ships three subagent storage shapes with two distinct durability +fates. Branch-scoped subagents (`sub_agents=[...]`, single-turn `AgentTool` +usage) and `isolation_scope`-filtered Task-API delegates both write into the +*same* `Session.events` list as the parent, tagged only by a longer `branch` +path (`src/google/adk/tools/agent_tool.py:385-398`) or a narrower +`isolation_scope` filter (`src/google/adk/events/event.py:136-149`) -- there is +no separate `Session` object for either. The fully separate, discouraged +multi-turn `AgentTool` path constructs a brand-new `Runner` with a brand-new +`InMemorySessionService()` per call +(`src/google/adk/tools/agent_tool.py:225-271`) and never persists that child +session anywhere beyond the call's own lifetime. + +- **On parent delete:** the two in-session models have "no separate store row, + and no parent-delete cascade concern because there is nothing separate to + cascade to" (dossier). The throwaway model has nothing to cascade either, + because nothing was ever durable. +- **On parent rewind:** `_apply_rewinds` naturally covers branch-tagged + subagent events, since they live in the same list as everything else being + rewound; no separate child-rewind-cascade concept exists. The throwaway + model is out of scope of rewind entirely, since its events never entered the + durable store. +- **On crash mid-subagent-call:** the in-session models leave whatever partial + branch-tagged events had already been appended, consistent with ADK's normal + per-event durability story. The throwaway model loses the entire child run -- + "a crash there is indistinguishable from a normal tool-call failure from + the parent session's point of view" (dossier, "Subagents and nested + sessions"). + +**Does this validate, challenge, or refine decision 6?** It validates the +core design on both of its load-bearing claims, and sharpens one thing +decision 6's text does not yet name. ADK's cheap, in-session branch model +proves the industry does have one legitimate way to sidestep cascade +complexity entirely: give the subagent no independent identity at all, so +there is nothing separate to invalidate on rewind or cascade on delete. That +is a real option decision 6 does not currently offer -- every one of our +delegations always gets a full child stream, dispatch saga, and cascade +eligibility, however small the subagent invocation. Recommendation 3 above +exists to make this an explicit choice rather than something the ADR is +silent on. Separately, ADK's *discouraged* throwaway model is the clearest +possible validation of why decision 6 insists a child gets durable identity +*before* it starts running: `DelegationDispatched` lands on the parent's +stream before child creation, and a reconciler repairs a missing child from +that fact alone if a crash happens in between ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6). ADK's +throwaway path has no equivalent durable dispatch fact at all -- the entire +delegation is invisible to the store until the tool call returns +successfully, which is precisely the failure decision 6's parent-first +dispatch is built to prevent, and ADK's own maintainers know it well enough to +discourage the pattern in their own docstring without removing it. + +### Retention on an unbounded log + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7 already takes a position: keep-forever, with +`SessionHidden` as a visibility tombstone, `RedactionApplied` for read-time +masking, `ArtifactErased` for out-of-band artifact-byte destruction, and +aggregate snapshots bounding replay cost rather than storage size. The +question is whether ADK's evidence validates that design or exposes a cost +the ADR does not currently bound. + +**What ADK does.** No TTL or scheduled cleanup exists in +`InMemorySessionService`, `DatabaseSessionService`, or `SqliteSessionService`; +`delete_session` is the only removal path and "nothing calls it automatically +anywhere searched in `src/google/adk/sessions/` or `src/google/adk/runners.py`" +(dossier, "Retention, deletion, and multi-host"). `VertexAiSessionService` +alone accepts a caller-supplied `ttl`/`expire_time` +(`src/google/adk/sessions/vertex_ai_session_service.py:179-200`), delegating +retention entirely to the remote, paid Agent Engine service rather than +answering it at the store layer. `app_states`/`user_states` rows are never +deleted by `delete_session` regardless of how many sessions referenced them -- +"an intentional consequence of the scoping model... but a real +orphan-accumulation risk with no visible cleanup path in this package" +(dossier, "Retention, deletion, and multi-host"). `list_sessions` has no +pagination parameters at all, so listing cost is "whatever a full per-app +(optionally per-user) table scan costs on the underlying engine" (dossier, +"Listing, summaries, and search"). No issue-tracker report of an actual +user-visible growth failure was found anywhere in the dossier -- this is +notably thinner evidence than, for example, the Cline comparison's +corroborated `cline/cline#9011` growth failure, and should be read as +inference from design shape, not a confirmed field failure. + +**Does this validate, challenge, or refine decision 7?** It validates the +core position that retention is not solved by any particular storage shape and +must be designed deliberately, and it refines decision 7 in one place its +text does not currently cover. ADK is a third independent data point +(alongside the two purest event-sourced products the original synthesis +names) showing that "no retention story at all" is not specific to pure +event-sourcing -- a mixed document-plus-log store has exactly the same gap. +That is direct support for decision 7's premise that nothing in any of these +patterns forces retention design; it has to be a deliberate decision, which is +what decision 7 already is. The refinement is `app_states`/`user_states`: +decision 7's redaction/hide/erase contract is scoped to one session's own +stream, and we have no equivalent to ADK's cross-session, app-scoped or +user-scoped shared state in `v1alpha1` today -- so this specific orphan risk +does not currently apply to us. But if such a feature is ever added, decision +7 would need an answer for a value with no single owning stream to redact, +hide, or erase, and ADK's own such state is direct evidence of what the +unaddressed version of that problem looks like: rows that outlive every +session that ever wrote them, with "no visible cleanup path" found anywhere in +the package. This is worth an explicit open question rather than assuming it +away by the absence of the feature today. + +## Open questions for the ADR + +1. Should [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) + facet 8 name the model-visible context compiler as the sole mandatory fold + point for both `Compacted` and `SessionRewound.keep_through` bounds, + foreclosing an independent ad hoc liveness check the way ADK's + `_apply_rewinds` needed two call sites to agree on its own? +2. Should decision 8 explicitly forbid an aggregate snapshot or any derived + read model from ever gaining a direct write path independent of `evolve`, + given that ADK's `state` column is exactly that shortcut and nothing in + ADK checks it against `events`? +3. Is a lightweight, non-`DispatchDelegation` path for a single-turn subagent + invocation that does not need independent resumability already fully + covered by an ordinary `ToolCallRequested`/`ToolCallCompleted` pair, or + does decision 6 want to name this explicitly as the sanctioned answer to + ADK's branch-scoped model? +4. If a future storage backend or tenant binding (draft + [ADR#0027](../../../../adr/0027-decider-multi-tenancy-primitive.md)) is + ever backed by something other than NATS JetStream, who is responsible for + verifying it independently satisfies the same `NoStream`/`At`/`Any` + guarantee facet 2 assumes today, given ADK shows a shared interface can + silently vary concurrency semantics per backend? +5. When `v1alpha1` promotes to `v1`, should the promotion include an explicit + schema-version marker and a written backward-compatibility policy (as fx's + comparison already proposes and ADK's `adk_internal_metadata` plus + migration README corroborate), rather than relying solely on additive + evolution? +6. Do we ever want a cross-session, app-scoped or user-scoped durable value + analogous to ADK's `app:`/`user:` prefixed state? If so, what stream owns + redacting, hiding, or erasing it under decision 7's contract, given ADK's + own such state has no owning session and "no visible cleanup path" once + every session that ever referenced it is gone? diff --git a/docs/research/session-store/products/goose.md b/docs/research/session-store/products/goose/index.md similarity index 92% rename from docs/research/session-store/products/goose.md rename to docs/research/session-store/products/goose/index.md index c010db057..b3fafb7c8 100644 --- a/docs/research/session-store/products/goose.md +++ b/docs/research/session-store/products/goose/index.md @@ -1,7 +1,7 @@ # Goose: how session transcripts are stored and resumed Part of Session Store Research. -Produced by running [RESEARCH_PROMPT](../RESEARCH_PROMPT.md). +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 @@ -32,7 +32,7 @@ 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 +- **`sessions`** -- one mutable row per session; the session "header" plus denormalized rollups (`session_manager.rs:926-956`): ```sql @@ -58,7 +58,7 @@ The source of truth is three tables (`create_schema`, ) ``` -- **`messages`** — the transcript, one row per message, insertion-ordered by an +- **`messages`** -- the transcript, one row per message, insertion-ordered by an autoincrement `id` and a `created_timestamp` (`session_manager.rs:962-978`): ```sql @@ -75,7 +75,7 @@ The source of truth is three tables (`create_schema`, ) ``` -- **`usage_ledger`** — an **append-only** per-session ledger of token/cost +- **`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). @@ -83,7 +83,7 @@ The source of truth is three tables (`create_schema`, Authoritative vs. derived: - **Authoritative**: the `sessions` row and its `messages` rows. Both are - mutated in place — the session row via `UPDATE` (`apply_update`, + 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_*`, @@ -116,7 +116,7 @@ than appending interpreted markers. 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 + `YYYYMMDD_N` -- today's date followed by a per-day monotonic counter computed inside the `INSERT` (`create_session`, `session_manager.rs:1497-1540`): ```sql @@ -155,35 +155,35 @@ reconstructed from `SessionManager`/`SessionStorage` methods, is: Session lifecycle: -- `create_session(working_dir, name, session_type, goose_mode) -> Session` — +- `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 +- `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 +- `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 +- `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` +- `add_message(id, &Message)` -- append one message row; bump `updated_at` (`1765-1798`). -- `replace_conversation(id, &Conversation)` — destructive full rewrite: +- `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 +- `get_conversation(id) -> Conversation` (internal) -- ordered read of all rows (`1727-1763`). -- `truncate_conversation(id, timestamp)` — `DELETE ... created_timestamp >= ?` +- `truncate_conversation(id, timestamp)` -- `DELETE ... created_timestamp >= ?` (`2344-2353`). -- `truncate_conversation_from_message(id, message_id)` — resolve the boundary +- `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 +- `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 +- `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`). @@ -191,13 +191,13 @@ Listing / analytics / search: - `list_sessions()` / `list_sessions_by_types(types)` / `list_all_sessions()` (`442-459`, `1952-2010`). -- `list_sessions_paged(SessionListPageQuery)` — keyset pagination by +- `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 +- `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 +- `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` @@ -268,7 +268,7 @@ and single lazy-init are reused. 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 +- **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. @@ -297,7 +297,7 @@ and single lazy-init are reused. 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. + bootstrapped or kept in sync -- the scan reads current rows every time. ## Entry/message structure and versioning @@ -306,7 +306,7 @@ and single lazy-init are reused. 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 + 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`): @@ -367,7 +367,7 @@ and single lazy-init are reused. 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 + 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`). @@ -455,7 +455,7 @@ never treats a foreign store as a live backend. 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 + 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 @@ -488,20 +488,20 @@ contrast: 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 + 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 + 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** — +- **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** — + 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 + 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. diff --git a/docs/research/session-store/products/grok-build.md b/docs/research/session-store/products/grok-build/index.md similarity index 99% rename from docs/research/session-store/products/grok-build.md rename to docs/research/session-store/products/grok-build/index.md index 0d43b49e3..a6204899c 100644 --- a/docs/research/session-store/products/grok-build.md +++ b/docs/research/session-store/products/grok-build/index.md @@ -1,7 +1,7 @@ # Grok Build: how session transcripts are stored and resumed Part of Session Store Research. -Produced by running [RESEARCH_PROMPT](../RESEARCH_PROMPT.md). +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 diff --git a/docs/research/session-store/products/hermes-agent.md b/docs/research/session-store/products/hermes-agent/index.md similarity index 92% rename from docs/research/session-store/products/hermes-agent.md rename to docs/research/session-store/products/hermes-agent/index.md index c9013849c..e16c844a8 100644 --- a/docs/research/session-store/products/hermes-agent.md +++ b/docs/research/session-store/products/hermes-agent/index.md @@ -1,7 +1,7 @@ # Hermes (Nous Research): how session transcripts are stored and resumed Part of Session Store Research. -Produced by running [RESEARCH_PROMPT](../RESEARCH_PROMPT.md). +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 @@ -34,14 +34,14 @@ transcript is *append-dominant but mutable*: messages are almost always added by edited in place through three flag columns rather than being a pure immutable log: -- `active INTEGER NOT NULL DEFAULT 1` — live vs. soft-deleted +- `active INTEGER NOT NULL DEFAULT 1` -- live vs. soft-deleted (`hermes_state.py:1069`). -- `compacted INTEGER NOT NULL DEFAULT 0` — summarized-away vs. normal +- `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) +`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 @@ -69,7 +69,7 @@ What is authoritative vs. derived: - **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 + -- 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 @@ -108,49 +108,49 @@ method opens its own cursor; all mutations go through `_execute_write`, which wr Session lifecycle: - `create_session(session_id, source, **kwargs) -> str` - (`hermes_state.py:3441`) — INSERT a `sessions` row (via `_insert_session_row`, + (`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 +- `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. + (`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`) — + 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, + (`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 + (`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 + (`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 +- `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). + (`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 + repair_alternation)` (`hermes_state.py:6334`) -- OpenAI role/content format for replay. -- `resolve_resume_session_id(session_id) -> str` (`hermes_state.py:6245`) — +- `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. + (`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 + (`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. + (`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`), @@ -170,8 +170,8 @@ Deletion / retention: 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 + (`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 @@ -187,8 +187,8 @@ ordering by autoincrement `id`; no expected-version precondition on any operatio (`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 + (`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 @@ -216,12 +216,12 @@ ordering by autoincrement `id`; no expected-version precondition on any operatio 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 + (`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 + FTS-rebuild recovery (`gateway/session.py:2600-2687`) -- at-least-once with a bounded buffer, not a durable outbox. ## Read and resume path @@ -246,7 +246,7 @@ ordering by autoincrement `id`; no expected-version precondition on any operatio - 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. + -- growth is managed by compaction, below. ## Listing, summaries, and search @@ -259,7 +259,7 @@ ordering by autoincrement `id`; no expected-version precondition on any operatio (`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 +- **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`, @@ -289,18 +289,18 @@ ordering by autoincrement `id`; no expected-version precondition on any operatio ## Entry/message structure and versioning -- **Message row shape** (`hermes_state.py:1050-1072`): envelope-ish flat columns — +- **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 + `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 +- **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 @@ -337,7 +337,7 @@ ordering by autoincrement `id`; no expected-version precondition on any operatio - **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 + 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 @@ -348,7 +348,7 @@ ordering by autoincrement `id`; no expected-version precondition on any operatio - 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. + 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. @@ -440,7 +440,7 @@ ordering by autoincrement `id`; no expected-version precondition on any operatio 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 + 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 @@ -452,7 +452,7 @@ 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 +(`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. @@ -472,7 +472,7 @@ 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 + 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. @@ -485,17 +485,17 @@ design is built on. Implications: 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 + 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 + 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 + 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. diff --git a/docs/research/session-store/products/kilo-code/index.md b/docs/research/session-store/products/kilo-code/index.md new file mode 100644 index 000000000..c067b9fcf --- /dev/null +++ b/docs/research/session-store/products/kilo-code/index.md @@ -0,0 +1,157 @@ +# Kilo Code: what diverged from Cline's session store + +Part of Session Store Research. +Fork delta report; see [backlog](../../backlog.md) Wave 5 for why this is a delta rather than a dossier. +Kilo Code pinned at `6ec20f23952b94517a106de366c23024a628e0b9` (MIT), compared against Roo Code at +`b867ec9145750d0ae1ff7f02d35406e9bf2a0b16` (Apache-2.0) and Cline at `5ec2d47b21b3a09aa7a094bfbbe0c7e8f7ddd3fa` +(Apache-2.0). Retrieved 2026-08-04. All three trees were read read-only at those pinned commits. +Upstream reference: [Cline](../cline/index.md), via [Roo Code](../roo-code/index.md). + +## Summary of divergence + +Kilo Code's session store diverged **completely** from Roo Code. The decision to replace it was Kilo's own, +but most of what replaced it was not authored by Kilo, so "Kilo's divergence" and "Kilo's design" are two +different claims throughout this report and are kept apart deliberately. +At this pinned commit, Kilo Code has replaced the entire Cline/Roo-lineage persistence layer +(per-task `api_conversation_history.json` + `ui_messages.json` flat files under VS Code global storage) with +a SQLite-backed session store (`SessionTable`/`MessageTable`/`PartTable`, Drizzle ORM, +`kilo: packages/core/src/session/sql.ts:22-99`) that is not authored by Kilo from scratch but **vendored +wholesale from OpenCode** (`sst`'s `@opencode-ai/core`, published under that exact package name inside Kilo's +own monorepo -- `kilo: packages/core/package.json:2`, confirmed by dense `// kilocode_change` patch markers +throughout that code and by merge commits like `28f2abe5e9 Merge remote-tracking branch 'origin/main' into +marius-kilocode/kilo-opencode-v1.17.9` in `kilo`'s own git history). Roo Code, by contrast, still runs the +classic Cline-style per-task JSON model nearly verbatim (`roo: +src/core/task-persistence/TaskHistoryStore.ts:23-25`, `roo: src/utils/storage.ts:53-58`). So for every concern +below, the correct three-way read is: Cline ≈ Roo (same storage medium and layout, though Roo did rewrite +cascade-delete semantics -- see the [Roo delta](../roo-code/index.md)) vs. Kilo (wholesale replacement sourced +from a fourth codebase, OpenCode, that is outside the Cline/Roo lineage entirely). The old Roo/Cline-style +format survives in Kilo only as a one-time import path for users upgrading from "legacy Kilo Code v5.x" (which +was itself an ordinary Roo Code fork -- confirmed by the legacy secret-storage key +`kilo: packages/kilo-vscode/src/legacy-migration/migration-service.ts:50`, +`const SECRET_KEY = "roo_cline_config_api_config"`). + +## Divergence attribution + +| Concern | Cline | Roo Code | Kilo Code | Attributed to | +| --- | --- | --- | --- | --- | +| Durable transcript | Two generations: legacy `api_conversation_history.json`+`ui_messages.json` (dead-write, read-only fallback), live SDK `{sessionId}.messages.json` (`cline: sdk/packages/core/src/services/session-data.ts:304-327`) | Classic dual files, still live: `api_conversation_history.json`+`ui_messages.json` per task dir | SQLite rows in `MessageTable`/`PartTable` (`kilo: packages/core/src/session/sql.ts:68-99`); classic files read only by the one-time legacy importer (`kilo: packages/kilo-vscode/src/legacy-migration/task-store.ts:6-7`) | **Kilo, original** -- total replacement, not present in Roo | +| Storage root / session id | `~/.cline/data/{sessions,tasks}` (VS Code global storage / SDK data dir), taskId = SDK sessionId | VS Code `globalStorage/tasks/{taskId}/` (`roo: src/utils/storage.ts:53-58`) | XDG data dir (`~/.local/share/kilo` etc.), `app = "kilo"` (`kilo: packages/core/src/global.ts:12,22-23`); session ids are `ses_` + descending id (`kilo: packages/core/src/session/schema.ts:12-20`), not VS Code taskIds | **Kilo, original** -- root relocated out of VS Code entirely, with an explicit migration path | +| SQLite session store | Yes, primary backend (`cline: sdk/packages/core/src/services/storage/sqlite-session-store.ts`) | No -- pure per-task JSON files, no DB | Yes, Drizzle/SQLite (`kilo: packages/core/src/database/database.ts:46,56-61`), db file literally named `kilo.db` | **Convergent, not shared** -- Kilo's SQLite code is unrelated to Cline's; Roo has none | +| Subagent/child model + cascade | Sibling sessions, relational `parentSessionId`, **one-level-only** cascade delete (`cline: sdk/packages/core/src/session/services/persistence-service.ts:557-609`) | Legacy `rootTaskId`/`parentTaskId` fields on `HistoryItem`, **plus** its own genuinely recursive cascade over `childIds` (`roo: src/core/webview/ClineProvider.ts:1747-1762`) | `parent_id` column + indexed query (`kilo: packages/core/src/session/sql.ts:31,64`); `remove()` recursively deletes all descendants, any depth (`kilo: packages/opencode/src/session/session.ts:670-704`); DB-level `onDelete: cascade` FK from `message`→`session` and `part`→`message` (`kilo: packages/core/src/session/sql.ts:75,89`) | **OpenCode, inherited**: the recursion carries no `kilocode_change` marker, so it arrives with the vendored core and is not evidence about Kilo's own design choices | +| Checkpoints | Private refs `refs/cline/checkpoints/{sessionId}/{runCount}` inside the user's own repo, via `git stash create` (`cline: sdk/packages/core/src/hooks/checkpoint-hooks.ts`) | Separate shadow git repo **per task**, `{shadowDir}/tasks/{taskId}/checkpoints` (`roo: src/services/checkpoints/RepoPerTaskCheckpointService.ts:10`), via `simple-git` with sanitized env (`roo: src/services/checkpoints/ShadowCheckpointService.ts:1-77`) | Shadow git dir keyed **per project+worktree hash** (not per task), `path.join(Global.Path.data, "snapshot", project.id, Hash.fast(worktree))` (`kilo: packages/opencode/src/snapshot/index.ts:116`), raw `git --git-dir/--work-tree` calls, plus Kilo-added 7-day retention/pruning, large-repo seeding/materialization, and cross-process locking (`kilo: packages/opencode/src/snapshot/index.ts:54-56,338-341,371-422`) | **Kilo, original** (base mechanism via OpenCode; retention/materialization/locking are Kilo's own patches) -- different from both Cline's and Roo's checkpoint designs | +| Compaction | Separate sidecar file, `{sessionId}.compaction.json`, full messages file left untouched (`cline: sdk/packages/core/src/session/models/session-compaction.ts:25-34`) | Own `condense` module (`roo: src/core/condense/`), not inspected in depth here | LLM-generated "anchored summary" persisted as a `type: "compaction"` message in the same `MessageTable` stream, referencing the prior compaction message rather than a separate file (`kilo: packages/core/src/session/compaction.ts:169,184-190`) | **Kilo, original** (via OpenCode) -- no sidecar file; summary lives in-line in the message table | +| Migrations | No live-format migration beyond a `// TODO` stub (`cline: apps/vscode/src/core/storage/state-migrations.ts:65-67`) | N/A (still the classic format) | ~30 Drizzle schema migrations evolving the session tables over time (`kilo: packages/core/src/database/migration/*.ts`, e.g. `20260312043431_session_message_cursor.ts`, `20260604172448_event_sourced_session_input.ts`) **plus** an explicit one-time importer for users arriving from "legacy Kilo Code v5.x" (a Roo Code fork) that reads the classic per-task files and Roo's own secret-storage key and writes sessions through the new SDK client (`kilo: packages/kilo-vscode/src/legacy-migration/migration-service.ts:1-6,50`, `kilo: packages/kilo-vscode/src/legacy-migration/task-store.ts`, `kilo: packages/kilo-vscode/src/legacy-migration/sessions/migrate.ts`) | **Kilo, original** -- substantial migration machinery with no Roo/Cline counterpart | + +## What Kilo Code diverged on its own + +None of the following exist in Roo Code's tree, so none is inherited Cline/Roo behavior. Where a +`// kilocode_change` marker is quoted, the code is also Kilo-authored rather than merely vendored; the next +section separates out what arrived pre-built with the OpenCode core. + +- **Full backend replacement.** The `kilo-vscode` VS Code extension package + (`kilo: packages/kilo-vscode/src`) contains no `core/task`, `core/task-persistence`, `core/checkpoints`, or + `core/condense` directories at all -- the directories that exist in Roo at the equivalent paths + (`roo: src/core/task-persistence`, `roo: src/core/checkpoints`, `roo: src/core/condense`) are simply absent. + Instead, `kilo-vscode` is a thin client (`kilo: packages/kilo-vscode/src/KiloProvider.ts:9-16`, importing + `@kilocode/sdk/v2/client` and `@opencode-ai/core/kilocode/cost/max-cost-nudge`) to a separate CLI/server + process built on the vendored OpenCode core. +- **Storage root relocated out of VS Code.** Session data now lives under an XDG data directory + (`kilo: packages/core/src/global.ts:12,22-23`, `app = "kilo"`) rather than the VS Code extension's global + storage path Roo and classic Cline use. This is a real on-disk-layout change, not a cosmetic rename: it is + precisely why the legacy-migration subsystem exists. +- **The SQLite DB filename itself was renamed with a compatibility read-path.** `kilo: + packages/core/src/database/database.ts:56-61` computes `kilo-{channel}.db` as the target file but falls back + to reading a pre-existing `opencode-{channel}.db` if the new name doesn't exist yet -- a concrete, small-scale + instance of "renamed storage root, with migration for existing users," this time for users of upstream + OpenCode rather than Roo/Cline. +- **Checkpoint retention and cross-process locking.** `kilo: packages/opencode/src/snapshot/index.ts:54-56` + (`retention = 7 * 24 * 60 * 60 * 1000`, hourly `git gc --prune=7.days` loop at lines 888-893) and the + cross-process `flock`-based locking around every snapshot mutation (`kilo: + packages/opencode/src/snapshot/index.ts:215-219`, explicitly commented "serialize snapshot repositories + across CLI and extension processes") -- neither Roo's nor Cline's checkpoint code has an equivalent retention + policy or explicit multi-process lock; this is necessitated by Kilo's own CLI+extension split architecture. +- **In-line compaction summaries instead of a sidecar file.** `kilo: packages/core/src/session/compaction.ts` + persists the LLM-generated summary as an ordinary message row (`type: "compaction"`) inside the same message + stream the raw transcript lives in, rather than Cline's separate `.compaction.json` sidecar or Roo's + `condense` module output. + +## What arrived with the vendored core, not from Kilo + +The bullets above are divergences *relative to Roo* -- they are absent from Roo's tree, so they are not +inherited Cline/Roo behavior. That is a weaker claim than Kilo having authored them, and the two must not be +conflated: most of this subsystem was written by OpenCode and merely vendored. The `// kilocode_change` +markers are the discriminator, and where they are absent the design decision is OpenCode's. + +- **The recursive cascade delete is OpenCode's, not Kilo's.** + `kilo: packages/opencode/src/session/session.ts:695-704` -- `remove()` fetches all direct children and calls + itself on each, cascading through the full descendant tree at any depth. The recursion carries **no** + `kilocode_change` marker (the nearest marker opens on the line after it, around `SandboxPolicy.dispose`), so + it came in with the vendored core. Attributing it to Kilo would double-count OpenCode's evidence. +- **Roo already solved this independently, and better than its own upstream.** Roo recurses over `childIds` + (`roo: src/core/webview/ClineProvider.ts:1747-1762`, `await collectChildIds(childId)` inside the child loop) + where Cline stops at one level (`cline: sdk/packages/core/src/session/services/persistence-service.ts:566`, + gated on `!row.isSubagent`). So the corpus's evidence that a one-level cascade gets corrected under real use + comes from the **Roo** delta, not this one. Kilo's cascade is a third codebase's answer, arriving + pre-built. + +## What is inherited from Roo Code + +None found. Kilo's persistence-layer code shares essentially no surface with Roo's +`task-persistence`/`checkpoints`/`condense` modules at this commit -- the entire subsystem was replaced rather +than modified. The only Roo-derived artifact still present is passive: Kilo's legacy importer reads Roo's +on-disk/secret-storage conventions (the `roo_cline_config_api_config` secret key, the +`api_conversation_history.json`/`ui_messages.json`/`history_item.json`/`_index.json` file set) purely as a +**source format to migrate away from** (`kilo: packages/kilo-vscode/src/legacy-migration/task-store.ts:6-7`, +`kilo: packages/kilo-vscode/src/legacy-migration/migration-service.ts:50`), not as a format Kilo's current +engine writes or reads going forward. This is Kilo's own historical footprint (a prior version of Kilo was a +Roo fork), not a live inherited behavior. + +## What did not diverge + +Nothing material was found. The one surface-level constant that survives unchanged is the **shape** of the +legacy fields Kilo's importer still knows how to parse -- `task`, `workspace`, `ts`, `mode`, `rootTaskId`, +`parentTaskId` (`kilo: packages/kilo-vscode/src/legacy-migration/task-store.ts:195-205`) -- which is a byte-for- +byte match to Roo/classic-Cline's `HistoryItem` shape. This is expected, not a finding: an importer must match +the format it imports. It says nothing about Kilo's live session store, which does not use this shape. + +## What this adds to the corpus + +Kilo Code is **independent evidence, but not about the Cline/Roo lineage** -- it is independent evidence about +a *different* upstream (OpenCode) that happens to have been grafted onto a Cline/Roo-descended product. For +the specific question this corpus wave asks ("what diverged from Cline's session store, with paths"), Kilo's +honest answer is: everything, because Kilo no longer runs a Cline/Roo-descended session store at all -- it runs +OpenCode's, with Kilo-specific patches (retention, locking, large-repo snapshot seeding, recursive cascade +delete already present in the base, XDG relocation, and the legacy-format importer). The corpus **already has** +an [OpenCode dossier](../opencode/index.md) (pinned at `62e4641235d7847dadc60da37cca8a023dd54fc1`), so this +cross-check is available now rather than hypothetical: Kilo's engine should be read against that dossier, not +against Cline, and the `// kilocode_change` markers are the mechanical discriminator between OpenCode's design +decisions and Kilo's own patches. Note the two are pinned at different upstream generations (Kilo's vendored +core tracks `v1.17.9`), so a divergence found by that comparison may be version skew rather than a Kilo patch; +the marker, not the diff, is what settles authorship. As a **fork-of-Roo** data point specifically, Kilo adds exactly +one useful fact: it demonstrates that a fork can abandon the entire inherited persistence architecture rather +than evolve it, which the "inherited vs. original" framing this wave is built around should be able to +represent. + +## Open questions + +- **Resolved during verification, recorded here because the original framing was wrong:** the `SessionV2` + module under `kilo: packages/core/src/session/` is neither a parallel engine nor an in-progress rewrite. It is + a *dependency of* the engine this report treats as authoritative -- `packages/opencode/src/session/session.ts` + imports `SessionV2` directly, as do `packages/opencode/src/session/schema.ts`, the control-plane HTTP + handlers (`packages/opencode/src/server/routes/instance/httpapi/handlers/control-plane.ts`), + `packages/server/src/groups/message.ts`, and four test files under `packages/opencode/test/`. So both modules + are live and there is no competition between them; `packages/core/src/session/sql.ts` is the schema the + `packages/opencode` engine runs against, which is why this report cites both trees for one store. +- Whether compaction in Kilo's engine ever deletes or archives the pre-compaction raw message rows, or only + ever appends a new summary message alongside them indefinitely -- I read `compaction.ts`'s summary-generation + and anchoring logic but did not trace a corresponding deletion/archival path, so I cannot confirm whether + Kilo's transcript is fully retained forever or eventually pruned post-compaction. +- Full provenance of `packages/core` as a fork of `sst/opencode` was inferred from the package name + (`@opencode-ai/core`), the density of `// kilocode_change` comments, and one merge-commit message + referencing `kilo-opencode-v1.17.9` in Kilo's git log -- I did not diff against an actual upstream OpenCode + checkout (none was provided for this task) to confirm how much of the unmarked code is verbatim OpenCode + versus independently rewritten by Kilo under the same file layout. +- Roo Code's own checkpoint/subtask code was read only far enough to attribute Kilo's divergence correctly + (confirming Roo still has `ShadowCheckpointService`/`RepoPerTaskCheckpointService` and no SQLite/recursive- + cascade equivalent); a full characterization of how Roo's checkpoint or subtask model itself diverges from + Cline is explicitly out of scope here and is being produced separately (see backlog Wave 5, Roo Code row). diff --git a/docs/research/session-store/products/langgraph.md b/docs/research/session-store/products/langgraph/index.md similarity index 92% rename from docs/research/session-store/products/langgraph.md rename to docs/research/session-store/products/langgraph/index.md index d361c0f3e..58a51a0f3 100644 --- a/docs/research/session-store/products/langgraph.md +++ b/docs/research/session-store/products/langgraph/index.md @@ -1,7 +1,7 @@ # LangGraph: how session (thread) state is stored and resumed Part of Session Store Research. -Produced by running [RESEARCH_PROMPT](../RESEARCH_PROMPT.md). +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 @@ -22,7 +22,7 @@ Authoritative anchors: > 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 +> 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 @@ -46,11 +46,11 @@ class Checkpoint(TypedDict): Two durable record kinds make up a thread's state: -1. **Checkpoints** — immutable, id-addressed snapshots forming a **parent-linked +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, +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`). @@ -61,7 +61,7 @@ What is authoritative vs. derived: `(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 + 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` @@ -85,7 +85,7 @@ fold over the chain. 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 +- **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; @@ -104,9 +104,9 @@ fold over the chain. - **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 + 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. +- **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`). @@ -147,7 +147,7 @@ class BaseCheckpointSaver(Generic[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 + 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 @@ -158,7 +158,7 @@ Contract notes drawn from the docstrings: - **`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 + (`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`). @@ -195,14 +195,14 @@ product's real extension point. 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 + 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 + 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 @@ -252,9 +252,9 @@ product's real extension point. `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 + 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 + -- which is orthogonal to the checkpointer and stores namespaced key-value "memories," not session transcripts. It is out of scope for session persistence. @@ -263,12 +263,12 @@ product's real extension point. - **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. + 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 +- **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 @@ -276,12 +276,12 @@ product's real extension point. 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 + (`: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 + 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 @@ -307,17 +307,17 @@ product's real extension point. - **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 + `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" + `"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 +- **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. @@ -334,15 +334,15 @@ product's real extension point. `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 + 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 +- **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 @@ -359,7 +359,7 @@ product's real extension point. 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 + 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. @@ -384,7 +384,7 @@ 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 + 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 @@ -398,7 +398,7 @@ Transferable ideas: - **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. + "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 @@ -407,15 +407,15 @@ Transferable ideas: - **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 +- **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` + 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 + 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. diff --git a/docs/research/session-store/products/letta/index.md b/docs/research/session-store/products/letta/index.md new file mode 100644 index 000000000..fcadc1064 --- /dev/null +++ b/docs/research/session-store/products/letta/index.md @@ -0,0 +1,758 @@ +# Letta: how session transcripts are stored and resumed + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT](../../RESEARCH_PROMPT.md). +Evidence snapshot retrieved 2026-08-04. Version-sensitive claims were checked +against a local clone of [letta-ai/letta](https://github.com/letta-ai/letta) +(formerly MemGPT) pinned at commit `ff19ffeafeb54bd2a7dc5d4a552f10191732a235`. +Letta is licensed Apache-2.0 (`LICENSE:1-5`). Authoritative anchors: +`letta/orm/agent.py` (the persistent "session" entity), `letta/orm/message.py` +and `letta/services/message_manager.py` (the durable message log), +`letta/orm/conversation.py`, `letta/orm/conversation_messages.py`, and +`letta/services/conversation_manager.py` (the newer relational +in-context-window model, in migration alongside the legacy JSON pointer), +`letta/orm/archive.py`, `letta/orm/passage.py`, and +`letta/services/archive_manager.py` (the archival/recall tier), +`letta/helpers/tpuf_client.py` (the external vector-search index), +`letta/services/memory_repo/` and `letta/services/block_manager_git.py` (an +opt-in, git-backed versioning system for core-memory blocks), and +`letta/orm/sqlalchemy_base.py` / `letta/server/db.py` (the generic ORM +CRUD/transaction layer). Letta runs as a server (FastAPI + SQLAlchemy over +Postgres or SQLite), not a local CLI, so "session" in this dossier means the +durable conversational state Letta's REST API exposes, not a terminal +session. + +## The storage model + +Letta does not have a first-class "Session" object. The durable, long-lived +entity is the **Agent** row itself (`letta/orm/agent.py:1-524`, table +`agents`) -- an agent is created once and persists indefinitely; there is no +separate session record that expires or gets swapped out. What most other +products call "resuming a session" is, in Letta, simply continuing to send +messages to the same `agent_id`. + +Two durable SQL tables carry the conversational history: + +- `messages` (`letta/orm/message.py:1-266`) -- one row per message, with a + server-assigned monotonic `sequence_id` (`letta/orm/message.py`, `BigInteger`, + unique) that is the authoritative ordering key, distinct from the row's + string `id` and from `created_at`. Message rows are overwhelmingly + append-only in practice, but they are not strictly immutable: `MessageManager` + exposes `update_message_by_id_async` (`letta/services/message_manager.py:705`) + and `_update_message_by_id_impl` (`letta/services/message_manager.py:794`), + and `delete_message_by_id_async` (`letta/services/message_manager.py:822`) + performs a genuine hard delete. +- `conversations` / `conversation_messages` (`letta/orm/conversation.py:1-77`, + `letta/orm/conversation_messages.py:1-74`) -- a newer, explicitly + in-progress relational model. The `ConversationMessage` docstring states it + "replaces the `message_ids` JSON list on agents with proper relational + modeling" of which messages are in-context for which conversation + (`letta/orm/conversation_messages.py:16-21`). + +Sitting on top of the message log is a piece of state that **cannot** be +reconstructed by replaying the log: the pointer that says which subset of +historical messages is currently "in context" for the LLM. In the legacy +model this is `Agent.message_ids`, a mutable JSON array column directly on +the agent row: + +```python +# letta/orm/agent.py:71 +message_ids: Mapped[Optional[List[str]]] = mapped_column(JSON, ...) +``` + +with an adjacent code comment calling this out as a known anti-pattern: + +```python +# letta/orm/agent.py:69-70 +# TODO: This should be a separate mapping table +# This is dangerously flexible with the JSON type +``` + +`AgentManager.reset_messages_async` makes the append-vs-pointer distinction +explicit in its own docstring: "Note: This only clears messages from the +agent's context, it does not delete them from the database" +(`letta/services/agent_manager.py:1671-1743`, quoted docstring at +`agent_manager.py:1686`). The implementation confirms it: it truncates +`agent.message_ids` down to `[system_message_id]` +(`letta/services/agent_manager.py:1711-1713`) -- the underlying `messages` +rows are untouched. The newer model replaces this single JSON array with a +per-row `ConversationMessage.in_context` boolean +(`letta/orm/conversation_messages.py`), which is the same kind of +non-replayable state, just modeled relationally instead of as a JSON blob. +`AgentManager` still exposes low-level pointer mutators -- +`set_in_context_messages`/`_async` (`letta/services/agent_manager.py:1616,1622`), +`trim_older_in_context_messages` (`agent_manager.py:1627`), +`trim_all_in_context_messages_except_system` (`agent_manager.py:1634`), +`prepend_to_in_context_messages` / `append_to_in_context_messages` / +`_async` (`agent_manager.py:1642,1650,1658`) -- all of which mutate the +pointer, never the `messages` table. + +A third tier, **archival memory**, is durable, permanent, and explicitly +separate from both of the above (see "Compaction" and "Rewind" below for +detail): `Archive` / `ArchivalPassage` rows (`letta/orm/archive.py:1-99`, +`letta/orm/passage.py:1-105`) created only by an explicit agent tool call, +never touched by context-window eviction. + +Which parts are authoritative vs. derived: + +- Authoritative / source of truth: `agents` row (including `message_ids`), + `messages` rows, `conversations`/`conversation_messages` rows, + `archival_passages` rows -- all in the primary SQL database (Postgres or + SQLite, see "Keying and identity"). +- Derived / rebuildable projections: the Turbopuffer vector index for + messages and passages (`letta/helpers/tpuf_client.py`, see "Listing, + summaries, and search" -- explicitly best-effort and can silently drift); + the agent's compiled system prompt (rebuilt from `Block` core-memory rows + on demand via `rebuild_system_prompt_async`, referenced at + `letta/services/agent_manager.py:1724`); and, when git-backed memory is + enabled for an agent, the `Block.value` column itself becomes a read cache + of the git repository, not the source of truth (see "Rewind, checkpoints, + and fork" -- `sync_blocks_from_git` docstring: "rebuild the PostgreSQL cache + from git source of truth", `letta/services/block_manager_git.py:571`). + +Best-fit conceptual model: **session-as-row-set with a mutable pointer**, +not a pure append-only log. The `messages` table is a durable, mostly-append +row set; the `agents.message_ids` array (legacy) or +`conversation_messages.in_context` flags (new) are mutable state layered on +top that determines what is "in the session" at any moment and is not +recoverable by replaying `messages` alone. + +## Keying and identity + +Every durable row is scoped to an **organization** via `OrganizationMixin` +(`letta/orm/mixins.py:1-99`), which is Letta's tenancy boundary -- not a +per-project/per-cwd scheme the way local-CLI tools key sessions. Within an +organization, the addressing hierarchy is: + +- `Agent.id` -- the top-level identity a client interacts with (all message + send/read operations take an `agent_id`). +- `Conversation.id` -- an optional, secondary scope for concurrent + conversations *within* one agent (`letta/orm/conversation.py`, docstring + "Conversations that can be created on an agent for concurrent messaging"). + `Conversation.agent_id` is a required FK with `ondelete="CASCADE"` + (`letta/orm/conversation.py`). +- `Run.id` -- an execution-attempt record created per processing turn, + optionally linked to a conversation via a nullable + `conversation_id` FK with `ondelete="SET NULL"` (`letta/orm/run.py:22-57`). + The `Run` docstring: "Runs are created when agents process messages and + represent a conversation or processing session. Unlike Jobs, Runs are + specifically tied to agent interactions and message processing" + (`letta/orm/run.py:23-25`). +- `Message.id` / `sequence_id` -- the leaf entries. + +IDs are server-minted, human-readable, prefixed UUIDs, not UUIDv7 and not +purely random: e.g. `Run.id` defaults to `f"run-{uuid.uuid4()}"` +(`letta/orm/run.py:37`), `Step.id` to `f"step-{uuid.uuid4()}"` +(`letta/orm/step.py:27`), `Group.id` to `f"group-{uuid.uuid4()}"` +(`letta/orm/group.py:21`), `BlockHistory.id` to +`f"block_hist-{uuid.uuid4()}"` (`letta/orm/block_history.py:25`). None of +these prefixes encode ordering; ordering is carried by separate columns +(`created_at`, and for messages the dedicated `sequence_id`). + +Listing is organization-scoped, not global: `ConversationManager.list_conversations` +filters `ConversationModel.organization_id == actor.organization_id` +unconditionally and additionally by `agent_id` when provided +(`letta/services/conversation_manager.py:390-397`). There is no +cross-project/cross-org enumeration path in the code read. + +There is no "relocation" or working-directory concept to reconcile -- Letta +is a server storing rows keyed by opaque IDs, not a local tool keyed by +filesystem path, so this part of research question 2 does not apply +(noted rather than answered, per the Method section). + +`otid` ("offline threading ID", `letta/schemas/message.py`) is a +client-supplied-or-server-generated field documented as the idempotency/dedup +mechanism for retried requests, distinct from the row's own `id`/`sequence_id`. + +## The store interface + +Letta has no pluggable storage adapter/protocol for sessions -- the "store" +is the SQLAlchemy ORM layer plus a set of service-layer managers +(`MessageManager`, `ConversationManager`, `ArchiveManager`, `PassageManager`, +`AgentManager`) that are the de facto internal interface. Reconstructed +operation contract, with call sites: + +**Generic CRUD** (`letta/orm/sqlalchemy_base.py:555-800`, base class for all +ORM models): +- `create_async` / `batch_create_async` -- insert, wrapped in the deadlock- + retry decorator (see "Write and append path"). +- `delete_async` -- **soft** delete (sets `is_deleted=True`); the default + delete path for models that support it. +- `hard_delete_async` / `bulk_hard_delete_async` -- genuine SQL `DELETE`. +- `update_async` (`letta/orm/sqlalchemy_base.py:747-791`) -- full-row update; + translates a SQLAlchemy `StaleDataError` into a domain + `ConcurrentUpdateError` for models using optimistic-locking (see below). + +**Messages** (`letta/services/message_manager.py`): +- `create_many_messages_async` (`message_manager.py:477-603`) -- the core + write path; batch-inserts message rows and, if a `run_id` is supplied, + guards against a race by checking the run exists before insert + (`message_manager.py:548-569`). +- `update_message_by_id_async` / `_update_message_by_id_impl` + (`message_manager.py:705`, `794`) -- in-place row mutation. +- `delete_message_by_id_async` (`message_manager.py:822`) and + `delete_messages_by_ids_async` (`message_manager.py:1094`) -- hard delete. +- `list_messages` (`message_manager.py:895-1044`) -- the primary read path, + cursor-paginated on `sequence_id` (`message_manager.py:1001-1024`), with + three-way `conversation_id` filter semantics: unset means "no filter", + `None` explicitly means "legacy/no-conversation messages", and a concrete + id filters to that conversation (`message_manager.py:957-972`). +- `delete_all_messages_for_agent_async` (`message_manager.py:1045-1093`) -- + bulk delete for one agent. +- `search_messages_async` (`message_manager.py:1142-1261`) -- routes to + Turbopuffer if configured, else falls back to a SQL query. +- `search_messages_org_async` (`message_manager.py:1262+`) -- org-wide search, + Turbopuffer-only, **no SQL fallback**. + +**Conversations** (`letta/services/conversation_manager.py`): +- `create_conversation` (`conversation_manager.py:50-104`). +- `fork_conversation` (`conversation_manager.py:105-174`) -- see "Rewind, + checkpoints, and fork." +- `fork_default_conversation` (`conversation_manager.py:175-221`) -- bridges + the legacy `agent.message_ids` array into the new relational model. +- `list_conversations` (`conversation_manager.py:338-518`) -- cursor-paginated + listing, sortable by `created_at`, `last_message_at`, or + `last_run_completion` (a joined aggregate over `runs`). +- `update_conversation` (`conversation_manager.py:519-555`), + `delete_conversation` (`conversation_manager.py:556-609`) -- see "Retention, + deletion, and multi-host." +- `add_messages_to_conversation` / `_add_messages_to_conversation_with_session` + (`conversation_manager.py:719`, `683`), `update_in_context_messages` + (`conversation_manager.py:752`), `list_conversation_messages` + (`conversation_manager.py:801`). + +**Archival memory** (`letta/services/archive_manager.py`, +`letta/services/passage_manager.py`): +- `create_archive_async` (`archive_manager.py:30-58`) -- binds a + `vector_db_provider` (`NATIVE` or `TPUF`) to the archive at creation time. +- `get_or_create_default_archive_for_agent_async` (`archive_manager.py:502-572`) + -- self-healing against a race via `IntegrityError` catch. +- `create_passage_in_archive_async` (`archive_manager.py:284-376`) -- the + archival write path (embed, SQL-insert, best-effort TPUF dual-write). +- `create_passages_in_archive_async` (`archive_manager.py:377-463`) -- batch + version. +- `delete_passage_from_archive_async` (`archive_manager.py:464-501`), + `delete_archive_async` (`archive_manager.py:266-279`, hard delete). +- `get_or_set_vector_db_namespace_async` (`archive_manager.py:691-717`) -- + lazy Turbopuffer namespace creation, cached on the `Archive` row. +- `PassageManager.insert_passage` (`passage_manager.py:543`), + `create_agent_passage_async` / `_passages_async` + (`passage_manager.py:134`, `199`), `agent_passage_size_async` + (`passage_manager.py:955`). + +**Agent-level context pointer** (`letta/services/agent_manager.py`): +- `get_in_context_messages` (`agent_manager.py:1413`), + `set_in_context_messages`/`_async` (`agent_manager.py:1616,1622`), + `trim_older_in_context_messages` (`agent_manager.py:1627`), + `append_to_in_context_messages`/`_async` (`agent_manager.py:1650,1658`), + `reset_messages_async` (`agent_manager.py:1671-1743`), + `delete_agent_async` (`agent_manager.py:1320-1397`). + +## Write and append path (ordering, durability, concurrency, delivery) + +**Ordering.** `Message.sequence_id` is the authoritative order/pagination +cursor (`letta/orm/message.py`, `BigInteger`, unique). On Postgres it is +generated server-side from a real sequence (`message_seq_id`, created and +backfilled in migration `alembic/versions/e991d2e3b428_add_monotonically_increasing_ids_to_.py`, +lines 1-40: `CREATE SEQUENCE message_seq_id START 1;` then a backfill query +ordered by `["created_at", "id"]`). SQLite has no equivalent server-side +sequence under concurrent insert, so Letta hand-rolls one: a singleton +`message_sequence` table incremented via two SQLAlchemy event listeners, +`set_sequence_id_for_sqlite_bulk` (`letta/orm/message.py:130`) and +`set_sequence_id_for_sqlite` (`letta/orm/message.py:204`), each doing an +atomic `UPDATE ... RETURNING` against that singleton row. + +**Durability/atomicity.** All writes go through +`db_registry.async_session()` (`letta/server/db.py:70-116`), an +`asynccontextmanager` that commits on success, rolls back on any exception +(including `asyncio.CancelledError`, handled separately because it is a +`BaseException` and would otherwise skip rollback and leak an +"idle in transaction" connection -- comment at `letta/server/db.py:77-80`), +and retries transient `ConnectionError`s up to 3 times with exponential +backoff (`letta/server/db.py:84-116`) before raising a domain +`LettaServiceUnavailableError`. There is no temp-file-and-rename pattern -- +durability is entirely the RDBMS transaction. + +**Concurrency.** Two distinct mechanisms: +- Nearly every write in `letta/orm/sqlalchemy_base.py` is wrapped in a + deadlock-retry-with-exponential-backoff decorator (constants referenced in + that module) that catches DB-reported deadlocks and retries the operation. +- Optimistic-concurrency version checking (SQLAlchemy's `version_id_col`) is + used on exactly one model in the whole ORM layer -- `Block` + (`letta/orm/block.py:61`: `__mapper_args__: ClassVar[dict] = + {"version_id_col": version}`) -- confirmed by grepping every file under + `letta/orm/` for `version_id_col`, which returns only this one hit. When a + stale `Block` write loses the race, `sqlalchemy_base.update_async` catches + SQLAlchemy's `StaleDataError` and re-raises it as a domain + `ConcurrentUpdateError(resource_type=class_name, resource_id=object_id)` + (`letta/orm/sqlalchemy_base.py:747-791`). No other model in the tree -- + not `Agent`, not `Message`, not `Conversation`, not `Archive` -- has this + guard. In particular, `Agent.message_ids` updates + (`agent.update_async(...)`, e.g. `letta/services/agent_manager.py:1713`) + are plain last-write-wins full-column overwrites with no version check: + two concurrent turns racing to update the same agent's context pointer can + silently clobber each other's context-window state (the underlying + `messages` rows are never lost, only the pointer's view of "what's in + context"). +- Message writes carry an explicit precondition check rather than a version + column: `create_many_messages_async` verifies the referenced `run_id` + exists before inserting, specifically to close a race window + (`letta/services/message_manager.py:548-569`). + +**Delivery semantics to the SQL store**: effectively exactly-once per +successful transaction (single RDBMS commit), at-least-once at the +client-retry level, deduplicated via the client-or-server-generated `otid` +field (`letta/schemas/message.py`) -- this is Letta's documented +idempotency/dedup key for retried sends, not `sequence_id` or `id`. + +**Delivery to the derived vector index is different and weaker**: message +embedding to Turbopuffer is backgrounded and fire-and-forget by default +(`_embed_messages_background`, `letta/services/message_manager.py:605-661`), +gated behind `strict_mode` if the caller wants to wait for/fail on it; +failures are logged and swallowed otherwise, so the SQL row and the vector +index can drift out of sync with no automatic reconciliation (see "Listing, +summaries, and search"). + +## Read and resume path + +Because Letta is a server with one authoritative Postgres/SQLite database +(there is no local on-disk cache layer in the paths read), "resume" is +simply: the client sends a new message to an existing `agent_id`, and the +server loads `Agent.message_ids` (or, on the new model, the set of +`ConversationMessage` rows with `in_context=True` for the active +conversation) and reads the referenced `Message` rows to reconstruct the +LLM's context window. There is no separate "cached view" distinct from the +SQL rows. + +- `list_messages` is the general-purpose paginated read, cursor-based on + `sequence_id` with `after`/`before` semantics + (`letta/services/message_manager.py:1001-1024`), not offset pagination, so + it does not degrade as the table grows. +- Materialized eagerly on resume: the `agent.message_ids` array (or + `in_context` conversation-message rows) and the referenced message rows -- + this is exactly the in-context window the model will see next turn. +- Lazily loaded: the full message history beyond the in-context window + (queried on demand via `list_messages`), and archival passages (queried + only when the agent explicitly calls the archival-search tool -- see + below). +- No stated bound on total transcript size in the code paths read; the + in-context window is bounded (see "Compaction"), but the durable + `messages` table itself has no code-enforced cap. + +## Listing, summaries, and search + +**Listing.** `ConversationManager.list_conversations` +(`letta/services/conversation_manager.py:338-518`) is an indexed SQL query, +not a directory scan: `Conversation` has `ix_conversations_agent_id` and +`ix_conversations_org_agent` indexes (added in migration +`alembic/versions/27de0f58e076_add_conversations_tables_and_run_.py:1-45`). +Cursor pagination supports three sort keys -- `created_at`, +`last_message_at`, and `last_run_completion` (the last computed via an +outer join and `MAX(runs.completed_at)` grouped by conversation, +`conversation_manager.py:413-420`). No stated cost numbers were found in +the source (nothing to quote). + +**Metadata sidecar.** `Conversation.summary` is a plain nullable `String` +column on the `Conversation` row itself (`letta/orm/conversation.py`), not a +separately maintained denormalized read-model table -- it is set at write +time by `compile_and_save_system_message_for_conversation` +(`letta/services/conversation_manager.py:222-310`, not fully read for its +summarization logic) and searched via a simple `.contains()` filter +(`conversation_manager.py:400-406`). This is the only "summary" artifact +found; there is no separate FTS index over conversation summaries. + +**Search.** Two independent search paths, chosen per call: +- SQL fallback -- a direct query against `messages` (used when Turbopuffer + is unavailable or unconfigured; routed inside + `search_messages_async`, `letta/services/message_manager.py:1142-1261`). +- Turbopuffer -- an external hosted vector-search service + (`letta/helpers/tpuf_client.py`, `TurbopufferClient` class, + `tpuf_client.py:223+`), used for both archival-passage search + (`query_passages`, `tpuf_client.py:909`) and message search + (`query_messages_by_agent_id`/`_by_org_id`, `tpuf_client.py:1056,1233`), + combining vector similarity and full-text search via reciprocal-rank + fusion (`_reciprocal_rank_fusion`, `tpuf_client.py:1489`). + +**Whether Turbopuffer is used at all is a server-wide, settings-gated +decision**, re-evaluated at each relevant call site: + +```python +# letta/helpers/tpuf_client.py:208-220 +def should_use_tpuf() -> bool: + # We need OpenAI since we default to their embedding model + return bool(settings.use_tpuf) and bool(settings.tpuf_api_key) and bool(model_settings.openai_api_key) + +def should_use_tpuf_for_messages() -> bool: + return should_use_tpuf() and bool(settings.embed_all_messages) +``` + +**How the index is bootstrapped and kept consistent** (the area the task +called out for extra depth): there is **no backfill/reindex code path**. +Every archival passage and every embedded message is indexed exactly once, +at write time: +- Archival passages: `create_passage_in_archive_async` synchronously + generates the embedding, writes the SQL row first (authoritative), then + best-effort dual-writes to Turbopuffer only if + `Archive.vector_db_provider == TPUF` + (`letta/services/archive_manager.py:349-368`); `vector_db_provider` is + decided once, at archive-creation time, from the server-wide + `should_use_tpuf()` gate (`archive_manager.py:30-58`) and then cached + permanently on the `Archive` row. +- Messages: embedding is asynchronous and fire-and-forget by default + (`_embed_messages_background`, `letta/services/message_manager.py:605-661`). + +I grepped `archive_manager.py`, `message_manager.py`, and `tpuf_client.py` +for `backfill`/`reindex`/`re-index` and found no job or migration that +retroactively embeds pre-existing rows into Turbopuffer. The one related hit +is a still-open TODO acknowledging a gap in the opposite direction (a schema +field, not historical data): `"# TODO: Once existing TPUF namespaces are +backfilled with is_deleted attribute,"` (`letta/helpers/tpuf_client.py:1053`). +Practical implication (inference): if Turbopuffer is enabled for the first +time on a server that already has agents with history, or if a single +`Archive`'s `vector_db_provider` was set to `NATIVE` before a later +org-wide policy change to `TPUF`, vector search will never surface the +pre-existing rows -- only the SQL fallback (`search_messages_async`) or, +for org-wide message search, nothing at all, since +`search_messages_org_async` is Turbopuffer-only with no fallback +(`letta/services/message_manager.py:1262+`). Consistency is best-effort and +eventual, never transactionally tied to the SQL write. + +## Entry/message structure and versioning + +`Message` (`letta/orm/message.py:1-266`, table `messages`) carries: `id` +(string, uuid-prefixed), `sequence_id` (`BigInteger`, unique, monotonic -- +the true order key), `created_at`, `role`, `content` (via +`MessageContentColumn`, a custom SQLAlchemy `TypeDecorator` over `JSON`: +`process_bind_param`/`process_result_value` call +`serialize_message_content`/`deserialize_message_content`, +`letta/orm/custom_columns.py:129-139`), `run_id` (FK to `runs`), +`conversation_id` (FK to `conversations`, nullable), `tool_call_id`, and +`otid`. The store parses and interprets this structure -- it is not an +opaque blob returned verbatim; `sequence_id` is what the store relies on for +ordering, and `otid` is what it relies on for dedup/idempotency +(`letta/schemas/message.py`). + +Client-facing message *kinds* are enumerated separately in +`letta/schemas/letta_message.py` (`MessageType` enum, 11 variants, e.g. +`SystemMessage`, `UserMessage` subclasses of a common `LettaMessage` base) -- +these are API-surface projections, distinct from the ORM row shape. + +**Format evolution** is handled through a linear Alembic migration chain +(167 files under `alembic/versions/`, validated in CI per +`.github/workflows/alembic-validation.yml`), each declaring a `down_revision` +pointer to its predecessor. Concrete evidence of schema evolution driven by +real production needs, read directly from migration files: +- `alembic/versions/e991d2e3b428_add_monotonically_increasing_ids_to_.py:1-40` + -- added `messages.sequence_id`, created a Postgres sequence + (`message_seq_id`), and backfilled existing rows ordered by + `["created_at", "id"]`; explicitly skipped for SQLite + (`if not settings.letta_pg_uri_no_default: return`). +- `alembic/versions/27de0f58e076_add_conversations_tables_and_run_.py:1-45` + -- added the entire `conversations`/`conversation_messages` schema and a + `runs.conversation_id` column, i.e. the newer relational in-context model + described above, dated `2026-01-01` in the migration header. +- `alembic/versions/95badb46fdf9_migrate_messages_to_the_orm.py` (name only, + not opened in full) and two data-backfill migrations, + `alembic/versions/9fa274fb0b83_backfill_hidden_for_subagent_role_tag.py` + and `alembic/versions/8149a781ac1b_backfill_encrypted_columns_for_....py` + (names only) -- further evidence that Letta treats in-place data backfill + migrations, not just DDL, as a normal part of its evolution. +- `alembic/versions/068588268b02_add_vector_db_provider_to_archives_table.py` + and `alembic/versions/f6cd5a1e519d_add_embedding_config_field_to_archives_.py` + (names only) -- the archival vector-search feature was added onto an + already-shipped `archives` table, consistent with `vector_db_provider` + being a late, per-archive add rather than a day-one design choice. +- A dedicated tool-call-ID backfill was also found at the application layer, + not in a migration: `backfill_missing_tool_call_ids` + (`letta/services/message_manager.py:30-113`), whose docstring/log message + explicitly ties it to "historical messages (oct 1-6, 2025 bug)" + (`message_manager.py:113`) -- i.e., Letta has shipped at least one + application-level self-healing pass to repair rows written during a + known bug window, applied lazily on read (called from both + `list_messages` and another read path, `message_manager.py:391,1040-1041`) + rather than as a one-time migration. + +Whether the migration ratchet is one-way or reversible: each Alembic +revision file conventionally supports `upgrade()`/`downgrade()`, but I found +no CI evidence (in the files read) that `downgrade()` is actually exercised +-- the GitHub Actions validation job name (`alembic-validation.yml`) suggests +forward-migration validation. Treat "reversible in principle, not verified +in practice" as inference, not a confirmed fact. + +## Compaction and history management + +Model-visible context shrinks via `Summarizer` +(`letta/services/summarizer/summarizer.py:36-895+`), which supports two +modes via a `SummarizationMode` enum: `STATIC_MESSAGE_BUFFER` and +`PARTIAL_EVICT_MESSAGE_BUFFER`, dispatched from `summarize()` +(`summarizer.py:75-123`). The eviction path, +`_partial_evict_buffer_summarization` (`summarizer.py:136-243`), operates on +an in-memory Python list of messages; its output is committed back by +rewriting the agent's context pointer (the same `message_ids`/ +`in_context` mechanism described above), not by deleting rows from +`messages`. This matches the `reset_messages_async` docstring pattern +exactly ("does not delete them from the database", +`letta/services/agent_manager.py:1686`): compaction is a context-window +concern layered over the durable log, and the log itself is untouched. +There is no explicit "compaction marker" appended to the log the way an +event-sourced design might record a synthetic boundary event -- the boundary +exists only implicitly, as whatever the pointer/`in_context` state was at +that moment, and is not itself a durable, replayable fact. + +Resume/replay across a compaction boundary: a resumed agent simply sees +whatever `message_ids` (or `in_context` rows) currently say, which already +reflects the post-compaction state -- there is no notion of "replay from +before the compaction" through the API paths read. + +## Rewind, checkpoints, and fork + +Two independent mechanisms answer this section, at two different tiers of +state: + +**Conversation forking** (message-log tier). `ConversationManager.fork_conversation` +(`letta/services/conversation_manager.py:105-174`) is a genuine +shared-prefix fork, not a copy: it creates a new `Conversation` row and a +new system message, then links the *same* underlying `Message` rows into +the new conversation via new `ConversationMessage` junction rows +(`message_ids_to_copy = source_message_ids[1:]`, linked, not duplicated). +`fork_default_conversation` (`conversation_manager.py:175-221`) is the +migration bridge that promotes an agent's legacy `message_ids` array into +this model the first time it is needed. Because messages can be +multiply-referenced this way, deletion is reference-aware: +`delete_conversation` (`conversation_manager.py:556-609`) soft-deletes the +`conversation_messages` junction rows for that conversation +(`conversation_manager.py:573-580`), then soft-deletes only the `Message` +rows that are **not** still referenced by any other non-deleted +conversation -- enforced with an explicit `NOT IN` subquery against +`conversation_messages` (`conversation_manager.py:582-597`, comment: "With +conversation forking, messages can be referenced by multiple conversations +via the conversation_messages junction table"). + +**Git-based memory-block checkpoints** (mutable-state tier, opt-in). This is +a second, independent versioning system layered over core-memory `Block` +rows, gated per-agent behind a tag (`GIT_MEMORY_ENABLED_TAG`, +`letta/services/block_manager_git.py:359-482`, +`GitEnabledBlockManager` class at `block_manager_git.py:30`). When enabled: +- Each memory block is rendered to a `{label}.md` file in a per-agent git + repository (`letta/services/memory_repo/git_operations.py`, + `GitOperations` class at `git_operations.py:49`), backed by an external + `StorageBackend` (blob storage, not opened in full). +- Every mutation is committed via `GitOperations.commit` + (`git_operations.py:351-414`), which acquires a **Redis distributed lock** + scoped to the agent (`acquire_memory_repo_lock`, + `git_operations.py:384-386`) before running `git reset --hard`, applying + file changes, and `git commit` (`_commit_with_lock`, + `git_operations.py:415-517`) -- this is the concurrency-control mechanism + for this tier (a mutex per agent, not optimistic versioning). +- `BlockManagerGit.get_block_at_commit` (`block_manager_git.py:510-529`) + reads a block's value **at a specific historical commit SHA** -- a true + point-in-time rewind read, not destructive to later history. +- `get_block_history` (`block_manager_git.py:533-560`) lists the commit + history for an agent's blocks (optionally filtered to one block/`label`). +- `sync_blocks_from_git` (`block_manager_git.py:564-596`) is an explicit + "rebuild the PostgreSQL cache from git source of truth" operation + (docstring, `block_manager_git.py:571`) -- confirming that once git-memory + is enabled for an agent, the `Block.value` column in Postgres/SQLite + becomes a derived cache, and the git repository (not the SQL row) is + authoritative for that agent's core memory. +- `enable_git_memory_for_agent` (`block_manager_git.py:359-481`) is + explicitly self-healing/idempotent: if the tag is already present but the + repo is missing blocks or missing entirely, it backfills from whatever + blocks currently exist in Postgres (`block_manager_git.py:379-442`, + comment: "tags can be added via the agent update endpoint... we treat the + tag as the source-of-truth 'desired state' and backfill the repo if + missing"). +- `disable_git_memory_for_agent` (`block_manager_git.py:485-506`) only + removes the tag; it explicitly "keeps the git repo for historical + reference" (docstring, `block_manager_git.py:492`) -- disabling is not + destructive to the checkpoint history. + +Separately, and without the git system, `Block` (core memory) is versioned +via SQLAlchemy's optimistic `version_id_col` (`letta/orm/block.py:61`) and +linked to a `block_history` table via `current_history_entry_id` +(`letta/orm/block.py`, FK column) -- `BlockHistory` +(`letta/orm/block_history.py:12-49`) stores a full snapshot per historical +state (`description`, `label`, `value`, `limit`, `metadata_` all copied, +plus `actor_type`/`actor_id` for attribution and a per-block monotonic +`sequence_number`, `letta/orm/block_history.py:46-48`), cascade-deleted with +its `Block` (`ondelete="CASCADE"`, `letta/orm/block_history.py:40-44`). This +looks like the always-on undo/redo trail (docstring: "Stores a single +historical state of a Block for undo/redo functionality", +`letta/orm/block_history.py:13`), distinct from and simpler than the opt-in +git system. + +## Subagents and nested sessions + +Letta's "subagent" concept is the **sleeptime agent** -- a background +memory-management agent linked to a "main" agent via a `Group` row +(`letta/orm/group.py:1-43`), with `Group.manager_type` set to +`ManagerType.sleeptime` or `ManagerType.voice_sleeptime`. Sleeptime agents +are first-class `Agent` rows (siblings), not entries nested inside the main +agent's transcript, and each maintains its own independent `messages` +history -- there is no shared-transcript nesting. + +The durable parent-child link is the `groups`/`groups_agents` join plus +`Group.manager_agent_id` (FK to `agents.id`, `ondelete="RESTRICT"`, +`letta/orm/group.py:24`). `RESTRICT` on that FK means the database itself +will refuse to delete a manager agent while a group still references it as +manager -- deletion has to go through the application-level cleanup path +below rather than a raw `DELETE`. + +`AgentManager.delete_agent_async` (`letta/services/agent_manager.py:1320-1397`) +is the actual cascade/cleanup code path, handling both delete directions: + +```python +# letta/services/agent_manager.py:1340-1341 +# Handle case where we're deleting a sleeptime agent (not the main agent) +# In this case, we need to clean up the group and the main agent's enable_sleeptime flag +``` + +- Deleting a sleeptime/voice-sleeptime agent directly: finds its + `Group` (`agent_manager.py:1344-1351`), deletes that group, and clears + `enable_sleeptime` on the main agent it belonged to + (`agent_manager.py:1353-1362, 1387-1390`). +- Deleting the main agent: if it has a `multi_agent_group` of manager type + `sleeptime`/`voice_sleeptime`, every participant sleeptime agent is loaded + and added to the deletion set alongside the main agent, and the group is + deleted too (`agent_manager.py:1365-1377`). All deletions in the batch + commit inside one `try`/`except`, rolling back together on failure + (`agent_manager.py:1379-1394`). + +This is explicit application-level cascade logic, not a database `ON DELETE +CASCADE` -- the FK from group to manager agent is `RESTRICT` precisely +because the app needs to run this cleanup first. Nesting is not depth-bounded +in the code read (a group has exactly one manager and a flat list of +participant agent IDs -- `Group.agent_ids`, `letta/orm/group.py:36` -- not a +recursive tree). + +## Retention, deletion, and multi-host + +**Retention/TTL.** No scheduled retention or TTL-cleanup job was found: +`letta/jobs/scheduler.py` contains no retention/expiry logic (grepped for +`TTL`/`expire`/`retention`/`cleanup`; the only match was an unrelated log +line about scheduler shutdown, `letta/jobs/scheduler.py:228`). Deletion is +entirely caller-driven (an explicit API call), never store-initiated. + +**Delete cascade behavior**, established from `letta/orm/mixins.py:1-99` +and per-model overrides: +- `AgentMixin.agent_id` FKs (used by `messages`, `conversations`, + `files_agents`, etc.) are `ondelete="CASCADE"` + (`letta/orm/mixins.py:40`) -- deleting an agent hard-cascades all of its + messages and conversations at the database level, in addition to the + application-level sleeptime-agent cleanup above. +- `ArchiveMixin.archive_id` FK (used by `archival_passages`) is also + `ondelete="CASCADE"` (`letta/orm/mixins.py:80`) -- passages cascade with + their *archive*, not with any one agent. +- The `archives_agents` junction table has `ondelete="CASCADE"` on **both** + FK directions (`letta/orm/archives_agents.py:23-24`), but that only + removes the attachment row linking an agent to an archive -- it never + deletes the `Archive` itself. No code path calls + `delete_archive_async` (`letta/services/archive_manager.py:266-279`, a + hard delete) from within `delete_agent_async`. Practical consequence: + **archival memory outlives agent deletion** by default; an archive (and + its passages) becomes an unreferenced-but-still-live row set once its last + agent is deleted, unless something explicitly calls `delete_archive_async`. +- `Conversation` deletion is reference-counted rather than a blanket + cascade, described in detail above (`conversation_manager.py:556-609`): + soft-delete the conversation and its own message associations, but only + soft-delete `Message` rows not still referenced by another live + conversation. +- Isolated per-conversation `Block`s are hard-deleted on conversation + delete (`Block` has no soft-delete support, per the comment at + `conversation_manager.py:602-603`), after first removing their junction + rows (`conversation_manager.py:605-609`). + +**Multi-host behavior.** Letta's engine/session setup +(`letta/server/db.py:1-146`) is a single shared async SQLAlchemy engine per +process (`create_async_engine`, `letta/server/db.py:58`), pooled with +`AsyncAdaptedQueuePool` (configurable pool size/overflow/timeout/recycle) or +`NullPool` if pooling is disabled (`letta/server/db.py:24-41`) -- this is the +standard "many stateless app servers, one shared Postgres" topology; nothing +in the code assumes a shared filesystem or does its own crash detection. +Statement caching is explicitly disabled for asyncpg +(`statement_cache_size: 0`, `prepared_statement_cache_size: 0`, +`letta/server/db.py:48-49`) and each connection gets a UUID-suffixed +prepared-statement name (`letta/server/db.py:47`) -- both of these are +classic PgBouncer/transaction-pooling-mode compatibility settings, i.e. +Letta's design assumes it may run behind a connection pooler in front of +Postgres, consistent with a genuinely multi-host deployment rather than a +single-box assumption (inference from the settings shape; no explicit +"PgBouncer" comment was found to cite directly). The `db_registry.async_session()` +context manager retries transient `ConnectionError`s (`letta/server/db.py:84-116`) +specifically to smooth over exactly the kind of blip multi-host/pooled +deployments produce. The Redis-backed lock used by the git-memory commit +path (`git_operations.py:384-386`) is the one place I found an explicit +cross-process/cross-host mutual-exclusion primitive outside the RDBMS +transaction itself -- everything else relies on Postgres transactions and +(for `Block` only) optimistic version checks for multi-host safety. + +## Interop with foreign session stores + +Letta does not read or import other agent frameworks' native session +stores. It has its own portable export/import format instead -- the +"agent file" (`.af`), defined in `letta/schemas/agent_file.py:1-60+` and +implemented in `letta/services/agent_serialization_manager.py` +(not read in depth; out of the scope this dossier prioritized). This is +Letta-to-Letta portability (`ImportResult`/`MessageSchema` types at +`letta/schemas/agent_file.py:29-52`), not consumption of a foreign product's +transcript format, so it does not answer "does Letta read another product's +store" in the sense research question 12 asks -- noted as a gap rather than +answered affirmatively. + +## What this implies for our Session Store (our inference) + +Letta's durable "session" is best understood as a mostly-append-only +`messages` row set, permanently owned by a long-lived `Agent` entity, with a +thin and explicitly-acknowledged-as-fragile mutable pointer +(`message_ids`/`in_context`) layered on top that determines what the model +currently sees -- that pointer, not the message log, is the one piece of +state that cannot be reconstructed by replay, and Letta's own maintainers +are mid-migration away from its riskiest form (a JSON array with no ORM +relationship) toward a proper join table. This maps cleanly onto our +event-sourced Session Store's distinction between the durable log and +derived read models, with two caveats worth carrying forward: (1) Letta +demonstrates that "the pointer" needs its own concurrency story -- a bare +last-write-wins column update is exactly the failure mode our design should +avoid for any per-turn in-context-window projection; and (2) Letta's +archival/vector-search tier shows that a derived search index can be +allowed to silently and permanently miss historical data if it is enabled +after data already exists, with no reconciliation job -- if our design wants +"enable search later" to actually mean "search everything," it needs an +explicit backfill path that Letta's own codebase does not have. + +## Open questions + +- **Turbopuffer backfill**: confirmed absent in the code paths read + (`letta/helpers/tpuf_client.py`, `letta/services/archive_manager.py`, + `letta/services/message_manager.py`), but I did not check for an + out-of-repo/ops-only backfill script (e.g. in a separate deployment repo) + that might exist outside this checkout. Treat "no backfill exists" as + "no backfill exists in `letta-ai/letta` at this commit," not as a claim + about Letta's hosted-cloud operations. +- **`StorageBackend`** behind the git-memory system + (`letta/services/memory_repo/git_operations.py:64`, constructor parameter) + was referenced but its concrete implementation (S3? local disk? something + else?) was not opened -- I cannot confirm what medium actually stores the + per-agent git repositories at rest. +- **`agent_file.py` / `agent_serialization_manager.py` export-import + semantics** (whether it's a snapshot-only export or supports true + round-trip resume) were not investigated beyond the type definitions + glimpsed at `letta/schemas/agent_file.py:1-60`; out of scope for this + pass given the task's emphasis elsewhere. +- **Downgrade-path testing**: I could not confirm from the files read + whether Alembic `downgrade()` functions are exercised in CI, only that an + `alembic-validation.yml` workflow exists; whether Letta's migration + ratchet is genuinely reversible in practice is unverified. +- **`Step` model and per-LLM-call metadata** (`letta/orm/step.py`) were + read only partially (first ~50 lines); its relationship to `Run` and to + the durability story for partial/streaming responses was not investigated + in depth. +- **`Summarizer`**: only `summarize()` (lines 75-123) and + `_partial_evict_buffer_summarization` (lines 136-243) were read in + detail out of roughly 900 lines in `letta/services/summarizer/summarizer.py`; + `_static_buffer_summarization` (starting line 244) and the LLM-driven + summarization call itself were not examined. +- **Passage tag storage**: `ArchivalPassage` has both a JSON `tags` column + and a `passage_tags` junction-table relationship + (`letta/orm/passage.py`) described in the source as complementary/dual + storage; I did not trace why both exist or which is authoritative. diff --git a/docs/research/session-store/products/letta/vs-session-events.md b/docs/research/session-store/products/letta/vs-session-events.md new file mode 100644 index 000000000..5be7971f5 --- /dev/null +++ b/docs/research/session-store/products/letta/vs-session-events.md @@ -0,0 +1,562 @@ +# Letta compared to our session event catalog + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT_COMPARISON](../../RESEARCH_PROMPT_COMPARISON.md). +Stage-one dossier: [Letta](./index.md). +Compared against `proto/trogonai/session/sessions/v1alpha1/` and [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) on 2026-08-04. + +**Store maturity: 11/12** -- evolution scars 3/3 (167 Alembic migration files with a +real cutover in progress: `alembic/versions/e991d2e3b428_add_monotonically_increasing_ids_to_.py:1-40` +added `messages.sequence_id` and backfilled existing rows ordered by +`["created_at", "id"]`, and `alembic/versions/27de0f58e076_add_conversations_tables_and_run_.py:1-45` +added the entire `conversations`/`conversation_messages` relational model +mid-migration away from the legacy `message_ids` JSON array), operational age +2/3 (real production hardening -- a deadlock-retry decorator wrapping nearly +every ORM write, `db_registry.async_session()` retrying transient +`ConnectionError`s up to three times with backoff at `letta/server/db.py:84-116`, +and an application-level self-healing pass, `backfill_missing_tool_call_ids` +(`letta/services/message_manager.py:30-113`), explicitly tied to "historical +messages (oct 1-6, 2025 bug)" at `message_manager.py:113` -- but none of this is +corroborated by an external issue tracker the way Cline's `cline#9011` is, so +it is scored short of 3), exposure 3/3 (a funded, shipped, multi-tenant server: +`OrganizationMixin` (`letta/orm/mixins.py:1-99`) scopes every row to a tenant, +and `letta/server/db.py:24-49` configures asyncpg specifically for +PgBouncer/transaction-pooling compatibility -- disabled statement caching, a +UUID-suffixed prepared-statement name per connection -- which is production +multi-host deployment evidence, not a toy default), design independence 3/3 +(no pluggable storage adapter and no evidence of persistence code inherited +from a fork; the store is Letta's own SQLAlchemy ORM plus service-layer +managers, evolved from its own predecessor MemGPT, not copied from another +product). + +## The one structural difference everything else follows from + +Letta durably separates two things: a mostly-append `messages` row set +(`letta/orm/message.py:1-266`), and a thin, mutable, unguarded **pointer** +that says which subset of that row set is currently "in context" for the LLM. +In the legacy model that pointer is `Agent.message_ids`, a JSON array column +directly on the agent row (`letta/orm/agent.py:71`), and Letta's own source +calls it out as a known anti-pattern immediately above the field: + +```python +# letta/orm/agent.py:69-70 +# TODO: This should be a separate mapping table +# This is dangerously flexible with the JSON type +``` + +`AgentManager.reset_messages_async`'s own docstring states the consequence +plainly: "Note: This only clears messages from the agent's context, it does +not delete them from the database" (`letta/services/agent_manager.py:1686`). +The newer relational model replaces the JSON array with a per-row +`ConversationMessage.in_context` boolean (`letta/orm/conversation_messages.py`), +but it is the same shape of problem: state that determines what the model +sees next turn, that cannot be reconstructed by replaying the message log, +and that is mutated in place. Concurrency control for this pointer does not +exist -- `Agent.message_ids` updates are plain last-write-wins full-column +overwrites (`letta/services/agent_manager.py:1713`) with no version check, so +two concurrent turns racing to update the same agent's context pointer can +silently clobber each other's view of "what's in context," even though the +underlying `messages` rows are never lost. + +We have no analogue of this pointer, anywhere. [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8 states +that "the model-visible context is compiled deterministically from the event +log bounded by the latest `Compacted` marker" -- it is a fold, recomputed on +demand, never a separately-mutated column that a writer can race against or +forget to update correctly. Where Letta needs a second concurrency story for +its pointer, and does not reliably have one (optimistic-concurrency version +checking, SQLAlchemy's `version_id_col`, is used on exactly one model in the +whole ORM layer, `Block` -- `letta/orm/block.py:61` -- confirmed by the +dossier's own grep of every file under `letta/orm/` for `version_id_col`, +which returns only that one hit), we need none, because there is no second +piece of authoritative state to protect. The fold *is* the pointer, derived +each time from facts already governed by our own `WRITE_PRECONDITION` +classification ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2). Letta is unusually strong evidence for +this design choice specifically because it is a funded, shipped product whose +own maintainers flagged the risk in a code comment and are mid-migration away +from it -- not a hypothetical failure mode, a self-documented one. + +## Mapping + +Two words collide across the two designs and deserve naming before the +table, because a naive lookup on either would silently produce a wrong +mapping (per the Method's warning on semantic mismatches): + +- **"Session."** The dossier is explicit that Letta has no first-class + Session object at all: the durable, long-lived entity is the `Agent` row + itself (`letta/orm/agent.py:1-524`), created once and persisting + indefinitely, with no separate record that expires or gets swapped out. + "Resuming a session" in Letta means "sending another message to the same + `agent_id`." Our `session_id` names a bounded execution + (`SessionStarted`) that reaches a terminal state (`SessionClosed`, + `SessionCancelled`, `SessionFailed`, `SessionHidden`) and is never resumed + as the same identity again -- continuation is a new `SessionForked` stream. + Nothing in our catalog corresponds to an identity that simply never + terminates. +- **"Checkpoint."** `letta/services/block_manager_git.py` calls its + git-backed memory-block versioning system a "checkpoint" concept in the + dossier's own section heading, and once git-memory is enabled for an + agent, `Block.value` in Postgres becomes a read cache and the git + repository becomes authoritative -- `sync_blocks_from_git`'s docstring says + so directly: "rebuild the PostgreSQL cache from git source of truth" + (`letta/services/block_manager_git.py:571`). Our `Checkpoint` + (`checkpoint.proto`) is never a second source of truth: [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 3 + is explicit that the typed event log is always authoritative and a harness + recovery checkpoint is "an opaque artifact used only when the platform + continues process state from an in-flight harness loop," discarded on + corruption in favor of replay. Letta's git-checkpoint inverts that + relationship for one tier of its own data (core memory): the durable SQL + store becomes the disposable cache and an external system becomes + authoritative. This is the sharpest semantic mismatch in the comparison -- + see the "What our design already does better" section. + +| Letta | Ours | Verdict | +| --- | --- | --- | +| `Agent.id` (long-lived, never terminates, `letta/orm/agent.py:1-524`) | `SessionId` (bounded, terminal-lifecycle) | Semantic mismatch -- see above; no equivalent to a persistent identity that outlives a bounded execution | +| `Conversation.id` (secondary, concurrent-messaging scope within one agent, `letta/orm/conversation.py`) | No equivalent -- a Session has exactly one linear turn sequence | Gap, by design: our Non-Goals defer a symmetric multi-party `Conversation` aggregate | +| `Run.id` (execution-attempt record per processing turn, optional `conversation_id` FK, `letta/orm/run.py:22-57`, docstring at `letta/orm/run.py:23-25`) | `ExecutionAttemptStarted`/`Ready`/`Ended` (`execution_attempt_started.proto`) | Equivalent -- both are "one attempt" units distinct from the parent identity | +| `Message.id` / `sequence_id` (`BigInteger`, unique, monotonic order key, `letta/orm/message.py`) | `CanonicalMessage.message_id` (`message.proto`); order is `SessionOrdinal`, fold-derived, never a stored counter (`session_ordinal.proto`) | Trade-off -- see below | +| `Agent.message_ids` JSON array / `ConversationMessage.in_context` (mutable, unguarded pointer) | No equivalent; model-visible context folds from the newest `Compacted` marker ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8) | Ours, decisively -- the structural difference above | +| `otid` ("offline threading ID," a schema field on the message payload itself, `letta/schemas/message.py`, the documented dedup key for retried sends) | The command idempotency key ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2), which "lives on the command, not the event" (decision 3) | Ours, decisively -- Letta conflates dedup identity with domain data; we deliberately never let a domain payload carry its own dedup key | +| `Block.version_id_col` (optimistic concurrency, the *only* model with it, `letta/orm/block.py:61`) | `WRITE_PRECONDITION` classified per fact (`NoStream`/`At`/`Any`, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2), applied to every invariant-bearing transition, not just one entity | Ours, decisively -- see "What not to copy" | +| `BlockHistory` (always-on undo/redo snapshot table, full-value copy per change, cascade-deleted with its `Block`, `letta/orm/block_history.py:12-49`) | No equivalent entity to snapshot; the event log itself is the undo/redo trail, kept forever ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7) | Ours -- no second table can drift from the log it is supposed to mirror, because there is nothing but the log | +| Git-backed memory-block checkpoints, opt-in, Postgres becomes a cache (`letta/services/block_manager_git.py`, `letta/services/memory_repo/git_operations.py`) | `Checkpoint`/`CheckpointProduced` (`checkpoint.proto`, `checkpoint_produced.proto`), always disposable, log always authoritative (decision 3) | Semantic mismatch -- see above | +| `ConversationManager.fork_conversation`: genuine shared-prefix fork, links the *same* `Message` rows into a new `Conversation` via new junction rows, not a copy (`letta/services/conversation_manager.py:105-174`) | `SessionForked{source_session_id, context_prefix_boundary}` (`session_forked.proto`); inherited by reference through a context projection keyed by `(source_session_id, context_prefix_boundary)`, never a physical copy ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 5) | Ours, validated independently -- two unrelated designs converged on "fork is a reference, not a copy" | +| `delete_conversation`'s reference-counted soft-delete: only removes a `Message` row if no other live conversation still references it via an explicit `NOT IN` subquery against `conversation_messages` (`letta/services/conversation_manager.py:582-597`) | No equivalent bookkeeping needed -- a fork never takes ownership of a source event, it only references it, so there is no reference count to maintain when a fork is retired | Ours, decisively -- the reference-counting problem does not arise because facet 5 never lets an event be co-owned in the first place | +| `Group`/`groups_agents`, `Group.manager_agent_id` FK `ondelete="RESTRICT"` (`letta/orm/group.py:1-43,24`); sleeptime agents are sibling `Agent` rows, not entries nested in a parent transcript | `DelegationDispatched`/`ParentLinked` (`delegation_dispatched.proto`, `parent_linked.proto`); a child session is its own logical stream ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6) | Similar shape (sibling stream, not nested), different lifecycle -- see "Subagent cascade" below | +| `archives_agents` junction, `ondelete="CASCADE"` on both directions but only removes the *attachment* row (`letta/orm/archives_agents.py:23-24`); no code path calls the archive's own hard delete from agent deletion | `ArtifactErased` (`artifact_erased.proto`) is a deliberate, separately-invoked event; nothing is ever *implicitly* orphaned because artifacts are never implicitly multi-owned the way an archive can be | Ours, decisively -- see "What not to copy" | +| No scheduled retention/TTL job found (`letta/jobs/scheduler.py` grepped for `TTL`/`expire`/`retention`/`cleanup`, only an unrelated log line at `scheduler.py:228`) | `SessionHidden` (visibility tombstone), `RedactionApplied` (read-time mask), `ArtifactErased` (byte destruction) -- an explicit three-tier privacy contract ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7) | Ours, decisively -- Letta has no policy at all, ours has a stated one even though both keep bytes | +| Turbopuffer vector index: fire-and-forget background embedding, no backfill/reindex code path found anywhere (`letta/helpers/tpuf_client.py`, `letta/services/archive_manager.py`, `letta/services/message_manager.py` all grepped) | No full-text/vector-search subsystem defined yet; [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8 explicitly scopes any future one as "a separate, independently bootstrapped projection off the same log, out of scope here" | Open risk on our side too -- see recommendation 3 | +| `list_messages`, cursor-paginated on `sequence_id` with `after`/`before` semantics, not offset (`letta/services/message_manager.py:1001-1024`) | `list_sessions`/`get_session` over a fold-derived KV projection, checkpointed by `last_applied_stream_position` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8) | Equivalent design principle: resume/list cost tracks a cursor, not table size | + +## What we should consider changing + +Ordered by how consequential the underlying question is, not by +implementation cost. + +### 1. Decide explicitly whether a persistent, cross-Session "agent identity" belongs in the ADR chain, or whether fork is meant to be the only continuity mechanism + +**The change.** Add an explicit answer -- in [ADR#0031](../../../../adr/0031-agent-implementation-and-session-plan.md) or [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) -- to +whether our platform needs a stable identity that spans many bounded +Sessions the way Letta's `Agent.id` spans arbitrarily many `Conversation`s +and `Run`s, or whether "fork a new Session from the last one" +(`SessionForked.source_session_id`, decision 5) is intended to be the sole +continuity primitive a caller ever needs. + +**Evidence anchor.** Letta, store maturity 11/12: `Agent.id` is the one +addressable identity every client operation takes (all message send/read +operations take an `agent_id`, `letta/orm/agent.py:1-524`); it never expires +and is never re-created. "Session," "conversation," and "resuming" are all +relative to that one persistent id. Our closest analogue, +`SessionForked{source_session_id, context_prefix_boundary}` +(`session_forked.proto`), mints a wholly new `session_id` for every +continuation, with only a one-hop backward pointer to its immediate +predecessor -- reconstructing "every Session that is really the same ongoing +assistant relationship" requires walking a fork chain of unknown length +rather than a single indexed lookup on a stable id. + +**Blast radius.** Additive, if adopted: a new optional identifier (for +example `agent_continuity_id`) threaded through `SessionStarted` and +`SessionForked`, populated by the caller, with a new projection indexed on +it. It would not change `session_id`, `SessionForked.source_session_id`, or +the fork-by-reference mechanism itself. + +**Why it is a good idea, or why it is not.** This is recorded as an open +question, not a firm recommendation, because [ADR#0031](../../../../adr/0031-agent-implementation-and-session-plan.md) already scopes a +Session to "one execution of a pinned agent revision" and [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md)'s +Non-Goals explicitly defer "mid-session model or runner switching" -- which +reads as a deliberate choice that a *revision change* forces a new Session +identity, with continuity expressed only through forking. That may already +be the intended, sufficient answer; Letta's opposite choice (one identity, +never terminates, arbitrarily many sub-scopes) is the strongest evidence in +the corpus that a real, shipped, funded product chose the other design, so +the question is worth settling explicitly rather than leaving it to be +inferred from what forking happens to make possible. + +**What it costs us.** If adopted: a new field on two creation events, a new +projection to maintain, and a product decision about whether the id is +caller-supplied or platform-minted. If rejected: nothing, but the rejection +itself should be recorded so this is not re-proposed on the strength of +Letta's example alone. + +### 2. State explicitly, as a standing rule, that no future field on the model-visible-context projection may become an authoritative, unguarded, last-write-wins pointer + +**The change.** Add a sentence to [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8 (or a Non-Goal) +stating that the model-visible-context compilation must remain a pure fold +of the event log, and that no future optimization (for example, caching +"the current in-context message set" as a denormalized, directly-writable +field for fast lookup) may be introduced without the same OCC discipline +decision 2 already applies to every other invariant-bearing transition. + +**Evidence anchor.** Letta, store maturity 11/12: `Agent.message_ids` +(`letta/orm/agent.py:71`) began, by the source's own comment, as exactly this +kind of convenience -- a directly-mutable field standing in for what should +be derived -- and is now flagged in the same file as a known anti-pattern +the team is migrating away from (`letta/orm/agent.py:69-70`), with no +concurrency guard at all on its updates (`letta/services/agent_manager.py:1713`). + +**Blast radius.** Additive -- a documentation clarification against a design +that already avoids the pattern structurally (see "The one structural +difference" above); nothing in the current schema needs to change. + +**Why.** The risk is not that today's design has this problem; it is that +a future implementer, chasing a legitimate performance concern (recomputing +the fold on every read is not free), could reintroduce a stored, mutable +"current window" field with good intentions and no guard, exactly as +Letta's own team apparently did. Naming the failure mode in the ADR, with +Letta's self-documented example as the citation, is cheap insurance against +a real, evidenced failure mode recurring in a design that otherwise avoids +it by construction. + +**What it costs us.** Nothing beyond the sentence; it becomes real cost +only if a future implementer would otherwise have shipped the pattern. + +### 3. Require a stated backfill/reindex contract before any full-text or vector-search projection ships + +**The change.** [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8 currently scopes any future full-text or +vector-search subsystem as "a separate, independently bootstrapped +projection off the same log, out of scope here." Before such a projection +ships, it should carry an explicit answer to "what happens to history that +predates the projection's existence, or predates a later policy change that +turns it on for previously-unindexed data." + +**Evidence anchor.** Letta, store maturity 11/12: Turbopuffer is a +per-archive, settings-gated decision made once, at creation time, and cached +permanently (`letta/services/archive_manager.py:30-58`); the dossier's own +grep of `archive_manager.py`, `message_manager.py`, and `tpuf_client.py` for +`backfill`/`reindex`/`re-index` found no job that retroactively embeds +pre-existing rows, only a still-open TODO acknowledging a schema-field gap +in the opposite direction (`letta/helpers/tpuf_client.py:1053`). The +dossier's own inference: enabling Turbopuffer for the first time on a server +with existing history, or changing one archive's `vector_db_provider` after +the fact, means vector search silently never surfaces the pre-existing rows, +with no reconciliation job to catch it. + +**Blast radius.** Additive -- this is a requirement on future work (any +search/vector projection built off the log), not a change to today's +schema or any shipped behavior. + +**Why.** Our own model-visible-context fold and read-side projections +(decision 8) are already immune to this class of gap, because they are +recomputed from the full log on catch-up, not populated once at creation +time and left to drift. A future search projection is the one place that +same discipline could quietly lapse if nobody states the requirement up +front, because a naive implementation (index going forward only) is the +easy path and Letta shows exactly what it costs: a permanent, silent blind +spot with no error signal. + +**What it costs us.** Nothing today; a real backfill job to design and +operate whenever such a projection is actually built. + +### 4. Do not relax optimistic concurrency on any invariant-bearing (`At`) transition for the sake of write-path performance + +**The change under consideration, and why to reject it.** A future proposal +to move one of the `At`-guarded events (for example `Compacted`, +`ExecutionAttemptStarted`, or `DelegationDispatched`) to `Any` in the name of +reducing head-check/retry overhead on a hot path. + +**Evidence anchor.** Letta, store maturity 11/12: optimistic-concurrency +version checking exists for exactly one model in the entire ORM layer, +`Block` (`letta/orm/block.py:61`) -- memory-configuration data, not the +highest-churn, highest-consequence mutable state in the system. The actual +per-turn hot pointer, `Agent.message_ids`, has no guard at all +(`letta/services/agent_manager.py:1713`). Letta protected the tier least +likely to matter under real concurrent load and left the tier most likely to +matter completely exposed. + +**Blast radius.** Breaking the decision -- [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2's +`WRITE_PRECONDITION` classification table names exactly which facts need +`At` because `decide` genuinely branches on the current head for them +(one active attempt, mutually exclusive approve/deny, one terminal outcome +per ledger operation); moving any of those to `Any` for performance +contradicts that classification's stated invariant, not merely its wire +shape. + +**Why not to do this.** Letta is direct cautionary evidence for the +opposite failure mode from the one decision 2 already guards against +(uniform OCC, rejected in Alternatives as taxing the high-volume path for no +gain): guarding the *wrong* tier, or guarding it inconsistently, is at least +as dangerous as guarding nothing, because it creates false confidence that +"we have OCC" when the actually load-bearing state has none. This +recommendation exists to make that failure mode explicit before it is +proposed as a performance optimization, since a `WRITE_PRECONDITION` +softened "just for this one hot path" is exactly how Letta's asymmetry +arose in the first place -- one entity at a time, each individually +reasonable. + +**What it costs us.** Nothing to reject it; the cost this recommendation +guards against is the one a future relaxation would introduce. + +## What our design already does better + +- **No mutable, unguarded pointer stands between the log and the model's + view.** Letta's `Agent.message_ids` is a self-documented anti-pattern in a + shipped, funded product; our model-visible context is a pure fold bounded + by the newest `Compacted` marker ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8), so there is no + second piece of state that can drift from the log or race against a + concurrent writer. +- **Optimistic concurrency is applied by classification, not by accident.** + Letta's OCC covers one model (`Block`) chosen, as far as the dossier + shows, without an explainable selection principle, while the actual hot + mutable pointer has none. Our `WRITE_PRECONDITION` table (decision 2) + names every invariant-bearing transition explicitly and states *why* each + one needs `At`; nothing is guarded by omission or left to be discovered + the hard way. +- **Dedup identity never lives on domain data.** Letta's `otid` is a field + on the message payload itself (`letta/schemas/message.py`); our + idempotency key lives strictly on the command ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2), never + on the event, so a payload never has to carry its own retry-identity + concern mixed in with its business meaning. +- **Fork-by-reference was independently validated.** Letta's + `fork_conversation` links the *same* `Message` rows into a new + `Conversation` via new junction rows rather than copying them + (`letta/services/conversation_manager.py:105-174`) -- precisely the + reference-not-copy principle [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 5 chose and defended in + Alternatives against a physical O(history) copy. Two structurally + unrelated designs converging on the same answer is stronger evidence for + that choice than either alone. +- **No implicit orphan on delete.** Letta's `archives_agents` junction only + removes the attachment row on cascade, never the `Archive` itself + (`letta/orm/archives_agents.py:23-24`), and no code path calls the + archive's hard delete from agent deletion -- so an archive silently + outlives every agent that ever referenced it. Our `ArtifactErased` is + always an explicit, separately-invoked event (`artifact_erased.proto`); + nothing in our design can become an orphan by omission the way an + unreferenced-but-still-live archive can in Letta. +- **An explicit, three-tier privacy contract exists at all.** Letta has no + scheduled retention/TTL job and no redaction concept anywhere in the + dossier -- deletion is either a real cascading hard delete + (`ondelete="CASCADE"` on `AgentMixin.agent_id`, `letta/orm/mixins.py:40`) + or nothing. [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7 gives three distinct, named operations -- + `SessionHidden` (visibility only), `RedactionApplied` (read-time mask), + `ArtifactErased` (byte destruction) -- none of which is a euphemism for the + others, and all of which keep the log itself intact. + +## Trade-offs, not gaps + +- **One linear turn sequence per Session versus concurrent conversations + under one agent.** Letta's `Conversation.id` lets multiple concurrent + message threads share one `Agent`'s memory and identity + (`letta/orm/conversation.py`, docstring: "Conversations that can be + created on an agent for concurrent messaging"). A Session in our model has + exactly one turn sequence; concurrent independent threads on the same + underlying assistant would need either multiple Sessions or the deferred + symmetric `Conversation` aggregate the Non-Goals name. Letta's answer + buys concurrent-thread ergonomics at the cost of the shared-mutable-state + problem this whole comparison is about (the context pointer); ours avoids + that cost by not offering the feature yet. +- **Real ACID transaction for entity cascade versus a reconciler.** + `AgentManager.delete_agent_async` deletes a manager agent and every + sleeptime participant it owns inside one database transaction + (`letta/services/agent_manager.py:1379-1394`), rolling back together on + failure -- genuinely atomic, because everything lives in one Postgres + instance. Our terminal cascade ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6) is deliberately + eventually consistent, because JetStream offers no atomic write across + subjects (Alternatives Considered: "Subagent cascade via a cross-stream + transaction or atomic multi-stream delete... rejected because it is + unavailable"). Letta's atomicity is a property of colocated storage, not + a design decision available to us; the trade-off we accepted (O(depth) + reconciler round-trips) is the honest cost of a topology that does not + offer Letta's shortcut. +- **A hard, cascading delete versus keep-forever plus masking.** Deleting a + Letta agent really deletes its messages and conversations + (`ondelete="CASCADE"` on `AgentMixin.agent_id`, `letta/orm/mixins.py:40`); + there is no intermediate option between "keep everything, unmasked, + forever" and "destroy it all." [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7 buys a middle ground -- + masked-but-present, or byte-erased-but-provenance-kept -- at the cost of a + more complex privacy model with three distinct operations to reason about + instead of one. + +## What not to copy + +- **A mutable, unguarded pointer standing in for a derivable fold.** + `Agent.message_ids` / `ConversationMessage.in_context` is the anti-pattern + the whole comparison is built around; Letta's own source names it as such. + If our model-visible-context compilation is ever "optimized" by caching + a directly-writable current-window field, it must not be optimized this + way -- see recommendation 2. +- **Guarding one entity with OCC while leaving the actual hot mutable state + unguarded.** `Block.version_id_col` being the *only* OCC-checked model, + while `Agent.message_ids` updates are last-write-wins with no check at + all, is worse than having no OCC anywhere, because it invites the false + belief that concurrency is handled. See recommendation 4. +- **Letting a resource become an implicit orphan through a partial + cascade.** `archives_agents`'s cascade removes only the attachment row, + never the archive; nothing calls `delete_archive_async` from + `delete_agent_async`. An archive with no remaining agent reference is a + silent, permanent leak with no error and no cleanup path found in the + dossier. Our `ArtifactErased` must stay an explicit, separately-decided + event, never an implicit side effect of some other entity's deletion. +- **Enabling a derived search index without a backfill contract.** + Turbopuffer's per-archive `vector_db_provider` decision, made once and + cached permanently, with no reindex path for data that predates it or a + later policy change, is a designed-in blind spot. See recommendation 3. +- **Treating a git-backed side system as source of truth for data the + primary store also holds, without naming which one wins.** Once + git-memory is enabled, `Block.value` in Postgres is explicitly a cache + and git is authoritative (`letta/services/block_manager_git.py:571`). + [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 3's four-records-with-separate-authority discipline + exists precisely to prevent this kind of ambiguity from arising in our own + design; it should stay that way rather than being loosened for a future + feature that wants a similar external source of truth. + +## The two gaps the industry has not closed + +### Subagent cascade + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6 already takes a position: a child session is its own +logical stream, linked by facts recorded on each side +(`DelegationDispatched`/`ParentLinked`); terminal cascade is driven by a +reconciler reacting to Session-level terminal markers, appending a distinct +atomic `[ParentTerminated, SessionCancelled]` batch per eligible child; +rewind invalidation is a separate, distinct batch +(`[ParentHistoryInvalidated, SessionCancelled{reason = +PARENT_REWIND_CASCADE}]`) governed by `CascadePolicy`; and acyclicity holds +by construction because `DispatchDelegation` always mints a fresh +`child_session_id`. The question here is whether Letta's evidence validates, +challenges, or refines that position, not whether we still need one. + +**What Letta does, and why it only partially answers the question.** +Letta's closest analogue to a "subagent" is not a dispatched, terminating +unit of work at all: it is the **sleeptime agent**, a background +memory-management agent linked to a "main" agent via a `Group` row +(`letta/orm/group.py:1-43`) with `manager_type = sleeptime` or +`voice_sleeptime`. Sleeptime agents are first-class, sibling `Agent` rows +that persist indefinitely alongside the main agent -- there is no dispatch +event, no operation ledger, and no terminal outcome for a sleeptime agent +the way there is for one of our delegated children. This is a genuine +structural mismatch: Letta has no concept of a bounded, one-shot delegated +child session at all, so its evidence cannot directly validate or challenge +decision 6's dispatch/detach saga machinery, which exists specifically for +that case. + +What it *does* offer is evidence about deletion cascade, and it is +instructive. `Group.manager_agent_id` is a foreign key with +`ondelete="RESTRICT"` (`letta/orm/group.py:24`) -- the database itself +refuses to delete a manager agent while a group still references it, +forcing the cascade through application code. `AgentManager.delete_agent_async` +(`letta/services/agent_manager.py:1320-1397`) is that application-level +cascade: deleting a sleeptime agent directly deletes its `Group` and clears +the main agent's `enable_sleeptime` flag; deleting the main agent loads +every sleeptime participant into the same deletion batch and deletes the +group too, all inside one transaction that rolls back together on failure +(`agent_manager.py:1340-1341, 1365-1394`). The comment at the call site is +explicit about why: "Handle case where we're deleting a sleeptime agent (not +the main agent). In this case, we need to clean up the group and the main +agent's enable_sleeptime flag" (`agent_manager.py:1340-1341`). + +**Does this validate, challenge, or refine decision 6?** It refines one +part and is silent on the rest. Letta independently arrived at the same +structural conclusion decision 6's Alternatives section reaches for a +different reason: a blind, database-native cascade (a plain `ON DELETE +CASCADE`) is not safe for a parent-child relationship with real invariants +to maintain, and application-level orchestration has to own the cleanup +instead. Letta chose `RESTRICT` plus app-level orchestration where a naive +`CASCADE` was available to it; we chose a reconciler process manager because +JetStream offers no cross-stream atomic write at all (Alternatives +Considered: "unavailable... JetStream offers no atomic write across +subjects"). The reasons differ, but both designs reject "let the storage +substrate cascade blindly" -- that convergence, from two unrelated starting +points, is a mild point in decision 6's favor. Where Letta's evidence does +not reach is depth, width, or eventual-consistency: sleeptime agents are not +nested (`Group.agent_ids` is a flat list, `letta/orm/group.py:36`, not a +recursive tree), so Letta offers no evidence at all about the fanout or +depth concerns already on record from other products in this corpus, and +because Letta's cascade is a single ACID transaction rather than a +reconciled saga, it offers no evidence about crash-mid-cascade behavior +either. Letta neither validates nor challenges decision 6's transitive, +eventually-consistent cascade design; it is simply evidence for a narrower, +adjacent claim (blind DB cascade is unsafe for this class of relationship) +that decision 6 already assumed. + +### Retention on an unbounded log + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7 already takes a position: keep-forever, with +`SessionHidden` as a visibility tombstone, `RedactionApplied` for read-time +masking, `ArtifactErased` for out-of-band artifact-byte destruction, and +aggregate snapshots that "bound replay, not storage." The question is +whether Letta's evidence validates, challenges, or refines that design. + +**What Letta does.** No scheduled retention or TTL-cleanup job exists +(`letta/jobs/scheduler.py`, grepped for `TTL`/`expire`/`retention`/ +`cleanup`, with only an unrelated shutdown log line at `scheduler.py:228` +matching) -- deletion is entirely caller-driven. Separately, and +independent of any explicit retention policy, Letta's own context-shrinking +mechanism never deletes durable history: `reset_messages_async`'s docstring +states plainly that clearing an agent's context "does not delete them from +the database" (`letta/services/agent_manager.py:1686`), and the +`Summarizer`'s eviction path (`letta/services/summarizer/summarizer.py:136-243`) +commits its output by rewriting the context pointer, never by deleting rows +from `messages`. Reads scale by cursor, not by table size: +`list_messages` paginates on `sequence_id` with `after`/`before` semantics, +not an offset (`letta/services/message_manager.py:1001-1024`), so resume and +listing cost does not degrade as a `messages` table grows without the +code-enforced cap the dossier confirms does not exist. + +Where Letta's retention story stops short of ours is granularity of +deletion, not the keep-forever direction itself. The only deletion path the +dossier documents is a real, hard, cascading delete at the whole-`Agent` +level (`ondelete="CASCADE"` on `AgentMixin.agent_id`, +`letta/orm/mixins.py:40`) or a reference-counted soft-delete scoped to one +`Conversation`'s messages (`letta/services/conversation_manager.py:556-609`). +There is no analogue anywhere in the dossier to masking specific events' +content while keeping them in the log (`RedactionApplied`), and no analogue +to destroying specific artifact bytes while retaining their provenance +(`ArtifactErased`). Letta's model gives a caller exactly two choices: keep +everything, unmasked, forever, or hard-delete the whole agent and everything +under it. + +**Does this validate, challenge, or refine decision 7?** It validates the +keep-forever direction and the "bound the *read* cost, not the log size" +principle -- Letta independently converged on both, as a real production +architecture rather than a proof of concept, and its cursor-paginated read +path demonstrates the same shape works at scale without a corroborating +growth-failure report the way Cline's dossier has one. It refines decision 7 +by sharpening exactly what the added complexity of a three-tier privacy +contract buys over the industry's evident default: Letta, the corpus's most +mature server-side, multi-tenant store, still has nothing between "keep it +all in full" and "delete it all," which is weaker than what decision 7 +specifies, not stronger. This is presented as validation that the +finer-grained contract is worth its complexity, not as a claim that Letta's +coarser model is wrong for Letta's own problem -- a single-tenant-per-agent +memory store may simply not need the granularity a multi-session platform +does. + +One cost decision 7 does not explicitly bound is worth naming here rather +than assuming away, because Letta's own `messages` table shares the same +unstated bound: nothing in the ADR states who is responsible for keeping +the *tail after the newest snapshot* from growing unboundedly long if +compaction is deferred or never triggers, the same open question already on +record from this corpus's other server-side comparisons. Letta's evidence +neither confirms nor refutes that this is a real problem for us; it simply +shows that a server-side store can ship for a long time on cursor-paginated +reads alone without it becoming visibly urgent, which is not the same claim +as "it is bounded." + +## Open questions for the ADR + +1. Does the platform need a stable, cross-Session "agent identity" the way + Letta's `Agent.id` spans arbitrarily many `Conversation`s and `Run`s, or + is fork-from-the-last-Session (`SessionForked.source_session_id`, + decision 5) intended to be the only continuity mechanism a caller ever + needs? (Recommendation 1.) +2. Should [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8 state explicitly that no future + model-visible-context optimization may introduce a stored, directly- + writable "current window" field, given that Letta's own team apparently + introduced exactly that field with good intentions and no concurrency + guard? (Recommendation 2.) +3. What backfill/reindex contract will a future full-text or vector-search + projection (decision 8's "separate, independently bootstrapped + projection") be required to satisfy before it ships, given Letta's + confirmed permanent blind spot for data that predates Turbopuffer's + enablement? (Recommendation 3.) +4. Should the ADR name, explicitly, that relaxing `At` to `Any` on any + currently invariant-bearing transition for performance reasons is + rejected on principle, using Letta's asymmetric OCC coverage (one model + guarded, the actual hot mutable pointer unguarded) as the citation? + (Recommendation 4.) +5. Letta's `Conversation.id` gives one persistent agent several concurrent + message threads sharing its memory; our Non-Goals already defer a + symmetric multi-party `Conversation` aggregate. Does that deferred + aggregate need to support *concurrent* threads under one continuity + identity (question 1), and if so, does the answer to question 1 + determine the shape of that future aggregate rather than the reverse? diff --git a/docs/research/session-store/products/mastra/index.md b/docs/research/session-store/products/mastra/index.md new file mode 100644 index 000000000..8b6f0c361 --- /dev/null +++ b/docs/research/session-store/products/mastra/index.md @@ -0,0 +1,1016 @@ +# Mastra: how session transcripts are stored and resumed + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT](../../RESEARCH_PROMPT.md). +Evidence snapshot retrieved 2026-08-04. Mastra is source-available, so every +claim below cites a repo-root-relative `path:line` against the pinned commit +rather than using [observed]/[literal] evidence tags (those apply to +closed-source, black-box targets like `fx`, not here). Version-sensitive +claims were checked against these anchors: + +- Source: local clone of `mastra-ai/mastra`, pinned at commit + `9e1dad8f7b1cab2bb7ade90e5b7561f24577b88a`. All citations below are + repo-root-relative paths within that clone. +- `packages/core/src/storage/domains/memory/base.ts` -- the abstract + `MemoryStorage` domain interface (this dossier's centerpiece). +- `packages/core/src/storage/types.ts`, `packages/core/src/storage/constants.ts`, + `packages/core/src/memory/types.ts`, + `packages/core/src/agent/message-list/state/types.ts` -- thread/message + type definitions and physical table schemas. +- `packages/core/src/storage/base.ts`, `packages/core/src/storage/retention.ts`, + `packages/core/src/storage/workflow-snapshot.ts` -- composite-store, + retention, and workflow-snapshot machinery. +- `packages/core/src/processors/memory/semantic-recall.ts`, + `packages/core/src/processors/memory/message-history.ts`, + `packages/core/src/processors/memory/working-memory.ts`, + `packages/core/src/memory/memory.ts` -- the read/resume and derived-index + paths. +- `packages/core/src/agent/agent.ts`, `packages/core/src/agent-controller/` + (`agent-controller.ts`, `tools.ts`, `session.ts`) -- the two independent + sub-agent/fork mechanisms. +- `packages/core/src/mastra/index.ts` -- ID generation. +- Backend adapters actually read for this dossier: + `stores/pg/src/storage/domains/memory/index.ts` (3077 lines, read directly), + `stores/libsql/src/storage/domains/memory/index.ts` (2738 lines), + `stores/dynamodb/src/storage/domains/memory/index.ts` (1133 lines), + `stores/mongodb/src/storage/domains/memory/index.ts` (2441 lines). These + four were chosen to span the space of storage models: relational-with-real- + transactions (pg), embedded-SQL-with-batch-not-transaction (libsql), + single-table NoSQL with no multi-item transaction primitive at all + (dynamodb), and document-store with topology-conditional transactions + (mongodb). **Not read**: `stores/clickhouse`, `stores/cloudflare-d1`, + `stores/convex`, `stores/dsql`, `stores/mssql`, `stores/mysql`, + `stores/redis`, `stores/spanner`, `stores/upstash` -- their + `domains/memory` implementations exist (confirmed by directory listing) but + were not opened; claims about them are limited to what the shared abstract + interface and error-message text state. + +**License note**: the repo root is Apache-2.0 +(`package.json`, `"license": "Apache-2.0"`), but `LICENSE.md` carves out +every `ee/` directory, which is instead governed by `ee/LICENSE` (the Mastra Enterprise +Edition license, a proprietary agreement with Kepler Software, Inc. required +for production use). Nothing in this dossier cites any path under `ee/` +(e.g. `packages/core/src/auth/ee/`, `packages/server/src/server/auth/ee/`); +every finding below comes from the Apache-2.0-licensed tree and is usable as +open-source precedent. + +## The storage model + +Mastra does not have one storage model -- it has at least three, layered +under one composition mechanism, and this dossier is scoped to the first. + +**1. Thread + Message (the session transcript, this dossier's subject).** +The source of truth is a row set: one `StorageThreadType` row per +conversation thread and one row per message, held in whatever the backend +natively is (Postgres/MySQL/libSQL tables, a single DynamoDB table via +ElectroDB entities, MongoDB collections). There is no single append-only log +file anywhere in this path -- durability is "one row insert per message," +not "one line appended to a stream." The physical column list is fixed by +`TABLE_SCHEMAS[TABLE_THREADS]` and `TABLE_SCHEMAS[TABLE_MESSAGES]` +(`packages/core/src/storage/constants.ts:661-668` and `:669-677`); every +backend is expected to reproduce this shape (see **The store interface**). + +**2. Derived/rebuildable projections layered on top of the message rows.** +Semantic recall's vector index is fully derived: it is (re)built by +embedding message content and upserting into a vector store keyed by +message ID (`packages/core/src/processors/memory/semantic-recall.ts:649-657`), +and the index itself is created idempotently on first use +(`ensureVectorIndex`, +`packages/core/src/processors/memory/semantic-recall.ts:511-533`) -- losing the vector +index loses nothing durable, it can be rebuilt by re-embedding the message +table. This is the clearest rebuildable-cache example in the codebase. + +**3. Two more "session-shaped" primitives that are *not* derived from +messages, and not each other:** +- **Observational Memory (OM)** is a separate, generation-versioned + "reflection" record (`ObservationalMemoryRecord`, + `packages/core/src/storage/types.ts:1164-1204+`) stored in its own table + (`TABLE_OBSERVATIONAL_MEMORY = 'mastra_observational_memory'`, + `packages/core/src/storage/constants.ts:14`, schema at + `packages/core/src/storage/constants.ts:503`). It is authoritative, not + a cache of the messages -- losing it loses the condensed memory, even + though the raw messages that produced it are still present. +- **Workflow run state** (`WorkflowRunState`, + `packages/core/src/storage/workflow-snapshot.ts:41-56`) is a single mutable + JSON document per run (`context, activePaths, suspendedPaths, status, + runId`, ...), stored and rewritten as a whole blob -- the polar opposite of + the messages table's insert-per-row model. `createEmptyWorkflowSnapshot()` + (`packages/core/src/storage/workflow-snapshot.ts:41`) and + `mergeWorkflowStepResult()` + (`packages/core/src/storage/workflow-snapshot.ts:57`) both operate on the entire document, not a + delta log. +- A fourth, higher-level notion -- `harness` domain `SessionRecord` -- wraps a + thread with session-level metadata (mode, model, pending approvals). It is + covered under **Subagents and nested sessions** because its most notable + feature for this dossier is an apparently-unused parent-child field. + +**Best-fit conceptual model**: session-as-row-set (thread row + append- +style message rows) for the piece this dossier is scoped to, with a +completely different session-as-mutable-document model for workflow runs +existing side by side in the same product. *(Inference: Mastra's own +internal primitives already disagree on whether "session state" should be +append-oriented or a single overwritten document -- this is itself evidence +that both shapes are legitimate, are chosen per-primitive based on access +pattern, not fixed by one platform-wide session abstraction.)* + +## Keying and identity + +- A thread is addressed by `StorageThreadType.id`, always scoped to a + `resourceId` (`packages/core/src/memory/types.ts:39-46`): + ```ts + export type StorageThreadType = { + id: string; + title?: string; + resourceId: string; + createdAt: Date; + updatedAt: Date; + metadata?: Record; + }; + ``` + `resourceId` is the tenant/user/agent-owner axis; `id` is the conversation + axis. Listing is filtered by `resourceId` (and optionally `metadata`) via + `StorageListThreadsInput.filter` (`packages/core/src/storage/types.ts:183-193`), + never by scanning all threads platform-wide by default. +- **ID minting**: the default is a plain `crypto.randomUUID()` returned by + `Mastra.generateId()` (`packages/core/src/mastra/index.ts:1128-1144`, + specifically the fallback at line 1143). This is pluggable -- a caller can + register a custom `#idGenerator(context: IdGeneratorContext)` -- but nothing + in core enforces or assumes an ordering-encoding scheme (no UUIDv7 by + default); ordering is carried entirely by the separate `createdAt` column, + not by the ID. +- **Sub-agent/forked thread IDs use two unrelated schemes** (detailed fully + under **Subagents and nested sessions**): + 1. The agent-to-agent delegation path mints an ID by string concatenation: + `` `${inputData.threadId}-${randomUUID()}` `` and + `` `${inputData.resourceId}-${agentName}` `` + (`packages/core/src/agent/agent.ts:4716-4731`). Nothing downstream + parses this string back apart -- a grep across `packages/core/src` for + any `startsWith`/pattern-based reconstruction of thread hierarchy from + this ID shape returns no hits. + 2. The `agent-controller`'s `subagent` tool fork path instead calls + `MemoryStorage.cloneThread()`, which mints its own independent + `crypto.randomUUID()` for the child thread unless a caller supplies + `newThreadId` (`packages/core/src/storage/types.ts:218-219`); parentage + is recorded in `thread.metadata`, not in the ID (see below). +- **Listing scope**: never cross-resource by default. The + `agent-controller`'s `listThreads` also does an *in-process* filter to hide + forked-subagent threads (`packages/core/src/agent-controller/agent-controller.ts:1005-1012`): + it fetches the full unfiltered page from storage + (`memoryStorage.listThreads({filter, perPage: false})`, line 1005) and + then array-filters on `metadata.forkedSubagent !== true` (line 1011) -- the + storage layer itself has no predicate for "exclude forks," so this + filtering does not benefit from any index and re-reads the full result set + every call. *(Inference.)* +- **Relocation/rename**: not applicable in the sense the prompt means for + filesystem-rooted CLI agents -- Mastra threads are pure database rows keyed + by `id`/`resourceId`, not paths tied to a working directory or worktree. + `updateThread` (`packages/core/src/storage/domains/memory/base.ts:63-71`) + is the rename path (title/metadata only; `id` and `resourceId` are + immutable post-creation in the abstract contract). + +## The store interface + +Mastra's storage layer is layered: a base `StorageDomain` class +(`packages/core/src/storage/domains/base.ts`, not read in detail -- out of +scope) is specialized per concern (memory, agents, workflows, harness, +observability, scores, thread-state, ...), and each concern is implemented +per backend under `stores/*/src/storage/domains//`. `MemoryStorage` +(`packages/core/src/storage/domains/memory/base.ts:38-456`) is the +thread/message contract -- the centerpiece of this dossier -- reproduced +verbatim below (JSDoc trimmed for space; every signature is exact): + +```ts +// packages/core/src/storage/domains/memory/base.ts +export abstract class MemoryStorage extends StorageDomain { + readonly supportsObservationalMemory?: boolean = false; // :44 + + // --- Threads: all four REQUIRED (abstract, no default) --- + abstract getThreadById({ threadId, resourceId }: { + threadId: string; resourceId?: string; + }): Promise; // :53-59 + + abstract saveThread({ thread }: { + thread: StorageThreadType; + }): Promise; // :61 + + abstract updateThread({ id, title, metadata }: { + id: string; title: string; metadata: Record; + }): Promise; // :63-71 + + abstract deleteThread({ threadId }: { threadId: string }): Promise; // :73 + + // --- Messages: mostly required, two opt-in --- + abstract listMessages(args: StorageListMessagesInput): + Promise; // :75 + + // OPTIONAL -- default throws. Backend opts in by overriding. + async listMessagesByResourceId(_args: StorageListMessagesByResourceIdInput): + Promise { + throw new Error( + `Resource-scoped message listing is not implemented by this storage adapter (${this.constructor.name}). ` + + `Use an adapter that supports Observational Memory (pg, libsql, mongodb, convex) or disable observational memory.`, + ); + } // :84-89 + + abstract listMessagesById({ messageIds }: { messageIds: string[] }): + Promise<{ messages: MastraDBMessage[] }>; // :91 + + abstract saveMessages(args: { messages: MastraDBMessage[] }): + Promise<{ messages: MastraDBMessage[] }>; // :93 + + abstract updateMessages(args: { + messages: (Partial> & { + id: string; + content?: { metadata?: MastraMessageContentV2['metadata']; content?: MastraMessageContentV2['content'] }; + })[]; + }): Promise; // :95-100 + + // OPTIONAL -- default throws. + async deleteMessages(_messageIds: string[]): Promise { + throw new Error( + `Message deletion is not supported by this storage adapter (${this.constructor.name}). ` + + `The deleteMessages method needs to be implemented in the storage adapter.`, + ); + } // :102-107 + + abstract listThreads(args: StorageListThreadsInput): + Promise; // :118 + + // OPTIONAL -- default throws. pg/libsql/mongodb/mysql/redis/upstash implement it (grep-confirmed); dynamodb does not. + async cloneThread(_args: StorageCloneThreadInput): Promise { + throw new Error( + `Thread cloning is not implemented by this storage adapter (${this.constructor.name}). ` + + `The cloneThread method needs to be implemented in the storage adapter.`, + ); + } // :127-132 + + // Resource (working-memory) methods: default-throwing, but the throw text + // itself says "This is likely a bug - all Mastra storage adapters should + // implement resource support." (:137, repeated verbatim at :145 and :157) + async getResourceById / saveResource / updateResource ... { throw ... } // :134-160 + + protected parseOrderBy(...) { ... } // :162-173 + + // 16 Observational Memory methods -- ALL default-throwing unless overridden: + // getObservationalMemory :183, getObservationalMemoryHistory :194, + // initializeObservationalMemory :207, updateActiveObservations :215, + // updateBufferedObservations :228, swapBufferedToActive :242, + // createReflectionGeneration :253, updateBufferedReflection :261, + // swapBufferedReflectionToActive :270, setReflectingFlag :279, + // setObservingFlag :286, setBufferingObservationFlag :297, + // setBufferingReflectionFlag :305, insertObservationalMemoryRecord :313, + // clearObservationalMemory :321, setPendingMessageTokens :330, + // updateObservationalMemoryConfig :338 + + protected deepMergeConfig / validateMetadataKeys / validatePagination / + validatePaginationInput ... // :346-455 +} +``` + +**Required vs optional, precisely**: `getThreadById`, `saveThread`, +`updateThread`, `deleteThread`, `listMessages`, `listMessagesById`, +`saveMessages`, `updateMessages`, `listThreads` are `abstract` -- every +backend must implement all nine or TypeScript will not compile it. +`listMessagesByResourceId`, `deleteMessages`, `cloneThread`, +`getResourceById`/`saveResource`/`updateResource`, and all 16 Observational +Memory methods are concrete methods on the base class that simply `throw` +-- a backend "supports" them purely by choosing to override the method; there +is no capability flag except `supportsObservationalMemory` (line 44, a +plain boolean the OM subsystem reads to decide whether to attempt OM calls +at all) -- the other optional methods are discovered only by calling them +and catching the throw, i.e., failure is the capability-detection +mechanism. *(Inference: this is a weaker capability model than a declared +feature-flag object -- a caller cannot introspect what a given store instance +supports without a trial call or reading its class.)* + +Message rows at the physical/storage-schema level are a distinct, flatter +shape from the domain-level `MastraDBMessage` -- see **Entry/message +structure and versioning**. + +## Write and append path (ordering, durability, concurrency, delivery) + +`saveMessages` is the append primitive (there is no separate "append single +message" method -- every save is a batch of one or more). Behavior diverges +sharply across the four backends read for this dossier, even though all +four implement the exact same abstract signature: + +**Postgres -- real, all-or-nothing transaction.** `saveMessages` +(`stores/pg/src/storage/domains/memory/index.ts:1355`) deduplicates by ID +first (`dedupeMessagesForSave()`, +`stores/pg/src/storage/domains/memory/index.ts:139-156`, which on a +duplicate ID keeps the *existing* record's `createdAt` rather than the +incoming one -- so re-saving an already-stored message id cannot rewrite +its timestamp), then wraps a chunked `INSERT ... ON CONFLICT (id) DO +UPDATE` (chunked by `MAX_MESSAGES_PER_INSERT`, +`stores/pg/src/storage/domains/memory/index.ts:1402-1438`) **and** the +thread's `updatedAt`/`updatedAtZ` bump +(`stores/pg/src/storage/domains/memory/index.ts:1440-1451`) inside one +`this.#db.client.tx(async t => {...})` block +(`stores/pg/src/storage/domains/memory/index.ts:1401-1452`). If the process +dies mid-write, either all of it lands or none of it does. `deleteThread` +(`stores/pg/src/storage/domains/memory/index.ts:765-803`) is likewise one +transaction: delete messages, scan `pg_tables` for `memory_messages%` +vector-index tables and purge rows tagged with that `thread_id` +(`stores/pg/src/storage/domains/memory/index.ts:772-786`), then delete the +thread row (`stores/pg/src/storage/domains/memory/index.ts:788`) -- all +inside `client.tx` (`stores/pg/src/storage/domains/memory/index.ts:769`). + +**libSQL -- batched but NOT fully atomic.** `saveMessages` +(`stores/libsql/src/storage/domains/memory/index.ts:725`) builds one +`INSERT ... ON CONFLICT(id) DO UPDATE` statement per message +(`stores/libsql/src/storage/domains/memory/index.ts:747-767`) and executes +them via `this.#client.batch(batch, 'write')` in chunks of 50 +(`BATCH_SIZE`, `stores/libsql/src/storage/domains/memory/index.ts:776,783-788`) +-- but the thread's `updatedAt` bump is pushed onto the *same* +`batchStatements` array +(`stores/libsql/src/storage/domains/memory/index.ts:770-773`) and then +explicitly sliced off and executed **separately**, outside any batch, via +a lone `this.#client.execute(...)` call +(`stores/libsql/src/storage/domains/memory/index.ts:791-792`). A crash +between the message batch(es) and this final `execute` leaves messages +durably saved with a stale `thread.updatedAt`. Additionally, when a save +exceeds 50 messages, it spans *multiple* `.batch()` calls -- each is its own +atomic unit, but the whole `saveMessages` invocation is not: a crash after +batch 1 but before batch 2 leaves a saved-but-partial message set with no +transaction rolling it back. `deleteThread` +(`stores/libsql/src/storage/domains/memory/index.ts:1356-1381`) is explicit +about *not* using a transaction, and says why in a code comment: "Not +using a transaction to avoid `SQLITE_BUSY` errors when multiple +`deleteThread` calls run concurrently... orphaned messages (if thread +delete fails) would be cleaned up on next delete attempt" +(`stores/libsql/src/storage/domains/memory/index.ts:1358-1361`) -- a +documented, deliberate atomicity-for-availability trade, with cleanup left +informal ("next delete attempt," which is never guaranteed to happen). + +**DynamoDB -- no multi-item transaction at all; manual, fallible rollback.** +`saveMessages` (`stores/dynamodb/src/storage/domains/memory/index.ts:599`) +writes messages **sequentially**, one ElectroDB `.put().go()` call per +message (`stores/dynamodb/src/storage/domains/memory/index.ts:644`), and +on any failure attempts a compensating rollback by deleting every message +already written in that call +(`stores/dynamodb/src/storage/domains/memory/index.ts:647-658`) -- but that +rollback loop itself just logs and swallows its own failures +(`stores/dynamodb/src/storage/domains/memory/index.ts:651-655`: `catch +(rollbackError) { this.logger.error(...) }`, no retry, no re-throw of the +rollback failure). The thread's `updatedAt` bump happens after the entire +message loop (`stores/dynamodb/src/storage/domains/memory/index.ts:663-668`) +and is not covered by the rollback at all -- if it throws, already-written +messages stay written. `deleteThread` +(`stores/dynamodb/src/storage/domains/memory/index.ts:281-320`) similarly +has zero compensating logic: it lists all messages (`perPage: false`, +`stores/dynamodb/src/storage/domains/memory/index.ts:287`), deletes them +in `Promise.all` batches of 25 +(`stores/dynamodb/src/storage/domains/memory/index.ts:290-304`, DynamoDB's +`BatchWriteItem` limit), then deletes the thread row +(`stores/dynamodb/src/storage/domains/memory/index.ts:308`) -- if the +thread-row delete fails after all messages are gone, nothing detects or +repairs the now-orphaned-but-message-less thread. `cloneThread` is not +implemented for this backend (no `async cloneThread` found in the file), +so the abstract base's default-throwing implementation is what callers get +(`packages/core/src/storage/domains/memory/base.ts:127-132`). + +**MongoDB -- atomicity conditional on cluster topology, and it degrades +silently.** `saveMessages` +(`stores/mongodb/src/storage/domains/memory/index.ts:695`) does a +`bulkWrite` of per-message `updateOne`+`upsert` operations +(`stores/mongodb/src/storage/domains/memory/index.ts:721-740`) plus each +touched thread's `updatedAt`, wrapped in +`this.#connector.withTransaction(async session => {...})` +(`stores/mongodb/src/storage/domains/memory/index.ts:750-755`), with an +explicit comment: "Operations are sequential because a transaction session +is not concurrency-safe; on a standalone server this degrades to the same +sequential best-effort behavior" +(`stores/mongodb/src/storage/domains/memory/index.ts:746-749`). +`withTransaction()` +(`stores/mongodb/src/storage/connectors/MongoDBConnector.ts:123-138`) probes +`supportsTransactions()` (cached after first check, line 124) and, if +unsupported (i.e. a standalone `mongod`, not a replica set), **runs the +callback with `session=undefined`** (line 126) -- no error, no warning +surfaced to the caller, just a quiet loss of atomicity. `deleteThread` +(`stores/mongodb/src/storage/domains/memory/index.ts:1212-1237`) goes +further and *never* uses a transaction, with a code comment explaining +exactly why: a transactional `deleteMany` is capped by MongoDB's +`transactionLifetimeLimitSeconds` (60s default) and must hold every pending +delete in memory until commit, so a sufficiently large thread would "abort +and become permanently undeletable"; a plain `deleteMany` "commits +incrementally and always completes" +(`stores/mongodb/src/storage/domains/memory/index.ts:1214-1222`, quoted in +full -- this is the single clearest piece of divergence evidence in the +whole corpus: an adapter deliberately giving up atomicity because the +transactional alternative has a hard ceiling the untransacted path does +not). + +**Ordering tiebreak also silently diverges.** Postgres and libSQL both +consistently order message reads by `(createdAt, id)` as a two-column +tiebreak: pg at `stores/pg/src/storage/domains/memory/index.ts:887,903,922`; +libsql at `stores/libsql/src/storage/domains/memory/index.ts:307-308,319-320,332`. +MongoDB is *inconsistent with itself*: the before/after window pair in one +method carries the `id` tiebreak in both directions, `{createdAt: -1, id: -1}` +for the messages at or before the target and `{createdAt: 1, id: 1}` for the +ones after it +(`stores/mongodb/src/storage/domains/memory/index.ts:289,298`), while at least +one other query sorts by `{createdAt: 1}` alone, with no `id` tiebreak +(`stores/mongodb/src/storage/domains/memory/index.ts:1322`). +Because `createdAt` values can collide +(multiple messages saved in the same batch share very close or identical +timestamps depending on clock resolution), two messages can come back in a +different relative order depending which code path reads them -- a real, +citable, backend-internal ordering inconsistency, not just a cross-backend +one. DynamoDB has no natural row order at all; its base entity key has none, +so ordering is entirely a property of whichever GSI/sort key the query +uses, and pagination/offset is emulated in application code rather than +being a native database feature (`stores/dynamodb/src/storage/domains/memory/index.ts:376-380` +computes an `offset`/`perPage` pair that the query layer then has to +reconcile against DynamoDB's cursor-based `LastEvaluatedKey` model -- +confirmed by the presence of `calculatePagination`/`normalizePerPage` +helpers at that call site (`stores/dynamodb/src/storage/domains/memory/index.ts:376,378`), +though the O(N) re-read cost this can imply for deep pages was reported by +an earlier deep-read of this file and was not independently re-derived +line-by-line in this final pass; flagged as *lower-confidence, inference* +rather than a directly quoted cost figure, see **Open questions**). + +**Concurrency/expected-version**: none of the abstract signatures +(`saveMessages`, `updateThread`, `saveThread`) accept an expected-version or +compare-and-swap precondition +(`packages/core/src/storage/domains/memory/base.ts:61-71,93`). Every +backend observed resolves same-ID conflicts with a last-write-wins upsert +(`ON CONFLICT ... DO UPDATE` in pg/libsql, ElectroDB `upsert`/`put` in +dynamodb, Mongo `updateOne({upsert:true})`). There is no optimistic-locking +mechanism anywhere in this path; concurrent writers to the same thread +race, and the last write physically committed wins. *(Inference: the +product assumes effectively single-writer-per-thread in practice, since +there is no protocol to detect or reject a stale write.)* + +**Delivery semantics**: no retry/outbox/at-least-once framework exists at +the `MemoryStorage` boundary itself -- it is called once per turn by the +in-process agent loop. The only idempotence guard is the primary-key +upsert on message `id` (so a caller-side retry that resends the same +message object is safe by construction, not because of any queue +deduplication layer). + +## Read and resume path + +Resume is **not** a full-log replay by default -- it is a bounded, eager +"last N messages" reload executed fresh on every turn. The +`MessageHistory` input processor +(`packages/core/src/processors/memory/message-history.ts:113-119`) issues: + +```ts +const result = await this.storage.listMessages({ + threadId, resourceId, page: 0, + perPage: this.lastMessages, + orderBy: { field: 'createdAt', direction: 'DESC' }, +}); +``` + +then reverses the DESC page back to chronological order +(`packages/core/src/processors/memory/message-history.ts:133`) before +handing it to the model. `lastMessages` defaults to `10` +(`packages/core/src/memory/memory.ts:83`) and is per-`Memory`-config; it +reads the durable store directly on every turn -- there is no separate +local cache read first. This is wired in only when +`effectiveConfig.lastMessages` is truthy and neither a user-supplied +`message-history` processor nor Observational Memory is already handling +message loading (`packages/core/src/memory/memory.ts:760-786`) -- when OM is +enabled, it "handles its own message loading and saving" instead +(`packages/core/src/memory/memory.ts:773-779`), i.e., the eager +last-N-messages window is bypassed entirely in favor of OM's condensed +generation record. + +Independently, when semantic recall is configured, it performs a **second** +read pass: `SemanticRecall.performSemanticSearch()` +(`packages/core/src/processors/memory/semantic-recall.ts:385-455`) queries +the vector index (filtered by `thread_id`/`resource_id`) for the most +relevant *older* messages, then re-fetches the **full** message content +from the durable store by ID rather than trusting the vector metadata +payload (confirmed by the vector metadata shape at +`packages/core/src/processors/memory/semantic-recall.ts:649-657` carrying only +`{message_id, thread_id, resource_id, role, content, created_at}` -- a +denormalized summary, not the canonical row) -- so semantic recall never +uses the vector store as the source of truth for message content, only as +an index into IDs. + +There is entry-level pagination (`page`/`perPage` on `listMessages`, +`packages/core/src/storage/types.ts:73-131`) and every backend enforces it; +there is no unbounded "load the whole thread" resume path unless a caller +explicitly passes `perPage: false`. Postgres additionally supports a +context-expansion pagination mode via `include: [{id, withPreviousMessages, +withNextMessages}]` (`packages/core/src/storage/types.ts:73-79`), implemented with a +cursor-based approach that explicitly replaced an earlier `ROW_NUMBER()` +window-function approach for performance reasons, per a code comment citing +a production GitHub issue (`stores/pg/src/storage/domains/memory/index.ts:805-809`, +"This replaces the previous `ROW_NUMBER()` approach which caused severe +performance issues on large tables (see GitHub issue #11150)"). + +## Listing, summaries, and search + +`listThreads` (`packages/core/src/storage/domains/memory/base.ts:118`, +input/output at `packages/core/src/storage/types.ts:168-198`) is the enumeration path: filter +by `resourceId` and/or shallow `metadata` key-value pairs (AND logic), +ordered by `createdAt`/`updatedAt`, paginated (`perPage` default 100, +`packages/core/src/storage/types.ts:171-172`). There is no separately-maintained summary +sidecar/read-model for threads distinct from the thread row itself -- the +thread row (`title`, `metadata`, timestamps) *is* the list-view record; a +picker reads the same table it would read for resume, just without +messages. + +Search is a genuinely separate indexed subsystem only for semantic recall: +a vector index named via `getDefaultIndexName()` +(`packages/core/src/processors/memory/semantic-recall.ts:496-510`, pattern +`` `mastra_memory_${sanitizedModel}` ``, truncated to 63 chars for backend +name-length limits) is created idempotently through `ensureVectorIndex()` +(`packages/core/src/processors/memory/semantic-recall.ts:511-533`), which calls +`this.vector.createIndex(...)` +(`packages/core/src/processors/memory/semantic-recall.ts:520`) guarded by an +in-memory dimension-validation +cache so repeated calls are cheap no-ops once the index exists. It is kept +consistent with the message log at write time: `processOutputResult()` +(`packages/core/src/processors/memory/semantic-recall.ts:534-657`) embeds new user/assistant messages (skipping +system messages) and immediately `vector.upsert(...)`s them +(`packages/core/src/processors/memory/semantic-recall.ts:649-657`) -- there is no separate background indexing +job; the index is updated synchronously in the same turn that produces the +message. If the vector store is lost or reset, it can be fully rebuilt by +re-embedding the message table (this was not observed as an actual +"rebuild" code path -- no explicit reindex-from-message-table function was +found in the surveyed files; this is an *inference* from the fact that the +index is keyed purely off message IDs and content that already live +durably elsewhere, not a confirmed reindex utility. Flagged under **Open +questions**). + +Observational Memory has its own, unrelated lookup path: records are +fetched by `lookupKey` ordered by `generationCount DESC LIMIT 1` +(e.g. `stores/pg/src/storage/domains/memory/index.ts:1997`, +`stores/libsql/src/storage/domains/memory/index.ts:1635`) -- a "most recent +generation" query, not a search index. + +## Entry/message structure and versioning + +Two distinct message shapes exist, one at the storage-row level and one at +the domain level, and the store's job is largely translating between them. + +**Storage-row shape** (`StorageMessageType`, +`packages/core/src/storage/types.ts:262-270`): +```ts +export type StorageMessageType = { + id: string; + thread_id: string; + content: string; // opaque serialized string at this layer + role: string; + type: string; + createdAt: Date; + resourceId: string | null; +}; +``` +and the matching physical column schema +(`packages/core/src/storage/constants.ts:669-677`) types `content` as +`'text'` -- i.e., every backend observed (pg, libsql, dynamodb, mongodb) +`JSON.stringify()`s the domain-level content object before writing and +`JSON.parse()`s it back on read (e.g. +`stores/pg/src/storage/domains/memory/index.ts:1411,1454-1459`; +`stores/libsql/src/storage/domains/memory/index.ts:760`; +`stores/dynamodb/src/storage/domains/memory/index.ts:622`; +`stores/mongodb/src/storage/domains/memory/index.ts:728`). The store treats +`content` as opaque bytes; it does not query into the JSON structure at the +SQL/query layer (metadata filtering, where supported, operates on a +separately-extracted shallow scalar map -- `StorageMetadataFilter`, +`packages/core/src/storage/types.ts:66-68` -- not on arbitrary JSON paths inside `content`). + +**Domain-level shape** (`MastraDBMessage`, +`packages/core/src/agent/message-list/state/types.ts:107-109`): +```ts +type MastraMessageShared = { // :16-23 + id: string; + role: 'user' | 'assistant' | 'system' | 'signal'; + createdAt: Date; + threadId?: string; + resourceId?: string; + type?: string; +}; +export type MastraDBMessage = MastraMessageShared & { + content: MastraMessageContentV2; // :107-109 +}; +export type MastraMessageContentV2 = { // :94-104 + format: 2; // format 2 === UIMessage in AI SDK v4 + parts: MastraMessagePart[]; + experimental_attachments?: UIMessageV4['experimental_attachments']; + content?: UIMessageV4['content']; + toolInvocations?: UIMessageV4['toolInvocations']; + reasoning?: UIMessageV4['reasoning']; + annotations?: UIMessageV4['annotations']; + metadata?: Record; + providerMetadata?: MastraProviderMetadata; +}; +``` +Message *kind* is distinguished by `role` (`'user'|'assistant'|'system'| +'signal'`) at the envelope level, and within `content.parts` by a +discriminated `type` tag on each `MastraMessagePart` -- `MastraToolInvocationPart` +(`packages/core/src/agent/message-list/state/types.ts:52-59`), +`MastraSourceDocumentPart` +(`packages/core/src/agent/message-list/state/types.ts:61-69`), +`MastraSourceUrlPart` +(`packages/core/src/agent/message-list/state/types.ts:71-74`), +`MastraStepStartPart` +(`packages/core/src/agent/message-list/state/types.ts:31-34`), plus the +inherited AI-SDK-v4 UI part types. Ordering into a thread is purely by the +envelope's `createdAt` (plus the backend-specific `id` tiebreak discussed +above) -- there is no `parentMessageId`/chain-link field anywhere in either +shape; a thread's message order is entirely a property of the row set, not +of any in-band linking field. + +**Schema evolution / versioning**: there is a `type: 'v1'|'v2'` tag stored +per message row (seen as `message.type || 'v2'` on every backend's insert, +e.g. `stores/pg/src/storage/domains/memory/index.ts:1415`) and a `format: 2` +tag inside `MastraMessageContentV2` itself +(`packages/core/src/agent/message-list/state/types.ts:95`). A distinct +legacy shape, `MastraMessageV1` +(`packages/core/src/memory/types.ts:21-32`, also redeclared at +`packages/core/src/agent/message-list/state/types.ts:112-123`), is not +stored anymore but is produced on read for +legacy consumers by a pure, non-persisted conversion function, +`convertToV1Messages()` +(`packages/core/src/agent/message-list/prompt/convert-to-mastra-v1.ts:58`), +which includes ID-splitting logic (a `__split-N` suffix pattern, +`packages/core/src/agent/message-list/prompt/convert-to-mastra-v1.ts:16`) for V2 messages that must be broken into +multiple V1 messages during downgrade. This is a read-time compatibility +shim, not a migration -- the durable row keeps its native v2 shape and the +v1 view is synthesized on demand. + +**Per-backend physical migrations** are real and additive. Postgres: +`OM_MIGRATION_COLUMNS` +(`stores/pg/src/storage/domains/memory/index.ts:40-56`) is an explicit list +of 15 columns added to the Observational Memory table for backward +compatibility, applied via `alterTable({tableName: OM_TABLE, ifNotExists: +OM_MIGRATION_COLUMNS})` +(`stores/pg/src/storage/domains/memory/index.ts:211-215`) and a similar +`alterTable({tableName: TABLE_MESSAGES, ifNotExists: ['resourceId']})` +(`stores/pg/src/storage/domains/memory/index.ts:217-221`) inside `init()` +(`stores/pg/src/storage/domains/memory/index.ts:192-232`) -- i.e., an +existing deployed table gains the `resourceId` column non-destructively on +next boot. A code comment at the top of that same `init()` documents a real +production incident this migration path is protecting against: a +dynamic-import-based schema guard that esbuild's bundling broke, tracked as +issue `#18298` (`stores/pg/src/storage/domains/memory/index.ts:197-202`, +"Don't switch this to `await import(...)`: that used to deadlock `mastra +build` output... the cycle never resolves when storage initializes during +module evaluation (`#18298`)"). LibSQL runs its own migration path via +SQLite's `PRAGMA +table_info(...)` introspection +(`stores/libsql/src/storage/factory-storage.ts:504,539`) followed by +per-missing-column `ALTER TABLE ... ADD COLUMN` +(`stores/libsql/src/storage/factory-storage.ts:543`), and for changes that cannot be expressed as an +additive `ALTER TABLE` (SQLite's DDL is limited), a shadow-table-and-rename +pattern (`stores/libsql/src/storage/factory-storage.ts:525`, `` `ALTER TABLE "${shadow}" RENAME TO +"${schema.name}"` ``). There is no single global schema-version integer +anywhere observed; each backend's migration is scoped to detecting missing +columns/tables at `init()` time, not to a monotonic version counter. + +## Compaction and history management + +No compaction of the *durable* message row set was found -- messages are +not truncated, summarized-in-place, or merged by the storage layer itself. +What shrinks the model-visible context window is upstream of storage: +`MessageHistory`'s `lastMessages` cap +(`packages/core/src/memory/memory.ts:83`, default 10) bounds how much of +the durable log is *read* per turn, and Observational Memory +(`ObservationalMemoryRecord.activeObservations`, +`packages/core/src/storage/types.ts:1198`) is a separately-durable +condensed *reflection* of history that can substitute for reading raw +messages at all (`packages/core/src/memory/memory.ts:773-779`) -- but it is +itself a first-class stored record with its own generation history +(`getObservationalMemoryHistory`, +`packages/core/src/storage/domains/memory/base.ts:194-201`, "returns +records in reverse chronological order"), not a compaction marker inside +the message table. There is no evidence of an in-place rewrite or +truncation marker written into `mastra_messages` itself; the raw log simply +keeps growing until retention (below) removes rows by age. + +## Rewind, checkpoints, and fork + +There is no retroactive "rewind/undo" primitive over the message log in +the surveyed anchors -- `updateMessages` +(`packages/core/src/storage/domains/memory/base.ts:95-100`) supports +in-place *edits* to specific message rows (see next paragraph) but nothing +resembling a rewind-to-checkpoint or branch-from-turn-N operation over the +raw thread. The closest thing to "fork" is `cloneThread` +(`packages/core/src/storage/domains/memory/base.ts:127-132`, input/output at +`packages/core/src/storage/types.ts:215-252`): +```ts +export type StorageCloneThreadInput = { + sourceThreadId: string; + newThreadId?: string; + resourceId?: string; + title?: string; + metadata?: Record; + options?: { + messageLimit?: number; + messageFilter?: { startDate?: Date; endDate?: Date; messageIds?: string[] }; + }; +}; +export type StorageCloneThreadOutput = { + thread: StorageThreadType; + clonedMessages: MastraDBMessage[]; + messageIdMap?: Record; // used for OM remapping +}; +``` +This is copy-plus-lineage, not a shared-prefix reference: the implementations +read (pg `stores/pg/src/storage/domains/memory/index.ts:1745`, libsql +`stores/libsql/src/storage/domains/memory/index.ts:1383`, mongodb) each +physically duplicate the selected messages into new rows under a new thread +ID, and libsql's implementation additionally supports filtering by +`messageLimit`/date-range at clone time +(`stores/libsql/src/storage/domains/memory/index.ts:1413-1447`). Lineage +metadata is left to the *caller's own convention* rather than a fixed schema +column -- `packages/core/src/storage/types.ts:203-210` defines one convention +(`ThreadCloneMetadata { sourceThreadId, clonedAt, lastMessageId }`) but the +`agent-controller`'s fork tool independently invents a *different* metadata +shape for the same purpose (`{forkedSubagent: true, parentThreadId}`, +`packages/core/src/agent-controller/agent-controller.ts:1855-1858`) -- two +different ad hoc lineage-tagging conventions coexist in the same codebase, +both stored in the same opaque `thread.metadata` JSON column +(`packages/core/src/storage/constants.ts:663`, no first-class +`parentThreadId` column exists in `TABLE_THREADS`). *(Noted as inference: +this suggests Mastra has not settled on one canonical "this thread came +from that thread" schema field.)* + +**Messages are not purely append-only.** `updateMessages` +(`packages/core/src/storage/domains/memory/base.ts:95-100`, +required/abstract) supports true in-place mutation. LibSQL's implementation +(`stores/libsql/src/storage/domains/memory/index.ts:809-913`) merges rather +than replaces the `content` field on update -- it deep-merges +`content.metadata` from the existing row with the incoming update +(`stores/libsql/src/storage/domains/memory/index.ts:854-868`) before +issuing an `UPDATE ... WHERE id = ?`. There is +no soft-delete/tombstone convention for messages at this layer; where +`deleteMessages` is implemented it is a hard `DELETE`. + +No file-state/environment checkpoint concept tied to individual turns was +found anywhere in `packages/core/src/storage` (out of scope for a +conversational-memory store; Mastra's file/workspace-state concerns, where +present, live in a different subsystem not explored here -- flagged under +**Open questions** rather than asserted absent). + +## Subagents and nested sessions + +Mastra has **three independent, mutually-unaware mechanisms** that all +touch "child session" concerns, at three different layers of the stack. +This divergence -- not any single clean design -- is the most important +finding in this section. + +**1. Cosmetic thread-ID concatenation (agent-to-agent delegation path).** +`packages/core/src/agent/agent.ts:4716-4731`: +```ts +const subAgentThreadId = inputData.threadId + ? `${inputData.threadId}-${randomUUID()}` + : context?.mastra?.generateId({ idType: 'thread', source: 'agent', entityId: agentName, resourceId }) || randomUUID(); +const subAgentResourceId = inputData.resourceId + ? `${inputData.resourceId}-${agentName}` + : context?.mastra?.generateId({ idType: 'generic', source: 'agent', entityId: agentName }) || `${slugify.default(this.id)}-${agentName}`; +``` +The child thread is a fully independent row with no schema-level link back +to the parent -- the parent-child relationship exists only in the shape of +the ID string. A grep of `packages/core/src` for any code that reconstructs +hierarchy from this pattern (`startsWith`, prefix matching, etc.) returns no +hits: it appears to be write-only metadata, informative to a human reading +IDs in a debugger, not consumed by any code path. + +**2. Real thread-cloning fork (the `agent-controller`'s `subagent` tool).** +This is a durable, storage-layer mechanism, not cosmetic. When the +`subagent` tool is invoked with `forked: true` +(`packages/core/src/agent-controller/tools.ts:150-235`), it calls +`opts.cloneThreadForFork`, which is wired in +`packages/core/src/agent-controller/agent-controller.ts:1848-1861`: +```ts +cloneThreadForFork: hasMemory + ? async ({ sourceThreadId, resourceId, title }) => { + const memory = await this.resolveMemory(session); + const result = await memory.cloneThread({ + sourceThreadId, + resourceId: resourceId ?? session.identity.getResourceId(), + title, + metadata: { forkedSubagent: true, parentThreadId: sourceThreadId }, + }); + return { id: result.thread.id, resourceId: result.thread.resourceId }; + } + : undefined, +``` +This genuinely calls the storage-layer `MemoryStorage.cloneThread()` +(physically copying messages into a new thread row -- see **Rewind, +checkpoints, and fork**), and the parent link (`parentThreadId`) plus a +`forkedSubagent: true` tag are written into the *cloned* thread's opaque +`metadata` JSON column (not a first-class schema column -- `TABLE_THREADS` +has no `parentThreadId` column, +`packages/core/src/storage/constants.ts:661-668`). This tag is read back in +exactly one place: `listThreads()`'s default filter +(`packages/core/src/agent-controller/agent-controller.ts:1005-1012`) checks `metadata?.forkedSubagent !== true` +to hide fork threads from normal thread pickers unless +`includeForkedSubagents` is explicitly requested -- confirmed by a grep +across `packages/core/src` for `forkedSubagent`/`parentThreadId`, which +returns only these two files (`agent-controller.ts`, and one more use of +`forkedSubagent`/`parentThreadId` in +`packages/core/src/loop/workflows/agentic-execution/goal-step.ts:308-310`, +a second call site that tags forked threads the same way for a different +execution path). **On parent-thread delete, nothing cascades to fork +children or vice versa**: none of the four backends' `deleteThread` +implementations read the `parentThreadId`/`forkedSubagent` metadata keys +(confirmed by grep across `stores/` -- zero hits for either string outside +`packages/core`), so deleting a parent thread orphans its fork children +(their `metadata.parentThreadId` now points at nothing) with no detection +or cleanup mechanism. *(Inference from the absence of any cascade code, not +a directly observed failure.)* + +**3. A separate, more formally-typed but seemingly-inert `harness` domain +link.** `packages/core/src/storage/domains/harness/types.ts:19-35` defines: +```ts +export interface SessionRecord { + id: string; ownerId: string; resourceId: string; threadId: string; + parentSessionId?: string; + subagentDepth?: number; + source?: { type: HarnessSessionOrigin; parentSessionId?: string; parentRunId?: string | null; parentTraceId?: string | null; subagentType?: string }; + origin: HarnessSessionOrigin; // 'top-level' | 'subagent-tool' | 'direct-local' | 'remote-resolve' + // ... modeId, modelId, title, metadata, state, pending, createdAt, lastActivityAt, closingAt, closeDeadlineAt, closedAt, deletedAt +} +``` +and its physical schema, `HARNESS_SESSIONS_SCHEMA` +(`packages/core/src/storage/constants.ts:442-464`), has real +`parentSessionId: { type: 'text', nullable: true }` (line 447) and +`subagentDepth: { type: 'integer', nullable: true }` (line 448) columns -- +this looks like exactly the durable parent-child link the other two +mechanisms lack. **However**, a grep of every non-test `.ts` file under +`packages/core/src` for `parentSessionId` finds it **only** in +`packages/core/src/storage/constants.ts:447` (the schema declaration) and +`packages/core/src/storage/domains/harness/types.ts:26,30` (the type declaration) -- no call +site anywhere in `packages/core/src` constructs a `SessionRecord` with a +populated `parentSessionId`, and `subagentDepth` likewise appears only in +those same two files. The `HarnessStorage` abstract class +(`packages/core/src/storage/domains/harness/base.ts:1-91`) exposes only +`loadSession`/`saveSession`/`listSessions` as truly abstract; `updateSession` +is a generic load-mutate-save cycle built on those three, and there is no +`deleteSession` at all -- deletion is reachable only via `updateSession(id, +{deletedAt: new Date()})`, a soft-delete-by-convention, not an enforced +schema rule (the in-memory reference implementation, +`packages/core/src/storage/domains/harness/inmemory.ts:26`, just passes +`deletedAt` through as a date-parse with no filtering of "deleted" sessions +from `listSessions()`). *(Finding, not inference: `parentSessionId` and +`subagentDepth` are real, migrated, nullable schema columns with zero +confirmed producers or consumers in `packages/core/src` at this commit -- an +apparently-aspirational or not-yet-wired field. This should be treated as +an open question, not asserted as either "used" or "dead," since it is +possible a call site exists outside `packages/core` (e.g. in a +closed-source or not-yet-explored deployment layer) that was not found by +this survey.)* + +**Nesting depth**: `subagentDepth` exists in the schema (suggesting an +intended bound) but was not observed being incremented or checked anywhere +in `packages/core/src`. The `subagent` tool's *prompt text* does enforce a +depth-one limit conversationally -- a forked subagent's system prompt is +told "Do not call the `subagent` tool. You are currently running inside a +forked subagent, and this is the maximum allowed subagent nesting level" +(`packages/core/src/agent-controller/tools.ts:25`) -- i.e., recursion is +blocked by instructing the model not to recurse and by the executor +patching the tool's `execute` for forked runs, not by any storage-layer +depth field or hard guard. + +## Retention, deletion, and multi-host + +Retention is opt-in, table-granular, age-based, and owned by the *product* +(a configured `RetentionConfig`), never auto-scheduled by the store itself. +`RetentionConfig`/`TableRetentionPolicy`/`PruneOptions` +(`packages/core/src/storage/retention.ts:19-227`) let a caller set +`{maxAge, batchSize?}` per table key per domain (e.g. +`memory: { messages: { maxAge: '30d' }, threads: {...} }`, +`packages/core/src/storage/retention.ts:178-187`), and +`DomainRetentionTables` (`packages/core/src/storage/retention.ts:144-155`) +fixes which table keys are valid per domain -- for `memory`: `'threads' | +'messages' | 'resources'` (`packages/core/src/storage/retention.ts:145`); +for `harness`: `'sessions'` +(`packages/core/src/storage/retention.ts:153`). A call to +`MastraCompositeStore.prune(options?: PruneOptions)` +(`packages/core/src/storage/base.ts:479-502`) iterates configured domains +and delegates to each domain's own `prune()`; nothing calls this on a +timer inside the library -- the doc comments are explicit that retention +only deletes rows and never reclaims disk +(`packages/core/src/storage/retention.ts:48-51`, "On SQLite/LibSQL freed +pages are reused... Handing disk back to the OS... is left to the +underlying database and the operator"). Postgres's memory domain anchors +retention on a timezone-aware mirror column, `createdAtZ` +(`retentionTables` descriptor, +`stores/pg/src/storage/domains/memory/index.ts:161-172`), whose doc +comment states directly: "Observational memory has no timestamp anchor and +is deliberately excluded" +(`stores/pg/src/storage/domains/memory/index.ts:165-166`) -- Observational +Memory is a durable record with no age-based retention path on this +backend. Pruning is cooperative and resumable: `PruneOptions` supports +`maxBatches`, `maxRows`, `pauseMs`, and an `AbortSignal` +(`packages/core/src/storage/retention.ts:53-84`), and `PruneResult.done: +false` signals a caller should call `prune()` again rather than the loop +running unbounded (`packages/core/src/storage/retention.ts:86-104`). + +**Delete cascades only within one domain's own tables**, not across the +fork/parent link (see previous section) and not into vector-store data +except where a backend explicitly scans for it -- Postgres's `deleteThread` +is the one observed backend that reaches into vector-index tables at all +(scanning `pg_tables` for `memory_messages%`, +`stores/pg/src/storage/domains/memory/index.ts:772-786`); libsql, +dynamodb, and mongodb's `deleteThread` implementations touch only their +own messages+thread tables, with no vector-store cleanup step observed in +the surveyed regions of those three files. + +**Multi-host**: nothing in the surveyed anchors treats multi-host/shared- +filesystem concerns as a first-class path -- every backend here is a +network database (Postgres/libSQL-over-network/DynamoDB/MongoDB), so +"multiple processes writing to the same store" is simply "multiple clients +of the same database," governed by whatever consistency guarantees that +database provides (see the atomicity divergence above). No crash-detection, +lease, or lock-file mechanism specific to multi-host session ownership was +found in `packages/core/src/storage`. + +## What this implies for our Session Store (our inference) + +*(Everything in this section is our inference, not a claim about Mastra's +own stated design intent.)* + +- Mastra's strongest, most load-bearing evidence for "a session store" is + the **thread/message row-set contract** (`MemoryStorage`), and it is + genuinely backend-independent at the *interface* level -- nine methods, + fixed signatures, implemented identically in shape across at least six + backends. But **semantics are not backend-independent**: atomicity, + ordering-tiebreak consistency, and delete-cascade completeness all vary, + sometimes by explicit, deliberate design trade-off (libsql's + and mongodb's non-transactional `deleteThread`, both with code comments + justifying the choice). This is direct evidence that a shared interface + contract does not, by itself, give callers a shared consistency contract + -- if our Session Store exposes one interface across backends, we need to + either (a) document per-backend consistency levels explicitly rather than + implying uniformity, or (b) push harder guarantees into the interface + itself (e.g. require atomic multi-row writes as part of the contract, + not as an implementation detail some backends opt out of). +- The cleanest pattern worth adopting directly: **treat the vector/search + index as purely derived**, keyed by a stable message ID, rebuildable from + the durable row set, updated synchronously at write time rather than via + a background job + (`packages/core/src/processors/memory/semantic-recall.ts:534-657`). This keeps "what is + authoritative" unambiguous. +- The messiest pattern worth deliberately avoiding: Mastra has **three + different parent-child linking conventions for child sessions** + (string-concatenated ID, opaque-metadata tag, and a real-but-apparently- + unused schema column) that do not interoperate and were evidently added + at different times by different subsystems. A single Session Store should + pick exactly one durable parent-child representation (a first-class + column, not opaque metadata) and make every producer of child sessions go + through it, specifically so a later auditor doesn't have to grep three + places to find out whether "delete parent" orphans children. +- Mastra's working-memory/Observational-Memory/semantic-recall split shows + that "session state beyond the raw transcript" is not one thing -- a + design that pre-declares a single mutable "memory blob" would have missed + this: Mastra ended up with a mutable single-field working memory, a + versioned-generation reflection record (OM), and a derived vector index, + each with different consistency/versioning needs. Our Session Store + should not assume derived/secondary memory is monolithic. +- `updateMessages`' deep-merge-not-replace behavior on `content.metadata` + (libsql, `stores/libsql/src/storage/domains/memory/index.ts:854-868`) is + a useful precedent for how a mutable-but-still-append-log-adjacent field + (message annotations/metadata added after the fact, e.g. approval status) + can coexist with an otherwise-immutable message body, without treating + the whole message row as freely rewritable. + +## Open questions + +- Whether `stores/*` backends not read for this dossier (clickhouse, + cloudflare-d1, convex, dsql, mssql, mysql, redis, spanner, upstash) + reproduce the same atomicity/ordering divergences found in pg/libsql/ + dynamodb/mongodb, or add new ones. Only the shared abstract interface and + a few error-message strings (which name pg/libsql/mongodb/convex as + Observational-Memory-capable, `packages/core/src/storage/domains/memory/base.ts:87`) + were checked for these. +- Whether `HARNESS_SESSIONS_SCHEMA.parentSessionId`/`subagentDepth` + (`packages/core/src/storage/constants.ts:447-448`) are populated anywhere + outside `packages/core/src` -- e.g. in a deployer, a cloud/hosted control + plane, or the `ee/` tree (which was not searched, since anything found + there could not be cited as open-source precedent anyway). This survey + found zero producers/consumers within `packages/core/src`, but that is + not proof the field is dead product-wide. +- Whether the vector index has an explicit, callable "rebuild from message + table" utility, or whether recovery from a lost/reset vector store is + purely an operational/manual procedure. No such function was found in + `packages/core/src/processors/memory/semantic-recall.ts`, but the file + was not read in its entirety (roughly lines 380-660 of a larger file were + read). +- Whether DynamoDB's emulated offset pagination genuinely costs O(N) to + reach a deep page (re-reading from the start each time) -- this was + reported by an earlier deep-read of + `stores/dynamodb/src/storage/domains/memory/index.ts` but the specific + cost claim was not re-derived line-by-line in this final verification + pass; the pagination-helper call site + (`stores/dynamodb/src/storage/domains/memory/index.ts:376,378`) was + confirmed to exist, but its algorithmic cost was not re-traced. +- Whether any code path anywhere in the product (not just + `packages/core/src`) reconstructs sub-agent hierarchy from the + `${threadId}-${uuid}` cosmetic ID pattern used by the agent-to-agent + delegation path (`packages/core/src/agent/agent.ts:4716-4731`) -- this + survey's grep was scoped to `packages/core/src` and found nothing, but + deployers/integrations/UI packages elsewhere in the monorepo were out of + scope and not checked. +- No file-state/environment-checkpoint concept tied to conversation turns + was found in the surveyed storage anchors; whether Mastra has such a + concept in a different subsystem (e.g. its workflow/tool-execution layer) + was out of scope for this dossier and was not investigated. diff --git a/docs/research/session-store/products/mastra/vs-session-events.md b/docs/research/session-store/products/mastra/vs-session-events.md new file mode 100644 index 000000000..508c8fe23 --- /dev/null +++ b/docs/research/session-store/products/mastra/vs-session-events.md @@ -0,0 +1,647 @@ +# Mastra compared to our session event catalog + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT_COMPARISON](../../RESEARCH_PROMPT_COMPARISON.md). +Stage-one dossier: [Mastra](./index.md). +Compared against `proto/trogonai/session/sessions/v1alpha1/` and [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) on 2026-08-04. + +**Store maturity: 11/12**: evolution scars 3/3 (`OM_MIGRATION_COLUMNS`, a +15-entry backward-compatibility column list carried forward on every init, +`stores/pg/src/storage/domains/memory/index.ts:40-55`, wired into an +`alterTable` call at `stores/pg/src/storage/domains/memory/index.ts:210-215`; +a v1/v2 message-shape migration with a read-time conversion shim, per the +dossier's [Entry/message structure and versioning](./index.md#entrymessage-structure-and-versioning) +section), operational age 3/3 (two independently corroborated, externally +filed, closed production incidents, each fixed with a code comment naming the +issue number: `stores/pg/src/storage/domains/memory/index.ts:197-202` names +issue #18298, a bundler-induced self-referential-import deadlock in `mastra +build` output, confirmed closed via `gh issue view 18298 --repo +mastra-ai/mastra` (created 2026-06-22); `stores/pg/src/storage/domains/memory/index.ts:808-810` +names issue #11150, a `ROW_NUMBER()`-based pagination query that caused +multi-minute scans on large `mastra_messages` tables, confirmed closed via +`gh issue view 11150 --repo mastra-ai/mastra` (created 2025-12-13); both are +real, technically specific field failures with a shipped fix, not merely a +defensive comment), exposure 3/3 (Apache-2.0, 19+ backend storage adapters +including Postgres, LibSQL, DynamoDB, MongoDB, and others per the dossier's +[Write and append path](./index.md#write-and-append-path-ordering-durability-concurrency-delivery) +section; every backend surveyed here is a network database, so multi-client +access from multiple hosts is load-bearing production behavior, not a toy +default; the `ee/` carve-out in `LICENSE` (lines 3-7) is independent +confirmation that a paid tier is built on top of this same open-source store), +design independence 3/3 (no evidence anywhere in the surveyed code of +persistence logic forked from an upstream project; the store is Mastra's own +`MemoryStorage` domain contract, and the divergent atomicity guarantees across +its own backends, documented below, are themselves evidence of organic, +independent per-backend evolution rather than a single inherited design). +Scored one point below Letta (11/12) would be an over-correction: the +evidence here is comparably strong, but is held to 11/12 rather than 12/12 +because operational-age evidence, while externally corroborated, is drawn +from a `stores/pg` implementation file whose current domain-refactored shape +is comparatively recent relative to the two-incident sample size; the +externally-verified incidents earn the third point on that axis, but the +overall score is reported conservatively rather than at the ceiling. + +## The one structural difference everything else follows from + +Mastra defines exactly one storage contract, `MemoryStorage` +(`packages/core/src/storage/domains/memory/base.ts:38-134`), and then lets +each of its many backend packages implement that contract with materially +different atomicity guarantees, silently, at the backend level. This is not +a hypothetical concern: it is directly observable across the four backends +this comparison surveyed in depth. + +`stores/pg/src/storage/domains/memory/index.ts:1355-1447` wraps `saveMessages` +in a real Postgres transaction (`this.#db.client.tx(async t => {...})`, +opened at line 1401), batching the message inserts and the thread +`updatedAt` bump inside one commit. Its `deleteThread` +(`stores/pg/src/storage/domains/memory/index.ts:765-803`) does the same: +message deletion, a scan of `pg_tables` for `memory_messages%` vector +tables, per-table vector-row purges, and the thread-row delete all happen +inside one `this.#db.client.tx(...)` call. + +`stores/libsql/src/storage/domains/memory/index.ts:1356-1381` deliberately +does not use a transaction for `deleteThread`, and says so in a code +comment at lines 1358-1361: "Not using a transaction to avoid SQLITE_BUSY +errors when multiple deleteThread calls run concurrently. The two deletes +are independent and orphaned messages (if thread delete fails) would be +cleaned up on next delete attempt." Atomicity is traded away explicitly, in +writing, for concurrent-write availability. + +`stores/dynamodb/src/storage/domains/memory/index.ts:599-671` writes each +message in `saveMessages` with a sequential `.put().go()` call +(line 644), and on any failure runs a compensating rollback loop +(lines 647-658) that deletes the messages already written. That rollback's +own failure path does not re-throw: it only logs +(`this.logger.error('Failed to rollback message during save error', ...)` +at lines 651-655), so a rollback that itself fails leaves partially-written +state with no propagated error. + +`stores/mongodb/src/storage/domains/memory/index.ts:695-760` wraps +`saveMessages` in `this.#connector.withTransaction(...)`, but that +connector method degrades silently when the deployment topology does not +support it. `stores/mongodb/src/storage/connectors/MongoDBConnector.ts:117-136` +documents this exactly in its own doc comment: "Runs `fn` inside a +transaction when the deployment supports it... On a standalone server (or +custom handler) it degrades to running `fn` directly with an undefined +session: best-effort sequential, no atomicity." `deleteThread` +(`stores/mongodb/src/storage/domains/memory/index.ts:1212-1238`) goes +further and never even attempts a transaction, with a comment (lines +1214-1222) explaining that a transactional `deleteMany` is capped by +`transactionLifetimeLimitSeconds` (60s default) and "a large thread would +abort and become permanently undeletable," so a plain, non-transactional +`deleteMany` is used instead because it "commits incrementally and always +completes." + +Four implementations of one interface, four different, independently +justified answers to "is this write atomic": always (pg), never by design +(libsql), sequential-with-fallible-compensation (dynamodb), and +topology-conditional-with-silent-degradation (mongodb). This is not a +mistake in any one backend; each comment is a reasoned, backend-specific +trade-off. It is the natural consequence of a single abstract interface +sitting on top of storage engines with genuinely different transaction +models, and no mechanism in the interface itself communicates which +guarantee a caller is actually getting for a given deployment. + +We do not have this problem, by construction rather than by discipline. +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 1 makes append-only mutation through `append_stream` the +only write path, and decision 2's `WRITE_PRECONDITION` classification +(`NoStream`/`At`/`Any`) is enforced once, at the substrate level, for every +command, not re-implemented per backend. Every multi-event fact our design +ever needs atomically is expressed as a single batch append under one +precondition on one stream: fork is `[SessionStarted, SessionForked]` under +`NoStream` (decision 5, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) lines 672-674), child creation is +`[SessionStarted, ParentLinked]` under `NoStream` (decision 6, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) lines +733-734), and terminal or rewind cascade are `[ParentTerminated, +SessionCancelled]` and `[ParentHistoryInvalidated, SessionCancelled]`, each +under `At` on the child's own stream (decision 6, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) lines 766-787). +There is exactly one substrate (NATS JetStream) and exactly one atomicity +guarantee (single-stream, single-writer, OCC-guarded append), so the +question "which atomicity do I actually get here" never has more than one +answer. Mastra's four-way divergence is the cleanest evidence in this +corpus that "one store interface" is not the same claim as "one storage +guarantee," and that the second claim is the one that actually matters at +the write path. + +## Mapping + +Two words need disambiguating before the table, because a naive lookup +would silently misread both: + +- **"Thread."** Mastra's `StorageThreadType` + (`packages/core/src/memory/types.ts:35-50`, confirmed no `parentThreadId` + field) is the closest analogue to our Session, but it is deliberately + thin: a thread has no execution-plan binding, no lifecycle state machine, + and no terminal state. Our `SessionStarted` (`proto/trogonai/session/sessions/v1alpha1/session_started.proto:1-25`) + binds a session to a `StoredSessionExecutionPlan` and a `WorkspaceRef` at + creation, and a session reaches one of four terminal states + (`SessionClosed`, `SessionCancelled`, `SessionFailed`, `SessionHidden`). + Mastra's thread has none of this: it is a container for messages, not a + bounded, terminable execution. +- **"Fork."** Mastra's `cloneThread` + (`stores/pg/src/storage/domains/memory/index.ts:1745-1790`) is a genuine, + observed physical copy: it fetches the source thread, mints a new thread + ID, and inside a transaction copies matching message rows into new rows + under the new thread ID (per `StorageCloneThreadInput`/`StorageCloneThreadOutput`, + `packages/core/src/storage/types.ts:215-252`). Our `SessionForked` + (`proto/trogonai/session/sessions/v1alpha1/session_forked.proto:1-39`) never copies an event: it appends a + `context_prefix_boundary` (`SessionOrdinal`) that the model-visible-context + projection resolves by reference into the source stream ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision + 5, lines 683-696). Mastra's word for the same English verb names the + opposite mechanism. + +| Mastra | Ours | Verdict | +| --- | --- | --- | +| `StorageThreadType` (`packages/core/src/memory/types.ts:35-50`) | `SessionStarted` (`proto/trogonai/session/sessions/v1alpha1/session_started.proto:1-25`) plus lifecycle events | Semantic mismatch, see above; Mastra's thread has no plan binding and no terminal state | +| `mastra.generateId()` falling back to `randomUUID()` (`packages/core/src/mastra/index.ts:1128-1143`) | `SessionId` opaque identity ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2, lines 140-141) | Equivalent principle: identity is an opaque key, not a derived or structured value | +| `Message.id`, ordered by `createdAt` with per-query, sometimes inconsistent tiebreaks, see below | `CanonicalMessage.message_id` (`proto/trogonai/session/sessions/v1alpha1/message.proto:1-40`); order is `SessionOrdinal`, fold-derived, never a stored or queried column (`proto/trogonai/session/sessions/v1alpha1/session_ordinal.proto:1-16`) | Ours, decisively: see recommendation 3 | +| `cloneThread` physical copy (`stores/pg/src/storage/domains/memory/index.ts:1745-1790`) | `SessionForked{source_session_id, context_prefix_boundary}` (`proto/trogonai/session/sessions/v1alpha1/session_forked.proto:1-39`), inherited by reference ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 5) | Semantic mismatch, see above; trade-off, see below | +| Cosmetic thread-ID string concatenation for agent-to-agent delegation, `` `${threadId}-${randomUUID()}` `` (`packages/core/src/agent/agent.ts:4716-4725`), no consumer reconstructs hierarchy from it | `DelegationDispatched`/`ParentLinked` (`proto/trogonai/session/sessions/v1alpha1/delegation_dispatched.proto:1-26`, `proto/trogonai/session/sessions/v1alpha1/parent_linked.proto:1-28`), a typed, fold-consumed fact pair | Ours, decisively: see "What our design already does better" | +| `forkedSubagent`/`parentThreadId` opaque metadata tags on a cloned thread (`packages/core/src/agent-controller/agent-controller.ts:1845-1861`), read back only by `listThreads`'s default filter (`packages/core/src/agent-controller/agent-controller.ts:1005-1013`) | Same as above; `CascadePolicy` (`proto/trogonai/session/sessions/v1alpha1/cascade_policy.proto:1-17`) governs whether a link matters for cascade at all | Ours, decisively: see below | +| `SessionRecord.parentSessionId`/`subagentDepth`, real migrated nullable schema columns (`packages/core/src/storage/constants.ts:447-448`, `packages/core/src/storage/domains/harness/types.ts:26-27`) with zero producers or consumers found anywhere in `packages/core/src` outside their own declarations | Same as above | Ours, decisively: a third, mutually-unaware mechanism is exactly the failure mode our single typed fact pair avoids | +| `HarnessStorage` has no `deleteSession` at all; deletion is `updateSession(id, {deletedAt: new Date()})`, a soft-delete-by-convention not enforced by any schema rule (`packages/core/src/storage/domains/harness/base.ts:1-91`) | `SessionHidden` (`proto/trogonai/session/sessions/v1alpha1/session_hidden.proto:1-26`), a typed, named terminal visibility tombstone with a `SessionHiddenReason` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7) | Ours, decisively: see below | +| `MessageHistory` processor: bounded eager reload, `perPage: this.lastMessages` (default 10), `orderBy: {field: 'createdAt', direction: 'DESC'}` (`packages/core/src/processors/memory/message-history.ts:113-119`, default at `packages/core/src/memory/memory.ts:82-83`) | Aggregate resume folds from the newest snapshot plus tail ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8, lines 943-953); model-visible context compiles from the log bounded by the latest `Compacted` marker (decision 8) | Trade-off, see below | +| `RetentionConfig`/`TableRetentionPolicy`/`PruneOptions`, age-based, table-granular, caller-scheduled, never auto-run (`packages/core/src/storage/retention.ts:19-97`); `prune()` "never reclaims disk" (`packages/core/src/storage/retention.ts:48-51`) | `SessionHidden`/`RedactionApplied`/`ArtifactErased`, a three-tier privacy contract over a keep-forever log ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7) | Different problem, see "The two gaps" below | +| No compaction primitive at the storage layer at all, per the dossier's [Compaction and history management](./index.md#compaction-and-history-management) section | `Compacted` (`proto/trogonai/session/sessions/v1alpha1/compacted.proto:1-50`), a self-sufficient in-stream marker the store only records ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 4) | Gap in Mastra, by the dossier's own account | +| `HarnessPendingItemRecord`/pending-item array on `SessionRecord` (`packages/core/src/storage/domains/harness/types.ts:7-19`) | `ToolCallApproved`/`ToolCallDenied` (facet 2, command matrix) | Roughly equivalent, different granularity: Mastra's is a mutable array field, ours is an append-only fact per call | + +## What we should consider changing + +Ordered by how consequential the underlying question is, not by +implementation cost. + +### 1. Add an explicit lint or test asserting every listing/projection path sorts strictly by `SessionOrdinal` with no alternate tiebreak + +**The change.** Add a repo-level check (test or lint rule) that any code +reading events for a session in order sorts strictly by the fold-derived +`SessionOrdinal` and never introduces a second, independently-chosen sort +key or tiebreak for the same logical sequence. + +**Evidence anchor.** Mastra, store maturity 11/12: +`stores/mongodb/src/storage/domains/memory/index.ts:289` sorts +`{ createdAt: -1, id: -1 }` (a tiebreak present), line 298 sorts +`{ createdAt: 1, id: 1 }` (a tiebreak present, opposite direction), but line +322 (`listMessagesById`) sorts only `{ createdAt: -1 }` (no tiebreak) and +line 1322, inside `cloneThread`'s message query, sorts only +`{ createdAt: 1 }` (no tiebreak). Four call sites in one backend file, over +the same conceptual ordering, with three distinct sort shapes. Where two +messages share a `createdAt` value (a real possibility if a caller does not +control clock resolution, or on bulk-imported/migrated rows), the +no-tiebreak call sites have no defined order between them, and the +tiebreak call sites disagree with each other about which field breaks the +tie and in which direction. + +**Blast radius.** Additive. This does not touch a proto file or an ADR +decision; it is a new automated check against existing behavior we already +intend ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2, lines 140-154: `SessionOrdinal` is "derived by +counting at fold time, never read from JetStream message metadata"). + +**Why.** Our fold-derived `SessionOrdinal` already makes this class of bug +structurally hard to introduce inside `evolve`, because ordering comes from +counting during a single, canonical fold, not from a query-time `ORDER BY` +clause chosen independently at each call site. But `SessionOrdinal` is a +domain-level guarantee; nothing today stops a future read-side projection +(decision 8) from adding its own `ORDER BY` against a denormalized KV +store and getting it subtly wrong the same way Mastra's four call sites +did, independently, inside one backend. Mastra is not evidence that our +design has this bug; it is evidence of exactly how easily it is introduced +when nothing enforces a single, canonical ordering rule at every read site, +which is worth guarding against explicitly rather than trusting to review. + +**What it costs us.** One test or lint rule to write and maintain; no +schema or behavior change to reject. + +### 2. Confirm that our multi-event atomic batches (fork, child creation, cascade) remain single-stream, single-append operations as the command surface grows, and do not acquire a Mastra-style "compensating rollback across independent writes" shape + +**The change.** No change to [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) today. This is a standing constraint +to preserve: every future command that needs to make more than one fact +true atomically must express that as a single batch append under one +`WRITE_PRECONDITION` on one stream (as fork, child creation, and cascade +already do), never as multiple independent appends across streams with an +application-level rollback if a later one fails. + +**Evidence anchor.** Mastra, store maturity 11/12: DynamoDB's `saveMessages` +(`stores/dynamodb/src/storage/domains/memory/index.ts:599-671`) writes +each message with an independent `.put().go()` call and, on failure midway, +runs a compensating delete loop over the messages already written +(lines 647-658) whose own failure path only logs +(`this.logger.error(...)`, lines 651-655) rather than propagating an error. +This is the shape a design gets when a logical multi-item write has no +single atomic primitive underneath it: correctness depends on a +best-effort undo that itself can silently fail. + +**Blast radius.** Additive as a standing principle; it names no specific +schema change today. It becomes breaking-the-decision only if a future +proposal tries to relax it, in which case [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decisions 5 and 6 (the +`[SessionStarted, SessionForked]` and `[SessionStarted, ParentLinked]` +atomic batches) are the decisions being contradicted. + +**Why.** Every multi-fact atomic operation [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) currently defines stays +inside one JetStream subject specifically because "JetStream offers no +atomic write across subjects" (Alternatives Considered: "Subagent cascade +via a cross-stream transaction or atomic multi-stream delete... rejected +because it is unavailable"). That constraint already forced the right +design for the cases we have. The risk this recommendation names is a +future command that needs to touch two streams' invariants at once, where +a well-intentioned implementer reaches for "write both, and roll back the +first if the second fails" instead of restructuring the operation as +two separately-guarded local facts joined by an operation id, the pattern +decision 6 already uses for detach (`DelegationDetached`/`ParentDetached`, +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) lines 793-808). DynamoDB's rollback-that-can-itself-fail is the +concrete shape of what happens when that discipline lapses. + +**What it costs us.** Nothing today; this recommendation exists to make the +constraint explicit enough that recommendation 2 is reached for +deliberately, not rediscovered after a future command's compensating +rollback fails in production. + +### 3. Do not introduce a second, mutable parent-child linking field for delegation without deprecating the others, if our system ever grows more than one + +**The change under consideration, and why to reject it as a general +practice.** A future feature (a debugger view, an export tool, an +analytics pipeline) proposing its own denormalized, independently-populated +field for "which session is this session's parent," separate from the +`ParentLinked`/`DelegationDispatched` fact pair. + +**Evidence anchor.** Mastra, store maturity 11/12: three mutually-unaware +mechanisms coexist for the same concept. (1) Cosmetic thread-ID +concatenation, `` `${inputData.threadId}-${randomUUID()}` `` and +`` `${inputData.resourceId}-${agentName}` `` +(`packages/core/src/agent/agent.ts:4716-4725`), with no consumer anywhere in +`packages/core/src` reconstructing hierarchy from the string shape. (2) The +`cloneThreadForFork` mechanism (`packages/core/src/agent-controller/agent-controller.ts:1845-1861`), +which tags a cloned thread's opaque `metadata` JSON column with +`forkedSubagent: true` and `parentThreadId`, read back in exactly one place +(the `listThreads` default filter, lines 1005-1013) plus one more +independent tagging call site for a different execution path +(`packages/core/src/loop/workflows/agentic-execution/goal-step.ts:308-310`). +(3) A formally-typed `SessionRecord.parentSessionId`/`subagentDepth` schema +column pair (`packages/core/src/storage/constants.ts:447-448`, +`packages/core/src/storage/domains/harness/types.ts:26-27`) with zero +producers or consumers anywhere in `packages/core/src` outside their own +declarations, confirmed by grep. None of the three mechanisms reads or +writes either of the others. + +**Blast radius.** Additive as a standing principle; no schema change is +proposed. It would become breaking-the-decision only if adopted, since +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6 already names `DelegationDispatched`/`ParentLinked` as +the sole parent-child linking mechanism, and a second field would +contradict that by construction. + +**Why.** None of Mastra's three mechanisms is individually a bad idea; each +was reasonable in isolation, at the moment it was added, for its own +call site. The failure is that nothing forced a single point where "does +this session have a parent" gets answered, so three answers now coexist, +one of which (the harness schema columns) is populated nowhere and reads as +"aspirational" rather than dead, per the dossier's own [Subagents and nested +sessions](./index.md#subagents-and-nested-sessions) section, which treats +this explicitly as an open question rather than a resolved dead-code +finding. `ParentLinked`/`DelegationDispatched` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6) is +already the single mechanism; this recommendation's value is naming, ahead +of time, why a second one should not get added later for a narrower +purpose (a debugger view, a fast-path query) the way Mastra's third +mechanism apparently was. + +**What it costs us.** Nothing to reject a second mechanism; the cost this +guards against is the confusion of maintaining three unreconciled sources +of the same fact, which is what would need to be paid down later if this +were not stated now. + +### 4. Consider allowing a bounded, cheap read path for the common "just the recent tail" resume case, distinct from full aggregate replay + +**The change.** [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8 already resumes efficiently by loading +"the newest snapshot for the session, then replay only the tail after it." +Consider whether a caller-facing read API should also expose a +lighter-weight, explicitly-bounded "last N messages" query, analogous to +Mastra's default, that does not require resolving snapshot state at all, +for callers (a chat-history preview UI, a debugging tool) that need recent +context but not full, correct aggregate state. + +**Evidence anchor.** Mastra, store maturity 11/12: `MessageHistory` +(`packages/core/src/processors/memory/message-history.ts:113-119`) resumes +by calling `storage.listMessages({..., perPage: this.lastMessages, +orderBy: {field: 'createdAt', direction: 'DESC'}})`, defaulting +`lastMessages` to 10 (`packages/core/src/memory/memory.ts:82-83`). This is a +bounded eager reload, not a replay: it is a cheap, single query with a +fixed page size, unrelated to snapshot cadence or fold correctness. + +**Blast radius.** Additive. This would be a new, explicitly non-authoritative +read-side query against the same log, not a change to how aggregate resume +works ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8's snapshot-plus-tail path stays exactly as +specified). + +**Why it is a good idea, or why it is not.** This is recorded as a +question, not a firm recommendation, because it is not clear the platform +has a caller that needs "recent tail, no correctness guarantee" as +distinct from "correct aggregate state, snapshot-bounded cost." Mastra's +default of 10 messages is also a real footgun for anything beyond casual +display: the dossier's [Read and resume path](./index.md#read-and-resume-path) +section notes this is a bounded eager reload, not a replay, meaning a +caller relying on it for anything beyond a UI preview gets a silently +truncated view of history with no signal that truncation happened. If we +add this, it must be clearly named as a preview query, never confused with +resume, and must not become a second, informal notion of "current state" the +way Letta's `Agent.message_ids` did (see the Letta comparison, +"What our design already does better"). + +**What it costs us.** If adopted: a new bounded query shape and a +documentation obligation to keep it clearly distinct from resume. If +rejected: nothing; aggregate resume via decision 8 already covers the +correctness-sensitive case, and this recommendation names the gap without +asserting it must be filled. + +## What our design already does better + +- **A single typed fact pair for parent-child linking, not three + independent, unreconciled mechanisms.** `DelegationDispatched`/`ParentLinked` + (`proto/trogonai/session/sessions/v1alpha1/delegation_dispatched.proto:1-26`, `proto/trogonai/session/sessions/v1alpha1/parent_linked.proto:1-28`) are the + only way a child-session relationship is ever recorded ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision + 6). Mastra's three coexisting mechanisms (cosmetic ID concatenation, + opaque clone metadata, unused harness schema columns) mean answering + "does this thread have a parent" depends on which of three places you + look, and the dossier confirms at least one of the three + (`parentSessionId`/`subagentDepth`) has no confirmed producer or consumer + at all. +- **Cascade on parent deletion, rewind, or crash is a named, typed policy, + not silent orphaning.** Mastra's grep-confirmed absence of any + `deleteThread` implementation reading `forkedSubagent`/`parentThreadId` + means deleting a parent thread orphans its fork children with no + detection or cleanup path, per the dossier's own inference in + [Subagents and nested sessions](./index.md#subagents-and-nested-sessions). + Our `CascadePolicy` (`proto/trogonai/session/sessions/v1alpha1/cascade_policy.proto:1-17`) and the reconciler-driven + `[ParentTerminated, SessionCancelled]`/`[ParentHistoryInvalidated, + SessionCancelled]` batches ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6, lines 773-791, 758-771) + make what happens on parent termination or rewind an explicit, typed, + reconciled outcome, never a link that simply stops resolving. +- **Fork is atomic and by-reference; nothing to keep consistent, nothing to + physically copy.** Mastra's `cloneThread` is a genuine, transactional + physical copy of message rows (`stores/pg/src/storage/domains/memory/index.ts:1745-1790`), + which means a large source thread makes forking an O(history) operation. + `SessionForked{source_session_id, context_prefix_boundary}` + (`proto/trogonai/session/sessions/v1alpha1/session_forked.proto:1-39`, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 5) makes fork O(1) at + write time and resolves inheritance by reference into a keep-forever log, + so no fork ever needs a copy to be kept in sync with anything. +- **Deletion is a named, typed vocabulary, not one soft-delete convention + reused everywhere.** `HarnessStorage` has no `deleteSession` method at + all (`packages/core/src/storage/domains/harness/base.ts:1-91`); deletion + is `updateSession(id, {deletedAt: new Date()})`, an unenforced + soft-delete-by-convention that the in-memory reference implementation + does not even filter on read + (per the dossier's [Subagents and nested sessions](./index.md#subagents-and-nested-sessions) + section). `SessionHidden` (`proto/trogonai/session/sessions/v1alpha1/session_hidden.proto:1-26`), `RedactionApplied` + (`proto/trogonai/session/sessions/v1alpha1/redaction_applied.proto:1-20`), and `ArtifactErased` + (`proto/trogonai/session/sessions/v1alpha1/artifact_erased.proto:1-18`) are three distinct, typed, `At`-guarded + events, each meaning exactly one thing ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7), so "what + happens when you delete something" is never left to a convention a + particular backend may or may not enforce. +- **Ordering is a single fold-derived fact, never a per-query choice.** + Mastra's four surveyed sort call sites in one backend file disagree with + each other about tiebreak field and direction + (`stores/mongodb/src/storage/domains/memory/index.ts:289,298,322,1322`). + Our `SessionOrdinal` (`proto/trogonai/session/sessions/v1alpha1/session_ordinal.proto:1-16`) is derived once, at + fold time, from a canonical order ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2), so there is + never more than one answer to "which of these two events came first." + +## Trade-offs, not gaps + +- **A real ACID transaction on one substrate versus JetStream's + single-stream atomicity.** Postgres's `saveMessages` and `deleteThread` + (`stores/pg/src/storage/domains/memory/index.ts:1355-1447, 765-803`) get a + genuine multi-table transaction because everything lives in one + relational database. Our atomic batches (fork, child creation, cascade) + are real, but bounded to one JetStream subject, because our substrate + offers no cross-subject atomic write (Alternatives Considered: "unavailable"). + Postgres's atomicity is a property of colocated storage available to one + Mastra backend, not a design choice available to every backend it ships, + or to us; the trade-off we accepted (single-stream atomic batches plus a + reconciler for cross-stream facts) is the honest cost of a topology that + does not offer that shortcut, matching the same trade-off already + recorded against Letta's colocated-transaction cascade. +- **A bounded eager reload versus fold-from-log-with-snapshot.** Mastra's + default resume (`MessageHistory`, `lastMessages: 10`, + `packages/core/src/memory/memory.ts:82-83`) is cheap and simple: one + query, fixed page size, no snapshot management. Our resume replays a + snapshot plus tail to reconstruct correct aggregate state ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) + decision 8). Mastra's approach is cheaper for the common "just show + recent messages" case and needs no snapshot infrastructure at all; ours + is more expensive to build and maintain but gives a caller a + correctness guarantee ("this is the actual, complete state") that a + bounded reload structurally cannot: Mastra's own default silently drops + anything older than the 10th-most-recent message, with no signal to the + caller that truncation occurred. +- **Physical fork copy versus fork-by-reference.** `cloneThread` + (`stores/pg/src/storage/domains/memory/index.ts:1745-1790`) gives a fork a + fully independent, self-contained set of message rows: nothing about the + source thread's later mutation, redaction, or deletion can affect the + clone, because the clone owns its own copies. `SessionForked` + (`proto/trogonai/session/sessions/v1alpha1/session_forked.proto:1-39`) is cheaper at fork time and automatically + inherits any later redaction of the source prefix, per [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision + 7's point that "redacting a source stream also automatically masks every + fork's inherited context" (lines 879-882), but that same property means a + fork is never fully independent of its source's continued existence and + masking policy. Mastra's model buys isolation at the cost of copy time and + storage; ours buys cheap forking and automatic redaction propagation at + the cost of the fork never being a self-contained artifact. + +## What not to copy + +- **Letting three independent mechanisms answer the same structural + question.** Cosmetic ID concatenation, opaque clone metadata, and unused + harness schema columns all separately claim to represent "this session's + parent," and none of the three is aware of the other two. If a future + feature needs a parent-child fact we do not already have, it must extend + `ParentLinked`/`DelegationDispatched`, never add a fourth, independent + mechanism alongside them. +- **A schema column that looks authoritative but has no confirmed producer + or consumer.** `parentSessionId`/`subagentDepth` + (`packages/core/src/storage/constants.ts:447-448`) are real, migrated, + nullable columns that read as load-bearing to anyone inspecting the + schema, but the dossier's own grep found no call site anywhere in + `packages/core/src` that populates or reads them. A typed field with no + confirmed writer is worse than no field at all, because it invites a + future reader to trust it. Every event in our catalog must have a real, + identifiable producer before it ships. +- **A default bounded reload that silently and permanently drops history + with no signal.** `MessageHistory`'s default of 10 messages + (`packages/core/src/memory/memory.ts:82-83`) means any message beyond the + most recent 10 is invisible to a resumed conversation by default, with no + indication to the caller that truncation happened. If we ever expose a + bounded "recent tail" read (recommendation 4), it must be explicitly + named as a preview, never presented as equivalent to full resumed state. +- **A compensating rollback whose own failure path is silent.** DynamoDB's + `saveMessages` rollback loop (`stores/dynamodb/src/storage/domains/memory/index.ts:647-658`) + only logs when the rollback itself fails, rather than surfacing that + failure to the caller. Any future compensating-action path in our system + (a reconciler repair, a saga step) must propagate its own failure rather + than swallowing it, exactly the discipline [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6's crash + repair already follows (a duplicate creation attempt no-ops on + `WrongExpectedVersion`, rather than silently continuing). +- **Silent atomicity degradation based on runtime topology, with no signal + to the caller.** MongoDB's `withTransaction` + (`stores/mongodb/src/storage/connectors/MongoDBConnector.ts:117-136`) + transparently downgrades from transactional to best-effort-sequential + depending on whether the connected deployment is a replica set or a + standalone server, with no error, warning, or capability flag exposed to + the caller. Any future capability that depends on the deployment topology + in our system must fail loudly or expose its degraded mode explicitly, + never degrade a stated guarantee silently based on what happens to be + running underneath it. + +## The two gaps the industry has not closed + +### Subagent cascade + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6 already takes a position: a child session is its own +logical stream, linked by two typed facts recorded on each side +(`DelegationDispatched` on the parent, `ParentLinked` on the child); +terminal cascade and rewind invalidation are separate, distinct reconciled +batches, governed by `CascadePolicy`; and acyclicity holds by construction +because `DispatchDelegation` always mints a fresh `child_session_id`. The +question is whether Mastra's evidence validates, challenges, or refines +that position. + +**What Mastra does when a parent is deleted, rewound, or crashes while a +child is live.** There is no rewind concept in Mastra's storage layer at +all (per the dossier's [Rewind, checkpoints, and fork](./index.md#rewind-checkpoints-and-fork) +section), so Mastra offers no evidence whatsoever about rewind cascade; +that half of decision 6 is untested by this comparison, not validated or +challenged. On deletion, the evidence is direct and specific: none of the +four backends' `deleteThread` implementations reads the `forkedSubagent` +or `parentThreadId` metadata keys, confirmed by grep across `stores/` +returning zero hits for either string outside `packages/core`. Concretely, +deleting a parent thread whose `packages/core/src/agent-controller/agent-controller.ts:1845-1861`-created fork children +still exist leaves those children's `metadata.parentThreadId` pointing at +a thread that no longer exists, with no detection and no cleanup path +observed in any backend's `deleteThread`. On crash: `HarnessStorage` has no +`deleteSession` and no cascade hook of any kind +(`packages/core/src/storage/domains/harness/base.ts:1-91`), and +`subagentDepth`, the one schema field that suggests an intended nesting +bound, is not incremented or checked anywhere in `packages/core/src` per +the dossier's own confirmed grep; the actual depth-one limit is enforced +by instructing the model not to recurse in a system prompt +(`packages/core/src/agent-controller/tools.ts:25`), not by any storage-layer +guard. + +**Does this validate, challenge, or refine decision 6?** It validates the +core structural claim decision 6's Alternatives section already makes: +that a blind, storage-native cascade is not something to rely on for a +parent-child relationship with real invariants, and that cascade needs to +be an explicit, orchestrated concern rather than left to the substrate. +Mastra does not even attempt storage-native cascade here (no backend reads +the link metadata on delete), so unlike Letta's `RESTRICT`-plus-app-level- +orchestration precedent, Mastra is not independent positive evidence for +any particular cascade mechanism; it is evidence of what happens when no +cascade mechanism exists at all: permanent, silent orphaning, confirmed by +absence of code rather than inferred from a comment. This sharpens the +case for decision 6's reconciler-driven, transitive cascade +(`[ParentTerminated, SessionCancelled]`, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) lines 773-791) as +something the industry has not converged on even at the level of "detect +the orphaning," let alone "guard it before it happens." Mastra is silent on +rewind invalidation specifically (it has no rewind primitive to compare +against), and its prompt-text depth limit offers no evidence for or against +our construction-based acyclicity guarantee, since Mastra's limit is +advisory (a system-prompt instruction) rather than structural. + +### Retention on an unbounded log + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7 already takes a position: keep-forever, with +`SessionHidden` as a visibility tombstone, `RedactionApplied` for read-time +masking, `ArtifactErased` for out-of-band artifact-byte destruction, and +aggregate snapshots that bound replay cost, not storage size. The question +is whether Mastra's evidence validates, challenges, or refines that design. + +**What Mastra does.** Retention is real, but it is the product's +responsibility, never the store's: `RetentionConfig`/`TableRetentionPolicy`/ +`PruneOptions` (`packages/core/src/storage/retention.ts:19-97`) let a caller +configure an age-based `maxAge` per table per domain, and +`MastraCompositeStore.prune()` (`packages/core/src/storage/base.ts:479-502`) +runs it, cooperatively and resumably (`maxBatches`/`maxRows`/`pauseMs`/ +`AbortSignal`, `packages/core/src/storage/retention.ts:53-84`), but nothing inside the library calls +`prune()` on any schedule; a deployment must wire that up itself. Deletion +is a real, hard row delete, not a mask: `TableRetentionPolicy.maxAge`-driven +pruning permanently removes rows, and the doc comment states plainly that +`prune()` "only deletes rows: it never reclaims disk," leaving actual disk +reclamation to the underlying database and its operator +(`packages/core/src/storage/retention.ts:48-51`). There is no redaction concept anywhere in the +surveyed code: a row is either present in full or gone. Delete cascade is +also scoped narrowly, and inconsistently across backends: Postgres's +`deleteThread` is the one observed backend that also sweeps vector-index +tables (`stores/pg/src/storage/domains/memory/index.ts:772-786`, scanning +`pg_tables` for `memory_messages%`), while libsql, dynamodb, and mongodb's +`deleteThread` implementations touch only their own message and thread +tables, per the dossier's [Retention, deletion, and multi-host](./index.md#retention-deletion-and-multi-host) +section. And separate from any explicit retention policy, cascade never +crosses the fork link either way, matching the same absence of cascade +already documented under "Subagent cascade" above. + +**Does this validate, challenge, or refine decision 7?** It refines +decision 7 by sharpening what the added complexity of a three-tier privacy +contract buys over Mastra's default. Mastra's model gives a caller exactly +two states for any given row: present in full, or permanently, physically +gone once a `maxAge`-based prune reaches it. There is no masking tier and +no artifact-byte-only erasure tier: `RedactionApplied` +(`proto/trogonai/session/sessions/v1alpha1/redaction_applied.proto:1-20`) and `ArtifactErased` +(`proto/trogonai/session/sessions/v1alpha1/artifact_erased.proto:1-18`) have no Mastra analogue at all. This is not +evidence that decision 7's finer granularity is unnecessary; it is evidence +that even a mature, shipped, multi-backend store with an explicit, +documented retention API still treats "keep" and "delete" as the only two +states, which sharpens exactly what decision 7's masking and byte-erasure +tiers add relative to the industry default: a middle ground between +"everything, unmasked, forever" and "gone." Mastra also validates the +"pruning must be cooperative and resumable, never a single unbounded +sweep" principle decision 7's cold-tiering language shares in spirit (the +`Object Store` restore path, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) lines 915-921), independently +arriving at `maxBatches`/`pauseMs`/`AbortSignal` for the same reason we +would need equivalent bounds on any batched, resumable operation over a +large log. + +One point is genuinely new evidence from Mastra, not already covered by +Letta's comparison: retention being purely a product-owned, unscheduled +concern (nothing in the library calls `prune()` on a timer) means the +actual growth-bound guarantee for any Mastra deployment depends entirely on +whether the deploying team remembered to wire up a scheduler. Decision 7 +does not have this exposure, because keep-forever is the explicit, stated +default with no retention job required for correctness; a deployment that +never wires anything up gets exactly the behavior decision 7 already +specifies (nothing is pruned), rather than an unbounded table nobody +noticed was never being pruned. This is presented as validation of +decision 7's choice to make "never delete" the unconditional default rather +than an opt-out from a scheduled job that a deployment might simply forget +to configure. + +## Open questions for the ADR + +1. Should [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) or a follow-up name, explicitly, a repo-level check that + every read path sorting events for a session must use `SessionOrdinal` + with no independently-chosen tiebreak, given Mastra's four internally + inconsistent sort call sites in one backend file? (Recommendation 1.) +2. Should the ADR state, as a standing constraint on future commands, that + no atomic multi-fact operation may ever be implemented as sequential + independent writes with an application-level compensating rollback, + given DynamoDB's `saveMessages` rollback whose own failure path is + silent? (Recommendation 2.) +3. If our system ever needs more than one mechanism to express a + parent-child relationship (for a debugger view, an export tool, or an + analytics pipeline), should the ADR require that mechanism to be built + on `ParentLinked`/`DelegationDispatched` rather than added as an + independent field, given that Mastra's three coexisting, mutually-unaware + mechanisms are the direct product of not requiring this? (Recommendation + 3.) +4. Does the platform need a caller-facing, explicitly-bounded "recent tail" + read query, distinct from full aggregate resume, for callers that need + cheap recent context without a correctness guarantee, the way Mastra's + `MessageHistory` default serves that need today? If added, how should it + be named and documented so it is never mistaken for resumed state? + (Recommendation 4.) +5. Mastra's retention is entirely product-scheduled, with no default + pruning job and no masking tier. Decision 7 already avoids the + "forgot to schedule the job" exposure by making keep-forever the + unconditional default; does the ADR need to say anything more about how + a future cold-tiering job ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) lines 915-921) should behave if a + deployment never enables it, to preserve that same "correct even if + nobody configures anything" property? +6. Mastra's cascade evidence is entirely an absence (no backend's + `deleteThread` reads fork-link metadata) rather than a positive + alternative design. Is there a case in the corpus so far where deletion + cascade for a subagent-style link has been implemented correctly at the + storage layer, or does this remain, across every product surveyed, a gap + nobody has actually solved rather than solved differently? diff --git a/docs/research/session-store/products/openai-agents-sdk/index.md b/docs/research/session-store/products/openai-agents-sdk/index.md new file mode 100644 index 000000000..f8ed125af --- /dev/null +++ b/docs/research/session-store/products/openai-agents-sdk/index.md @@ -0,0 +1,860 @@ +# OpenAI Agents SDK (Python): 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/openai-agents-python` +at commit `7b7587425a17676f5a713d346abec76db30e0eab` (committed 2026-08-04, +license MIT, package `openai-agents` version `0.19.2` per `pyproject.toml`). +Every citation below was verified against this exact commit on 2026-08-04. +Paths are relative to the repository root (`src/agents/...`, `docs/...`) +unless stated otherwise; citations use `path:line` shorthand. + +Authoritative anchors: + +- `openai/openai-agents-python` @ `7b7587425a17676f5a713d346abec76db30e0eab` +- `src/agents/memory/session.py` (the `Session` protocol/ABC) +- `src/agents/memory/sqlite_session.py` (default local backend) +- `src/agents/memory/session_settings.py`, `src/agents/memory/util.py` +- `src/agents/run_internal/session_persistence.py` (Runner-facing + append/read/rewind/compaction orchestration) +- `src/agents/extensions/memory/**` (`SQLAlchemySession`, `RedisSession`, + `MongoDBSession`, `DaprSession`, `AdvancedSQLiteSession`, + `AsyncSQLiteSession`, `EncryptedSession`) +- `src/agents/extensions/experimental/codex/**` (subprocess wrapper around + the `codex` CLI binary -- the sole discovered interop surface with Codex CLI) +- `docs/sessions/index.md`, `docs/ref/memory.md` + +> Scope note. This dossier is written specifically to answer a comparison +> question already raised in this corpus: OpenAI ships **Codex CLI** +> (documented at +> [`../codex-cli/index.md`](../codex-cli/index.md)), whose durable record is +> an append-only JSONL rollout log with a derived SQLite projection, and it +> *separately* ships this Agents SDK with a `Session` abstraction whose +> reference backend is a plain SQLite table storing JSON blobs with no +> append-only guarantee at all. Both products live in the same GitHub org +> (`openai`) and the same commit history era, but -- as documented below -- +> they share no code, no on-disk format, and no store-level interop path. +> The only bridge between them is a CLI/subprocess wrapper +> (`src/agents/extensions/experimental/codex/`) that shells out to the +> `codex` binary and tracks its opaque `thread_id` string; it does not read +> or write Codex's rollout files, and the Agents SDK's `Session` protocol is +> never used to store or serve Codex CLI data. Any explanation of *why* +> OpenAI maintains two incompatible session models is not attempted here; +> only what and where they differ is documented, and it is marked +> **[inference]** wherever this dossier goes beyond what the source shows. + +## The storage model + +The SDK's own docs describe the durable session as a conversation-item list, +not a log or event stream: + +> "Sessions stores conversation history for a specific session, allowing +> agents to maintain context without requiring explicit manual memory +> management." (`docs/sessions/index.md:5`) + +> "Before each run: The runner automatically retrieves the conversation +> history for the session and prepends it to the input items. After each +> run: All new items generated during the run (user input, assistant +> responses, tool calls, etc.) are automatically stored in the session." +> (`docs/sessions/index.md:66-67`) + +There is no single canonical storage format -- the `Session` protocol +(`src/agents/memory/session.py:13-54`) is pluggable, and the SDK ships at +least nine concrete backends with materially different physical models: + +- **`SQLiteSession`** (`src/agents/memory/sqlite_session.py:17`) -- a SQLite + table of JSON-blob rows, one row per conversation item, WAL-mode, default + `:memory:` unless a file path is given (`src/agents/memory/sqlite_session.py:30-33`). +- **`AsyncSQLiteSession`** -- same physical model via `aiosqlite` + (`src/agents/extensions/memory/async_sqlite_session.py`, header confirmed; + not read in full). +- **`AdvancedSQLiteSession`** (`src/agents/extensions/memory/advanced_sqlite_session.py:1`, + class declared subclassing `SQLiteSession`) -- the same row-per-item table + plus two additional tables, `message_structure` and `turn_usage`, that + index/annotate the same rows for branching and usage analytics + (`src/agents/extensions/memory/advanced_sqlite_session.py:43-160`). +- **`SQLAlchemySession`** (`src/agents/extensions/memory/sqlalchemy_session.py`) + -- any SQLAlchemy-async-capable SQL database (Postgres, MySQL, SQLite via + `aiosqlite`), real transactions, `busy_timeout` tuning + (`src/agents/extensions/memory/sqlalchemy_session.py`, `_SQLITE_BUSY_TIMEOUT_MS = 5000`). +- **`RedisSession`** (`src/agents/extensions/memory/redis_session.py`) -- Redis + keys/lists, headers read, full implementation not read line-by-line. +- **`MongoDBSession`** (`src/agents/extensions/memory/mongodb_session.py`) -- + two Mongo collections, `agent_sessions` and `agent_messages`, each message + document carrying a monotonically increasing `seq` field for ordering + across concurrent writers (`docs/sessions/index.md:453`). +- **`DaprSession`** (`src/agents/extensions/memory/dapr_session.py`) -- a Dapr + state-store key/value entry, backend-agnostic (30+ possible physical + stores behind Dapr), with ETag-based optimistic concurrency and optional + TTL (`docs/sessions/index.md:391-421`). +- **`OpenAIConversationsSession`** (`src/agents/memory/openai_conversations_session.py:1-139`) + -- no local storage at all; every item lives server-side in OpenAI's + Conversations API. The class is a thin RPC client, not a local store. +- **`OpenAIResponsesCompactionSession`** (`src/agents/memory/openai_responses_compaction_session.py:1-534`) + -- not a storage backend itself, a *decorator* that wraps any other + `Session` and periodically replaces its contents via `responses.compact`. +- **`EncryptedSession`** (`src/agents/extensions/memory/encrypt_session.py:1-214`) + -- also a decorator: wraps any other `Session`, storing Fernet-encrypted + envelopes instead of plaintext JSON in whatever the underlying backend is. + +Given this, the single most accurate general description is: **the durable +session is a conversation-item list (an ordered set of opaque JSON blobs) +whose physical representation is entirely backend-dependent, and none of the +built-in backends store an append-only event log** -- the two operations +that mutate history after the fact (`pop_item`, used for rewind and +corrections, and `run_compaction`, used for compaction) are implemented as +destructive deletes/rewrites in every backend read for this dossier, not as +appended tombstone/marker records. This is the central contrast with Codex +CLI's rollout log, which never deletes a line and instead appends +`Compacted`/`ThreadRolledBack` markers +(see [`../codex-cli/index.md`](../codex-cli/index.md)). + +There is no authoritative/derived split inside a single backend the way +Codex CLI has (JSONL log authoritative, SQLite `state` db derived). Each +Agents-SDK backend's row set/document *is* the authoritative record; the +only backend with an explicit "derived" concept is `AdvancedSQLiteSession`'s +`message_structure` table, which is a same-database indirection table over +the shared `agent_messages` rows for branching, not a separate rebuildable +projection (see **Rewind, checkpoints, and fork** below). + +Conceptual-model fit: **session-as-row-set / session-as-document**, +depending on backend -- never session-as-append-only-log in the built-in +backends. (The one exception found is the `examples/memory/file_session.py` +sample, which is explicitly a full-file JSON array rewrite on every write, +i.e. session-as-mutable-document, and is example code rather than a shipped +backend.) + +## Keying and identity + +- A session is addressed by a single opaque `session_id: str`, declared + directly on the `Session` protocol (`src/agents/memory/session.py:21`) and + on `SessionABC` (`src/agents/memory/session.py:67`). There is no + project/workspace/cwd component in the protocol itself -- any such + namespacing is left to the caller (e.g. by choosing `sessions_table`/ + `messages_table` names or a `db_path`, or by prefixing the `session_id` + string). +- Session ids are **caller-supplied**, not minted by the SDK. Every + constructor takes `session_id` as a required first argument (e.g. + `SQLiteSession.__init__(self, session_id: str, ...)`, + `src/agents/memory/sqlite_session.py:31-32`). There is no UUID generation, + no ordering-encoding scheme, and no client-vs-server minting distinction -- + identity is whatever string the application chooses + (`docs/sessions/index.md:513-519` recommends patterns like `"user_12345"` + or `"thread_abc123"` but these are conventions, not enforced formats). +- `OpenAIConversationsSession` is the one exception: it can either mint a + new server-side conversation (`conversation_id=None`, lazily created on + first use) or resume an existing one by passing `conversation_id=...` + (`src/agents/memory/openai_conversations_session.py`, `docs/sessions/index.md:236-237`). + In that backend the durable identity lives entirely in OpenAI's Conversations + API, not in the SDK. +- **No listing/enumeration API exists anywhere in the core protocol.** The + `Session` Protocol and `SessionABC` expose only `get_items`, `add_items`, + `pop_item`, `clear_session` (`src/agents/memory/session.py:24-54`, + `:70-104`) -- there is no `list_sessions`/`list_session_ids` method, and a + repo-wide search for such names in `src/agents/` returns no hits. Listing, + if needed, is entirely up to the chosen backend's native tooling (e.g. + querying the SQLite `agent_sessions` table directly, or the Mongo + `agent_sessions` collection) -- it is not a protocol-level concept. +- Because `session_id` is a plain, uninterpreted string, there is no + concept of relocation/rename reconciliation (no cwd, no worktree, no + encoded path) -- nothing to reconcile at the identity layer. **[inference]** + This is a natural consequence of the protocol treating identity purely as + an opaque key handed to whatever backend, with no filesystem or + project-path semantics baked in anywhere in `src/agents/memory/`. + +## The store interface + +The protocol is pluggable and is captured **verbatim** below, exactly as +defined in `src/agents/memory/session.py:13-104`: + +```python +@runtime_checkable +class Session(Protocol): + """Protocol for session implementations. + + Session stores conversation history for a specific session, allowing + agents to maintain context without requiring explicit manual memory management. + """ + + session_id: str + session_settings: SessionSettings | None = None + + async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: + """Retrieve the conversation history for this session. + + Args: + limit: Maximum number of items to retrieve. If None, retrieves all items. + When specified, returns the latest N items in chronological order. + + Returns: + List of input items representing the conversation history + """ + ... + + async def add_items(self, items: list[TResponseInputItem]) -> None: + """Add new items to the conversation history. + + Args: + items: List of input items to add to the history + """ + ... + + async def pop_item(self) -> TResponseInputItem | None: + """Remove and return the most recent item from the session. + + Returns: + The most recent item if it exists, None if the session is empty + """ + ... + + async def clear_session(self) -> None: + """Clear all items for this session.""" + ... +``` + +(`src/agents/memory/session.py:13-54`) + +`SessionABC` (`src/agents/memory/session.py:57-104`) is an identical +`abc.ABC`-based restatement of the same four abstract methods, "intended +for internal use and as a base class for concrete implementations" while +"third-party libraries should implement the `Session` protocol instead" +(`src/agents/memory/session.py:63-64`). All four methods are `async` and +**all four are required** -- there is no optional method in the base +contract. + +One optional extension protocol exists, layered on top of `Session`: + +```python +@runtime_checkable +class OpenAIResponsesCompactionAwareSession(Session, Protocol): + """Protocol for session implementations that support responses compaction.""" + + async def run_compaction(self, args: OpenAIResponsesCompactionArgs | None = None) -> None: + """Run the compaction process for the session.""" + ... +``` + +(`src/agents/memory/session.py:131-137`), detected at runtime via +`is_openai_responses_compaction_aware_session()` +(`src/agents/memory/session.py:140-150`), which does a `getattr`/`callable` +duck-type check rather than an `isinstance` check on the `Protocol` (an +`isinstance` check against a `@runtime_checkable` `Protocol` only verifies +method *presence*, not signature -- the helper still relies on structural +typing, consistent with the rest of the module). + +Two instance attributes are part of the protocol surface but not methods: +`session_id: str` (required) and `session_settings: SessionSettings | None = None` +(optional, defaults to `None` at the protocol level; +`src/agents/memory/session.py:21-22`). `SessionSettings` +(`src/agents/memory/session_settings.py:30-64`) is a pydantic dataclass with +a single documented field today, `limit: int | None = None` +("Maximum number of items to retrieve. If `None`, retrieves all items.", +`src/agents/memory/session_settings.py:38-39`), plus a `.resolve(override)` +method that overlays non-`None` fields from a per-call override onto the +session's default settings (`src/agents/memory/session_settings.py:41-60`) -- +used by `RunConfig.session_settings` to override a session's default +`limit` on a single `Runner.run` call (`docs/sessions/index.md:110-133`). +`resolve_session_limit(explicit_limit, settings)` +(`src/agents/memory/session_settings.py:18-27`) picks an explicit +per-call `limit` first, else falls back to the session's own settings, else +`None` (unbounded). + +Call sites confirming when each method fires (all in +`src/agents/run_internal/session_persistence.py`, the Runner-facing +orchestration layer -- see next two sections for detail): + +| Operation | Caller | path:line | +|---|---|---| +| `get_items` | `prepare_input_with_session` (pre-run history fetch) | `src/agents/run_internal/session_persistence.py:189,191` | +| `add_items` | `save_result_to_session` (post-turn append) | `src/agents/run_internal/session_persistence.py:452` | +| `pop_item` | `_rewind_session_tail_suffix` (retry/rewind path) | `src/agents/run_internal/session_persistence.py:531,555` and `:761` (helper, read but not quoted here) | +| `clear_session` + `add_items` | `OpenAIResponsesCompactionSession.run_compaction` (destructive replace) | `src/agents/memory/openai_responses_compaction_session.py` (method body, full file read) | + +## Write and append path (ordering, durability, concurrency, delivery) + +**Ordering.** In the reference `SQLiteSession`, ordering is the SQLite +`INTEGER PRIMARY KEY AUTOINCREMENT` column `id` on the messages table +(`src/agents/memory/sqlite_session.py:162`), populated purely by insertion +order (`ORDER BY id ASC`/`DESC` in every read query, +`src/agents/memory/sqlite_session.py:237,252,271`). There is no +client-supplied sequence number or timestamp used for ordering -- the +`created_at` columns exist (`src/agents/memory/sqlite_session.py:153,165`) +but are not read back anywhere in this file. `MongoDBSession` instead +attaches "an atomic sequence counter" (`seq`) per message document +specifically because Mongo has no auto-increment primary key +(`docs/sessions/index.md:209,453`) -- the same ordering *guarantee*, a +different *mechanism*, because the backend lacks a native monotonic key. + +**Durability/atomicity -- `SQLiteSession`.** `add_items` wraps +`_insert_items` + `conn.commit()` in a `try/except` that explicitly calls +`conn.rollback()` on any exception, with a comment spelling out why: + +> "`_locked_connection()` does not manage transactions; roll back +> explicitly so a failure partway through the insert never leaves a partial +> mutation or an open transaction on this cached connection. An open write +> transaction would hold the SQLite write lock for the lifetime of the +> connection and block every later writer." +> (`src/agents/memory/sqlite_session.py:295-299`) + +`_insert_items` itself does three statements inside the (manually managed) +transaction: `INSERT OR IGNORE` the session row, `executemany` the batch of +message rows, then `UPDATE ... SET updated_at = CURRENT_TIMESTAMP` +(`src/agents/memory/sqlite_session.py:181-204`) -- all items in one +`add_items` call are appended as a single batch/transaction, not one +transaction per item. + +**Concurrency -- `SQLiteSession`.** Concurrency is handled by an +**in-process** lock, not a SQLite-level primitive: a per-resolved-file-path +`threading.RLock`, shared across all `SQLiteSession` instances pointed at +the same file via class-level `_file_locks`/`_file_lock_counts` dictionaries +guarded by `_file_locks_guard` (`src/agents/memory/sqlite_session.py:26-28,91-116`). +Every DB operation runs inside `_locked_connection()` +(`src/agents/memory/sqlite_session.py:117-121`), which just acquires that +lock and yields a connection -- so within one Python process, writers to the +same file are fully serialized. `PRAGMA journal_mode=WAL` is set on every +connection opened (`src/agents/memory/sqlite_session.py:76,82,138`), but +**no `PRAGMA busy_timeout` is set anywhere in `sqlite_session.py`** (confirmed +by reading the file in full -- the string `busy_timeout` does not appear). +This means: for **two separate processes** writing the same SQLite file +concurrently, `SQLiteSession` has no application-level defense against +`SQLITE_BUSY` beyond whatever behavior WAL mode's default (effectively zero) +busy handler gives it -- cross-process contention could raise immediately +rather than retry. By contrast, `SQLAlchemySession` explicitly sets +`_SQLITE_BUSY_TIMEOUT_MS = 5000` and layers a bounded exponential-backoff +retry loop on top (`_SQLITE_LOCK_RETRY_DELAYS = (0.05, 0.1, 0.2, 0.4, 0.8)`, +`src/agents/extensions/memory/sqlalchemy_session.py`) specifically to +tolerate "database is locked" errors -- a materially more robust concurrency +story than the base backend, for the SQLite dialect. `DaprSession` is the +only backend offering true optimistic concurrency control: writes carry an +ETag precondition against the underlying state store, with a +`consistency=DAPR_CONSISTENCY_STRONG` option available for stronger +read-after-write guarantees (`docs/sessions/index.md:405-419`; source file +`src/agents/extensions/memory/dapr_session.py` header/API read, not read in +full). + +**Delivery semantics / idempotence.** Above the backend layer, the Runner +orchestration in `src/agents/run_internal/session_persistence.py` treats +`add_items` as at-least-once and builds its own client-side dedup on top, +because retries (e.g. after a network error mid-turn) could otherwise +double-append. `save_result_to_session` +(`src/agents/run_internal/session_persistence.py:350-492`) tracks +`run_state._current_turn_persisted_item_count` to avoid re-sending +already-persisted items on retry (`:367,376,449,455`), and separately +computes a **content fingerprint** for every candidate item via +`fingerprint_input_item`/`_fingerprint_or_repr` +(`src/agents/run_internal/session_persistence.py:411-431`, +`src/agents/run_internal/items.py:334-369`) before calling +`deduplicate_input_items_preferring_latest` +(`src/agents/run_internal/session_persistence.py:423`). `fingerprint_input_item` +strips internal metadata and (optionally) the `id` field, then returns +`json.dumps(payload, sort_keys=True, default=str)` +(`src/agents/run_internal/items.py:334-369`) -- a normalized-JSON string, not +a hash. A related helper, `digest_input_item` +(`src/agents/run_internal/items.py:372-391`), additionally SHA-256-hashes +that fingerprint string (`hashlib.sha256(fingerprint.encode("utf-8")).hexdigest()`, +`src/agents/run_internal/items.py:391`) for "durable occurrence tracking." +**There is no store-assigned entry id used for identity** -- identity for +dedup/rewind purposes is entirely **content-fingerprint-based**, computed +by the Runner layer, not by any backend. + +## Read and resume path + +`prepare_input_with_session` +(`src/agents/run_internal/session_persistence.py:157-290`) is the sole +resume entry point used by the Runner. On every call it does a **full or +limit-bounded `get_items` read** -- not a cursor/offset read -- then merges it +with the new turn's input: + +```python +if resolved_settings.limit is not None: + history = await session.get_items(limit=resolved_settings.limit) +else: + history = await session.get_items() +``` +(`src/agents/run_internal/session_persistence.py:188-191`) + +There is no separate "cache" read on resume -- the session's `get_items` call +*is* the read path; nothing is read from a local filesystem cache first +(confirmed: no cache/memoization wrapper exists around `get_items` in this +file). The docs describe the same two-step model narratively: + +> "1. Session history (retrieved from `session.get_items(...)`) 2. New turn +> input" (`docs/sessions/index.md:76-77`) + +**Pagination/size bound.** `get_items(limit=...)` is the only bound offered +by the protocol, and it is a **tail-window count**, not a byte/offset +pagination cursor -- "retrieves the latest N items" per the protocol +docstring (`src/agents/memory/session.py:28-29`). In `SQLiteSession`, when +`session_limit > 0`, the implementation does a `SELECT ... ORDER BY id DESC +LIMIT ?` window, decodes rows, and **doubles the window and retries** if +fewer than `session_limit` valid (non-corrupt) items came back, to guarantee +"limit counts valid conversation items" +(`src/agents/memory/sqlite_session.py:243-263`, comment at `:244-245`). +Corrupt/non-JSON rows are silently skipped during decode +(`src/agents/memory/sqlite_session.py:218-227`, `except (json.JSONDecodeError, TypeError): continue`). +When `limit is None`, it is a single unbounded `SELECT ... ORDER BY id ASC` +(`src/agents/memory/sqlite_session.py:231-241`) -- the entire history is +materialized into memory in one call; there is no streaming/incremental +read. + +**`RunConfig.session_input_callback`** (`SessionInputCallback`, +`src/agents/memory/util.py:8-11`) lets a caller intercept the merge of +`history` and `new_input` immediately before the model call -- "Use this +when you need custom pruning, reordering, or selective inclusion of +history without changing how the session stores items" +(`docs/sessions/index.md:108`). Critically, this callback affects only the +*model-visible* input for that turn; it does not change what gets persisted +-- `prepare_input_with_session` separately tracks which combined-output items +came from `new_input` vs. `history` (via reference/frequency maps, +`src/agents/run_internal/session_persistence.py:226-263`) specifically "so +retries and custom merge strategies do not accidentally re-persist old +history as fresh input" (`src/agents/run_internal/session_persistence.py:173-178`). +This is the one mechanism in the SDK that lets an application-level caller +bound what the *model* sees without bounding what the *store* holds -- i.e. +model-context trimming and store growth are explicitly decoupled here. + +## Listing, summaries, and search + +There is **no listing/summary/search subsystem in the core `Session` +protocol** (see **Keying and identity** above -- no `list_sessions` method +exists anywhere in `src/agents/memory/` or `src/agents/run_internal/`). +Backend-specific facilities exist only in the two SQLite variants: + +- `AdvancedSQLiteSession.find_turns_by_content(...)` does a plain SQL `LIKE` + substring scan over the `message_structure`/`agent_messages` tables + (`src/agents/extensions/memory/advanced_sqlite_session.py`, method read in + the 1090-1165 range) -- there is no full-text-search (FTS) index and no + vector index; it is a linear `LIKE` query. +- `AdvancedSQLiteSession.list_branches(...)` + (`src/agents/extensions/memory/advanced_sqlite_session.py`, 808-960 range) + enumerates branch metadata from the `message_structure` table -- this is + branch listing within one session, not cross-session listing. + +No backend maintains a separate denormalized "summary" read-model at write +time analogous to Codex CLI's SQLite `state` projection +(see [`../codex-cli/index.md`](../codex-cli/index.md)). **[inference]** +The absence of any cross-session listing primitive in the base protocol +suggests session enumeration is treated entirely as an application/hosting +concern outside the SDK's scope -- every backend that supports it does so by +exposing its own native storage medium (SQL table, Mongo collection) for the +caller to query directly, not through a protocol-level API. + +## Entry/message structure and versioning + +**Entry type.** The item type stored and returned by every backend is +`TResponseInputItem`, which is a type alias, not an SDK-defined class: + +```python +TResponseInputItem = ResponseInputItemParam +``` +confirmed at `src/agents/items.py:76` -- `ResponseInputItemParam` is an +OpenAI Python SDK (`openai` package) type, i.e. the **wire shape of OpenAI's +Responses API input items**, not an Agents-SDK-specific envelope. There is +no Agents-SDK-level wrapper (no `{type, timestamp, id}` outer envelope +added by the store) -- every backend persists and returns these items +directly. + +**Opaque vs. parsed.** `SQLiteSession` treats items as fully opaque blobs: +`json.dumps(item)` on write (`src/agents/memory/sqlite_session.py:189`), +`json.loads(message_data)` on read with no schema validation +(`src/agents/memory/sqlite_session.py:222`) -- the store never inspects +field contents except to catch decode failures. The one place the *Runner* +(not the store) inspects item shape is the content-fingerprinting/dedup +logic described above, and `OpenAIConversationsSession`'s +`_sanitize_openai_conversation_item`/`_is_unpersistable_for_openai_conversation` +helpers (`src/agents/run_internal/session_persistence.py:697-727`, read), +which strip or reject certain items before sending them to OpenAI's server +because that particular backend's server enforces its own item-shape rules. +So: **opaque at the local-backend layer, lightly parsed at the Runner +orchestration layer for identity/compat reasons, and inspected by the +server for the `OpenAIConversationsSession` backend specifically.** + +**Versioning.** No schema-version field was found anywhere in +`src/agents/memory/` or `src/agents/run_internal/session_persistence.py` -- +neither an item-level `schema_version` nor a store-format version stamp. +Because the entry format is simply "whatever `ResponseInputItemParam` is in +the currently installed `openai` package version," format evolution is +implicitly tied to the `openai` SDK's own versioning, not to anything the +Agents SDK's session layer manages. This is stated as a **gap** (see **Open +questions**) rather than a confirmed absence of any version-compat handling +elsewhere in the `openai` package, which was out of scope for this +checkout. + +## Compaction and history management + +There is **no compaction in the store/protocol layer** -- `Session`, +`SessionABC`, and every plain backend (`SQLiteSession`, `RedisSession`, +`MongoDBSession`, `SQLAlchemySession`, `DaprSession`, +`OpenAIConversationsSession`) hold every item indefinitely; nothing trims +them. Growth is bounded only by two things: + +1. **Retrieval-time limiting** -- `SessionSettings.limit`/ + `get_items(limit=...)` bounds what is *read into the model context* per + turn, not what is stored (`src/agents/memory/session_settings.py:38-39`, + `docs/sessions/index.md:110-133`). The underlying row/document count is + unaffected. +2. **`OpenAIResponsesCompactionSession`** -- an explicit opt-in decorator + (`src/agents/memory/openai_responses_compaction_session.py:1-534`) that + wraps another `Session` and can trim it. This is the only place actual + compaction logic lives, and it is emphatically **not part of the store + layer** -- it is a separate class layered on top, matching the research + brief's framing. + +`OpenAIResponsesCompactionSession` mechanics: `DEFAULT_COMPACTION_THRESHOLD = 10` +candidate items trigger auto-compaction after a turn by default (checked via +`should_trigger_compaction`, overridable per-instance, +`docs/sessions/index.md:277,298`). `select_compaction_candidate_items` +excludes user messages and items that are themselves prior compaction +summaries from the candidate set (full file read). `run_compaction(args)` +calls OpenAI's `responses.compact` API to get a compacted summary, then +performs a **destructive replace of the underlying session**: it calls the +underlying session's `clear_session()` and then `add_items()` with the new +(compacted) item set, with compensating restore-on-failure logic if the +compact call itself fails partway (full file read, +`src/agents/memory/openai_responses_compaction_session.py`). This is +triggered from the Runner via `save_result_to_session`: + +```python +if response_id and is_openai_responses_compaction_aware_session(session): + ... + await session.run_compaction(compaction_args) +``` +(`src/agents/run_internal/session_persistence.py:457-490`) + +The docs explicitly warn this blocks streaming completion: "Compaction +clears and rewrites the session history, so the SDK waits for compaction to +finish before considering the run complete... `run.stream_events()` can +stay open for a few seconds after the last output token if compaction is +heavy" (`docs/sessions/index.md:284-285`). + +**Contrast with Codex CLI:** Codex's compaction leaves a `Compacted` marker +appended to the still-intact rollout log -- the original lines are never +deleted (see [`../codex-cli/index.md`](../codex-cli/index.md)). The Agents +SDK's only compaction mechanism is the opposite: a full destructive +`clear_session()` + `add_items()` cycle that leaves no trace of the +pre-compaction items in the store once it completes successfully. There is +no append-only marker anywhere in this SDK's compaction path. + +## Rewind, checkpoints, and fork + +**Rewind.** Implemented via repeated calls to `pop_item()`, which is +**destructive row/item deletion**, not an appended marker. In the reference +backend: + +```python +cursor = conn.execute( + f""" + DELETE FROM {self.messages_table} + WHERE id = ( + SELECT id FROM {self.messages_table} + WHERE session_id = ? + ORDER BY id DESC + LIMIT 1 + ) + RETURNING message_data + """, + (self.session_id,), +) +``` +(`src/agents/memory/sqlite_session.py:315-326`) -- an atomic +`DELETE ... RETURNING` in one statement, looped to skip past corrupt JSON +rows (`src/agents/memory/sqlite_session.py:332-354`). The Runner-level +`rewind_session_items`/`_rewind_session_tail_suffix` +(`src/agents/run_internal/session_persistence.py:519-621`, `:761` for the +suffix helper) calls `pop_item()` repeatedly to undo a specific +content-fingerprinted suffix of recently-added items when "a conversation +retry is needed, so we do not accumulate duplicate inputs on lock errors" +(`src/agents/run_internal/session_persistence.py:525-526`). This is +explicitly **best-effort**: it matches the current tail against expected +fingerprints and *skips* the rewind entirely with a warning if the tail +doesn't match (`:560-563`), and `wait_for_session_cleanup` +(`src/agents/run_internal/session_persistence.py:624-661`) polls +`get_items(limit=window)` up to 5 times to confirm the rewound items are +actually gone, rather than assuming a strong read-after-write guarantee -- +direct evidence that the store's consistency semantics under retry are not +fully trusted even by the SDK's own orchestration code. `pop_item` also +doubles as an explicit user-facing correction primitive: +"`pop_item` is particularly useful when you want to undo or modify the last +item in a conversation" (`docs/sessions/index.md:165-166`). + +**Checkpoints.** `RunState.to_json()`/`RunState.from_json()` +(`src/agents/run_state.py`, `to_json` at line 728, `from_string` at 1105, +`from_json` at 1146 per grep of `class RunState`) is an **application-managed, +out-of-band checkpoint** for interrupted/human-in-the-loop runs -- explicitly +separate from the `Session` protocol. The docs show the pattern: + +```python +result = await Runner.run(agent, "Delete temporary files...", session=session) +if result.interruptions: + state = result.to_state() + for interruption in result.interruptions: + state.approve(interruption) + result = await Runner.run(agent, state, session=session) +``` +(`docs/sessions/index.md:53-59`) -- the `state` object round-trips through +the caller's own persistence (e.g. serialized to a file, per +`examples/memory/file_hitl_example.py`, partially read), not through +`session.add_items`/`get_items`. This is **not file-state or diff-based +checkpointing** in the sense Codex CLI's environment snapshots are; it is a +serialized run-state object for resuming a paused agent loop. + +**Fork/branching.** Only `AdvancedSQLiteSession` implements branching, via +`create_branch_from_turn`/`create_branch_from_content`/`switch_to_branch`/ +`delete_branch`/`list_branches` +(`src/agents/extensions/memory/advanced_sqlite_session.py`, methods present +in the 808-1283 range read). The mechanism is a **shared-row-by-reference +fork**: `_copy_messages_to_new_branch` (same file) copies entries into the +`message_structure` indirection table for the new branch while the +underlying `agent_messages.message_id` rows are shared/reused rather than +duplicated -- conceptually the closest thing in this SDK to Codex CLI's +`history_base` shared-prefix pointer fork, but implemented as SQL rows in +one database rather than file lineage across files. A `_generation` counter +provides optimistic-concurrency protection between branch-pointer updates +and a concurrent `clear_session()` call (same file, 1229-1283 range read). +No other backend (SQLite base, Redis, Mongo, SQLAlchemy, Dapr, +OpenAIConversations) has any fork/branch concept at all. + +## Subagents and nested sessions + +This SDK has **two distinct "subagent" mechanisms with opposite durability +stories**: + +**Handoffs** -- the new agent takes over the *same* `Runner.run` loop and +the *same* session; there is no separate child session at all. The +handoff-history mapping comment states this directly: + +> "The mapped history is the exact model input. New items stay unchanged +> for session history." (`src/agents/handoffs/history.py:151-152`, inside +> `nest_handoff_history`, `src/agents/handoffs/history.py:83`) + +So a handoff's conversation is durable exactly to the extent the parent +run's session is durable -- it is one continuous append stream from the +store's point of view, with `HandoffCallItem`/`HandoffOutputItem` +(`src/agents/items.py`, read at lines 270-310) persisted as ordinary items +carrying `source_agent`/`target_agent` references, not as a separate +storage record. + +**Agents-as-tools** (`Agent.as_tool(...)`, +`src/agents/agent.py:575-597`) is architecturally different: it spawns a +fully separate nested `Runner.run(...)`/`Runner.run_streamed(...)` call +(`src/agents/agent.py:941-953` for the non-streaming path, `~859-871` for +the streaming path) with its **own, independent `session` parameter that +defaults to `None`**: + +```python +def as_tool( + self, + tool_name: str | None, + tool_description: str | None, + ... + session: Session | None = None, + ... +) -> FunctionTool: +``` +(`src/agents/agent.py:575-597`, `session` param at `:590`) + +```python +run_result = await Runner.run( + starting_agent=cast(Agent[Any], self), + input=resume_state or resolved_input, + ... + session=session, +) +``` +(`src/agents/agent.py:941-953`) + +Because `session=None` is the default and it is threaded straight through +to the nested `Runner.run` call, **a nested agent-as-tool run has no +durable session at all unless the caller explicitly constructs and passes +one.** By default its conversation state is purely runtime/ephemeral: only +the nested run's *final output string* (or the `custom_output_extractor`'s +result) round-trips back into the parent's session, as an ordinary +tool-call-output item -- the nested run's own intermediate steps are never +persisted anywhere unless the caller wires a `session=` explicitly. + +**On parent failure:** because there is no separate child session record by +default (agents-as-tools) and no separate session at all (handoffs), there +is nothing SDK-managed to cascade/orphan/reconcile. If the caller *did* +supply an explicit `session=` for a nested agent-as-tool run, that session +is an ordinary, independently-addressed `Session` instance the caller owns +-- the SDK does not establish or track any parent-child link between the +parent's session and that child session; a parent failure has no special +effect on it beyond whatever partial items were already `add_items`'d before +the failure (subject to the same best-effort rewind semantics described +above, if the caller's own retry path calls `rewind_session_items`). +**[inference]** No code path was found that deletes, orphans, or reconciles +a nested session on parent crash -- the absence appears to be because the +SDK does not model a parent-child session relationship as a first-class +concept at all, only as an implementation detail of whatever `session=` +value a caller happens to pass into `as_tool(...)`. + +There is also `max_concurrent_subagents: int | None` in +`src/agents/extensions/experimental/hosted_multi_agent/model.py` (grepped, +not read in full) -- this is a runtime concurrency-limiting config field for +an experimental hosted-multi-agent extension, not a storage/session concept; +noted here for completeness but not further analyzed (see **Open +questions**). + +## Retention, deletion, and multi-host + +**Retention/deletion** is entirely backend-specific; the protocol itself +offers only `clear_session()` (wipe everything for one `session_id`) with no +TTL/lifecycle concept at the protocol level. Backend behaviors observed: + +- `SQLiteSession.clear_session()` issues two `DELETE` statements (messages, + then the session row) inside the file's serialized lock, no soft-delete + (`src/agents/memory/sqlite_session.py:359-374`). +- `DaprSession` supports a `ttl=...` constructor option "to let the backing + state store expire old session data automatically when the store supports + TTL" (`docs/sessions/index.md:418`) -- the only backend with native + TTL-based retention. +- `EncryptedSession` layers its own `ttl` on top of any backend: encrypted + envelopes carry an expiry, and expired entries are silently skipped on + decrypt rather than actively purged (`src/agents/extensions/memory/encrypt_session.py:1-214`, + full file read) -- this is application-level silent-expiry, not + storage-level deletion. +- `OpenAIConversationsSession` has no local retention concept -- retention is + whatever OpenAI's Conversations API enforces server-side; out of scope for + this checkout. + +**Multi-host.** No backend in this SDK is designed around a single-host +assumption analogous to Codex CLI's design +(see [`../codex-cli/index.md`](../codex-cli/index.md)) -- quite the +opposite: `RedisSession`, `MongoDBSession`, `SQLAlchemySession` (against a +networked SQL server), and `DaprSession` are all explicitly positioned as +multi-process/multi-host-safe by their docs ("shared memory across +workers/services," `docs/sessions/index.md:207`; "multi-process storage," +`:209`; "cloud-native deployments," `:210`). `SQLiteSession` itself is the +one backend that is **not** advertised as multi-host-safe -- its +concurrency story (in-process `RLock`, no `busy_timeout`, see **Write and +append path**) is explicitly single-machine/single-file-oriented, and the +docs position it as "Local development and simple apps" +(`docs/sessions/index.md:205`). So: **multi-host support is a first-class, +per-backend design choice in this SDK, not a workaround** -- callers pick a +backend precisely on this axis, per the backend-selection table in +`docs/sessions/index.md:203-214`. + +## Interop with foreign session stores + +**Checked directly, not assumed.** A repository-wide case-insensitive +search for "codex" across `src/`, `docs/`, and `pyproject.toml` turns up +exactly one relevant hit outside of test files and unrelated model-name +strings (e.g. `"gpt-5.2-codex"` as a model identifier): the experimental +package `src/agents/extensions/experimental/codex/`. + +This package is a **subprocess/CLI wrapper around the actual `codex` +binary**, not a shared storage layer. `CodexExec.run` +(`src/agents/extensions/experimental/codex/exec.py`) builds a command line: + +```python +# Build the CLI args for `codex exec --experimental-json`. +command_args: list[str] = ["exec", "--experimental-json"] +... +command_args.extend(["resume", args.thread_id]) +``` +(`src/agents/extensions/experimental/codex/exec.py:62-63,108`) and spawns it +via `asyncio.create_subprocess_exec(...)` +(`src/agents/extensions/experimental/codex/exec.py:119`) -- i.e. it drives +Codex CLI as an external process over its `--experimental-json` protocol, +the same interface a human or script would use from a shell. `Thread` +(`src/agents/extensions/experimental/codex/thread.py:1-215`) wraps this +subprocess and captures a `ThreadStartedEvent` +(`src/agents/extensions/experimental/codex/events.py:14-15`, +`class ThreadStartedEvent(_DictLike): thread_id: str`) on first run +(`src/agents/extensions/experimental/codex/thread.py:155-158`), then uses +that same `thread_id` string on subsequent calls to `resume` that Codex CLI +thread, per the `["resume", args.thread_id]` argument above. + +The item vocabulary this extension deals with -- +`CommandExecutionItem`, `FileChangeItem`, `McpToolCallItem`, +`AgentMessageItem`, `ReasoningItem` +(`src/agents/extensions/experimental/codex/items.py`, lines 1-80 read) -- is +**structurally distinct** from `TResponseInputItem`/`ResponseInputItemParam` +used by the `Session` protocol; there is no shared type between the two +vocabularies. `codex_tool.py` +(`src/agents/extensions/experimental/codex/codex_tool.py`, lines 1-80 and +grep of `thread_id` usage) exposes this whole thing as a regular Agents-SDK +tool: `CodexToolResult(thread_id=...)` is returned as an ordinary function +tool output, which the calling agent's own `Session` then persists as a +normal opaque tool-call-output item -- the `thread_id` string is the *only* +thing that crosses from Codex's world into the Agents SDK's session, and it +crosses as an opaque string value inside an ordinary tool-output item, not +as a shared record format. + +**Conclusion, directly stated:** there is **no shared code, no shared +on-disk/wire format, and no store-level interop path** between the Agents +SDK's `Session` store and Codex CLI's JSONL rollout store. The Agents SDK +never reads or writes a Codex rollout file, and Codex CLI has no awareness +of the Agents SDK's `Session` protocol. The only connection is a +process-boundary integration: the experimental `codex` extension shells out +to the `codex` binary and tracks its `thread_id`, entirely at the CLI/RPC +level, never at the storage layer. This is a positive finding checked by +direct code inspection (full read of the six files in +`src/agents/extensions/experimental/codex/` plus a repo-wide grep), not an +assumption. + +No other foreign-store interop (e.g. reading LangChain, LlamaIndex, or any +other framework's session format) was found anywhere in `src/agents/`. + +## What this implies for our Session Store (our inference) + +**[inference]** In this SDK, "a stored session" is whatever a chosen +`Session` backend's `get_items`/`add_items`/`pop_item`/`clear_session` +implementation happens to persist -- the protocol defines an operational +contract, not a data model, and every shipped backend implements that +contract as a **mutable row-set or document**, not an append-only log. Two +operations that would be natural append-only-log candidates -- rewind and +compaction -- are both implemented destructively (row deletion via +`pop_item`, full clear-and-replace via `run_compaction`) in every backend +examined. There is no built-in backend in this SDK that is "close to an +append-only log with derived projections" the way this corpus's Codex CLI +dossier found Codex CLI's rollout+state-db design to be +(see [`../codex-cli/index.md`](../codex-cli/index.md)) -- the two products, +from the same vendor, sit at opposite ends of the append-only-vs-mutable +spectrum this research program cares about. For our own event-sourced +Session Store, the useful takeaways from this SDK are less about its +storage physics (which we should not imitate -- destructive rewind and +destructive compaction directly conflict with an event-sourced design) and +more about two narrower ideas worth stealing on their own merits: (1) a +narrow, uniform four-method store contract that many physically different +backends can implement without leaking backend-specific concerns into the +Runner, and (2) explicit decoupling of "what the model sees this turn" +(`session_input_callback`, `SessionSettings.limit`) from "what the store +holds," which is a useful separation of concerns regardless of whether the +store itself is a log or a document. + +## Open questions + +- `docs/ref/realtime/session.md` and `docs/ref/responses_websocket_session.md` + exist in the tree (2 and 3 lines respectively per a `wc -l` check) but were + **not read** in this pass -- it is not verified whether either describes a + session-adjacent concept relevant to this dossier (e.g. a realtime/ + websocket-specific session notion distinct from the `Session` protocol + covered here). Flagged rather than assumed to be irrelevant. +- The `hosted_multi_agent` experimental extension's `max_concurrent_subagents` + field (`src/agents/extensions/experimental/hosted_multi_agent/model.py`) was + only grepped, not read in full -- whether this experimental extension has + any deeper session-durability implication beyond a concurrency-limit + config field is not verified. +- `RedisSession`, `MongoDBSession`, and `DaprSession` were characterized from + their headers, the `docs/sessions/index.md` narrative, and partial reads, + not full line-by-line reads the way `sqlite_session.py`, + `sqlalchemy_session.py`, `openai_conversations_session.py`, + `openai_responses_compaction_session.py`, `encrypt_session.py`, and the + `codex` extension package were. Their exact retry/backoff semantics under + contention (beyond what `docs/sessions/index.md` states narratively) are + not independently confirmed from source in this dossier. +- No schema-version field was found in the item format or any backend's + schema (see **Entry/message structure and versioning**), but whether the + `openai` package's own `ResponseInputItemParam` type carries any + version-compat handling internally was out of scope for this checkout and + was not investigated. +- Whether any first-party tooling outside this SDK (e.g. a companion CLI or + admin tool) provides session listing/search across backends was not + investigated -- the finding above is limited to what `src/agents/` itself + exposes. +- `examples/memory/file_session.py` and `examples/memory/file_hitl_example.py` + were read only partially (first ~40-50 lines each); they are example code, + not shipped backends, and were used only to confirm the `RunState` + checkpoint pattern and to note the existence of a mutable-JSON-document + example backend -- not analyzed exhaustively. diff --git a/docs/research/session-store/products/openai-agents-sdk/vs-session-events.md b/docs/research/session-store/products/openai-agents-sdk/vs-session-events.md new file mode 100644 index 000000000..b06d1864f --- /dev/null +++ b/docs/research/session-store/products/openai-agents-sdk/vs-session-events.md @@ -0,0 +1,517 @@ +# OpenAI Agents SDK compared to our session event catalog + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT_COMPARISON](../../RESEARCH_PROMPT_COMPARISON.md). +Stage-one dossier: [OpenAI Agents SDK](./index.md). +Compared against `proto/trogonai/session/sessions/v1alpha1/` and [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) on 2026-08-04. + +**Store maturity: 5/12.** Evolution scars 0/3: no schema-version field was found +"anywhere in `src/agents/memory/` or `src/agents/run_internal/session_persistence.py`" +(the dossier's [Entry/message structure and +versioning](./index.md#entrymessage-structure-and-versioning) section), no +migration file, no legacy-format sniffing, and no format-version constant appear +anywhere in the dossier. Operational age 1/3: package version `0.19.2` per +`pyproject.toml` is young; the one concrete hardening-under-load evidence found +is `SQLAlchemySession`'s bounded exponential-backoff retry loop +(`_SQLITE_LOCK_RETRY_DELAYS = (0.05, 0.1, 0.2, 0.4, 0.8)`, +`src/agents/extensions/memory/sqlalchemy_session.py:69,123-132`) built +specifically "to tolerate 'database is locked' errors," a real but narrow concurrency fix; no +issue-tracker corroboration of corruption, growth, or lock-contention failure is +cited anywhere in the dossier, unlike Cline's `cline/cline#9011`. Exposure 2/3: +vendor-shipped by OpenAI with `SQLAlchemySession`, `RedisSession`, +`MongoDBSession`, and `DaprSession` explicitly positioned as multi-process/ +cloud-native-safe by the product's own docs (`docs/sessions/index.md:203-214`), +but three of those four backends were only "characterized from their headers... +and partial reads, not full line-by-line reads" (dossier, Open questions), and no +field-level adoption evidence (an issue report, a scale number) is cited anywhere. +Design independence 2/3: no evidence the store was forked from another product's +persistence code, but the dossier did not check whether the four-method `Session` +protocol shape is itself derivative of a common memory-abstraction pattern used +by comparable frameworks, so independence here is an absence-of-contrary-evidence +finding, not a directly confirmed one. + +This is below the maturity threshold RESEARCH_PROMPT_COMPARISON.md sets: a +recommendation supported only by a store scoring under 6 is **thin evidence** and +must not be presented as an industry norm. Every recommendation below is weighted +down accordingly, and cross-checked against a higher-scoring product's evidence +(mostly [Cline, 10/12](../cline/vs-session-events.md)) wherever the same failure +mode recurs there, which is noted explicitly each time it happens. + +## The one structural difference everything else follows from + +Two products from the same vendor, in the same commit-history era, sit at +opposite ends of the append-only-log-versus-mutable-store spectrum this research +program cares about, and the divergence is not an accident of tooling age. Codex +CLI's rollout log never deletes a line; retroactive operations are appended +markers (`ThreadRolledBack`, `Compacted{replacement_history}`) interpreted at +replay (see [`../codex-cli/index.md`](../codex-cli/index.md)). This Agents SDK's +`Session` protocol has exactly four methods, `get_items`, `add_items`, +`pop_item`, `clear_session` (`src/agents/memory/session.py:13-54`, dossier +verbatim), and every one of the nine shipped backends implements `pop_item` as a +destructive row/entry delete and `run_compaction` as a destructive +`clear_session()` + `add_items()` replace. Both are OpenAI products, both are +current, both are actively maintained; the difference is a deliberate design +choice made twice by the same organization, not a legacy-versus-modern artifact. +That makes this comparison's single most useful data point not "which store is +more mature" but "what does the vendor decide differently when it optimizes for +resumable rewind-as-audit-trail (Codex CLI) versus for a narrow, backend-agnostic +storage contract many different systems can implement (this SDK)." + +The structural fact everything else in this dossier follows from is where the +Agents SDK draws its store boundary: **identity, mutation-safety, and +consistency-under-retry are the caller's problem, not the store's.** The `Session` +protocol defines an operational contract (four verbs), not a data model, and it +places zero obligations on identity or idempotence. Nothing in `add_items` +promises exactly-once delivery; nothing in `pop_item` promises the deleted row +was the one the caller thinks it is once two writers race. The Runner +orchestration layer (`src/agents/run_internal/session_persistence.py`) is where +identity actually gets defended, and it defends it with a **content fingerprint**, +not a store-assigned id: `fingerprint_input_item` strips internal metadata and +optionally the `id` field, then returns `json.dumps(payload, sort_keys=True, +default=str)` (`src/agents/run_internal/items.py:334-369`, dossier verbatim); a +companion `digest_input_item` SHA-256-hashes that string "for durable occurrence +tracking" (`src/agents/run_internal/items.py:372-391`). The dossier states this +plainly: "there is no store-assigned entry id used for identity, identity for +dedup/rewind purposes is entirely content-fingerprint-based, computed by the +Runner layer, not by any backend." The Runner does not even trust its own store's +consistency under this scheme: `wait_for_session_cleanup` polls `get_items` up to +five times after a rewind "to confirm the rewound items are actually gone, rather +than assuming a strong read-after-write guarantee" (`src/agents/run_internal/session_persistence.py:624-661`, dossier). That is direct evidence, from the SDK's own code, that pushing +identity and consistency out of the store does not make either problem go away; +it moves the coping mechanism (polling, best-effort rewind that "skips the rewind +entirely with a warning if the tail doesn't match") into application code that +every caller of every backend has to trust separately. + +**A semantic trap worth naming explicitly**, because it is the kind of nominal +match RESEARCH_PROMPT_COMPARISON.md's method warns is more dangerous than a gap: +our own design also keeps identity out of the domain payload, "no domain payload +gains a separate identity field of its own" ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2). Read quickly, +that sounds like the same idea. It is not. Our runtime derives the envelope +`Event.id` deterministically, "UUIDv5 over (resolved stream subject, command +type, command idempotency key, index of the event within the decision's batch)" +([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2), which is a function of a **caller-supplied idempotency +key**, stable across redelivery, never a function of the payload's content. The +SDK's fingerprint is a function of the **content itself**, which is exactly why +it had to grow field-stripping logic (dropping `id`, dropping "internal +metadata") to keep semantically-identical items comparable. Our scheme is immune +to that specific failure mode by construction, because identity never depends on +what the caller put in the payload; the SDK's scheme is permanently exposed to +it, because identity is nothing but a normalized view of the payload. Two +products independently discovered the same content-hash-as-identity fragility +from opposite directions worth flagging here rather than re-deriving: this SDK's +`fingerprint_input_item` strips `id`/metadata specifically so re-hashing the same +logical item after a shape change still matches, and +[Cline's `source_prefix_hash`](../cline/vs-session-events.md) "had to be +redefined mid-flight to exclude `id`/`ts`... after the team discovered hashing +transport-identity fields made projection fail for semantically identical +prefixes, so persistence was silently rejected every turn." Neither team got the +field-stripping list right on the first attempt. That is the named failure mode +of content-hash-as-identity in general: the set of fields that must be excluded +for the hash to mean "same logical thing" is itself an evolving, easy-to-get-wrong +contract, and it is invisible until a shape change breaks it in production. + +## Mapping + +| OpenAI Agents SDK | Ours | Verdict | +| --- | --- | --- | +| `Session.session_id: str`, caller-supplied, no minting scheme (`src/agents/memory/session.py:21`) | Opaque `SessionId`; one logical stream per session on a subject the runtime assigns ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 1) | Equivalent identity concept, opposite minting discipline: caller-chosen string vs. runtime-scoped subject | +| `Session.get_items` / `add_items` / `pop_item` / `clear_session`, four required async methods, no write precondition of any kind (`src/agents/memory/session.py:13-54`) | `decide`/`evolve`/`append_stream`, gated by a three-way `WRITE_PRECONDITION` (`NoStream`/`At(current_position)`/`Any`) per command ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2) | Ours, decisively: every one of the SDK's four verbs is unguarded; ours classifies each command's precondition explicitly | +| `fingerprint_input_item`/`digest_input_item`: `json.dumps(payload, sort_keys=True, default=str)` plus a SHA-256 of that string, computed by the Runner, not the store (`src/agents/run_internal/items.py:334-391`) | Envelope `Event.id`, "UUIDv5 over (resolved stream subject, command type, command idempotency key, index of the event within the decision's batch)," derived by the runtime from a caller-supplied idempotency key ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2) | Semantic mismatch, not a plain equivalence: both keep identity out of the payload, but one hashes content, the other hashes a caller-asserted key; see structural difference above | +| No listing/enumeration method anywhere in `Session`/`SessionABC`; "listing, if needed, is entirely up to the chosen backend's native tooling" (dossier, Keying and identity) | `SessionProjection` folded by `Projector::catch_up`, queried by `verb + noun` functions (`get_session`, `list_sessions`) over one rebuildable read model ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8) | Ours, decisively: see the recommendation below | +| `pop_item()`: `DELETE FROM messages_table WHERE id = (SELECT ... ORDER BY id DESC LIMIT 1) RETURNING message_data` (`src/agents/memory/sqlite_session.py:315-326`), used for rewind | `SessionRewound{session_id, keep_through, reason}`, an appended marker; events `[1..keep_through]` remain valid, nothing is deleted (`session_rewound.proto`, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2 and 6) | Ours, decisively: see "What not to copy" | +| `run_compaction`: `clear_session()` then `add_items()` with the compacted set, a destructive full replace (`src/agents/memory/openai_responses_compaction_session.py`) | `Compacted{session_id, summary_id, summary_content, covers_from, covers_through, trigger, ...}`, a self-sufficient in-stream marker; covered events stay on the stream (`compacted.proto`, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 4) | Ours, decisively: see "What not to copy" | +| `RunState.to_json()`/`from_json()`, an application-managed, out-of-band serialized run-state object for human-in-the-loop resume, round-tripped through the caller's own storage, never through `Session` (`src/agents/run_state.py`, dossier) | `Checkpoint{reference, checkpoint_type, digest, checkpoint_id, producing_execution_attempt_id, covers_through, session_execution_plan_digest}` inside `CheckpointProduced`, restored via `ExecutionAttemptStarted.restored_checkpoint`, digest-verified and joined by `checkpoint_id` (`checkpoint.proto`, `checkpoint_produced.proto`) | Semantic mismatch: both are called "resuming a paused run," but `RunState` is caller-owned bytes with no store relationship at all, while ours is a self-describing, store-recorded, digest-verified reference the aggregate itself validates before restore | +| `AdvancedSQLiteSession` branching: `create_branch_from_turn`, a shared-row-by-reference fork where `message_structure` indirects into shared `agent_messages` rows (`src/agents/extensions/memory/advanced_sqlite_session.py`, 808-1283 range) | `SessionForked{session_id, source_session_id, context_prefix_boundary, reason}`, an atomic `[SessionStarted, SessionForked]` batch; inheritance is by reference through the context projection, never a fold of source events into child state (`session_forked.proto`, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 5) | Ours, decisively: only one of nine backends has any fork concept at all, and it is scoped to one database, not a first-class session-store operation | +| Two subagent mechanisms, opposite durability: Handoffs share the *same* session/stream; Agents-as-tools (`Agent.as_tool`) spawns a nested `Runner.run(session: Session \| None = None)`, defaulting to no durable session at all (`src/agents/agent.py:575-597,941-953`) | `DelegationDispatched{child_session_id, operation_id, cascade_policy}` on the parent, `ParentLinked{parent_session_id, parent_dispatched_at, cascade_policy, operation_id}` on the child, always a persisted sibling stream (`delegation_dispatched.proto`, `parent_linked.proto`, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6) | Deliberate divergence, tested against decision 6 below | +| Experimental Codex-CLI subprocess wrapper: tracks Codex's opaque `thread_id` string as an ordinary tool-output item, "the only thing that crosses from Codex's world into the Agents SDK's session," no shared format (`src/agents/extensions/experimental/codex/`, dossier) | `ExternalDelegationDispatched{operation_id, delegate_reference, authenticated_remote_subject, authorization_reference, request_digest, correlation_id}` on the dispatching session's own stream (`external_delegation_dispatched.proto`, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6) | Ours, decisively: the SDK's own cautionary example is an unaudited opaque string; ours records exactly the evidence [ADR#0031](../../../../adr/0031-agent-implementation-and-session-plan.md) requires for the same kind of cross-store delegation | +| `TResponseInputItem = ResponseInputItemParam`, a type alias onto the OpenAI Python SDK's own wire type, no Agents-SDK-level envelope, no schema-version field anywhere (`src/agents/items.py:76`, dossier) | `CanonicalMessage{message_id, role, content, model, usage, created_at}` with a typed `ContentBlock` oneof (`text`, `artifact_ref`, `ThinkingBlock`, `ToolUseBlock`, `ToolResultBlock`, `bytes redacted_thinking`, `ProviderBlock`) (`message.proto`) | Ours, decisively: a normalized, model-agnostic shape with an explicit unmodelled-provider escape hatch (`ProviderBlock`), versus a bare alias onto a third party's wire type with no version field of our own | +| `SQLiteSession` corrupt-row handling: silently `continue`s past undecodable rows on read, then doubles the read window and retries until `limit` valid items are returned (`src/agents/memory/sqlite_session.py:218-263`) | A decode-failure metric is a stated substrate obligation ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2, Substrate obligations) | Ours, decisively: a malformed event is observable, not silently absorbed by a widened read window | +| `MongoDBSession` message `seq`, "an atomic sequence counter" attached per document, needed because Mongo has no auto-increment primary key (`docs/sessions/index.md:209,453`) | `SessionOrdinal`, the 1-indexed fold-derived position of an already-appended event, "never read from JetStream message metadata... stable across restore, backfill, migration, and cold-tier relocation" (`session_ordinal.proto`, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2) | Ours, decisively: a fold-derived logical position survives storage relocation; a stored counter value is only as consistent as the increment operation that assigned it | +| `SessionSettings.limit` / `RunConfig.session_input_callback`, explicitly decoupling what the model sees this turn from what the store holds (`src/agents/memory/session_settings.py`, `src/agents/memory/util.py:8-11`) | Model-visible context "compiled deterministically from the event log bounded by the latest `Compacted` marker," a read-side projection over the full log ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8) | Equivalent in spirit: both treat "what the model reads" as a bounded view derived from an unbounded durable record, computed independently of each other | +| `DaprSession(ttl=...)`, the one backend with native TTL; `EncryptedSession`'s own `ttl` layered on any backend, expired entries silently skipped on decrypt, not purged (`docs/sessions/index.md:418`, `src/agents/extensions/memory/encrypt_session.py`) | `SessionHidden{reason}` (visibility tombstone, no bytes deleted), `RedactionApplied{redacted_event_ids, reason}` (read-time masking), `ArtifactErased{artifact_id, reason}` (out-of-band byte destruction) ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7) | Trade-off, not a plain win: see the retention gap below, the SDK's `SQLiteSession.clear_session()` also does something ours deliberately does not, real physical deletion | + +## What we should consider changing + +### 1. Name, in the ADR or in `checkpoint_produced.proto`'s comment, exactly which bytes the checkpoint evidence digest covers + +**The change.** [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2's fold rule for `CheckpointProduced` states +that the command idempotency key includes "a canonical digest of the complete +checkpoint evidence (the exact `Checkpoint` bytes the command persists)," so +conflicting evidence remains visible while byte-identical redelivery collapses +through the event identity contract. That names the covered bytes; what +neither `checkpoint.proto` nor the decision text pins down, in writing, is the +canonical encoding those bytes rely on and that no field of it may vary +harmlessly between logically identical requests. + +**Evidence anchor.** This SDK, store maturity 5/12 (thin evidence on its own): +`fingerprint_input_item` "strips internal metadata and (optionally) the `id` +field" before hashing (`src/agents/run_internal/items.py:334-369`), a fix that +exists only because an earlier version of the same idea did not strip those +fields and broke identity across semantically-identical items. Corroborated by +a materially stronger source, [Cline (10/12)](../cline/vs-session-events.md): +its `source_prefix_hash` "had to be redefined mid-flight to exclude `id`/`ts`... +after the team discovered hashing transport-identity fields made projection +fail for semantically identical prefixes, so persistence was silently rejected +every turn." Two unrelated teams hit the identical bug class from opposite +starting points; this is not thin evidence once the two are read together, even +though neither alone would justify the recommendation. + +**Blast radius.** Additive if the digest's covered-bytes contract is already +correct and this only writes the contract down; breaking, cheap if today's +digest computation turns out to include a volatile field (for example a +wall-clock timestamp on the checkpoint artifact), since fixing that only +changes what future digests are computed over, with no persisted-event rewrite +needed (old `CheckpointProduced` events keep whatever digest they were written +with; only the *comparison* rule for new evidence changes). + +**Why.** `Checkpoint.digest` (`checkpoint.proto`) is a `Digest` over "the +checkpoint bytes," and the first-evidence-per-checkpoint-id fold rule depends on +byte-identical redelivery actually producing the same digest. If the artifact +serialization the digest covers ever grows a field that varies harmlessly +between two logically-identical checkpoints (a producing timestamp, a +serializer's map key ordering before it was pinned), the fold would treat +identical restarts as *conflicting* evidence rather than a duplicate, silently +defeating the "first evidence wins, retained for audit" guarantee the decision +promises. This has not happened to us yet, by design it cannot be observed until +it does, which is exactly the shape of both failure reports cited above. + +**Cost.** Writing the contract down costs a paragraph. If it surfaces an actual +bug in what the digest covers today, the cost is redefining the digest input for +new checkpoints going forward, a serializer-level change, not a proto change. + +### 2. State explicitly whether a delegated child session may skip persistence entirely for short-lived, tool-like nested invocations + +**The change.** [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6 models every delegated child as a persisted +sibling stream: `DispatchDelegation` always mints a fresh `child_session_id` and +always creates a real `[SessionStarted, ParentLinked]` batch. Nothing in the +decision says whether a lightweight, tool-like nested agent invocation is +expected to go through this path at all, versus staying inside the parent's own +`ToolCallRequested`/`ToolCallCompleted` pair with no child session minted. + +**Evidence anchor.** This SDK, store maturity 5/12 (thin evidence): its +agents-as-tools mechanism (`Agent.as_tool`, `src/agents/agent.py:575-597`) spawns +a fully separate nested `Runner.run(session: Session | None = None)`, defaulting +to `None`; "a nested agent-as-tool run has no durable session at all unless the +caller explicitly constructs and passes one... only the nested run's final +output string round-trips back into the parent's session, as an ordinary +tool-call-output item" (dossier, Subagents and nested sessions). Handoffs, the +SDK's other subagent mechanism, take the opposite position: they never leave the +parent's stream at all ("the mapped history is the exact model input, new items +stay unchanged for session history," `src/agents/handoffs/history.py:151-152`). + +**Blast radius.** Additive. Nothing in `DispatchDelegation`'s definition forces +a caller to invoke it for every nested-agent call; a harness that wants +ephemeral, tool-like subagent semantics can already keep the whole interaction +inside ordinary `ToolCallRequested`/`ToolCallStarted`/`ToolCallCompleted` events +on the parent's own stream today, with no new event type and no schema change. +What is missing is not a mechanism, it is the ADR stating this is the *intended* +default for that case, so a future implementer does not assume +`DispatchDelegation` is mandatory for every nested-agent invocation regardless +of how short-lived or tool-like it is. + +**Why.** Decision 6's transitive cascade and two-fact detach saga are real +machinery, worth their cost for a genuinely independent, long-lived, +resumable child session. They are needless overhead for a nested call whose +entire lifecycle is "ask a sub-agent a question, get a string back, done," +which is exactly the shape a tool call already models. The SDK's `session=None` +default is a real, shipped answer to "should every nested invocation be a +first-class session," and the answer it gives is no. Handoffs' opposite answer +(no separation at all, same stream) suggests the more general point: not every +subagent relationship is well-modeled as a sibling stream, and decision 6 +should say which shape of nesting it is scoped to rather than reading as +universal. + +**Cost.** None beyond the decision text itself. If the ADR wants to go further +and formalize "no session" as a first-class option (rather than "just don't call +`DispatchDelegation`"), that would need a documented convention for how such a +nested call's context is recorded on the parent (likely already covered by +existing `ToolCallRequested`/`Completed` fields), which is a design discussion, +not a schema change on its own. + +### 3. Do not let a future listing/search projection fragment into per-backend query surfaces + +**The change under consideration, and why to reject it.** Nothing in [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) +proposes this today; this recommendation exists to record why it should stay +rejected. A tempting shortcut for a future feature (project-scoped listing, a +picker UI) is to let each storage backend or deployment expose its own native +query surface directly, the way this SDK does. + +**Evidence anchor.** This SDK, store maturity 5/12: "there is no +`list_sessions`/`list_session_ids` method... a repo-wide search for such names +in `src/agents/` returns no hits... listing, if needed, is entirely up to the +chosen backend's native tooling (e.g. querying the SQLite `agent_sessions` table +directly, or the Mongo `agent_sessions` collection)" (dossier, Keying and +identity, and Listing/summaries/search). The result is nine backends with no +shared listing contract at all; a caller who switches backends loses whatever +listing code they had built. + +**Blast radius.** N/A, this recommends holding the line on an existing decision, +not changing anything. + +**Why not to do this.** [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8 already answers this correctly: +listing is a rebuildable `SessionProjection`, queried by `get_session`/ +`list_sessions`, never the backend's native storage medium. The SDK is the +clearest cautionary counterexample available in this corpus for why: a store +with no listing contract does not have "flexible, pluggable listing," it has +nine independently-reinvented, backend-coupled listing implementations, none of +which is portable if the backend ever changes. Recording this here is meant to +stop a future "just query NATS/KV directly for this one dashboard" shortcut from +being proposed as a small convenience; it is the exact shape of the gap this +product actually shipped. + +**Cost.** None; this is a reaffirmation, not a new obligation. + +## What our design already does better + +- **Server-side write preconditions vs. no write contract at all.** Every one + of the `Session` protocol's four methods (`get_items`, `add_items`, + `pop_item`, `clear_session`) is unguarded; concurrency is left entirely to + the backend, and the base `SQLiteSession` backend has "no `PRAGMA + busy_timeout` set anywhere" and no cross-process defense against + `SQLITE_BUSY` at all (dossier, Write and append path). Our `WRITE_PRECONDITION` + classification (`NoStream`/`At`/`Any`, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2) is enforced by the + broker for every invariant-bearing transition, not assumed away by whichever + backend a caller happened to configure. +- **Identity that survives a payload's content changing.** As detailed above, + our envelope `Event.id` is derived from a caller-supplied idempotency key, + never from the payload's bytes, so it cannot be broken by the exact class of + bug that hit both this SDK's `fingerprint_input_item` and Cline's + `source_prefix_hash` independently. +- **A decode-failure metric, not a silently widened read window.** + `SQLiteSession.get_items(limit=N)` skips corrupt rows during decode and + doubles its read window until it finds `N` valid items + (`src/agents/memory/sqlite_session.py:218-263`), with no signal to the caller + that anything was skipped. Our decode-failure metric ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2, + Substrate obligations) makes a malformed event observable instead of quietly + compensated for. +- **Rewind and compaction as appended facts, not destructive operations.** + `pop_item`'s `DELETE ... RETURNING` and `run_compaction`'s + `clear_session()` + `add_items()` both destroy the prior state; our + `SessionRewound` and `Compacted` are both appended markers interpreted at + replay, and the covered events "stay on the stream (keep-forever)" + (`compacted.proto`). +- **A single, reused `Digest` type versus a one-off hashing utility.** Our + `Digest{algorithm, value}` backs `ArtifactRef`, `Checkpoint`, + `ResourceObservation`, and `OperationOutcomeRecorded` uniformly. This SDK's + content-hashing (`digest_input_item`) exists in exactly one place, purpose-built + for Runner-internal dedup, with no shared type or reuse across the rest of the + codebase. +- **A named escape hatch for content we do not understand yet.** `ProviderBlock` + (`message.proto`) lets a new provider-specific content shape be recorded + without a schema change; `ResponseInputItemParam`'s shape is entirely owned by + the `openai` package's own versioning, with "no schema-version field... found + anywhere" on the Agents SDK's own side to track drift in that upstream type. +- **Cross-store delegation with an evidentiary contract.** `ExternalDelegationDispatched` + records the authenticated remote subject, authorization reference, and a + request digest for any delegate outside our own store. This SDK's only + cross-store bridge (the Codex-CLI subprocess wrapper) crosses as "an opaque + string value inside an ordinary tool-output item," with none of that evidence + captured anywhere. + +## Trade-offs, not gaps + +- **Nine pluggable backends vs. one substrate-guaranteed store.** The `Session` + protocol's four-method minimalism is what lets it be implemented over SQLite, + Postgres, MySQL, Redis, MongoDB, Dapr, and OpenAI's own Conversations API with + no changes to the Runner. That same minimalism is why none of those backends + gets a shared identity, ordering, or concurrency guarantee for free; each one + re-derives its own answer (an autoincrement column, a Mongo `seq` counter, a + Dapr ETag), several imperfectly (`SQLiteSession` has no cross-process busy + handling at all). Our design buys uniform, substrate-enforced guarantees at + the cost of being one store, on one substrate, not a portable interface many + storage technologies can each independently satisfy. +- **Opaque JSON blobs vs. schema-validated typed protobuf.** `SQLiteSession` + treats every item as an opaque blob, `json.dumps`/`json.loads` with no + validation (`src/agents/memory/sqlite_session.py:189,222`); this is precisely + what lets the same store code serve any `TResponseInputItem` shape the + installed `openai` package happens to define, with zero coupling to a schema + we would have to keep in sync. Our decision 3 schema-validates every event at + the storage boundary, which catches malformed events early at the cost of + every event type needing an explicit, versioned proto definition before it can + be recorded. +- **Physical deletion vs. keep-forever masking.** `SQLiteSession.clear_session()` + issues real `DELETE` statements against both tables, no soft-delete + (`src/agents/memory/sqlite_session.py:359-374`); `DaprSession`'s TTL actually + expires data at the storage layer. Our `SessionHidden`/`RedactionApplied` + keep-forever-and-mask design (decision 7) buys full audit history and + fork-safe redaction (masking a source stream automatically masks every fork's + inherited context, since a fork reads by reference) at the cost of not + offering real physical erasure today; that gap is explicitly named, not + accidental, and is tested against this SDK's evidence below. + +## What not to copy + +- **`pop_item`'s destructive `DELETE ... RETURNING` as a rewind primitive.** + This directly contradicts [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2's forced position that "every + retroactive operation, rewind, revert, compaction, hide, is a new appended + event interpreted at replay, never an edit or a delete of stored messages." + It also has a demonstrated correctness cost inside the SDK itself: rewind is + "explicitly best-effort," matching a content-fingerprinted tail and *skipping* + the rewind with only a warning if the match fails, and the orchestration layer + has to poll `get_items` up to five times afterward "rather than assuming a + strong read-after-write guarantee." +- **`run_compaction`'s destructive `clear_session()` + `add_items()` replace.** + This is the exact opposite of decision 4's in-stream marker; it leaves "no + trace of the pre-compaction items in the store once it completes + successfully," which is unrecoverable if the compaction summary itself turns + out to be wrong, and it blocks completion of the run while it happens ("the + SDK waits for compaction to finish before considering the run complete," + `docs/sessions/index.md:284-285`). +- **Content-fingerprint-as-identity for anything durable.** As detailed above, + hashing normalized payload content to stand in for identity requires an + evolving, easy-to-get-wrong field-exclusion list, and has already broken in + production for two unrelated teams (this SDK's `fingerprint_input_item`, + Cline's `source_prefix_hash`). Wherever our own design computes a content + digest for comparison purposes (`ResourceObservation.content_digest`, the + canonical complete-evidence digest feeding a `CheckpointProduced` + idempotency key), the covered bytes must be pinned down explicitly, per + recommendation 1, rather than left to whatever a serializer happens to emit. +- **Letting listing be "whatever the backend's native tooling supports."** Nine + backends, nine incompatible listing stories, none portable across a backend + change. Decision 8's single rebuildable `SessionProjection` is the fix; do not + let a future feature carve a shortcut back to per-backend native queries. +- **A four-method contract with zero version discipline on the item shape it + carries.** `TResponseInputItem` is a bare alias onto a third-party package's + wire type, with "no schema-version field... found anywhere"; format evolution + is implicitly whatever the `openai` package's own versioning happens to do. + We already made the opposite call (decision 3, typed protobuf, schema-validated + at the boundary); nothing here argues for revisiting that. + +## The two gaps the industry has not closed + +### Subagent cascade + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6 already takes a position: a delegated child is always its +own logical stream, linked by `DelegationDispatched`/`ParentLinked` on each +side, with a `CascadePolicy` (`CASCADE_ON_PARENT_TERMINAL` or `INDEPENDENT`) +governing what happens on parent termination, and `ParentHistoryInvalidated` +handling the separate case of a parent rewind that invalidates a child's +dispatch point without the parent being terminal. The question here is whether +this SDK's evidence validates, refines, or challenges that position, not +whether we still need one. + +**What this SDK does.** It has two subagent mechanisms with opposite answers, +and neither one matches the sibling-stream-plus-pointer shape convergence #6 +in the [cross-product synthesis](../../synthesis.md) found almost everywhere +else in the corpus. Handoffs never leave the parent's own stream at all, "the +mapped history is the exact model input, new items stay unchanged for session +history" (`src/agents/handoffs/history.py:151-152`), so there is no separate +child to cascade anything to; the parent-child question simply does not arise. +Agents-as-tools spawns a genuinely separate nested `Runner.run`, but its +`session` parameter "defaults to `None`," and when it does, "a nested +agent-as-tool run has no durable session at all... only the nested run's final +output string round-trips back into the parent's session." On parent crash, +directly quoting the dossier's code-path finding rather than its docs: "no code +path was found that deletes, orphans, or reconciles a nested session on parent +crash, the SDK does not model a parent-child session relationship as a +first-class concept at all." Even in the one case a caller *does* pass an +explicit `session=` for a nested agents-as-tool call, "the SDK does not +establish or track any parent-child link between the parent's session and that +child session" (dossier, marked **[inference]** by the dossier itself, since no +negative-existence proof is absolute); the durable session, if one exists at +all, is simply an unrelated `Session` instance the caller happens to own. + +**Does this validate, refine, or challenge decision 6?** It refines it by +surfacing a third position the rest of the corpus had not shown. Every other +product's subagent mechanism at least records a parent pointer even where +cascade-on-delete is unhandled (synthesis convergence #6 and #7: "every product +that has subagents has the same unresolved gap," meaning an orphaned pointer, +not a missing one). This SDK's agents-as-tools default is a level further back: +by default, there is no persisted child at all to orphan, because nothing is +dispatched into a session-store concept in the first place; nesting is treated +as pure runtime state unless a caller opts in. That is not evidence against +decision 6's design for the case it targets (an independently resumable, +audited child session), it is evidence that decision 6 currently reads as if +*every* nested agent invocation should go through that machinery, when a real, +shipped product from a major vendor treats "no durable session at all" as the +correct default for a large, common class of nested calls (short-lived, +tool-like, single-question-single-answer). Recommendation 2 above is the +concrete response: state explicitly that skipping `DispatchDelegation` for that +class of call is the intended, supported behavior, not an oversight to close. +Handoffs' opposite answer (share the exact same stream, no separation +whatsoever) is a genuinely different case our catalog does not model at all; +whether "a different agent config takes over the same conversation" belongs in +the Session Store's scope or is purely an agent-loop concern is left as an open +question below rather than resolved here, since nothing in the dossier or our +own ADR speaks to it directly. + +### Retention on an unbounded log + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7 already takes a position: keep-forever, with `SessionHidden` +as a visibility tombstone that "does not promise erasure the log does not +perform," `RedactionApplied` for read-time masking that "the fold and every +projection" apply while "original bytes remain on the keep-forever log," and +`ArtifactErased` for out-of-band artifact-byte destruction with the artifact's +digest and metadata staying on the log as provenance. Erasure-grade deletion +(cryptographic shredding) is explicitly deferred to a named follow-up ADR. The +question is whether this SDK's evidence validates that interim posture or +exposes a cost it does not bound. + +**What growth looks like in this SDK, quoting the code path.** "There is no +compaction in the store/protocol layer, `Session`, `SessionABC`, and every +plain backend... hold every item indefinitely; nothing trims them" (dossier, +Compaction and history management). `SessionSettings.limit` bounds what is read +into the model context per turn, "the underlying row/document count is +unaffected." The only thing that actually shrinks storage is +`OpenAIResponsesCompactionSession`, an opt-in decorator whose mechanism is the +destructive `clear_session()` + `add_items()` already flagged above, which +means the *only* built-in bound on this SDK's storage growth is destructive by +construction; there is no non-destructive compaction option at all. No +GitHub-issue-level corroboration of growth becoming user-visible (comparable to +Cline's `cline/cline#9011`) is cited anywhere in this dossier; that absence is +itself an open item, not a confirmed "no failures happened," since the dossier +did not investigate the `openai-agents-python` issue tracker for this pass. + +**What deletion looks like, the sharper angle this product actually adds.** +Unlike Cline (which never gave this comparison a real physical-delete +primitive to weigh against decision 7), this SDK does: `SQLiteSession.clear_session()` +issues real `DELETE FROM messages`/`DELETE FROM sessions` statements, "no +soft-delete" (`src/agents/memory/sqlite_session.py:359-374`), and `DaprSession`'s +TTL actually expires state-store entries. That is a real, shipped, working +full-erasure primitive, crude (it is all-or-nothing per session, no selective +redaction, no provenance kept afterward) but genuinely present today, not +deferred to a future ADR the way ours is. + +**Does this validate, refine, or challenge decision 7?** It is a genuine but +thin-evidence challenge, not a validation, and the ADR should read it as such +given the 5/12 score. On the growth axis, this SDK's evidence is weak: it shows +the same "the two purest event-sourced products have no retention story" +pattern the synthesis already names for T3 Code and OpenCode, generalized to +"the two products with no meaningful append-only discipline at all also have no +non-destructive retention story," which is consistent with our decision 7's +premise that keep-forever needs a deliberate redaction/erasure contract rather +than something the pattern gives you for free, so nothing here argues for +changing decision 7's shape. On the erasure axis, though, the challenge is real +even if the evidence backing it is thin: decision 7's interim posture (masking +now, cryptographic shredding deferred) is a genuine capability gap against a +product that already ships full physical deletion today, however crude. This +does not mean copy `clear_session()` (see "What not to copy": all-or-nothing, +no selective redaction, no fork-safety, no provenance); it means the named +follow-up ADR for erasure-grade deletion is not a nice-to-have relative to the +rest of the industry, at least one shipped, vendor-backed alternative already +offers a cruder version of exactly the capability decision 7 defers. + +## Open questions for the ADR + +1. Should the ADR state explicitly that a caller may choose not to call + `DispatchDelegation` for a short-lived, tool-like nested agent invocation, + keeping it inside the parent's own `ToolCallRequested`/`ToolCallCompleted` + pair with no child session minted at all, the way this SDK's + agents-as-tools default (`session=None`) effectively does? See + recommendation 2 and the subagent-cascade section above. +2. Is a "different agent configuration continues the same conversation, no + session boundary at all" pattern (this SDK's Handoffs) in scope for the + Session Store, or is it purely an agent-loop concern the store never needs + to represent? Nothing in [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) or this dossier answers this directly. +3. The complete-evidence digest feeding a `CheckpointProduced` command's + idempotency key now names its covered bytes (the exact `Checkpoint` bytes + the command persists), but what canonical encoding produces them, and could + any serialization detail vary (a non-canonical map ordering, a + producing-side timestamp) in a way that makes byte-identical checkpoints + hash differently? See recommendation 1. +4. Given decision 7 defers erasure-grade deletion to a named follow-up ADR, and + at least one shipped vendor product already offers a cruder but real + physical-delete primitive today, should that follow-up ADR be prioritized + ahead of other open work, or is the masking-plus-tombstone interim story + considered sufficient for the deployments this store targets in the near + term? diff --git a/docs/research/session-store/products/opencode.md b/docs/research/session-store/products/opencode/index.md similarity index 95% rename from docs/research/session-store/products/opencode.md rename to docs/research/session-store/products/opencode/index.md index 4d0ebecab..76b4dd326 100644 --- a/docs/research/session-store/products/opencode.md +++ b/docs/research/session-store/products/opencode/index.md @@ -1,7 +1,7 @@ # OpenCode: how session transcripts are stored and resumed Part of Session Store Research. -Produced by running [RESEARCH_PROMPT](../RESEARCH_PROMPT.md). +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 @@ -170,11 +170,11 @@ export interface Interface { } ``` -`PublishOptions` includes a **`commit(seq)` hook** — "Local operational +`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, +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 @@ -208,32 +208,32 @@ 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, +- `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` +- `list(input?) -> Info[]` -- keyset-paginated SQL over `session` (`session.ts:268-303`). -- `messages({sessionID, limit?, order?, cursor?}) -> Message[]` — seq-ordered, +- `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 +- `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 +- `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({sessionID, after?, limit}) -> {events, hasMore}` -- paged raw event history (`session.ts:352-359`). -- `switchAgent` / `switchModel` — publish the corresponding events +- `switchAgent` / `switchModel` -- publish the corresponding events (`session.ts:393-416`). -- `prompt({id?, sessionID, prompt, delivery?, resume?}) -> Admitted` — admits a +- `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 +- `shell` / `skill` / `compact` / `wait` -- currently return `OperationUnavailableError` in this build (`session.ts:387-424`). -- `resume(sessionID)` / `interrupt(sessionID)` / `active` — execution control +- `resume(sessionID)` / `interrupt(sessionID)` / `active` -- execution control (`session.ts:425-432`). -- `revert.stage` / `revert.clear` / `revert.commit` — retroactive rewind +- `revert.stage` / `revert.clear` / `revert.commit` -- retroactive rewind (`session.ts:433-453`). Notably, **there is no `delete` on the v2 `SessionV2.Service`** (the interface @@ -266,10 +266,10 @@ legacy `session.deleted` event (`projector.ts:259-261`) and the raw (`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 + `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 + 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 @@ -348,7 +348,7 @@ legacy `session.deleted` event (`projector.ts:259-261`) and the raw (`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 + `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 @@ -364,12 +364,12 @@ There are two layered structures: the **durable event** (what is stored) and the `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, + 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` + 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`). @@ -396,7 +396,7 @@ There are two layered structures: the **durable event** (what is stored) and the 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 + 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 @@ -439,7 +439,7 @@ There are two layered structures: the **durable event** (what is stored) and the (`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 + 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 @@ -476,7 +476,7 @@ There are two layered structures: the **durable event** (what is stored) and the 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 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). @@ -491,7 +491,7 @@ There are two layered structures: the **durable event** (what is stored) and the `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; + `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 @@ -556,12 +556,12 @@ same transaction as the append.** What it adds beyond T3: `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 + 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 + 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 @@ -571,12 +571,12 @@ same transaction as the append.** What it adds beyond T3: - **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 +- **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 + 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 diff --git a/docs/research/session-store/products/openhands/index.md b/docs/research/session-store/products/openhands/index.md new file mode 100644 index 000000000..aeb002fc3 --- /dev/null +++ b/docs/research/session-store/products/openhands/index.md @@ -0,0 +1,631 @@ +# OpenHands: how session transcripts are stored and resumed + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT](../../RESEARCH_PROMPT.md). +Evidence snapshot retrieved 2026-08-04. Version-sensitive claims were checked +against these authoritative anchors: + +- `OpenHands/software-agent-sdk` at commit `973c35134f0be00f3ff65b9552b4b304433a74e2` + (PRIMARY anchor: the SDK, tools, and agent-server all live here). +- `OpenHands/OpenHands` at commit `866512a485c88fbeb34579cd9155a629ae42ed2f` + (SECONDARY anchor: the web app/frontend, checked only to confirm it has no + independent transcript persistence). + +Citations are `path:line` relative to the corresponding checkout root unless +otherwise noted (e.g. `openhands-sdk/openhands/sdk/conversation/state.py:315` +resolves under the `software-agent-sdk` checkout). + +## The storage model + +A conversation's durable state is split across two different kinds of files +under one persistence directory: + +```text +{persistence_dir}/ + base_state.json # single mutable document, rewritten wholesale + events/ + event-00000-{id}.json # one immutable file per event, never rewritten + event-00001-{id}.json + ... + .eventlog.lock +``` + +`persistence_const.py` is the single source of truth for these two file +concerns: + +```python +BASE_STATE = "base_state.json" +EVENTS_DIR = "events" +# Accept 5+ digits: the writer pads to a 5-digit minimum but does not cap width. +EVENT_NAME_RE = re.compile( + r"^event-(?P\d{5,})-(?P[0-9a-fA-F\-]{8,})\.json$" +) +EVENT_FILE_PATTERN = "event-{idx:05d}-{event_id}.json" +``` +(`openhands-sdk/openhands/sdk/conversation/persistence_const.py:1-11`) + +`events/` is append-only and authoritative for conversation history: every +`EventLog.append()` call writes a brand-new file and never edits or deletes +an existing one (`openhands-sdk/openhands/sdk/conversation/event_store.py:184-227`). +`base_state.json` is a separate, also-authoritative document for everything +that is not itself an event: the agent snapshot, workspace, `leaf_event_id` +HEAD pointer, stats, secret registry, and tags +(`openhands-sdk/openhands/sdk/conversation/state.py:82-230`). It is rewritten +in full on every autosaved field mutation via `ConversationState.__setattr__`, +which calls `_save_base_state()`: + +```python +def _save_base_state(self, fs: FileStore) -> None: + payload = self.model_dump_json(exclude_none=True, context=context) + if self._write_guard is None: + fs.write(BASE_STATE, payload) + else: + with self._write_guard(): + fs.write(BASE_STATE, payload) +``` +(`openhands-sdk/openhands/sdk/conversation/state.py:421-442`, autosave trigger +at `state.py:581-634`) + +Neither file is a pure cache of the other: `base_state.json` cannot be +rebuilt from `events/` alone (agent config, secrets, and tags have no event +representation), and `events/` cannot be reconstructed from `base_state.json` +(it holds only the current HEAD pointer, not history). The one genuine, +non-persisted cache is the in-memory `View` (`ConversationState._view`, +`state.py:240`): "Cached projection of `_events` for the *active branch*, +lazily updated on read. Derived state -- never persisted." +(`state.py:236-239`). It is rebuilt via `View.from_events()` on cold load, +fork, navigation, and error recovery (`state.py:383-395`). + +The closest conceptual fit is **session-as-directory**: a directory holding +one mutable document (for non-event state) plus an append-only, per-entry +event log (for history) -- not a single log, not a single document, and not +event-sourcing in the strict sense, since `base_state.json` carries data that +is not derivable by replaying `events/`. + +## Keying and identity + +- A conversation is addressed by a `ConversationID` (a UUID); its persistence + directory is `str(Path(persistence_base_dir) / conversation_id.hex)` + (`openhands-sdk/openhands/sdk/conversation/base.py:315-330`). +- IDs are server/client-generated `uuid4()`, not time-ordered: + `StartConversationRequest.conversation_id: UUID | None` -- "If not provided, + a random UUID will be generated" (`openhands-sdk/openhands/sdk/conversation/request.py`, + re-verified this session at the field's docstring). Unlike some peer + products, the ID scheme encodes no ordering or location information. +- Sub-agent Task-tool conversations mint their own fresh `conversation_id` + via `uuid.uuid4()` inside `TaskManager._generate_ids()`, alongside a + process-local `task_id` string (`f"task_{task_number:08x}"`, derived from + `len(self._tasks) + 1`) that is never itself persisted anywhere -- it is a + purely in-memory, per-`TaskManager`-instance counter + (`openhands-tools/openhands/tools/task/manager.py:148-153`). +- Listing at the agent-server layer is a full scan of persisted conversation + metadata at startup, not scoped per-project inherently (each conversation + record does carry its own `workspace`, but discovery is a flat catalog + scan -- see "Listing" below). +- No relocation/rename reconciliation logic (moved working directory, + worktree change) was found anywhere in the reviewed source -- see Open + questions. + +## The store interface + +There is no publicly exported, pluggable "session store" protocol type in +the RESEARCH_PROMPT sense. The effective interface is reconstructed from two +layers: + +**SDK layer -- `FileStore` (pluggable byte-store abstraction)**, the +substrate both `base_state.json` and `events/` are written through +(`openhands-sdk/openhands/sdk/io/base.py`, full class read this session): +an abstract contract with `write(path, contents)`, `read(path) -> str`, +`list(path) -> list[str]`, `delete(path)`, `exists(path) -> bool`, +`get_absolute_path(path) -> str`, and `lock(path, timeout) -> context manager`. +Two concrete implementations: `LocalFileStore` (plain `open()`/`write()`, +**no** atomic temp-file-and-rename for regular writes, `FileLock`-based +locking, and an explicit docstring warning that flock "does NOT work +reliably on NFS mounts or network filesystems" -- `openhands-sdk/openhands/sdk/io/local.py`, +full read this session) and `InMemoryFileStore` (test/ephemeral, backed by +`MemoryLRUCache`, `openhands-sdk/openhands/sdk/io/memory.py`). + +**SDK layer -- `EventLog` (the append-only log)** +(`openhands-sdk/openhands/sdk/conversation/event_store.py`, full read this +session): `append(event)`, `__getitem__(idx)` / `_get_single_item`, +`__iter__`, `__len__`, `__contains__`, `get_index(event_id) -> int`, +`get_id(idx) -> EventID`, `path_to_root(leaf_id, limit=None) -> list[Event]`. +There is **no** `clear`/`delete`/`prune` method -- a dedicated test asserts +this directly: `test_event_log_clear_functionality` in +`tests/sdk/conversation/test_event_store.py` (full file read this session) +contains `assert not hasattr(log, "clear")`. + +**SDK layer -- `ConversationState` (the open-or-create factory + mutation +surface)** (`openhands-sdk/openhands/sdk/conversation/state.py`): +`ConversationState.create(id, agent, workspace, persistence_dir, ...)` is the +single entry point for both fresh creation and resume (`state.py:445-578`); +`append_event(event)` is "the single storage chokepoint: stamp parent_id, +append, advance HEAD" (`state.py:315-334`); `view` (property, incremental, +`state.py:336-381`) and `rebuild_view()` (full replay, +`state.py:383-395`) expose the model-visible projection; +`get_unmatched_actions(events)` (`state.py:661-701`) is a static helper for +pending-confirmation reconciliation. + +**Agent-server layer -- conversation lifecycle** (`openhands-agent-server/openhands/agent_server/conversation_service.py`, +multiple ranges read in full this session): `start_conversation`, +`interrupt_conversation`, `resume_conversation`, `update_conversation`, +`fork_conversation`, `delete_conversation`, `search_conversations`, +`count_conversations`. **Event query surface** +(`openhands-agent-server/openhands/agent_server/event_service.py:1-260,537-627`): +`search_events`, `count_events`, `_count_events_sync`. Every operation above +carries a repo `path:line`, so this reconstruction, though not an exported +type, is a complete call-site-verified contract. + +## Write and append path (ordering, durability, concurrency, delivery) + +- **Ordering**: positional, encoded directly in the filename + (`event-{idx:05d}-{event_id}.json`) -- there is no independent sequence + number field on the `Event` model itself; order is purely "which index did + the writer assign at append time" + (`openhands-sdk/openhands/sdk/conversation/persistence_const.py:10`). +- **Durability**: each event append acquires `self._fs.lock(self._lock_path, + timeout=LOCK_TIMEOUT_SECONDS)` (30s) around a read-check-write critical + section, then writes the event to its own new, never-reused file + (`openhands-sdk/openhands/sdk/conversation/event_store.py:184-227`). + Regular `FileStore.write()` calls (both for events and for + `base_state.json`) are **not** temp-file-and-rename atomic in + `LocalFileStore` -- that atomic-write pattern exists only in the unrelated + settings/secrets persistence module + (`openhands-agent-server/openhands/agent_server/persistence/store.py:184-235`, + `_atomic_write_json`), which this repo's own docstring frames as mirroring + "OpenHands app-server's FileSettingsStore" -- a different subsystem from + conversation/event persistence, confirmed by direct read this session. +- **Concurrency**: multi-writer is explicitly supported via a lock plus a + disk-resync-before-append check: + + ```python + with self._fs.lock(self._lock_path, timeout=LOCK_TIMEOUT_SECONDS): + disk_length = self._count_events_on_disk() + if disk_length > self._length: + self._sync_from_disk(disk_length) + ... + ``` + (`event_store.py:194-199`). But `_count_events_on_disk()` does a full + `self._fs.list(self._dir)` directory scan **on every single append** + (`event_store.py:235-249`), with no fast path -- this is the same + operation flagged in `OpenHands/software-agent-sdk#3906` as an O(N²) + cost across a whole conversation, with a measured 33× slowdown at N=2000 + events (7408ms vs 222ms). Reading the code at the pinned commit confirms + this cost is still present; there is no cached count, mtime check, or + length pointer file that would avoid the listdir. +- A stale in-memory index (e.g. from another process's concurrent write) is + recovered lazily: `_get_single_item` catches the resulting `KeyError`, + logs "Stale EventLog index... rebuilding from disk," calls + `_scan_and_build_index()`, and retries once (`event_store.py:150-159`). +- **Delivery semantics / idempotence**: `append()` raises `ValueError` if an + event with the same ID already exists, and raises `ValueError` if an + explicit non-root `parent_id` does not exist in the log + (`event_store.py:201-215`). This is a hard, fail-fast guard, not a + silent-dedup at-least-once contract -- a duplicate append is a bug + surfaced immediately, not swallowed. +- `base_state.json` writes batch multiple field mutations into a single I/O + operation via a context-manager depth counter: `with state:` increments + `_save_depth`; mutations inside the block set `_dirty = True` instead of + writing immediately; `__exit__` flushes once when `_save_depth` returns to + zero (`state.py:728-749`, mechanism declared at `state.py:256-257`). + +## Read and resume path + +`ConversationState.create()` is the single resume/create entry point +(`state.py:445-578`). It attempts `file_store.read(BASE_STATE)` first: + +- **Resume path** (`base_state.json` found): deserializes the JSON into a + `ConversationState`, verifies the requested `id` matches the persisted + one, re-attaches `_fs` and a fresh `EventLog(file_store, dir_path=EVENTS_DIR)` + (which eagerly re-scans the `events/` directory listing -- not file + contents -- to rebuild the id↔index mapping, + `event_store.py:49-57,282-333`), then calls `state.rebuild_view()` + ("Cold-load: rebuild the cached view with full property enforcement -- + persisted events may come from an older code version or be corrupted", + `state.py:535-538`), then verifies the runtime agent is compatible with + the persisted one via `agent.verify(state.agent, events=state._events)` + (`state.py:541`). +- **Fresh path** (no `base_state.json`): constructs a new `ConversationState`, + attaches a fresh empty `EventLog`, and immediately calls + `_save_base_state()` to write the initial snapshot (`state.py:561-578`). +- There is no separate local cache read before the durable store on resume -- + `create()` reads `base_state.json` and the `events/` directory directly. + `rebuild_view()` replays the full **active branch** (`path_to_root(leaf)`, + which excludes abandoned/forked-away branches, not the entire event log) + -- so cold resume cost scales with the active branch length, with no + page/cursor bound. A bounded-tail read is available separately via + `active_branch(limit=...)`, "kept O(limit)" by walking back from the leaf + (`state.py:295-302`), but this is not what cold resume uses. +- Individual event file contents are read and parsed lazily, one at a time, + and memoized: `EventLog._get_single_item` reads the file, calls + `Event.model_validate_json(txt)`, and stores the result in + `self._event_cache: dict[int, Event]` (`event_store.py:140-165`). The + *index* (which idx maps to which event id) is built eagerly at + `EventLog.__init__` time from a directory listing, but event *bodies* are + not read until requested. + +## Listing, summaries, and search + +- Conversation listing (`search_conversations`/`count_conversations`, + `conversation_service.py`, ranges read in full this session) is an + in-memory linear filter/sort/paginate over a catalog populated by a full + scan of persisted conversation metadata at startup + (`_load_catalog_sync`) -- there is no index, database, or paginated + storage-side query; the cost is proportional to the total conversation + count at request time. +- Event listing/search (`search_events`/`count_events`, + `openhands-agent-server/openhands/agent_server/event_service.py:537-627`) + is likewise a linear scan over the `EventLog`, reading each event's + payload to test `kind`/`source`/body-substring/timestamp-range filters. + Only a length-based fast path exists when no filters are supplied -- there + is no full-text, vector, or other separate search index anywhere in the + SDK or agent-server persistence layer. +- `sub_conversation_ids` (the reverse parent→children pointer) is explicitly + a computed field, not a stored one: "IDs of conversations naming this one + as their parent. Derived from the server catalog; empty on webhook + payloads" (`openhands-agent-server/openhands/agent_server/models.py:245-252`). + It is recomputed by `_children_index()`/`_children_of()` via a full linear + scan of the catalog on every call -- not cached -- "because the catalog is + mutated from several places and a cache could go stale" (comment + confirmed earlier this session against `conversation_service.py`). + +## Entry/message structure and versioning + +- The base persisted unit is an `Event` (`openhands-sdk/openhands/sdk/event/base.py`, + full read this session), with an `LLMConvertibleEvent` subtype exposing + `to_llm_message()` for the subset of events the LLM actually sees. + Concrete event kinds relevant to condensation + (`openhands-sdk/openhands/sdk/event/condenser.py`, full read this + session): `Condensation` (`forgotten_event_ids: set[EventID]`, + `summary: str | None`, `summary_offset: int | None`, + `llm_response_id: EventID`), `CondensationRequest` (a plain marker event), + and `CondensationSummaryEvent` (`summary: str`, generated dynamically, not + itself a file on disk -- see Compaction below). +- Entries are **not** opaque to the store: each event file is written via + `event.model_dump_json(exclude_none=True)` and read back via + `Event.model_validate_json(txt)` (`event_store.py:163-164,217`), so the + store must know the discriminated `Event` type hierarchy to parse events + at all -- it is a typed, parsed record, not a blob. +- Identity/dedup relies on the event's own `id` field, checked against + `EventLog`'s in-memory `_id_to_idx` map before every append + (`event_store.py:201-206`). +- No `schema_version` field or migration entry point was found on + `ConversationState` or `Event` (contrast with the unrelated + `PersistedSettings.schema_version` / `from_persisted()` migration pattern + used for settings/secrets, `openhands-agent-server/openhands/agent_server/persistence/models.py:113,281-308`, + confirmed this session to be a *different* subsystem from + conversation/event storage). The one observed forward/backward-compat + mechanism for the event file format is a **more permissive filename + regex**, not a version field: `EVENT_NAME_RE` accepts `\d{5,}` (five or + more digits) rather than exactly five, with the comment "the writer pads + to a 5-digit minimum but does not cap width" + (`persistence_const.py:6-8`) -- this is the fix for the historical + 100,000-event bug described below. +- Legacy (pre-tree-feature) events without a `parent_id` are still readable: + `EventLog._effective_parent_id` falls back to treating the previous index + as the implicit parent, "so old conversations load unbranched with no + disk rewrite" (`event_store.py:91-104`). + +## Compaction and history management + +`CondenserBase.condense(view, agent_llm) -> View | Condensation` +(`openhands-sdk/openhands/sdk/context/condenser/base.py:16-52`, full read +this session) is the abstract contract. Its concrete `RollingCondenser` +subclass drives the actual policy: + +```python +def condense(self, view: View, agent_llm=None) -> View | Condensation: + request = self.condensation_requirement(view, agent_llm=agent_llm) + if request is not None: + try: + return self.get_condensation(view, agent_llm=agent_llm) + except NoCondensationAvailableException as e: + if request == CondensationRequirement.SOFT: + return view + elif request == CondensationRequirement.HARD: + ... hard_context_reset(...) or re-raise + else: + return view +``` +(`context/condenser/base.py:159-198`) + +The critical retention fact: **`Condensation` is itself an ordinary, +durably-persisted `Event`** -- it goes through the exact same +`EventLog.append()` path as any action or observation, written to its own +new `event-{idx:05d}-{id}.json` file. It never deletes or rewrites any prior +event file. `Condensation.apply(events)` operates purely on an in-memory +`list[LLMConvertibleEvent]`: + +```python +def apply(self, events: list[LLMConvertibleEvent]) -> list[LLMConvertibleEvent]: + output = [e for e in events if e.id not in self.forgotten_event_ids] + if self.has_summary_metadata: + output.insert(self.summary_offset, self.summary_event) + return output +``` +(`openhands-sdk/openhands/sdk/event/condenser.py:83-96`) + +`View.append_event()` invokes this `apply()` only when replaying a +`Condensation` event into the transient `View` (`openhands-sdk/openhands/sdk/context/view/view.py:111-140`). +The `CondensationSummaryEvent` shown to the LLM is synthesized on the fly +from the `Condensation.summary` string field via a `@property` with a +deterministic id (`f"{self.id}-summary"`) -- "these events are not intended +to be stored alongside regular events" (`event/condenser.py:52-70`) -- so +even the summary text is not written as its own separate file; it is +re-derived from the persisted `Condensation` event every time the view is +rebuilt. + +Net effect on the durable record: **`events/` never shrinks from +condensation.** Only the model-visible `View` shrinks. Growth of the +durable directory is unbounded absent something outside this code path. +This is confirmed as a real, user-facing problem by the issue tracker: + +- `OpenHands/software-agent-sdk#3926` -- a confirmed, since-fixed silent + data-loss/corruption bug at exactly 100,000 events, caused directly by + unbounded append-only growth colliding with a fixed-5-digit filename + regex (the writer zero-pads to 5 digits but does not cap width past + 99,999; the old reader regex matched exactly 5 digits, so the 100,000th + event's 6-digit index silently failed to match, and the gap-detection + logic -- "if n not in by_idx: ... break" (`event_store.py:308-317`) -- + truncates the log at the gap rather than raising). At the pinned commit, + this specific bug is already fixed (the regex reads `\d{5,}`, + `persistence_const.py:8`); the issue is cited here as first-hand, + primary-source evidence of what unbounded growth breaks, not as a live + defect in this commit. +- `OpenHands/software-agent-sdk#3906` -- the O(N²) `_count_events_on_disk()` + listdir-per-append cost described above, with a measured 33× slowdown at + N=2000 events. Reading the code at the pinned commit confirms this cost + is **still present** (unfixed) here. +- `OpenHands/software-agent-sdk#1824` ("Proposal: don't use full events + history in the OH ecosystem") -- a maintainer's own first-hand account that + 1,000+ events cause CLI slowdowns/crashes, and that conversations have been + observed to reach roughly 30,000 events via the SDK. + +Default condenser thresholds (`openhands-sdk/openhands/sdk/context/condenser/llm_summarizing_condenser.py`, +full read this session): `LLMSummarizingCondenser` defaults to +`max_size=240` events before condensing, `keep_first=2`, +`minimum_progress=0.1` (condensation must remove at least 10% of events or +it errors). A separate `default_condenser()` factory +(`max_size=80`, `keep_first=4`) is used for both the default top-level agent +and every sub-agent spawned via the registry, confirmed again this session +at `openhands-sdk/openhands/sdk/subagent/registry.py:271-275`: when +`agent_def.condenser is None`, `condenser = default_condenser(llm.model_copy(...))` +-- "Sub-agents get a summarizing condenser by default (parity with the +top-level agent) so deep runs auto-compact instead of erroring on context +overflow" (`openhands-sdk/openhands/sdk/subagent/registry.py:253-256`). + +## Rewind, checkpoints, and fork + +Events form a tree, not a strict line: each event carries an optional +`parent_id`; `ROOT_PARENT_ID` is an explicit sentinel for a new root; legacy +pre-tree events fall back to implicit linear chaining +(`event_store.py:91-104`, quoted above). `ConversationState.leaf_event_id` is +the movable HEAD -- "the parent of the next appended event... Moving it +re-roots the active branch" (`state.py:176-183`). `_resolve_active_leaf()` +resolves an unset leaf by walking backward from the tail, skipping trailing +non-tree bookkeeping events (`ConversationStateUpdateEvent`, +`ConversationErrorEvent`) so a server restart mid-write cannot strand +history (`state.py:263-293`, referencing bug `#4057` in its own comment). + +`navigate_to()` and `fork()` (`openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:679-858`, +full read this session) are the two retroactive operations: + +- **`navigate_to()`** re-roots HEAD in place without copying anything: all + branches stay on disk; appending after navigating creates a sibling + branch; events on the abandoned branch remain in the log but drop out of + `state.view` on the next rebuild. +- **`fork()`** is deep-copy plus lineage metadata, not a shared-prefix + reference or an identity rewrite: it deep-copies the agent via a JSON + round-trip (avoiding thread-lock pickling issues per upstream issues + #2917/#3443), holds the state lock during the read to avoid a torn read, + and supports either a full-log copy or a `from_event_id`-scoped + branch-slice copy via `path_to_root`, then calls `rebuild_view()` on the + new conversation. Lineage is recorded as plain fields -- + `forked_from_conversation_id`, `forked_from_event_id` -- on + `StoredConversation`/`ConversationInfo` + (`openhands-agent-server/openhands/agent_server/models.py:96-110,224-237`), + not as a shared-storage pointer: the forked conversation gets its own + independent copy of the (possibly branch-sliced) event files under its + own conversation id/directory. + +No file-content or workspace/environment checkpoint tied to individual turns +was found in the reviewed conversation/event persistence source -- see Open +questions. + +## Subagents and nested sessions + +Two independent, non-overlapping delegation mechanisms exist. + +**(a) SDK Task tool** -- `openhands-tools/openhands/tools/task/manager.py` +(full read this session), `definition.py`, `impl.py`. `TaskManager.start_task()` +creates (or resumes) a fully independent `LocalConversation` per task, with +its own fresh `conversation_id` (`_generate_ids()`, `openhands-tools/openhands/tools/task/manager.py:148-153`) +and its own persistence directory: when the parent conversation itself +persists, sub-agent conversations live under +`Path(parent_persistence_dir) / "subagents"` +(`_SUBAGENTS_DIR: Final[str] = "subagents"`, `openhands-tools/openhands/tools/task/manager.py:46,127-133`); +when the parent has no `persistence_dir`, they fall back to +`tempfile.mkdtemp(prefix="openhands_tasks_")` (`openhands-tools/openhands/tools/task/manager.py:135-137`). Both +freshly-created (`_create_task`, `openhands-tools/openhands/tools/task/manager.py:236-285`) and resumed +(`_resume_task`, `openhands-tools/openhands/tools/task/manager.py:201-234`) sub-agent conversations are +constructed with `delete_on_close=True` (`openhands-tools/openhands/tools/task/manager.py:219,314`) -- but this +flag is inert at the `LocalConversation` layer (verified earlier this +session by reading `LocalConversation.close()` in full: it never +references `self.delete_on_close`; only `RemoteConversation` acts on it), +so setting it here has no independent deletion effect through this code +path. + +The durable parent-child link is thin by design: + +- The `Task` record (`id`, `status`, `conversation_id`, `result`/`error`, + the live `conversation` object) lives only in + `TaskManager._tasks: dict[str, Task]` (`openhands-tools/openhands/tools/task/manager.py:103`) -- in-memory, + scoped to that `TaskManager` instance. `task_id` itself + (`f"task_{task_number:08x}"`) is a sequential in-process counter, not a + stable durable identity that survives a process restart on its own. +- The only durable trace inside the *parent's* own event log is a + `TaskObservation` (`task_id`, `subagent`, `status`, result-or-error text) + produced by `TaskExecutor.__call__` (`openhands-tools/openhands/tools/task/impl.py:26-66`) + and appended through the normal tool-call action/observation cycle. +- On completion (success, non-finished stop, or exception), `_run_task()` + calls `_evict_task()`, which pauses and closes the sub-agent's + `LocalConversation` and replaces the in-memory record with + `task.model_copy(update={"conversation": None})` (`openhands-tools/openhands/tools/task/manager.py:155-160,346-377`). + The live conversation object is dropped from memory; its on-disk + directory under `subagents/` is **not** deleted at this point. +- `TaskManager.close()` only removes the sub-agents directory when the + *parent itself has no persistence*: "Only clean up when using a temp dir + (parent had no persistence). When the parent persists, subagent data + lives under its directory" (`openhands-tools/openhands/tools/task/manager.py:446-459`) -- meaning when the + parent does persist, sub-agent directories under `/subagents/` + are never deleted by any code path reviewed here. +- Resuming a task (`resume="task_..."`) re-opens the *same* `conversation_id` + under the parent's `subagents/` directory, round-tripping through the + ordinary `ConversationState.create()` resume path (`openhands-tools/openhands/tools/task/manager.py:201-220`). +- On a sub-agent crash, `_run_task()`'s `except Exception` records + `task.error` and reports it back to the parent via + `TaskObservation(is_error=True)` (`openhands-tools/openhands/tools/task/manager.py:370-372`); whatever the + sub-agent had already durably appended to its own `events/` remains on + disk untouched -- there is no rollback. +- Sub-agent LLM usage is folded into the parent's own stats (not the parent's + conversation transcript) by key: `parent.conversation_stats.usage_to_metrics[f"task:{task.id}"] = ...` + (`openhands-tools/openhands/tools/task/manager.py:436-444`). + +**(b) Agent-server `parent_conversation_id`** -- a separate, coarser +mechanism linking two independent, first-class, top-level conversations +(not an SDK Task). `_ConversationInfoBase.parent_conversation_id` +(`openhands-agent-server/openhands/agent_server/models.py:238-244`) is +client-supplied and validated at creation: `InvalidParentConversation` is +raised if it is "unknown, self-referential, or in a different workspace" +(`conversation_service.py`, verified earlier this session), the workspace +check comparing resolved `working_dir` paths via `_same_workspace()`. The +reverse pointer, `sub_conversation_ids`, is explicitly derived (not stored) +as described in Listing above. Deleting a parent **orphans, not cascades**: +"Children are orphaned, not cascaded: `parent_conversation_id` is left +dangling, like `forked_from_conversation_id` on source delete" (verbatim +comment confirmed earlier this session in `conversation_service.py` around +the delete-conversation handler). No bound on nesting depth for +`parent_conversation_id` chains was found -- see Open questions. + +## Retention, deletion, and multi-host + +No TTL, lifecycle policy, or scheduled cleanup job for conversation/event +data exists anywhere in the reviewed SDK or agent-server source. Retention +is either explicit (an operator or client calls `delete_conversation`) or +incidental (the Task-tool temp-dir cleanup described above, which only +fires when the *parent* has no persistence). `EventLog` itself exposes no +delete/prune/clear operation at all, confirmed by direct test assertion +(`tests/sdk/conversation/test_event_store.py`, full file read this +session): `assert not hasattr(log, "clear")`. + +`delete_conversation` removes the target conversation's own catalog +entry/directory and orphans (does not cascade-delete) any children pointing +at it via `parent_conversation_id`, per the quote above. + +Multi-host/crash-detection is handled by a dedicated lease mechanism, +`ConversationLease` (`openhands-agent-server/openhands/agent_server/conversation_lease.py`, +full 282-line file read this session): an `owner_lease.json` payload +(TTL, monotonic `generation`, optional `owner_host`/`owner_pid`) guarded by +a `.owner_lease.lock` `FileLock`. Taking over an existing lease requires +either the TTL to have expired, or the previous owner's PID to be confirmed +dead via `os.kill(pid, 0)` -- and that PID check is only trusted when +`owner_host` matches the current host, "since cross-host PID checks are +meaningless." Every disk write during the lease's lifetime goes through +`guarded_write()`, which re-asserts ownership and raises +`ConversationOwnershipLostError` if the generation has gone stale under it. +This is a first-class, if filesystem-bound, crash-handover design -- not a +distributed lock service. The underlying filesystem assumption is explicit +and admittedly narrow: both `LocalFileStore`'s own docstring and +`EventLog`'s own docstring warn, near-verbatim, that flock-based locking +"does NOT work reliably on NFS mounts or network filesystems" +(`openhands-sdk/openhands/sdk/io/local.py`, `event_store.py:37-40`). + +## Interop with foreign session stores + +The only foreign-store-adjacent surface confirmed by direct read this +session is the frontend, which has **no** independent transcript +persistence of its own: `openhands-app/src/utils/conversation-local-storage.ts` +(342 lines, full read) stores only UI-preference state in browser +`localStorage` -- selected tab, unpinned tabs, conversation mode, a draft +message, and Files-tab view-mode toggles -- explicitly scoped away from real +conversations by `shouldSkipPersistence()`, which skips both empty ids and +temporary `"task-{uuid}"` placeholder ids used during conversation +initialization (`conversation-local-storage.ts:139-150`). This is UI state, +not a session store, and not interop with any foreign product. + +Whether the ACP (Agent Client Protocol) subprocess integration (which drives +foreign coding-agent backends such as codex-acp, claude-agent-acp, and +gemini-cli as subprocesses) ever reads or imports one of those backends' own +native session-transcript files, as opposed to treating their credentials +purely as opaque secrets, was described in an earlier phase of this research +but was **not re-verified against source in this session's final pass** -- +treat that specific claim as unconfirmed rather than established; see Open +questions. + +## What this implies for our Session Store (our inference) + +- OpenHands sits close to, but is not, pure event sourcing: the events + directory is a genuine append-only log, but `base_state.json` carries + data that cannot be reconstructed by replaying that log (agent + configuration, secrets, tags, the HEAD pointer as a practical shortcut). + For our Session Store, this is a useful cautionary boundary -- if we want + a strict claim that "the log is the only durable truth," we need to keep + auditing that no field silently becomes log-independent state the way it + has here. +- The condenser design -- a compaction step is recorded as one more ordinary, + immutable event, never a rewrite or deletion of prior history -- is a + pattern worth adopting outright: it means a crash or torn write during + compaction cannot corrupt history (compaction either fully lands as a new + append, or it doesn't happen at all), at the cost of leaving storage + growth completely unbounded unless something external prunes it. OpenHands + does not itself bound this; the issue tracker (`#3926`, `#3906`, `#1824`) + shows that punting retention entirely to ops has produced real, + user-visible pain (crashes, corruption, and multi-thousand-x slower reads) + at scale. +- The two coexisting delegation mechanisms -- a cheap, ephemeral SDK Task + primitive whose only durable trace in the parent is a summary observation + event, versus a heavier, durably-linked, orphan-on-delete + `parent_conversation_id` relationship between two first-class + conversations -- is a workable reference model for us: it demonstrates + that a single product can offer both a lightweight subagent primitive and + a first-class nested-conversation primitive without conflating their + storage or lifecycle semantics. +- The still-present O(N) `_count_events_on_disk()` directory listing on + every single append (`#3906`, confirmed live at this pinned commit) is a + direct warning against implementing an append-time concurrency check as an + unindexed full scan: it is correct, but its cost is invisible in + development and only surfaces catastrophically at scale. Our own + append-path concurrency check should use a bounded, O(1)-ish signal (a + versioned position pointer or compare-and-swap primitive) rather than a + listdir. + +## Open questions + +- Is there any schema-version field or explicit migration entry point for + the `ConversationState`/`Event` JSON format itself, comparable to + `PersistedSettings.schema_version`/`from_persisted()`? None was found in + `state.py` or `event/base.py`; the only observed forward/backward-compat + mechanism for the event file format is the widened filename regex + (`\d{5,}` instead of a hard 5-digit cap), not a versioned migration. +- Is there any external, ops-level retention/TTL/lifecycle-cleanup process + for conversation directories outside the SDK/agent-server source reviewed + here? The code itself implements none. +- Is nesting depth bounded for chained `parent_conversation_id` links + (parent-of-parent-of-parent...)? Not addressed anywhere in the reviewed + source. +- Does the ACP subprocess integration ever read or import a foreign agent's + own native session-transcript store (e.g. a Codex rollout file or a + Claude Code session file) rather than treating it purely as an opaque + credential/secret store? An earlier pass of this research concluded "no," + but that conclusion was not re-verified against source in this session's + final pass and should be treated as unconfirmed. +- Is the `subagents/` directory under a persisting parent ever + garbage-collected by anything (a background job, a separate CLI command, + an ops process) given that `TaskManager.close()` explicitly skips cleanup + whenever the parent itself persists? +- Are file-content or workspace-state checkpoints (diffs, snapshots) tied to + individual turns anywhere in the codebase outside the event/state + persistence layer covered here? None were found in the paths reviewed. +- How are relocations (moved working directory, renamed workspace) + reconciled to a conversation's identity, if at all? No such mechanism was + found. diff --git a/docs/research/session-store/products/openhands/vs-session-events.md b/docs/research/session-store/products/openhands/vs-session-events.md new file mode 100644 index 000000000..9efedcda2 --- /dev/null +++ b/docs/research/session-store/products/openhands/vs-session-events.md @@ -0,0 +1,432 @@ +# OpenHands compared to our session event catalog + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT_COMPARISON](../../RESEARCH_PROMPT_COMPARISON.md). +Stage-one dossier: [OpenHands](./index.md). +Compared against `proto/trogonai/session/sessions/v1alpha1/` and [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) on 2026-08-04. + +**Store maturity: 9/12** -- evolution scars 2/3 (a real back-compat read path, +`_effective_parent_id`, for pre-tree events, and a widened `EVENT_NAME_RE` +shipped as a corruption fix; but no schema-version field anywhere in the +event/state format and the O(N²) append-cost bug is still unfixed at the +pinned commit), operational age 3/3 (three independently filed, numbered +issues with quantified failure data: #3926 corruption at 100k events, #3906 a +measured 33x slowdown at 2,000 events, #1824 a maintainer account of +30,000-event conversations degrading to unusable), exposure 2/3 (a +vendor-shipped, actively developed product with a documented multi-host +`ConversationLease` and an explicit NFS-unreliability disclaimer on its +locking primitive, but no evidence in the dossier of paid-tier scale +comparable to a hosted product with SLA-backed resume guarantees), design +independence 2/3 (the SDK and agent-server stores are original OpenHands +designs, not forked from an upstream session-store project, but the dossier +does not establish how much of the design was carried over unchanged from an +earlier in-house iteration, so full independence is inferred, not confirmed). + +## The one structural difference everything else follows from + +OpenHands splits session state across two records with **separate, +non-overlapping authority**: `events/` (append-only, one file per event, the +sole record of history) and `base_state.json` (a single mutable document, +rewritten whole on every save, holding the agent snapshot, workspace binding, +`leaf_event_id` HEAD pointer, running stats, secret registry, and free-form +tags). Neither file is derivable from the other. The dossier is explicit that +`base_state.json` "cannot be rebuilt from `events/` alone," because agent +config, secrets, and tags "have no event representation" at all, and that +`events/` cannot be reconstructed from `base_state.json`, because it holds +only the current HEAD, not history. + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) rejects this shape twice over: decision 1 makes the event log the +only place a session's state is committed, and decision 8 states "no read +model is authoritative" -- every projection, snapshot, or checkpoint is +disposable and rebuildable by replay. OpenHands' `base_state.json` is not a +disposable projection; it is a second permanent source of truth that a full +replay of `events/` cannot recover. + +Walking through what actually lives in that second authority, category by +category, is more informative than treating it as one gap: + +- **Agent/runner configuration.** We already fold this into the log: + `SessionStarted.execution_plan` (a `StoredSessionExecutionPlan`, see + `proto/trogonai/session/sessions/v1alpha1/execution_plan.proto`) is an + event, fully replayable. No gap. +- **Secrets.** OpenHands stores a secret registry inside `base_state.json` + itself, a second durable location holding data the event log deliberately + never receives. We do not have a parallel secret store because we take a + structurally different approach at facet 7: an ingress rule keeps secrets + out of the durable log altogether (confirmed by grep -- the only two + `secret` hits in the entire 57-file catalog are the comments on + `ExternalArtifact.source_url` in + `proto/trogonai/session/sessions/v1alpha1/artifact.proto:79-80`, + documenting that source URLs must be credential-free before they are + recorded). This is not a smaller version of OpenHands' problem; it removes + the reason a second authoritative store would be needed for secrets in the + first place. +- **Tags.** Grepping the catalog (`grep -rniE "\btag" + proto/trogonai/session/sessions/v1alpha1/`) returns no field or event + anywhere. The catalog has `SessionRenamed`, `SessionArchived`, and + `SessionUnarchived` for organization state, but nothing for free-form + categorization. This is a genuine, narrow gap -- see recommendation 2. +- **The `leaf_event_id` HEAD pointer.** This is the one category where + OpenHands' split buys something real: a movable pointer that lets a caller + jump to any prior event and grow a new sibling branch from it + (`navigate_to()`), all within one conversation id. We do not have an + equivalent, and do not think we need one -- see "Trade-offs, not gaps" + below -- because our fork (`SessionForked`, decision 5) mints a new + aggregate by reference rather than moving a pointer within one aggregate, + and our rewind (`SessionRewound.keep_through`, decisions 2 and 6) is a + forward-appended marker interpreted at replay, not an in-place + re-rooting operation. + +So the honest read is not "OpenHands has a second authoritative store and we +don't" as a blanket win. It is: two of the four categories that force +OpenHands' split (config, secrets) are already solved without one in our +design, one (HEAD) is solved by not needing the operation it enables, and one +(tags) is a real, small, additive gap we should close. + +## Mapping + +| OpenHands construct | Our equivalent | Verdict | +| --- | --- | --- | +| `events/` -- append-only dir, `event-{idx:05d}-{id}.json` | Logical stream per session on the shared `SESSION_EVENTS` JetStream stream (ADR facet 1) | Ours -- broker-native, no filename-encoded index | +| `base_state.json` (agent snapshot, workspace, HEAD, stats, secrets, tags) | No single equivalent -- see structural-difference section above | Split verdict: ours ahead on config/secrets, gap on tags, HEAD not needed | +| `Event.id` + in-memory `_id_to_idx`, linear id-seen check before append | `Event.id` deterministically derived from `(stream subject, command type, idempotency key, batch index)` (ADR facet 2) | Ours -- principled derivation, not a dedup scan | +| Filename-encoded index = order (`event-{idx:05d}`) | `SessionOrdinal`, fold-derived, not physically assigned (`proto/trogonai/session/sessions/v1alpha1/session_ordinal.proto`) | Ours -- survives restore/backfill/cold-tier relocation; theirs is coupled to the filename | +| `parent_id` event tree + `navigate_to()` / `fork()` | `SessionRewound.keep_through` (forward marker) + `SessionForked` (new aggregate) | Trade-off -- see below | +| `ConversationLease` (`owner_lease.json`: TTL, generation, host/PID) | JetStream's native `Nats-Expected-Last-Subject-Sequence` compare-and-swap for `At`-guarded commands (ADR facet 2) | Ours -- no filesystem lease needed | +| `Condensation` event (`forgotten_event_ids`, `summary`, `summary_offset`, `llm_response_id`) | `Compacted` (`covers_from`, `covers_through`, `trigger`, `guidance`, `tokens_before`, `tokens_after`, `usage`; `proto/trogonai/session/sessions/v1alpha1/compacted.proto`) | Ours records token/usage provenance theirs does not; theirs permits a non-contiguous `forgotten_event_ids` set where ours requires a contiguous range -- see Open questions | +| `CondensationSummaryEvent`, regenerated in-memory from `Condensation.summary` on every view rebuild | `Compacted.summary_content`, stored inline once | Trade-off -- see below | +| SDK `TaskManager` Task tool: in-process `task_id` counter, `TaskObservation` summary event | No ephemeral/lightweight delegation primitive -- every delegation is a first-class `Session` via `DelegationDispatched` / `ParentLinked` | Deliberate difference -- see "the two gaps the industry has not closed" | +| Agent-server `parent_conversation_id`: client-supplied, orphan-not-cascade on delete | `DelegationDispatched` / `ParentLinked` / `CascadePolicy` (`proto/trogonai/session/sessions/v1alpha1/delegation_dispatched.proto`, `parent_linked.proto`, `cascade_policy.proto`) | Ours, decisively -- typed, reconciler-driven cascade or independence, recorded as a fact at dispatch time | +| `sub_conversation_ids` -- derived via full linear scan of the catalog on every call | Parent-to-children lineage projection folded from `DelegationDispatched` (ADR facet 6, facet 8) | Same principle (derived, not stored); ours is incrementally checkpointed, theirs re-scans every call | +| No detach/undelegate concept; only orphan-on-delete | `DelegationDetached` / `ParentDetached`, a two-fact saga joined by `detach_operation_id` (`delegation_detached.proto`, `parent_detached.proto`) | Ours, decisively -- a gap in theirs | +| `usage_to_metrics[f"task:{task.id}"]` -- in-memory-only cost rollup | No rollup field; delegation cost is recoverable by folding the child's own stream | Ours, deliberately -- see "What not to copy" | +| No `schema_version` on `Event` or conversation state; `PersistedSettings.schema_version` exists for a sibling subsystem | No per-event version field either; additive-only evolution (ADR decision 3); read-model versioned at the package level, `projections/v1` (ADR facet 8) | Validated, not gapped -- see "What our design already does better" | +| `LocalFileStore`: no atomic temp-file-rename, `FileLock`-based locking, documented as unreliable on NFS | JetStream durable log; broker-native atomicity, no filesystem lock | Ours | +| Fork = deep-copy (JSON round-trip) of the full or branch-sliced event set into a new conversation directory | `SessionForked` = O(child events) only, inherits by reference via context projection (ADR decision 5) | Ours, decisively | +| No turn/round concept; only per-event `parent_id` tree position | `turn_id` stamped on `UserMessageRecorded`, all `AssistantMessage*` events, and `ToolCallRequested` / `Started` / `Completed` / `Failed` (ADR decision 3) | Ours, decisively | +| No workspace relocation/rebind reconciliation found anywhere in the reviewed source | `WorkspaceRef` immutable per session; rebind requires a new session or fork (`proto/trogonai/session/sessions/v1alpha1/workspace.proto`) | Ours -- same answer as most of the corpus, more explicit | +| `EventLog` itself has no clear/delete/prune method; `delete_conversation` at the service layer *does* remove the conversation's directory outright | `SessionHidden` / `RedactionApplied` / `ArtifactErased` -- three graduated, distinct operations (ADR decision 7) | Mixed -- see "What not to copy" | +| `_count_events_on_disk()` -- full directory listing on every append, O(N²) across a session's life (#3906) | Server-side `Nats-Expected-Last-Subject-Sequence` compare-and-swap, O(1) at the broker, for `At`-guarded commands; no guard at all for `Any`-guarded commands | Ours, decisively -- see "the two gaps the industry has not closed" | +| `search_conversations` / `count_conversations`: full linear scan over all conversations per call; `sub_conversation_ids`: full linear scan per call | `SessionProjection`, a KV-backed read model incrementally checkpointed after each event via `Projector::catch_up` (ADR decision 8) | Ours -- incremental vs. full-scan-per-request | +| Free-form `tags` in `base_state.json` | No equivalent anywhere in the catalog (grep-confirmed) | Gap -- see recommendation 2 | + +## What we should consider changing + +### 1. Do not adopt a second, non-replayable authoritative store + +**The change** this would be: adding a `base_state.json`-style document +outside the event log to hold per-session config, secrets, or metadata that +the log itself does not carry -- i.e., contradicting ADR decision 1 +("every retroactive operation is a new appended event") and decision 8 +("no read model is authoritative"). + +**Evidence anchor**: OpenHands dossier (store maturity 9/12), the +`base_state.json` / `events/` split described in the structural-difference +section above, specifically that agent config, secrets, and tags "have no +event representation" and cannot be rebuilt from `events/` alone. + +**Blast radius**: Breaking the decision ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decisions 1 and 8). + +**Why not**: walking through OpenHands' own four categories shows the split +buys them very little that a well-designed event log can't already do more +cheaply. Config already folds from `SessionStarted.execution_plan`. Secrets +are better solved by never admitting them to the log (our ingress rule) than +by admitting them to a *different* durable document that still has to be +protected, backed up, and kept in sync with restore/migration tooling. The +only thing their split earns that ours structurally can't is movable HEAD +navigation, which is a different trade-off (see below), not a reason to +duplicate authority. A second authoritative store also reintroduces exactly +the "which one wins" ambiguity [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) was written to remove -- the dossier's +own account of `_resolve_active_leaf()` falling back to a best-effort scan +when the HEAD pointer is unset or stale is a symptom of that ambiguity, not a +feature of it. + +**Cost beyond migration**: a second store means a second backup path, a +second consistency check on resume, and a second thing that can drift from +the log during a partial write -- the exact failure class decision 1 exists +to close off. + +### 2. Add a generic session-tags concept + +**The change**: a new event, e.g. `SessionTagged` (or a `repeated string +tags` field folded the same way `SessionRenamed`/`SessionArchived` already +are), mirroring the existing "reversible listing-state" pattern the catalog +uses for session organization. + +**Evidence anchor**: OpenHands' `base_state.json.tags` field (per the +dossier's account of `state.py`), a real, shipped, user-facing categorization +mechanism with no event representation on their side either; and our own +grep of `proto/trogonai/session/sessions/v1alpha1/` returning zero hits for +any tag-like field. + +**Blast radius**: Additive. + +**Why**: this is cheap and low-risk precisely because the pattern already +exists in the catalog (`SessionRenamed`, `SessionArchived`, +`SessionUnarchived`) -- tags are one more piece of user-facing listing +metadata, not a new kind of state. It closes the one category from the +structural-difference analysis that is a genuine gap rather than a +solved-differently problem. + +**Cost beyond migration**: one more event type to fold into +`SessionProjection`, and a decision (left open below) on whether tag mutation +should be its own event or ride along on an existing organization event. + +### 3. Do not add a delegation-cost rollup field to `OperationOutcomeRecorded` + +**The change** this would be: adding an aggregated `TokenUsage`/cost field to +`OperationSucceeded` (`proto/trogonai/session/sessions/v1alpha1/operation_outcome_recorded.proto`) +so a parent gets a rolled-up cost for a completed delegation without folding +the child's stream. + +**Evidence anchor**: OpenHands' `usage_to_metrics[f"task:{task.id}"]` +rollup (per the dossier), which aggregates a sub-agent's LLM usage into the +parent's stats under a task-id key. + +**Blast radius**: Additive (if done) / no-op (rejected). + +**Why not**: OpenHands' rollup is in-memory only -- not durable, not part of +`events/`, and lost on restart unless recomputed. It is weaker evidence for +the field than it looks. More importantly, adding a denormalized rollup +contradicts the discipline the rest of our own catalog already follows: cost +and usage are recorded once, per message, on the stream that produced them +(`TokenUsage`, decision 3), and anything that needs an aggregate is expected +to fold the relevant stream rather than carry a second, potentially-stale +counter. `OperationSucceeded` already carries `response_digest` and an +optional `response_ref`; it should stay a receipt, not a ledger. + +**Cost beyond migration**: none avoided -- this is a "do not do this" entry +specifically so the idea isn't re-proposed the next time someone reads +OpenHands' `usage_to_metrics` and wants to imitate it. + +## What our design already does better + +- **Turn identity is a stamped fact, not an inferred one.** OpenHands has no + turn/round concept at all -- only per-event `parent_id` tree position, which + encodes branch topology, not conversational turn boundaries. Decision 3's + `turn_id` on `UserMessageRecorded`, `AssistantMessage*`, and the + `ToolCallRequested`/`Started`/`Completed`/`Failed` family exists precisely + because concurrent `Any`-precondition appends give no reliable "next event + after" relation to infer membership from -- a problem OpenHands' tree + doesn't need to solve because it has no turn concept to begin with, not + because it solved it more cheaply. +- **Fork is O(child events), not a deep copy.** OpenHands' fork performs a + JSON round-trip of the full (or branch-sliced) event set into a new + conversation directory -- an actual physical copy. `SessionForked` (decision + 5) inherits by reference via a context projection; replay cost is bounded + by the child's own events regardless of fork depth or how large the parent + became before the fork. +- **Cascade and detach are typed, reconciler-driven, and recorded as facts -- + not left as a side effect of deletion.** Both of OpenHands' delegation + mechanisms orphan children rather than cascade: the agent-server's + `parent_conversation_id` is explicitly left dangling on parent delete (the + dossier quotes the code comment: children are "orphaned, not cascaded"), + and the SDK's Task-tool subagent directories are documented as never + garbage-collected when the parent conversation persists. Decision 6's + `CascadePolicy` (`CASCADE_ON_PARENT_TERMINAL` / `INDEPENDENT`) makes this an + explicit, typed, per-delegation fact recorded at dispatch time, with a + reconciler that repairs the link after a crash -- not an implicit + consequence of whichever code path happens to run at delete time. +- **Concurrency control is broker-native, not a filesystem scan.** OpenHands' + `_count_events_on_disk()` performs a full directory listing on every + append to detect a stale index -- an O(N²) cost across a session's life, + still unfixed at the pinned commit (#3906, a measured 33x slowdown at + 2,000 events). Our `At`-guarded commands use JetStream's native + `Nats-Expected-Last-Subject-Sequence` header, an O(1) broker-side + compare-and-swap (decision 2); `Any`-guarded commands carry no server-side + position check at all. Neither path has an analogue to a client-side + listdir. +- **Privacy is graduated and explicit, not a single destructive delete.** + OpenHands' `delete_conversation` removes the conversation's directory + outright -- an irreversible deletion with no distinction between "hide from + listings" and "erase the bytes." Decision 7 replaces the single + `SessionDeleted` concept with three distinct, ordered operations -- + `SessionHidden` (visibility tombstone), `RedactionApplied` (read-time + masking, bytes remain), `ArtifactErased` (out-of-band byte destruction, + digest and metadata remain as provenance) -- specifically because a single + "delete" conflates operations with very different guarantees. +- **The read model is versioned where OpenHands independently agrees it + should be, and unversioned where OpenHands independently agrees it should + be.** OpenHands has no `schema_version` on `Event` or conversation state, + but does have one on the sibling `PersistedSettings` document -- a + mutable, whole-document read-modify-write structure, unlike the + append-only event log. That is exactly the split ADR decision 3 (additive, + unversioned event evolution) and decision 8 (`SessionProjection` versioned + at the package level, `projections/v1`) already encode. OpenHands arriving + independently at the same split is evidence *for* decision 3, not a gap in + it -- see "Open questions" for the one nuance worth flagging. + +## Trade-offs, not gaps + +- **Movable HEAD / in-place branch navigation vs. no in-aggregate branch + switching.** OpenHands' `leaf_event_id` in `base_state.json` lets a caller + `navigate_to()` any prior event and grow a new sibling branch from it, all + within one conversation id, with earlier branches still on disk and + re-selectable later. Our model has no equivalent: revisiting an earlier + point in a session's history means either a forward rewind marker + (`SessionRewound.keep_through`, not reversible except by another rewind) + or a fork into a brand-new session id (decision 5). OpenHands buys + cheap, repeated, non-destructive exploration of a single identity's history + at the cost of a movable, out-of-band pointer that must be persisted, + recovered on crash (the dossier's `_resolve_active_leaf()` fallback, + referencing bug #4057, for when that pointer is unset or stale), and kept + distinct from the append-only log it points into. We buy a purely + fold-derived position (`SessionOrdinal`, decision 2) with nothing extra to + persist or recover, at the cost of every branch exploration outside a + single rewind becoming a new session identity rather than a revisitable + node in one tree. Neither is strictly better; they answer different + questions about what "one session" is allowed to mean. +- **Contiguous compaction range vs. arbitrary forgotten-event set.** + `Compacted.covers_from`/`covers_through` (decision 4) is a contiguous + inclusive range, validated as such (`covers_from <= covers_through`). + OpenHands' `Condensation.forgotten_event_ids` is a set, which is + structurally capable of expressing a non-contiguous forgetting policy + (keep some early events, drop a non-contiguous middle segment, keep + recent ones). The range form is simpler to validate and query; the set + form is more expressive. Whether that extra expressiveness is ever + exercised in practice is unconfirmed -- see Open questions. + +## What not to copy + +- **A second permanently authoritative document alongside the log** + (`base_state.json`). Even setting aside decisions 1 and 8, it is a second + thing that must be backed up, migrated, and kept consistent with the log + it cannot be derived from or rebuild. +- **Client-side, unindexed directory listing as a concurrency check** + (`_count_events_on_disk()`). This is the clearest anti-pattern in the + dossier: an O(N²) cost across a session's life, filed as #3906, still + unfixed at the pinned commit. +- **In-memory-only cost rollups** (`usage_to_metrics[f"task:{task.id}"]`) as + a substitute for folding the source of truth. Not durable, and duplicates + what a projection can already compute by folding the child's own stream. +- **Physically deleting the event directory on `delete_conversation`** with + no masking or erasure distinction. This forecloses audit and rewind + entirely and collapses exactly the "hide vs. redact vs. erase" distinction + decision 7 was written to preserve. +- **A cleanup flag that is silently inert.** The dossier notes + `delete_on_close=True` is accepted as a parameter but never checked by the + code path that would need to act on it (`LocalConversation.close()`). A + control surface that looks like it does something and does nothing is + worse than no control surface at all. +- **Two structurally different, non-overlapping delegation mechanisms** + (the SDK's ephemeral Task tool and the agent-server's + `parent_conversation_id`) that both still leave children orphaned rather + than cascaded, and whose subagent directories are never garbage-collected + when the parent persists. Pick one coherent model; decision 6 already did. +- **Filesystem locking with a documented network-filesystem disclaimer** + used for the primary event log. The `ConversationLease` design (TTL, + monotonic generation, PID liveness) is sound engineering, but it sits on + top of `LocalFileStore`'s `FileLock`, which the dossier notes is + explicitly documented as unreliable on NFS. A broker-native log removes + the need for a filesystem lease altogether. + +## The two gaps the industry has not closed + +### Subagent cascade + +OpenHands' evidence tests decision 6 twice, on two independent mechanisms, +and both times lands on the same answer: orphan, don't cascade. On the +agent-server side, the dossier quotes the code path directly -- deleting a +parent leaves `parent_conversation_id` dangling on any children, described +in-code as "orphaned, not cascaded," the same treatment given to +`forked_from_conversation_id` on source deletion. On the SDK side, a +sub-agent's own crash inside `_run_task()` is caught, recorded as +`task.error`, and reported to the parent via `TaskObservation(is_error=True)` +-- but whatever the sub-agent had already durably appended to its own +`events/` remains on disk, untouched, with no rollback and no cascade signal +beyond that one observation. Neither mechanism has any documented behavior +for a *parent* rewind cascading to a live child, and neither has a +crash-of-the-parent-host cascade path distinct from the parent's own +in-process exception handling -- the dossier's crash-recovery machinery +(`ConversationLease`) governs which process owns a conversation, not what +happens to that conversation's children when it is lost. + +This validates, rather than challenges, decision 6. The ADR's own comment on +`CascadePolicy` in +`proto/trogonai/session/sessions/v1alpha1/cascade_policy.proto` calls +`CASCADE_ON_PARENT_TERMINAL` the "safe default" precisely because +orphan-by-default -- the choice both of OpenHands' independent delegation +mechanisms make -- is the failure mode it exists to prevent. The dossier's +observation that Task-tool subagent directories are never garbage-collected +when the parent persists is a concrete instance of exactly the drift decision +6's typed, reconciler-repaired cascade (`ParentTerminated`/ +`ParentHistoryInvalidated`/`SessionCancelled`) and decision 7's +`SessionHidden`-based visibility model were designed to replace with an +explicit, recorded fact instead of a silent, permanent directory leak. Where +OpenHands refines the picture is in showing that the ADR's own +"parent-first dispatch, then link" ordering (`DelegationDispatched` → +`ParentLinked`) is not something every product bothers to get right even +once, let alone twice: OpenHands ships two disjoint answers to the same +question and neither one closes the gap decision 6 closes with one. + +### Retention on an unbounded log + +OpenHands confirms, with numbers, that "keep forever" without a bounding +mechanism is a shipped, user-visible failure, not a theoretical one: #3926 +(event-log corruption reported at roughly 100,000 events), #3906 (a measured +33x append-time slowdown at 2,000 events, from the O(N²) +`_count_events_on_disk()` directory listing, still present at the pinned +commit), and #1824 (a maintainer's own account of conversations reaching +roughly 30,000 events and becoming unusably slow or crash-prone). Its +`Condensation` event is instructive here too: condensation is an ordinary, +durably persisted event, exactly like our `Compacted` (decision 4) -- only +the in-memory `View` shrinks; `events/` itself never does. That part of the +picture matches our own design already (decision 7 explicitly accepts that +the log grows forever and treats storage as a capacity-planning concern, not +something compaction reduces). + +Testing OpenHands' three specific cost categories against decision 7 (plus +decisions 2 and 8, which is where the actual bounding mechanisms live): + +- **Per-append cost.** OpenHands' failure is a client-side, unindexed, + full-directory listing on every append. Our `At`-guarded commands use + JetStream's native `Nats-Expected-Last-Subject-Sequence` header, an O(1) + broker-side compare-and-swap; `Any`-guarded commands carry no server-side + position check at all. Neither path has an analogue to a listdir. No gap. +- **Listing/projection-catch-up cost.** OpenHands' `search_conversations`, + `count_conversations`, and `sub_conversation_ids` are each a full linear + scan at request time, with cost proportional to total conversation count. + Decision 8's `SessionProjection` is checkpointed after each event via + `Projector::catch_up`, so a query's cost tracks events since the last + checkpoint, not the size of the whole catalog. No gap, provided the + projector is kept caught up. +- **Resume/replay cost.** OpenHands' cold resume rebuilds the active branch + with, per the dossier, no page or cursor bound. Decision 8 states resume + cost "tracks snapshot cadence, not transcript length." This is a real + structural answer, but it is conditional on snapshots actually being taken + regularly -- the ADR does not pin a maximum snapshot staleness anywhere in + the text reviewed, and exact snapshot cadence is explicitly left to + implementation-level follow-up in the Non-Goals. If that cadence is + allowed to lag, replay cost degrades toward the same profile as + OpenHands' unbounded active-branch replay. This is the one place OpenHands' + evidence refines decision 7/8 rather than simply validating it -- see Open + questions. + +## Open questions for the ADR + +- Decision 8 states that resume cost "tracks snapshot cadence, not + transcript length," but neither the Decision nor the Non-Goals sections + pin a maximum snapshot staleness. Given OpenHands' quantified failure + thresholds (corruption near 100,000 events, a 33x slowdown at 2,000, and a + maintainer-reported 30,000-event conversation becoming unusable), is a + numeric snapshot-cadence bound worth recording now, even as a target for + the implementation-level follow-up the ADR already defers this to, rather + than leaving it fully open? +- Should a generic session `tags` concept (recommendation 2) be its own + event (`SessionTagged`), or does free-form categorization belong on a + metadata surface outside the append-only contract entirely? The catalog + currently has no organization-state precedent for arbitrary, user-defined + values (as opposed to the fixed `SessionRenamed`/`SessionArchived`/ + `SessionUnarchived` set). +- Is `Compacted`'s contiguous `covers_from`/`covers_through` range shape ever + going to be insufficient for a condenser policy that wants to keep some + early context and drop a non-contiguous middle segment in a single marker? + OpenHands' `forgotten_event_ids: set[EventID]` permits this structurally, + though the dossier does not confirm any condenser policy actually exercises + non-contiguous forgetting in practice. [inference] +- Do we want any form of non-destructive, within-identity branch + re-exploration (OpenHands' `navigate_to()`), or is minting a new session + via fork the accepted, permanent answer for every case where a caller + wants to revisit an earlier point and try something different? This is a + product-scope question, not a storage-design gap -- the trade-off section + above states what each side buys. diff --git a/docs/research/session-store/products/pi/index.md b/docs/research/session-store/products/pi/index.md new file mode 100644 index 000000000..8a8288579 --- /dev/null +++ b/docs/research/session-store/products/pi/index.md @@ -0,0 +1,1339 @@ +# Pi: 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 +[earendil-works/pi](https://github.com/earendil-works/pi) at commit +`a96fb984d8c8b065fc5d193309fc812a882adee0` (committed 2026-08-03 22:50:31 ++0000, "chore: approve contributors from issue #7554"), MIT licensed +(`LICENSE`, copyright Mario Zechner). Retrieved and verified 2026-08-04. The +repository was previously named `pi-mono`; in-repo docs still link to +`github.com/earendil-works/pi-mono/blob/main/...`, which is the pre-rename +name of this same repository, not a separate product. Citations below are +`path:line` against the pinned commit unless otherwise noted. + +Anchors used, by package: + +- `@earendil-works/pi-coding-agent` (`packages/coding-agent/`) -- the shipped + `pi` CLI/TUI. Its own hand-rolled session store: + `src/core/session-manager.ts`, `src/core/messages.ts`, + `src/core/compaction/{compaction.ts,branch-summarization.ts}`, + `src/modes/interactive/components/session-selector.ts`, + `docs/session-format.md`, `docs/sessions.md`, `docs/compaction.md`, + `scripts/migrate-sessions.sh`. +- `@earendil-works/pi-agent-core` (`packages/agent/`) -- a separate, pluggable + SDK-level session abstraction: `src/harness/types.ts`, + `src/harness/session/{repository.ts,jsonl-repo.ts,memory-repo.ts, + array-session-index.ts,search.ts,session.ts,keyed-operation-queue.ts}`, + `docs/harness.md`, `docs/harness-v2.md`, `test/harness/{session.test.ts, + session-backends.test.ts}`. +- `@earendil-works/pi-storage-sqlite-node` (`packages/storage/sqlite-node/`) + -- a third, SQLite-backed implementation of the same pluggable interface: + `src/sqlite/{repo.ts,search-backend.ts,migrations.ts,storage/*.ts}`. +- `@earendil-works/pi-client` / `@earendil-works/pi-protocol` + (`packages/client/`, `packages/protocol/`) -- a wire-protocol client for + attaching to a *live, running* session over a byte stream; not a storage + layer, but relevant to the multi-process story. +- `@earendil-works/pi-server` (`packages/server/`) -- the counterpart server + for the above (`src/sessions.ts`), not read in depth for this dossier. +- `@earendil-works/pi-ai` (`packages/ai/`) -- base `Message`/`Usage` types. + +**The single most consequential finding, load-bearing for every section +below:** the monorepo ships **two independent, non-integrated session +storage implementations**, plus a third that exists but is wired into +neither shipped product. The `pi` CLI's `SessionManager` +(`packages/coding-agent/src/core/session-manager.ts`) does not use, import, +or know about the pluggable `SessionRepository`/`SessionStorage` interface +from `pi-agent-core`. This is not inference: `coding-agent/package.json:46` +depends on `@earendil-works/pi-agent-core`, but every import site in +`packages/coding-agent/src` pulls only agent-loop primitives +(`Agent`, `AgentMessage`, `AgentState`, `AgentTool`, `ThinkingLevel`, +`setDefaultStreamFn`, `StreamFn`) -- never `SessionRepository`, +`JsonlSessionRepository`, `InMemorySessionRepository`, or anything under +`harness/session`. The checked-in format spec +(`packages/coding-agent/docs/session-format.md`) documents the CLI's format +but, in at least one place (compaction's `retainedTail` field, see +"Entry/message structure and versioning" below), quietly describes a field +the CLI never writes and only the harness's implementation produces -- the +doc conflates the two systems. Everywhere below, "the CLI" and "the harness" +are called out explicitly because their answers to the same research +question frequently differ. + +## The storage model + +Both systems agree on the physical shape: **one JSONL file per session**, +first line a header, every subsequent line one JSON object with a `type` +tag, entries linked into a tree via `id`/`parentId` rather than read +top-to-bottom as a flat log. The checked-in spec states this directly: +"Sessions are stored as JSONL (JSON Lines) files. Each line is a JSON object +with a `type` field. Session entries form a tree structure via `id`/`parentId` +fields, enabling in-place branching without creating new files." +(`packages/coding-agent/docs/session-format.md:3`). + +Source of truth vs. derived state: + +- **The CLI** (`SessionManager`): `this.fileEntries: FileEntry[]` (header + + entries, in on-disk order) is the authoritative record -- it is what + `_rewriteFile()` and `_persist()` write + (`packages/coding-agent/src/core/session-manager.ts:979-989,1015-1042`). + `this.byId`, `this.labelsById`, `this.labelTimestampsById`, and + `this.leafId` are rebuildable indexes/projections: `_buildIndex()` + reconstructs all four from `fileEntries` in a single forward pass every + time a file is loaded (`session-manager.ts:957-976`). Critically, + `this.leafId` is *not* itself persisted anywhere in the header or as a + distinct entry type in the CLI's format -- see "Read and resume path" below + for what this implies. +- **The harness** (`pi-agent-core`): the JSONL file (or, for + `InMemorySessionRepository`, an in-process array) is authoritative. + `ArraySessionIndex` (`packages/agent/src/harness/session/ + array-session-index.ts`) is an explicit, named rebuildable projection -- + its own doc comment says so: "Ordered entries and derived projections for + array-backed session storage" (`array-session-index.ts:57`). It derives + `leafId`, a `labelsById` map, a session `name`, and running token/cost + `stats` from a linear scan of the entries (`applyProjection`, + `array-session-index.ts:24-55`), and is rebuilt wholesale on every load + via `replace()` (`array-session-index.ts:82-99`). Unlike the CLI, the + harness's `leafId` derivation also honors a first-class `LeafEntry` + (`type: "leaf"`) when present -- see "Rewind, checkpoints, and fork". +- **The SQLite backend** (`pi-storage-sqlite-node`): genuinely different -- + here the database *is* the source of truth in the SQL sense (rows in a + `sessions` table and a `session_entries` table, + `packages/storage/sqlite-node/src/sqlite/storage/sessions.ts:4-10`), and a + parallel `session_search_fts` virtual table is an explicitly + trigger-maintained secondary index (`search-backend.ts:35-52`), not a + separate rebuild-from-scratch cache. This is the only one of the three + backends with real transactional durability (`db.transaction(...)`, + `repo.ts:87-88`) and an actual indexed full-text search subsystem. + +Conceptual model: none of RESEARCH_PROMPT's categories fits cleanly. +"Session-as-transcript" undersells it (there's no single linear order to +replay -- see below), and "session-as-log" undersells the tree. The best +description is **session-as-tree-of-entries, materialized as an append-only +JSONL log whose physical (on-disk) order is not the same thing as its +logical (parent-chain) order**. Every entry still has a place in on-disk +append order (line N), but the entries a reader must assemble to reconstruct +"the current conversation" are found by walking `parentId` pointers from a +leaf back to a root, not by reading the file start-to-finish. Two different +entries appended at physically adjacent lines can belong to two different +branches with no ancestor/descendant relationship to each other. + +## Keying and identity + +- **File path** encodes the CWD and is where identity effectively lives for + the CLI, since there is no session-id-indexed lookup structure: `~/.pi/ + agent/sessions/----/_.jsonl` + (`packages/coding-agent/docs/session-format.md:5-11`). Both the CLI and + the harness independently implement the *same* encoding scheme in two + places: + - CLI, `getDefaultSessionDirPath` -- `` `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--` `` + (`packages/coding-agent/src/core/session-manager.ts:479-482`). + - Harness, `encodeCwd` -- the identical regex, verbatim + (`packages/agent/src/harness/session/jsonl-repo.ts:186-188`). + + Neither module imports the other's implementation; the scheme is + duplicated by convention rather than shared code. +- **Session id**: both systems mint a `uuidv7()` (time-ordered, so + lexicographic/creation order coincide) -- CLI's `createSessionId()` + (`session-manager.ts:208-210`) and the harness's `createSessionId()` + (`packages/agent/src/harness/session/repository.ts:14-16`), both from + `@earendil-works/pi-ai`'s re-exported `uuidv7`. `assertValidSessionId()` + constrains a caller-supplied id to + `^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$` (`session-manager.ts:212-219`). +- **Entry id**: the two systems use *different* minting strategies for the + same conceptual thing. CLI's `generateId()` takes the first 8 hex + characters of a fresh `crypto.randomUUID()`, retried up to 100 times + against the in-memory `byId` set before falling back to a full UUID + (`session-manager.ts:221-227`). The harness's `Session.createEntryId()` + instead takes the last 8 characters of a `uuidv7()`, same 100-try + collision loop and full-UUID fallback (`packages/agent/src/harness/session/ + session.ts:229-235`). Both produce short, collision-checked but + *not globally unique* ids -- uniqueness is only guaranteed within one + session file's `byId`/index, confirmed independently by the harness's own + append guard: `JsonlSessionBackend.appendEntry()` throws `SessionError("invalid_entry", "Entry ${entry.id} already exists")` + if the id is already present in that session's index (`jsonl-repo.ts:270`). +- **Listing scope**: scoped-by-default with an explicit global escape hatch, + on both sides. + - CLI: `SessionManager.list(cwd, sessionDir?, onProgress?)` walks one + directory and additionally filters by header `cwd` when the caller + supplied a non-default `sessionDir` + (`session-manager.ts:1637-1646`, `sessionCwdMatches` at `:630-632`). + `SessionManager.listAll(onProgress?)` instead walks every subdirectory + under the sessions root (`session-manager.ts:~1697` onward, confirmed + call site using `buildSessionInfosWithConcurrency` over `allFiles` + gathered across all `----` directories). + - Harness: `JsonlSessionListOptions { cwd?: string }` + (`packages/agent/src/harness/types.ts:579-581`). When `cwd` is omitted, + `JsonlSessionBackend.listSessions()` calls `listSessionDirs()`, which + enumerates every directory under the sessions root + (`jsonl-repo.ts:220-234, 421-426`) -- i.e. cross-project by default + unless a `cwd` is passed. +- **Relocation/rename reconciliation**: there is none, beyond a one-time + historical bugfix script. `packages/coding-agent/scripts/ + migrate-sessions.sh` exists specifically to move session files that were + (per its own comment) "saved to `~/.pi/agent/*.jsonl`" directly, "instead + of `~/.pi/agent/sessions//`" due to "the bug in v0.30.0" + (`migrate-sessions.sh:1-6`). It re-derives the target directory from the + header's `cwd` field via `jq` and a shell port of the same encoding regex + (`migrate-sessions.sh:41-62`), and is a manual, one-off operator tool, not + something the CLI or harness runs automatically. Beyond that: if a + project's working directory is literally renamed or moved, its encoded + session directory does not move with it, and there is no reconciliation + path found in source -- a session opened by explicit `--session ` + still works, but `pi -c` / `pi -r` / `SessionManager.continueRecent()` + scoped to the *new* cwd path will not find it (this is inference from the + directory-derivation code; no explicit relocation-detection logic was + found -- see Open questions). +- **Renaming the session's display name** (distinct from the file path) is + implemented as a new tree entry, not a file or header rewrite. The + interactive picker's rename callback opens a *fresh* `SessionManager` on + the target path and calls `appendSessionInfo(next)`: + ```ts + renameSession: async (sessionFilePath: string, nextName: string | undefined) => { + const next = (nextName ?? "").trim(); + if (!next) return; + const mgr = SessionManager.open(sessionFilePath); + mgr.appendSessionInfo(next); + }, + ``` + (`packages/coding-agent/src/modes/interactive/interactive-mode.ts:5069-5075`). + `appendSessionInfo()` appends a `SessionInfoEntry` as a child of *that + manager's current leaf* (`session-manager.ts:1135-1147`), and + `getSessionName()` resolves the display name by scanning entries in + reverse for the most recent `session_info` entry, where "empty names + explicitly clear the session title" (`session-manager.ts:1149-1160`). + Renaming a session that is not the active one therefore appends to + whatever the *last persisted* leaf was, independent of any in-memory + navigation state of the process actually running that session. + +## The store interface + +There is no single interface; there are three implementations of one +pluggable interface (harness) plus one separate, non-pluggable concrete +class (CLI). Per RESEARCH_PROMPT's instruction, both are captured in full. + +### 1. The pluggable interface (`@earendil-works/pi-agent-core`) + +Two verbatim contracts. First, the repository-level contract -- how a caller +creates, opens, lists, deletes, or forks a session: + +```ts +export interface SessionRepository< + TMetadata extends SessionMetadata = SessionMetadata, + TCreateOptions extends SessionCreateOptions = SessionCreateOptions, + TListOptions = void, +> extends AsyncDisposable { + create(options: TCreateOptions): Promise>; + open(metadata: TMetadata): Promise>; + list(options?: TListOptions): Promise; + delete(metadata: TMetadata): Promise; + fork(source: TMetadata, options: SessionForkOptions & TCreateOptions): Promise>; +} +``` +(`packages/agent/src/harness/session/repository.ts:22-32`). All five methods +are required; there is no optional method on this interface. `AsyncDisposable` +requires an `[Symbol.asyncDispose]` implementation as well, used to drain +pending writes on shutdown (see "Write and append path"). + +Second, the per-session storage contract -- what an opened session can do +against its own entries once a repository has produced it: + +```ts +export interface SessionStorage { + readonly metadata: TMetadata; + /** Rejects with `invalid_session` when a non-null active leaf does not reference a stored entry. */ + readHead(): Promise; + readEntry(id: string): Promise; + readEntries(options?: SessionEntryCursorOptions): Promise; + appendEntry(entry: SessionTreeEntry): Promise; + findEntriesOnBranch(query: SessionBranchQuery & { start: string | null }): Promise; + readPathToRootOrCompaction(leafId: string | null): Promise; + getLabel(id: string): Promise; + getName(): Promise; + getStats(): Promise; +} +``` +(`packages/agent/src/harness/types.ts:558-571`). All nine members are +required (`metadata` is a required readonly property, not a method). None +are marked optional in the source. + +Supporting types referenced by the contract, also verbatim: + +```ts +export interface SessionMetadata { id: string; createdAt: string; } +export interface SessionCreateOptions { id?: string; } +export interface SessionForkOptions { entryId?: string; position?: "before" | "at"; id?: string; } +export type SessionForkSelection = + | { kind: "all" } + | { kind: "before_user_message"; entryId: string } + | { kind: "through_entry"; entryId: string }; +export interface SessionBranchQuery { + start?: string | null; + stopAtType?: SessionTreeEntry["type"]; + stopAtId?: string; + type?: SessionTreeEntry["type"]; + customType?: string; + order?: "newestFirst" | "oldestFirst"; + limit?: number; +} +export interface SessionEntryCursorOptions { afterEntrySeq?: number; limit?: number; } +export interface SessionHead { leafId: string | null; } +``` +(`packages/agent/src/harness/types.ts:481-484, 501-503, 523-556, 493-497`). + +Three concrete implementations of `SessionRepository` exist in the pinned +commit: + +| Implementation | Package | Backing store | Concurrency primitive | +|---|---|---|---| +| `JsonlSessionRepository` / `JsonlSessionBackend` | `pi-agent-core` | one JSONL file per session, via an injected `FileSystem` capability | `KeyedOperationQueue` keyed by session path (`jsonl-repo.ts:100`) | +| `InMemorySessionRepository` / `InMemorySessionBackend` | `pi-agent-core` | `Map` in process memory | `KeyedOperationQueue` keyed by session id (`memory-repo.ts:29`) | +| `SqliteSessionRepository` / `SqliteSessionBackend` | `pi-storage-sqlite-node` | SQLite database (`sessions`, `session_entries` tables), via an injected `SqliteDatabaseFactory` | a simpler `SerialOperationQueue` -- one global tail, no per-key sharding, no concurrency cap (`packages/storage/sqlite-node/src/sqlite/repo.ts:48-61`), backed by real `db.transaction(...)` calls | + +Additionally there is a generic, non-indexed search adapter usable with any +of the above: + +```ts +export function createScanningSessionSearch( + source: Pick, "list" | "open">, +): SessionSearch +``` +(`packages/agent/src/harness/session/search.ts:42-48`) -- see "Listing, +summaries, and search" for what it actually does, and how the SQLite +package's `SqliteSessionSearch` differs by being a real index instead. + +None of these three `SessionRepository` implementations, nor the harness's +`SessionStorage`/`Session` abstraction at all, are imported anywhere under +`packages/coding-agent/src` (confirmed by grep across the whole +`coding-agent` source tree; the only `@earendil-works/pi-agent-core` imports +there are `Agent`, `AgentMessage`, `AgentState`, `AgentTool`, +`ThinkingLevel`, `setDefaultStreamFn`, `StreamFn`). The shipped `pi` CLI does +not use the pluggable interface described above at all. + +### 2. The CLI's own interface (reconstructed; `SessionManager`) + +`packages/coding-agent/src/core/session-manager.ts` has no exported +interface type; `SessionManager` is a single concrete class instantiated +directly. The effective operational contract, reconstructed from its public +surface (spec's own summary at +`packages/coding-agent/docs/session-format.md:386-439` cross-checked +against the class body): + +| Operation | Signature (reconstructed) | Notes | +|---|---|---| +| Create | `static create(cwd, sessionDir?, options?): SessionManager` (`session-manager.ts:1519-1522`) | Constructs and defers the actual file write (see write path) | +| Open | `static open(path, sessionDir?, cwdOverride?): SessionManager` (`:1530-1548`) | Loads and, if needed, migrates the file synchronously | +| Continue most recent | `static continueRecent(cwd, sessionDir?): SessionManager` (`:1557-1564`) | Scans directory mtimes via `findMostRecentSession` | +| In-memory | `static inMemory(cwd?, options?): SessionManager` (`:1568-1570`) | `persist = false`; never touches disk | +| Fork (whole file) | `static forkFrom(sourcePath, targetCwd, sessionDir?, options?): SessionManager` (`:1579-1630`) | Copies every non-header entry verbatim; see "Rewind, checkpoints, and fork" | +| List (scoped) | `static list(cwd, sessionDir?, onProgress?): Promise` (`:1637-1646`) | | +| List (global) | `static listAll(onProgress?): Promise` | Cross-project | +| Append message | `appendMessage(message): string` | Returns new entry id | +| Append model/thinking/compaction/custom/label/name | `appendModelChange`, `appendThinkingLevelChange`, `appendCompaction`, `appendCustomEntry`, `appendCustomMessageEntry`, `appendLabelChange`, `appendSessionInfo` | All funnel through `_appendEntry` → `_persist` | +| Move leaf (in-memory only) | `branch(entryId): void` (`:1360-1365`) | No persisted marker -- see below | +| Move leaf + summary | `branchWithSummary(entryId, summary, details?, fromHook?, usage?): string` (`:1381-1409`) | Appends a `BranchSummaryEntry` | +| Reset leaf | `resetLeaf(): void` (`:1372-1374`) | Sets `leafId = null` | +| Extract branch to new file | `createBranchedSession(leafId): string \| undefined` (`:1412-1512`) | See below | +| Tree read | `getTree(): SessionTreeNode[]`, `getChildren(parentId): SessionEntry[]`, `getBranch(fromId?)`, `getEntries()`, `getEntry(id)` | `getTree` and `getChildren` are O(n) scans, not indexed | +| Context build | `buildContextEntries()`, `buildSessionContext()` | Compaction-aware; see "Compaction" | +| Info | `getSessionName()`, `getHeader()`, `getCwd()`, `getSessionId()`, `getSessionFile()`, `isPersisted()` | | + +There is **no `delete()` method on `SessionManager` at all.** Deletion is +implemented entirely one layer up, in the interactive TUI's session +selector -- see "Retention, deletion, and multi-host". + +Ordering/consistency guarantees for the CLI's reconstructed interface: every +mutating call above is synchronous, runs on the Node.js single-threaded +event loop, and mutates `this.fileEntries`/`this.byId`/`this.leafId` in +place before (deferred) persistence -- there is no async gap in which a +second in-process caller could interleave a conflicting mutation against the +*same* `SessionManager` instance. There is, however, no protection at all +against two different `SessionManager` instances (in the same process or a +different one) writing to the same file path concurrently -- see "Write and +append path". + +## Write and append path (ordering, durability, concurrency, delivery) + +**Ordering.** Both systems order entries by position in the JSONL file, +which for the CLI is simply array-push order into `fileEntries` +(`session-manager.ts:1042-1046`, `_appendEntry`) and for the harness is +`appendEntry` on the storage backend after the in-process `appendTail` +promise resolves (`session.ts:240-249`). There is no separate sequence +number field on entries in either format -- physical line position *is* the +ordering key, and `SessionEntryCursorOptions.afterEntrySeq` in the harness +interface is explicitly described as "sequence" over array index +(`packages/agent/src/harness/types.ts:493-497`), confirmed by `ArraySessionIndex.readEntries()` +slicing its in-memory array by that index (`array-session-index.ts:112-116`) +-- i.e. it is a position into the already-loaded array, not a disk offset or +a durable per-entry counter. + +**CLI durability/atomicity.** Three distinct write shapes exist: + +1. *Deferred first write.* `_persist(entry)` checks whether any assistant + message exists yet in `fileEntries`; if not, and the manager has never + flushed, the entry is held only in memory (`this.flushed` stays `false`) + -- nothing touches disk (`session-manager.ts:1015-1024`). The comment + elsewhere in the class explains the intent is to avoid littering the + sessions directory with aborted, reply-less sessions. If the manager + *has* already flushed once (e.g. a reopened session with prior history), + entries are appended immediately even without an assistant message yet + (`:1018-1020`). +2. *First real flush.* Once an assistant message exists and the file has + never been flushed, `_persist` does an **exclusive create**: + `openSync(this.sessionFile, "wx")`, then writes every accumulated entry + with `writeFileSync`, closes, and sets `flushed = true` + (`session-manager.ts:1026-1034`). `"wx"` fails if the path already + exists, so this path assumes the file does not yet exist on disk. +3. *Steady-state append.* Once flushed, every subsequent entry is one + `appendFileSync(this.sessionFile, JSON.stringify(entry) + "\n")` call + (`:1035-1036`). + +None of these three paths call `fsync`, use a lock file, or write to a +temp path and rename. The **migration rewrite** path is the sharpest +contrast: `_rewriteFile()` opens the file with the truncating flag `"w"` +(not append, not a temp+rename swap) and rewrites every entry from scratch +(`session-manager.ts:979-989`), invoked synchronously from `_setSessionFile` +whenever `migrateToCurrentVersion()` reports a migration was applied +(`:895-919`). A process crash between `openSync(path, "w")` and the final +`closeSync` would leave a truncated, partially-rewritten file -- there is no +atomicity guard here (documented as an observation, not found stated as a +known risk in-repo; see Open questions). + +**Harness durability/atomicity.** `JsonlSessionBackend.appendEntry()` +performs one `fs.appendFile(metadata.path, JSON.stringify(entry) + "\n")` +call through the injected, abstract `FileSystem` capability +(`jsonl-repo.ts:257-276`), then commits the entry into the in-memory +`ArraySessionIndex` only after that write succeeds. `createDocument()` (used +by both `create()` and `fork()`) instead builds the entire file content -- +header plus every initial entry, joined by `\n` -- and issues **one** +`fs.writeFile()` call (`jsonl-repo.ts:354-382`). Whether the underlying +`FileSystem.writeFile`/`appendFile` implementation is itself atomic (temp +file + rename, fsync, etc.) is not specified by the `FileSystem` interface +itself (`packages/agent/src/harness/types.ts:291-341`, which only documents +"Create or overwrite a file, creating parent directories when supported") +and was not traced to a concrete Node filesystem adapter in this pass -- left +as an open question rather than asserted either way. + +**Concurrency model -- the key nuance.** Both the CLI and the harness only +serialize writers that share the same in-process object graph; **neither +implements any cross-process lock** (no `flock`, no lock file, no +compare-and-swap on an expected file size/mtime found anywhere in either +tree). + +- CLI: serialization is purely single-instance, single-thread -- there is no + queue at all, just synchronous mutation of one `SessionManager`'s own + state. Two `SessionManager` instances opened on the same path (same + process or different processes) have no coordination whatsoever. +- Harness: `KeyedOperationQueue` (`packages/agent/src/harness/session/ + keyed-operation-queue.ts`) provides two layers of in-process + serialization: + ```ts + enqueue(key: TKey, operation: () => Promise | T): Promise { + const previous = this.tails.get(key) ?? Promise.resolve(); + const result = Promise.all([this.barrier, previous]).then(() => this.runOperation(operation)); + const tail = result.then(() => undefined, () => undefined); + this.tails.set(key, tail); + void tail.then(() => { if (this.tails.get(key) === tail) this.tails.delete(key); }); + return result; + } + enqueueBarrier(operation: () => Promise | T): Promise { + const result = Promise.all([this.barrier, ...this.tails.values()]).then(() => this.runOperation(operation)); + this.barrier = result.then(() => undefined, () => undefined); + return result; + } + async drain(): Promise { + await Promise.all([this.barrier, ...this.tails.values()]); + } + ``` + (`keyed-operation-queue.ts:18-43`, exact). `enqueue` chains an operation + behind both a global `barrier` and the given key's own tail -- i.e. + per-key FIFO serialization, but every key-scoped operation also waits for + any pending barrier. `enqueueBarrier` does the opposite: it waits for the + barrier *and every currently-known key's tail*, then becomes the new + barrier itself, so it acts as a full stop-the-world checkpoint relative + to all in-flight per-key operations -- used by `JsonlSessionBackend.list()` + (`jsonl-repo.ts:220`) so a directory listing waits for every pending + append across every open session before reading directory contents. + `runOperation` additionally gates on an optional global semaphore + (`acquirePermit`/`releasePermit`, `:45-68`) bounding total concurrent + operations; `JsonlSessionBackend` defaults this to + `DEFAULT_MAX_CONCURRENT_OPERATIONS = 4` (`jsonl-repo.ts:44`). None of this + is a lock in the OS sense -- it is pure in-process promise-chain + serialization, scoped to one `KeyedOperationQueue` instance (i.e. one + `JsonlSessionBackend`/repository instance, i.e. effectively one process). + A second process opening the same JSONL file is completely uncoordinated + with the first, exactly as with the CLI. + In addition, `Session` itself (the per-session object handed back to + callers) layers a *third*, per-instance serialization on top: every append + method funnels through `enqueueAppend`, which chains onto a private + `appendTail: Promise` (`session.ts:157, 237-255`) -- so two calls on + the *same* `Session` object are strictly ordered even before either one's + promise reaches the backend's `KeyedOperationQueue`. + The SQLite backend uses a fourth, simpler primitive, + `SerialOperationQueue` -- a single global tail with no per-key sharding and + no concurrency cap (`packages/storage/sqlite-node/src/sqlite/repo.ts:48-61`) + -- but backs it with genuine SQLite transactions + (`db.transaction(() => ...)`, `repo.ts:87-88`), so its durability story is + categorically different (WAL mode, `PRAGMA synchronous=FULL`, + `PRAGMA busy_timeout=5000`, set in `configureSqliteDatabase`, + `repo.ts:35-39` and `search-backend.ts:19-23`) even though its in-process + concurrency model is cruder than the JSONL backend's. + +**Delivery semantics / idempotence.** The harness's `JsonlSessionBackend. +appendEntry()` explicitly rejects a duplicate id before writing: `if +(entries.has(entry.id)) throw new SessionError("invalid_entry", "Entry +${entry.id} already exists")` (`jsonl-repo.ts:270`) -- genuine client-side +dedup-by-entry-id, enforced against the in-memory index, not the file. The +CLI's `_appendEntry`/`_persist` path has **no equivalent check** -- nothing +in `session-manager.ts` guards against writing two entries with the same +`id` other than `generateId`'s own collision-avoidance loop at mint time +(`:221-227`); a caller that supplied its own colliding id (there is no public +API for that on the message/entry-append paths, but `newSession({id})` and +`forkFrom({id})` do accept caller-supplied session ids) would not be caught +here. + +## Read and resume path + +**Both systems fully materialize the file on open; there is no lazy/partial +load in either.** + +- CLI: `SessionManager.open(path, ...)` first does an *optional* fast-path + bounded header scan (`readSessionHeader`, capped at + `MAX_SESSION_HEADER_SCAN_BYTES = 1024 * 1024` bytes, + `session-manager.ts:494, 563-608`) purely to determine the session's `cwd` + before constructing the object -- the exception handler for + `SessionHeaderScanLimitError` explicitly documents the scan as "only a + discovery optimization. A full load remains authoritative for legacy files + with very large headers or prefixes." (`:1538-1541`). Regardless of that + outcome, `_setSessionFile` always calls `loadEntriesFromFile(path)` + (`:511-553`) to read the *entire* file into `this.fileEntries` -- a + streaming line-reader over `readSync` chunks, but the *whole* file is + buffered into one in-memory array before use. There is no cursor, no + partial materialization, and no size bound on this second, authoritative + read. +- Harness: `loadJsonlSession()` calls `fs.readTextFile(path)` (whole file at + once) and splits on `\n` (`jsonl-repo.ts:159-174`); the resulting entries + populate a fresh `ArraySessionIndex` in full (`jsonl-repo.ts:145-149`). + `SessionEntryCursorOptions{afterEntrySeq, limit}` on `readEntries()` looks + like pagination but is a slice over that already-fully-loaded in-memory + array (`array-session-index.ts:112-116`) -- not a disk-level cursor. + +**Resume reads the durable store directly; there is no separate local +cache checked first** on either side. The CLI's `_setSessionFile` reads the +target `.jsonl` file itself with no intermediate cache layer; the harness's +`JsonlSessionBackend` similarly loads straight from the injected +`FileSystem`, with the in-memory `entryIndexesByPath` map acting only as a +per-open-session cache to avoid re-reading on every operation, not as a +resume source distinct from the file (populated by `loadDocument`, +invalidated only implicitly by process lifetime). + +**What is materialized eagerly vs. lazily.** Both systems materialize the +raw entries eagerly on open, but neither eagerly computes the "active +context" (the LLM-ready message list) at open time -- that is built lazily, +on demand, by walking from leaf to root: +- CLI: `getBranch(fromId?)` walks `parentId` pointers from a leaf backward + (referenced throughout, e.g. `createBranchedSession` at `:1417`), and + `buildContextEntries()`/`buildSessionContext()` are only invoked when the + agent loop actually needs the next LLM request's message list. +- Harness: `Session.getBranch(fromId?)` calls + `storage.readPathToRootOrCompaction(...)` (`session.ts:184-186`), and + `buildContext()`/`buildContextEntries()` similarly derive the LLM view + from that path on demand (`session.ts:201-207`, and the module-level + `buildSessionContext`/`buildContextEntries` functions at + `session.ts:94-150`). + +**Active-branch (leaf) resolution on load -- asymmetry between the two +systems, one of this dossier's central findings.** The CLI's `_buildIndex()` +resolves the resumed leaf purely by *physical last line*: it iterates +`fileEntries` in on-disk order and unconditionally sets +`this.leafId = entry.id` for every non-header entry it visits +(`session-manager.ts:957-976`), so after a full reload the leaf is always +"whichever entry the file's last line encodes" -- i.e. append order, full +stop. The CLI's `branch(branchFromId)` -- the operation that moves the leaf +to an earlier point for `/tree` navigation -- is implemented as exactly one +line: `this.leafId = branchFromId;` (`session-manager.ts:1360-1365`), with +**no entry appended and nothing written to disk**. Consequence: if a CLI +process calls `branch()` to jump to an earlier point and then exits (or +crashes) before appending anything new, the file on disk is completely +unaffected -- a subsequent `SessionManager.open()` on that same file will +resolve the leaf back to the physically-last entry, silently undoing the +branch. The branch only "sticks" durably once a new entry is appended after +it, because that new entry becomes the new last physical line. The harness +does not have this gap: its `Session.moveTo(entryId, summary?)` calls a +private `setLeafId()` which itself appends a real, persisted `LeafEntry` +(`{ type: "leaf", targetId: string | null }`, `packages/agent/src/harness/types.ts:448-451`) via the +same `enqueueAppend` path as any other entry (`session.ts:257-264`), and +`ArraySessionIndex.append()`/`.replace()` both special-case this entry type +when deriving `leafId`: `` this.leafId = entry.type === "leaf" ? entry.targetId : entry.id; `` +(`array-session-index.ts:78, 92`). So in the harness, a bare leaf move with +no follow-up message is fully durable and survives a reload; in the CLI, it +is not durable until the next append. This is a real behavioral difference +between the two systems' definitions of "the current branch," not just an +implementation detail. + +**Orphaned/broken tree entries are tolerated, not fatal**, on the CLI side: +`getTree()`'s doc comment states "Orphaned entries (broken parent chain) are +also returned as roots" (`session-manager.ts:1307-1309`), and the +implementation treats any entry whose `parentId` does not resolve to a +known node as an additional root rather than throwing +(`:1322-1330`). The harness's equivalent traversal is stricter and fails +loudly instead: `findEntriesOnBranch()` throws `SessionError("invalid_session", "Entry ${current.parentId} not found")` +when a `parentId` does not resolve (`array-session-index.ts:136-139`), as +does `readPathToRootOrCompaction()` (`:180-183`). This is another CLI/harness +divergence worth flagging: the same broken-chain condition is silently +tolerated in one system and a hard failure in the other. + +## Listing, summaries, and search + +**Enumeration is a directory scan on all three backends; there is no +persistent index of "which sessions exist" anywhere.** + +- CLI: `SessionManager.list`/`listAll` call `readdirSync` over the target + directory (or every directory under the sessions root for `listAll`) and + then build a `SessionInfo` per `.jsonl` file found, bounded to + `MAX_CONCURRENT_SESSION_INFO_LOADS = 10` files in flight at once + (`session-manager.ts:769, 800`, `buildSessionInfosWithConcurrency` + at `:771-810`). +- Harness `JsonlSessionBackend.list()`: routed through + `enqueueBarrier` (so it waits out any in-flight writes first, + `jsonl-repo.ts:220`), then lists either the one `cwd`-derived directory or + every directory under the sessions root (`:222-234`), and calls + `loadJsonlSessionMetadata` per file -- but that helper only reads the + **first line** of each file (`fs.readTextLines(path, { maxLines: 1 })`, + `jsonl-repo.ts:150-156`), i.e. the harness's list operation is + header-only and cheap, unlike the CLI's. + +**A per-file summary/read-model *is* built at listing time, but only in +memory, and only by the CLI -- the harness's list returns bare metadata with +no summary.** `buildSessionInfo(filePath)` (`session-manager.ts:687-758`) +streams each candidate file line-by-line with Node's `readline` (not the raw +`readSync` loop `loadEntriesFromFile` uses) and accumulates: header id/cwd/ +`parentSessionPath`, the latest `session_info` name (including explicit +clears -- `name = entry.name?.trim() || undefined`, `:711-713`), a running +`messageCount`, the most recent user/assistant "activity" timestamp, +`firstMessage` (first user message's text), and **`allMessagesText`** -- the +concatenation of every user/assistant message's extracted text +(`:735-741`). This is a genuine per-file read-model, but it is rebuilt from +scratch on every `list()`/`listAll()` call -- nothing persists it to disk as +a sidecar, so its cost scales linearly with total session count and total +bytes across all session files on every picker open, bounded only by the +concurrency cap above. This matches the doc's framing of `/resume` as +letting the user "search by typing" (`packages/coding-agent/docs/ +sessions.md:39-49`): the search is a client-side substring filter over this +in-memory `SessionInfo[]`, not a query against any index. + +**The generic harness search adapter is the same "no index" story, made +explicit in its own doc comment**: "Searches canonical sessions directly and +therefore has no index to maintain." +(`packages/agent/src/harness/session/search.ts:16`). Its implementation is a +brute-force scan: for every session in `list()`, `open()` it, get every +entry, `JSON.stringify` it, and substring-match (case-insensitive) against +the query (`search.ts:24-39`). + +**The one genuine indexed search subsystem in the whole monorepo lives in +the SQLite package, and is not wired into anything shipped.** +`packages/storage/sqlite-node/src/sqlite/search-backend.ts` maintains a +real SQLite FTS5 virtual table kept consistent via triggers on the +`session_entries` table: +```sql +CREATE VIRTUAL TABLE IF NOT EXISTS session_search_fts USING fts5( + payload, content = 'session_entries', content_rowid = 'rowid', + tokenize = 'trigram remove_diacritics 1' +); +CREATE TRIGGER IF NOT EXISTS session_search_fts_ai AFTER INSERT ON session_entries BEGIN + INSERT INTO session_search_fts(rowid, payload) VALUES (new.rowid, new.payload); +END; +-- ...ad/au triggers mirror deletes/updates the same way +``` +(`search-backend.ts:38-52`, elided only the delete/update trigger bodies, +which are structurally identical). Queries rank by BM25 and join back to +session/entry rows for metadata +(`bm25(session_search_fts) AS score`, `search-backend.ts:96-101`). This +package (`@earendil-works/pi-storage-sqlite-node`) is **not** a dependency +of `coding-agent`, `client`, or `server`'s `package.json` (checked directly) +-- it exists in the tree, implements the same `SessionRepository`/ +`SessionSearch` interfaces as the JSONL/in-memory backends, but nothing in +the shipped product currently constructs or uses it. + +## Entry/message structure and versioning + +**Envelope.** Every entry (both systems) is `{ type: string; id: string; +parentId: string | null; timestamp: string } & ` -- +the CLI calls this `SessionEntryBase` (`session-manager.ts:44-49`), the +harness calls the identical shape `SessionTreeEntryBase` +(`packages/agent/src/harness/types.ts:375-380`). `parentId: null` marks a +root. Ordering/threading is entirely via this `parentId` pointer, not a +separate sequence field. + +**Entry type unions differ between the two systems** -- same tag names for +the shared subset, but the harness's union is a strict superset with two +additions the CLI does not have: + +- CLI (`SessionEntry`, `session-manager.ts:144-153`): `message`, + `thinking_level_change`, `model_change`, `compaction`, `branch_summary`, + `custom`, `custom_message`, `label`, `session_info`. +- Harness (`SessionTreeEntry`, `packages/agent/src/harness/types.ts:453-464`): the same nine, **plus** + `active_tools_change` (`ActiveToolsChangeEntry`, `packages/agent/src/harness/types.ts:398-401`) and + `leaf` (`LeafEntry`, `packages/agent/src/harness/types.ts:448-451`, discussed above). The harness's + `SessionInfoEntry` also carries an explicit code comment marking it + `// legacy name, kept for backwards compatibility` (`packages/agent/src/harness/types.ts:444`), + signaling that even within the harness codebase this entry type is + considered a holdover rather than a first-class current concept. + +**`CompactionEntry` itself differs in field requiredness between the two +systems -- a genuine schema divergence under the same `type` tag, and the +clearest case in this research where the checked-in doc and the actual +shipped code disagree.** + +- CLI's type (`session-manager.ts:69-79`): + ```ts + export interface CompactionEntry extends SessionEntryBase { + type: "compaction"; + summary: string; + firstKeptEntryId: string; // required + tokensBefore: number; + details?: T; + usage?: Usage; + fromHook?: boolean; + // no retainedTail field exists on this type at all + } + ``` + Grepping the CLI's actual compaction implementation + (`packages/coding-agent/src/core/compaction/compaction.ts`) for + `retainedTail` returns **zero matches** -- the CLI never sets this field, + because its own type doesn't have it. +- Harness's type (`packages/agent/src/harness/types.ts:403-412`): + ```ts + export interface CompactionEntry extends SessionTreeEntryBase { + type: "compaction"; + summary: string; + firstKeptEntryId?: string; // optional + tokensBefore: number; + retainedTail?: AgentMessage[]; // new, harness-only + details?: T; + usage?: Usage; + fromHook?: boolean; + } + ``` +- Yet the **checked-in spec document that is supposed to describe the CLI's + own format** shows an example `CompactionEntry` with `retainedTail` + populated and *no* `firstKeptEntryId` at all + (`packages/coding-agent/docs/session-format.md:240`), and explicitly + attributes the field to a different producer: "`retainedTail`: ... + **Newer harness-generated compactions** include it so we can rebuild + context from this checkpoint without walking older entries before the + compaction entry." (`session-format.md:245`, emphasis added). Per this + research's ground rule that code wins where doc and code disagree: the + shipped `pi` CLI's own `compaction.ts` cannot and does not produce a + `retainedTail`-bearing `CompactionEntry` -- that behavior belongs + exclusively to the harness's `Session.appendCompaction()` + (`packages/agent/src/harness/session/session.ts:317-340`, which accepts an + optional `retainedTail` parameter and threads it straight onto the entry). + The checked-in `session-format.md` conflates the two systems into one + narrative even though, as established above, they are not integrated -- + this is worth recording as a finding in its own right, exactly as the + research method anticipates specs can drift from code. + +**Message-type hierarchy** (RESEARCH_PROMPT section 7's "quote the type +definitions"): base roles (`user`, `assistant`, `toolResult`) come from +`@earendil-works/pi-ai`'s `Message` union (not re-quoted here -- out of the +coding-agent package, only referenced); the coding-agent layer extends this +via TypeScript declaration merging into `AgentMessage`: +```ts +declare module "@earendil-works/pi-agent-core" { + interface CustomAgentMessages { + bashExecution: BashExecutionMessage; + custom: CustomMessage; + branchSummary: BranchSummaryMessage; + compactionSummary: CompactionSummaryMessage; + } +} +``` +(`packages/coding-agent/src/core/messages.ts:69-77`), where e.g. +```ts +export interface CompactionSummaryMessage { + role: "compactionSummary"; + summary: string; + tokensBefore: number; + timestamp: number; +} +``` +(`packages/coding-agent/src/core/messages.ts:62-67`). `convertToLlm()` (`packages/coding-agent/src/core/messages.ts:148-195`) is the single +function that turns the full `AgentMessage[]` (including these +coding-agent-specific roles) into the base `Message[]` shape an LLM +provider actually receives -- every non-base role becomes a synthesized +`user` message wrapping prefixed/suffixed text (e.g. +`COMPACTION_SUMMARY_PREFIX`/`SUFFIX`, `packages/coding-agent/src/core/messages.ts:11-24`), and a +`bashExecution` message flagged `excludeFromContext` is dropped entirely +(`packages/coding-agent/src/core/messages.ts:152-156`). + +**Format versioning is a header field, not a per-entry field, and lives only +in the CLI's world -- the harness has no version concept, only a hard-coded +constant.** +```ts +export interface SessionHeader { + type: "session"; + version?: number; // v1 sessions don't have this + id: string; + timestamp: string; + cwd: string; + parentSession?: string; +} +export const CURRENT_SESSION_VERSION = 3; +``` +(`session-manager.ts:30, 32-38`). Migration is **sequential, cascading, and +destructive-on-load** -- traced end to end: +1. `migrateToCurrentVersion(entries)` reads the header's `version` (default + `1` if absent, matching the comment above), and does nothing if already + `>= CURRENT_SESSION_VERSION`; otherwise runs `migrateV1ToV2` (if + `version < 2`) then `migrateV2ToV3` (if `version < 3`) -- unconditionally + both, not "either/or" (`session-manager.ts:277-291`). +2. `migrateV1ToV2` (`:230-257`): for every entry, bumps the header's + `version` to `2`; for every non-header entry, mints a fresh 8-hex id and + chains `parentId` to the previous entry's new id -- this is the point + where the *tree* structure (`id`/`parentId`) is retrofitted onto what was + previously an implicitly-linear array; and for `compaction` entries + specifically, converts a legacy `firstKeptEntryIndex: number` (an array + offset) into the new `firstKeptEntryId: string` by looking up + `entries[firstKeptEntryIndex]` and using *its* freshly-minted id, + deleting the old index field. +3. `migrateV2ToV3` (`:259-275`): bumps header `version` to `3`; rewrites any + `message` entry whose nested `AgentMessage.role === "hookMessage"` to + `role: "custom"` -- the "extensions unification" rename the checked-in doc + describes at `session-format.md:25`. +4. `_setSessionFile` calls `migrateToCurrentVersion` on every load and, if + it returned `true` (a migration was applied), immediately calls + `_rewriteFile()` to persist the migrated entries back to the same path + before doing anything else (`session-manager.ts:915-919`) -- i.e. + migration is not lazy or on-demand; opening an old file rewrites it on + disk on first touch, using the same non-atomic truncating write + described in "Write and append path." + +This is **one-way**: there is no `migrateV3ToV2` or downgrade path anywhere +in the source, and the header's `version` field is only ever written as +`CURRENT_SESSION_VERSION` (`newSession()`, `:930`) or bumped upward inside +the two migration functions -- never decremented. **A v3 file opened by an +older build** that still checks for `version === 2` (or lacks v3 awareness) +is not something this codebase can characterize by definition (that build no +longer exists in this repo); what is confirmed is that the *current* code's +own loader tolerates versions `1` through `3` inclusive and always upgrades +to `3` -- there is no branch that rejects a too-new version, but a version +number *greater than* `CURRENT_SESSION_VERSION` was not exercised by any +test found and its behavior is unconfirmed (see Open questions). + +**By contrast, the harness's JSONL backend enforces exactly one version and +has no migration logic whatsoever**: `parseHeader()` throws unless +`header.version === 3` exactly -- +```ts +if (header.type !== "session" || header.version !== 3) { + throw invalidSession( + path, + header.type === "session" ? "unsupported session version" : "first line is not a valid session header", + ); +} +``` +(`packages/agent/src/harness/session/jsonl-repo.ts:78-83`). A v1 or v2 CLI +session file, opened directly through `JsonlSessionRepository`, would fail +outright with `SessionError("invalid_session", "unsupported session +version")` rather than being auto-migrated -- migration is exclusively a +CLI-side, `session-manager.ts`-only behavior. + +**The SQLite package versions its *schema*, not the session data**, via an +ordered, idempotent SQL migration list applied through a tracking table: +```ts +export async function loadMigrations(): Promise { + return [ + { id: "001_initial.sql", order: 1, sql: await loadMigrationSql("./migrations/001_initial.sql") }, + { id: "002_branch_tips.sql", order: 2, sql: await loadMigrationSql("./migrations/002_branch_tips.sql") }, + ]; +} +``` +(`packages/storage/sqlite-node/src/sqlite/migrations.ts:15-27`), tracked in a +`migrations(id TEXT PRIMARY KEY, applied_at TEXT NOT NULL)` table +(`:29-34`). This is a conventional additive-migration model, structurally +unlike either the JSONL header-version scheme or the harness's fixed-v3 +gate. + +## Compaction and history management + +Compaction is entirely an upstream (application-layer) concern that leaves a +marker entry in the durable log; the store itself does not truncate or +rewrite older entries. Both compaction and its sibling "branch +summarization" share one structured-summary format and one file-tracking +mechanism (`packages/coding-agent/docs/compaction.md:14-23`). + +**Trigger.** Auto-compaction fires when +`contextTokens > contextWindow - reserveTokens`, `reserveTokens` defaulting +to 16384 and configurable via `~/.pi/agent/settings.json` +(`compaction.md:27-35`); `/compact [instructions]` triggers it manually. + +**Mechanism** (`compaction.md:39-79`, cross-checked against +`packages/coding-agent/src/core/compaction/compaction.ts`): walk backward +from the newest message accumulating token estimates until +`keepRecentTokens` (default 20k) is reached; call the LLM to summarize +everything from the previous kept boundary (or session start) up to that +cut point, feeding the previous summary back in as iterative context when +one exists; append one new `CompactionEntry` recording `summary` and +`firstKeptEntryId` (the CLI always sets `firstKeptEntryId`; see the +required-vs-optional divergence above); the session then reloads its active +context using the summary in place of everything before +`firstKeptEntryId`. On a *second* compaction, the span to summarize starts +at the *previous* compaction's `firstKeptEntryId`, not at the earlier +compaction entry itself (falling back to "the entry after the previous +compaction" if that kept entry cannot be found on the current path) -- this +preserves messages that survived the first compaction by folding them into +the second pass too (`compaction.md:79`). `tokensBefore` is recomputed from +the freshly rebuilt session context immediately before the new entry is +written, so it reflects the actual pre-compaction context size being +replaced, not a stale estimate. + +**Split turns.** A "turn" is one user message plus every assistant/tool +response until the next user message; compaction normally cuts at turn +boundaries. When a single turn alone exceeds `keepRecentTokens`, the cut +point instead lands mid-turn at an assistant message (a "split turn"), and +the tool generates and merges *two* summaries: one for prior history, one +for the early part of the oversized turn (`compaction.md:81-107`). Valid cut +points are user messages, assistant messages, `BashExecution` messages, and +custom messages (`custom_message`, `branch_summary`); **tool results are +never a valid cut point**, because they must stay attached to their +originating tool call (`compaction.md:109-117`). + +**Artifact left in the log**: exactly one appended entry per compaction -- +`CompactionEntry` -- never an external snapshot file and never an in-place +rewrite of older entries (those remain untouched in the JSONL file +indefinitely; they are merely excluded from the *active context* view, not +deleted). `details?: T` is extension point for arbitrary JSON; the CLI's +own default compaction populates it with `{ readFiles: string[]; +modifiedFiles: string[] }`, tracked *cumulatively* across compactions by +reading the previous compaction's `details` and merging in new file +operations discovered in the newly-summarized span +(`compaction.md:179-185`). + +**Resume/replay across a compaction boundary** is exactly the mechanism +already described under "The storage model"/"Read and resume path": +`defaultContextEntryTransform` (harness, +`packages/agent/src/harness/session/session.ts:61-92`) finds the single +most-recent `compaction` entry on the path and either (a) if +`retainedTail` is present, treats the compaction as a **self-contained +checkpoint** and includes only the compaction entry plus everything +appended after it, never re-walking anything earlier (`session.ts:74-79`); +or (b) if only `firstKeptEntryId` is present (the CLI's only mode), walks +forward from that id to the compaction entry, keeping everything in +between, then everything after (`session.ts:80-91`). The doc frames this +exactly the same way: "`retainedTail` is optional only so older sessions +that only store `firstKeptEntryId` continue to load correctly." +(`session-format.md:342`) -- i.e. `retainedTail` is presented as a forward +looking upgrade to the *same* mechanism, even though (per the versioning +section above) only the harness, not the CLI, currently produces it. + +**Branch summarization** is the sibling mechanism triggered specifically by +`/tree` navigation away from a branch, not by context pressure: find the +deepest common ancestor of the old and new leaf positions, collect every +entry on the abandoned path back to that ancestor, summarize under a token +budget (newest-first), and append one `BranchSummaryEntry` at the new +navigation point (`compaction.md:150-177`, structure mirrors +`CompactionEntry` minus `firstKeptEntryId`/`retainedTail`, plus a `fromId` +back-pointer to the entry navigated away from). File-operation tracking +here is cumulative in the same way as compaction's. + +## Rewind, checkpoints, and fork + +**Rewind ("branch") is expressed purely as a leaf-pointer move, not a +destructive edit -- but the two systems disagree on whether that move is +itself durable, as already established under "Read and resume path."** +Nothing is ever deleted or mutated in place when navigating to an earlier +point; existing entries remain on disk regardless of which system is used. + +There is **no file-state or environment checkpoint tied to turns** anywhere +in the session-storage layer itself -- no full-content/diff/hash file +snapshots were found associated with entries in either `session-manager.ts` +or the harness types. The closest thing is the `readFiles`/`modifiedFiles` +string-array tracking inside `CompactionEntry.details`/`BranchSummaryEntry. +details`, which records *paths*, not file content, diffs, or hashes +(`compaction.md:179-185`) -- this is bookkeeping for the summarization +prompt, not a restorable checkpoint of workspace state. (RESEARCH_PROMPT +section 9's file-state-checkpoint question therefore has essentially a "no" +answer for Pi at this commit; recorded as a gap rather than guessed.) + +**Fork has three distinct implementations across the CLI alone, plus a +fourth (harness) shape -- this is one of the sharpest findings in the whole +dossier.** + +1. **`/tree`** (in-place branch, same file): `SessionManager.branch()` / + `branchWithSummary()` -- leaf pointer move only, covered above. Not really + a "fork" by RESEARCH_PROMPT's definition (no new session identity), but + the closest thing to "shared-prefix reference" semantics: nothing is + copied, the shared history is simply the same physical file. +2. **`/clone`** and **user-initiated `/fork` from an earlier message**, both + implemented by `createBranchedSession(leafId)` + (`session-manager.ts:1412-1512`): extracts *only the root-to-leaf path* + for the given `leafId` (via `getBranch`) into a **new** file. It strips + `LabelEntry`s out of the copied path, then re-derives and re-appends + them as a trailing sub-chain pointing at their (re-chained) targets, so + labels survive the extraction without polluting the main path + (`:~1420-1470`, per the file's own comment: "Filter out LabelEntry from + path -- we'll recreate them from the resolved map. Because labels are + real tree entries, later entries can be children of labels"). This is + copy-plus-lineage in the sense that entry *content* is copied, but ids + are **not** preserved verbatim for labels (they're re-derived), while + non-label entries on the path keep their original ids and `parentId` + chain intact. Like `newSession`, it only writes to disk immediately if + the extracted path already contains an assistant message -- same + deferred-write contract as ordinary sessions. +3. **`SessionManager.forkFrom(sourcePath, targetCwd, sessionDir?, options?)`** + (`:1579-1630`) is a *completely different* operation from #2 despite the + shared name "fork": it copies **every non-header entry from the entire + source file, verbatim, in original order** -- not filtered by leaf or + branch at all -- into a brand-new file, with a new session id, a new + `cwd` (`targetCwd`), and a header `parentSession` field set to the + *source file's path* (`:1607-1613`, `newHeader.parentSession = + resolvedSourcePath`). This is used specifically for forking a session + from one project directory into another (its own doc comment: "Fork a + session from another project directory into the current project. + Creates a new session in the target cwd with the full history from the + source session.", `:1573-1577`). Lineage metadata recorded: exactly one + field, the source file's absolute path, in the new header's + `parentSession`. There is no reverse index from a source file to its + forks -- lineage is discoverable only by scanning every session's header + for a matching `parentSession` value (which is exactly what the + interactive picker's "threaded" sort mode does, see below). +4. **Harness `fork()`** (`SessionRepository.fork(source, options)`): + selection-based, not path-based like the CLI's `/fork`/`/clone`, and + computed centrally by shared helpers rather than duplicated per backend: + ```ts + export function createSessionForkSelection(options: SessionForkOptions): SessionForkSelection { + if (!options.entryId) return { kind: "all" }; + return (options.position ?? "before") === "at" + ? { kind: "through_entry", entryId: options.entryId } + : { kind: "before_user_message", entryId: options.entryId }; + } + ``` + (`packages/agent/src/harness/session/repository.ts:51-56`) -- no + `entryId` copies everything (closest analogue to `forkFrom`'s whole-file + copy, but scoped to the *active path*, not the whole file's entries, + since `readSessionEntriesForFork`'s `"all"` branch reads + `source.readEntries()`, i.e. every entry ever appended to that session, + matching `forkFrom`'s "copy everything" semantics rather than + `createBranchedSession`'s "copy only the active path" semantics); an + `entryId` with `position: "at"` copies the root-to-that-entry path + inclusive (`readPathToRootOrCompaction(target.id)`); an `entryId` with + `position: "before"` (the default) requires the target to be a `user` + message and copies the path up to but excluding it + (`readSessionEntriesForFork`, `repository.ts:59-71`, validated: + `if (target.type !== "message" || target.message.role !== "user") throw new SessionError("invalid_fork_target", ...)`). + `JsonlSessionBackend.fork()` (`jsonl-repo.ts:309-337`) reads the source + under its own operation-queue key, computes the selection, then creates + the new document under a fresh key -- two separate serialized operations + chained by awaiting a promise, not one atomic cross-session + transaction. Lineage: the new document's header `parentSession` defaults + to the source's path unless the caller overrides it + (`jsonl-repo.ts:330-333`). + +The user-facing comparison table in the docs captures the CLI's three +navigation-affecting commands succinctly: `/tree` stays in the same file +with full-tree view and an optional branch summary; `/fork` creates a new +file via a user-message selector with no summary; `/clone` creates a new +file duplicating the current active branch, also with no summary +(`packages/coding-agent/docs/sessions.md:118-127`) -- all three are +distinct from `SessionManager.forkFrom`, which has no interactive command +at all and is reachable only via `pi --fork ` at CLI startup, per +the flags table (`sessions.md:9-16`). + +## Subagents and nested sessions + +No first-class subagent/child-session concept was found in Pi's core at +this commit. A targeted search across `packages/coding-agent/src` and +`packages/agent/src` for the literal string `subagent` returned **zero +matches** outside documentation. The only place the concept appears is: + +- `packages/coding-agent/docs/extensions.md:2969`, a catalog table entry: + `| \`subagent/\` | Spawn sub-agents | \`registerTool\`, \`exec\` |` -- + listing an *example extension*, not a built-in feature. +- The corresponding source lives under + `packages/coding-agent/examples/extensions/subagent/` (`index.ts`, + `agents.ts`, a `README.md`, and a handful of markdown agent-persona + files under `agents/`) -- i.e. this is sample/reference code shipped to + show extension authors *how they could* build subagent spawning on top of + the public `registerTool`/`exec` extension API, not a shipped, in-product + feature with its own session-storage semantics. + +Consequently there is no durable parent-child link for subagents to +document, no inheritance-vs-isolation answer for a child transcript, and no +bounded-nesting or cascade/orphan/reconcile behavior to report -- this +section is a genuine "no position" per the task's instructions, not a gap +in research coverage. The one adjacent, *actually-implemented* parent-child +concept in the codebase is the session-header `parentSession` field used by +`forkFrom()`/harness `fork()` (see "Rewind, checkpoints, and fork" above), +which links two independent, full-fledged sessions (one per project/context +switch), not a subagent's nested execution within one parent turn -- this is +a different concept and should not be conflated with subagent support. + +## Retention, deletion, and multi-host + +**No TTL or lifecycle policy was found anywhere in the source.** Sessions +persist indefinitely until a human explicitly deletes them; there is no +scheduled cleanup, no automatic expiry, and no size-based eviction observed +in either `session-manager.ts` or the harness packages. + +**Deletion is entirely a CLI/UI-layer concern; neither `SessionManager` nor +`SessionRepository` semantically "owns" retention beyond the repository's +own `delete()` primitive.** Concretely: + +- `SessionManager` has **no `delete()` method** at all -- confirmed by + reading the full class. Deletion logic lives one layer up, in + `packages/coding-agent/src/modes/interactive/components/ + session-selector.ts` (not `session-picker.ts`, despite the module being + colloquially "the session picker"). The actual function: + ```ts + async function deleteSessionFile( + sessionPath: string, + ): Promise<{ ok: boolean; method: "trash" | "unlink"; error?: string }> { + const trashArgs = sessionPath.startsWith("-") ? ["--", sessionPath] : [sessionPath]; + const trashResult = spawnSync("trash", trashArgs, { encoding: "utf-8" }); + // ... + if (trashResult.status === 0 || !existsSync(sessionPath)) { + return { ok: true, method: "trash" }; + } + try { + await unlink(sessionPath); + return { ok: true, method: "unlink" }; + } catch (err) { /* ... */ } + } + ``` + (`session-selector.ts:645-680`, elided only the error-hint string + formatting). This confirms the checked-in spec's claim verbatim: "When + available, pi uses the `trash` CLI to avoid permanent deletion." + (`session-format.md:17`, `sessions.md:50`) -- `trash` is tried first and, + only if it fails *and* the file still exists afterward, the code falls + back to a hard `unlink`. +- Deletion is **blocked for the currently active session**: + `startDeleteConfirmationForSelectedSession()` checks + `isCurrentSessionPath(selected.session.path)` and, if true, calls + `this.onError?.("Cannot delete the currently active session")` and + returns without prompting for confirmation at all + (`session-selector.ts:393-400`). +- Deletion requires an explicit confirmation step: pressing the delete key + only calls `setConfirmingDeletePath(path)` (`:404`); the actual + `deleteSessionFile` call only fires from a separate confirmed-delete + handler (`onDeleteSession`, wired at `:830-833`), matching the + documented UX ("delete with Ctrl+D, then confirm", `sessions.md:48`). +- Deletion has **no cascade to any other artifact** -- because there is no + other artifact. No summary sidecar, no search index entry (the CLI has + none to begin with), nothing else references the file by path except the + in-memory `SessionInfo[]` arrays the picker itself maintains, which it + updates by filtering out the deleted path locally after a successful + delete (`session-selector.ts:833-844`). +- The harness's `JsonlSessionBackend.delete()` is the store-level analogue, + and is a plain filesystem remove with no trash/undo semantics at all: + `getFileSystemResultOrThrow(await this.fs.remove(metadata.path, { force: true }), ...)`, + followed by clearing the in-memory `entryIndexesByPath`/ + `operationKeysByPath` entries for that path (`jsonl-repo.ts:297-307`). + The `trash`-CLI-first behavior is exclusively a CLI/TUI-layer nicety; + the pluggable store interface's own `delete()` contract makes no promise + about recoverability. + +**Session-list tree vs. entry tree -- an easily-confused pair of distinct +concepts, both present in this codebase, worth disambiguating explicitly.** +The interactive picker builds and displays a *tree of session files* (not +to be confused with the in-file entry tree discussed throughout this +dossier), keyed by each file's header `parentSessionPath` -- i.e. the fork +lineage recorded by `forkFrom`/harness `fork`: +`buildSessionTree()`/`flattenSessionTree()` +(`session-selector.ts:209-278`), selectable via a "threaded" sort mode +(default), toggled alongside "recent" and "relevance" (fuzzy) modes +(`session-selector.ts:985-986`). This is a second, independent tree +structure layered on top of everything described in "Rewind, checkpoints, +and fork" -- the *entry* tree lives inside one file; the *session* tree +spans multiple files via header back-references, reconstructed at list +time by scanning every session's `parentSessionPath`, with no persisted +index of the reverse direction. + +**Multi-host / multi-process is not a first-class path anywhere in the +storage layer -- it is, at most, a workaround the operator must arrange +themselves (e.g. a shared filesystem mount), and the storage code does +nothing to detect or coordinate across hosts.** No network-filesystem +awareness, no crash-detection heuristic (e.g. a stale-lock check, a PID +file, an mtime-based staleness test), and no remote-writeback path were +found in `session-manager.ts` or the harness JSONL/SQLite backends. As +established under "Write and append path," even *same-host, same-file* +concurrent writers from two separate OS processes are unguarded -- there is +no OS-level lock anywhere in this codebase's session-storage layer. + +The one place multi-*client* (not multi-host storage) concurrency is +handled explicitly is a completely different layer: `@earendil-works/ +pi-client`'s `SessionLease`/`SessionHandle` +(`packages/client/src/session-handle.ts`), which lets multiple clients +attach to **one already-running, in-process** session over a wire protocol +(`@earendil-works/pi-protocol`) with `SessionLeaseMode = "shared" | +"exclusive"` (`session-handle.ts:12-16`). This is a live-process attach/ +detach/lease negotiation for a session that is already open in one server +process (`@earendil-works/pi-server`'s `src/sessions.ts`, not read in depth +here) -- it says nothing about, and does not replace, the on-disk JSONL/ +SQLite storage layer itself; it is a concurrency-control mechanism for +*viewing and steering* one live agent run from multiple UI clients, which +is a different problem from multi-host *storage* consistency. Recorded here +because it is the closest thing in the repo to a "multi-host" answer, but +flagged clearly as answering a different question than RESEARCH_PROMPT +section 11 asks. + +**A note on an unshipped future design.** `packages/agent/docs/harness.md` +and `packages/agent/docs/harness-v2.md` are design documents (not source) +proposing a substantially different execution/storage model -- "lanes" +(named concurrent execution positions within one session), durable +"operations" with crash-recoverable boundaries, and (per `harness.md`, +not fully re-verified against `harness-v2.md`'s newer text) a planned v4 +JSONL format interleaving harness-bookkeeping entries with session entries. +`harness-v2.md` is explicit that this is a **design document for +not-yet-shipped work**, not a description of current behavior: it opens +with "**Decision note.** This is the chosen design..." and states a +compatibility policy in its own words: "Old coding-agent v3 JSONL sessions +must open and restore idle. This is the only backward-compatibility +requirement. All other formats and APIs in `packages/agent/src/harness` and +`packages/storage/sqlite-node` (and their respective tests) may break. We do +not write migrations, schema versioning, or conversion paths for anything +else." (`packages/agent/docs/harness-v2.md`, the document's second +blockquote, immediately following its title). This directly confirms two +things checked independently in this pass: (a) the `SessionTreeEntry` union +actually present in `packages/agent/src/harness/types.ts` at this commit -- +read in full -- contains no harness-entry types (`OperationStartedEntry`, +`HarnessEntryBase`, or similar), confirming the plan is not yet implemented +in `types.ts`; and (b) `packages/storage/sqlite-node` is named in the plan's +own compatibility policy as one of the pieces expected to change, consistent +with it being early/foundational work for that plan rather than a finished, +independent product feature. Nothing from either harness doc is otherwise +relied upon above as a description of current behavior. + +## Interop with foreign session stores + +Not applicable. No code path reading another product's native session +format (Claude Code, Codex CLI, etc.) was found in either the CLI or the +harness packages; `pi`'s own JSONL format is the only format its loaders +understand, gated by the strict version checks described above. + +## What this implies for our Session Store (our inference) + +Everything below is our synthesis, not a claim sourced from Pi's own docs. + +Pi's most valuable lesson for our design is negative: **a tree-of-entries +model with a movable "leaf" pointer is a good fit for branch/rewind +semantics, but Pi's own two implementations disagree on whether the leaf +position is itself part of the durable log** -- the CLI treats it as +derived-from-append-order (i.e. leaf = last physical line, with `branch()` +a transient in-memory-only pointer move that a follow-up append is required +to make durable), while the harness's `LeafEntry` persists leaf moves as +first-class entries. For an event-sourced store, the harness's approach is +the sound one: **a "move active pointer" operation should itself be an +event**, not an inferred side effect of physical file order, precisely +because "the last thing that happened to be appended" is not a reliable +encoding of "the position a resuming client should restore to" once +non-linear navigation is possible. We should treat leaf/cursor moves as +first-class, appended, replayable events, exactly as the harness's +`LeafEntry` does, and avoid the CLI's implicit-by-append-order approach. + +Second, Pi is a cautionary example of what happens when a pluggable storage +abstraction and a shipped product's actual persistence code are allowed to +diverge for long enough: the two implementations now disagree on required +fields for the same entry `type` tag (`CompactionEntry.firstKeptEntryId`), +on orphaned-entry tolerance (silently-repaired vs. hard failure), and on +whether format-version mismatches are auto-migrated or rejected outright -- +and the checked-in documentation describes a blend of both as if it were +one coherent system. If our platform maintains both a "reference" +implementation and a "pluggable interface for embedders," the interface and +the reference implementation should either be the same code path, or the +divergence should be continuously tested (a cross-implementation +conformance test suite), not merely documented in prose that can silently +go stale. + +Third, the near-total absence of any cross-process write coordination in +either of Pi's implementations (no file locks, no compare-and-swap, no +"expected version" precondition on append) is worth treating as a hazard to +design against deliberately, not something to reproduce by omission. All of +Pi's serialization guarantees are in-process promise chains +(`KeyedOperationQueue`, `SerialOperationQueue`, `Session.appendTail`) -- +real for a single process, worthless the moment two processes (or two +hosts) touch the same file. Our event-sourced Session Store should bake an +expected-position precondition into its append primitive from the start +(optimistic concurrency keyed by last-known sequence/offset), specifically +because retrofitting it later, as this dossier shows, tends to produce +exactly the kind of two-tier "some paths get it, some don't" inconsistency +Pi currently has between its SQLite backend (real transactions) and its +JSONL backends (none). + +Fourth, the deferred-first-write optimization in the CLI (`_persist`'s +`hasAssistant` gate) is a reasonable UX-driven idea -- don't litter storage +with sessions nobody actually used -- but as implemented it makes the +"is this session persisted yet" question stateful and process-local +(`this.flushed`), which is exactly the kind of implicit state an +event-sourced design should instead make an explicit, queryable fact (e.g. +a session lifecycle state transition that is itself an event, or a +first-class "materialize on N-th write" policy applied uniformly by the +store rather than folded into the append method's control flow). + +Fifth, based on all of the above: what makes something "a stored session" +in Pi is, at minimum, one physical file (or one row-set) holding a +tree-shaped sequence of typed, `parentId`-linked entries whose *reachable +subset from a leaf* -- not the file's full contents -- defines "the current +conversation." Pi is closer to an append-only log with derived projections +than to a mutable document (nothing is ever rewritten in place except by +the version-migration path, which is itself best understood as "replaying +old entries and re-emitting them in the new schema," not as ad hoc mutation +of live data), but it falls short of a clean event-sourced design in two +respects our platform should not repeat: its "current position" state is +sometimes derivable only by convention (append order) rather than always +by an explicit event, and its two production storage implementations have +been allowed to drift into incompatible dialects of the same conceptual +format under one version-tag scheme. + +## Open questions + +- Whether a v3 (or, hypothetically, a future v4) session file opened by a + strictly older build that only understands versions up to 2 fails + gracefully, silently mis-reads the file, or crashes -- no such + version-rejection branch (`version > CURRENT_SESSION_VERSION`) was found + in `session-manager.ts`'s migration path, and no test exercising a + too-new version was located in the two test files inspected + (`packages/agent/test/harness/session.test.ts`, + `session-backends.test.ts`) or in the CLI's own compaction tests + referenced by `compaction.md`. This is a genuine gap, not an inferred + answer. +- Whether the underlying `FileSystem.writeFile`/`appendFile` + implementation(s) actually used in production (as opposed to the abstract + capability interface at `packages/agent/src/harness/types.ts:291-341`) + provide any atomicity (temp-file-and-rename, fsync) beneath the harness's + JSONL backend. The interface itself makes no such guarantee, and no + concrete Node.js-backed implementation of `FileSystem` was traced in this + pass. +- Whether working-directory relocation (a project folder moved or renamed + on disk) has any reconciliation path beyond the one historical + `migrate-sessions.sh` bugfix script -- no generalized "cwd changed, find my + old sessions" logic was found, but the absence of a feature is harder to + prove exhaustively than its presence; flagged as unconfirmed rather than + asserted as definitely absent. +- The exact runtime behavior of `packages/agent/docs/harness.md`'s and + `harness-v2.md`'s planned "lanes"/durable-operation/v4-format design + relative to what ships today was checked only to the extent of (a) + confirming `harness-v2.md` explicitly labels itself a design decision + document with an explicit compatibility policy for *future* breakage, and + (b) confirming no harness-entry types from that plan exist in the current + `packages/agent/src/harness/types.ts`. The full 2321-line `harness.md` and + the "generator" variant referenced inside `harness-v2.md` + (`harness-v2-generator.md`, said to be preserved "at commit `01eeafd1`") + were not read to completion; nothing from either was used as a claim + about current behavior above, but a reader wanting the complete planned + design (as opposed to "is it shipped yet," which this dossier does + answer: no) should read those documents directly. +- Whether `@earendil-works/pi-server`'s `src/sessions.ts` (353 lines, + read only via `wc -l` and a name check in this pass) wires the + `SessionLease`/`SessionHandle` protocol on the client side to any of the + three `SessionRepository` implementations described above, to the + harness's `AgentHarness` execution engine, or to some other mechanism + entirely -- not traced in this pass; the multi-client leasing story in + "Retention, deletion, and multi-host" above is therefore necessarily + incomplete on the server side. +- Whether `packages/storage/sqlite-node`'s `SqliteSessionRepository` is + actually exercised by anything at all in this monorepo (a dedicated test + suite, an internal tool, a not-yet-merged integration) beyond existing as + a standalone package with no consumer found in the three shipped + `package.json` files checked (`coding-agent`, `client`, `server`) -- its + purpose (foundational work for the `harness-v2.md` plan, a standalone + offering for third-party embedders, or something else) is inference on + our part, not something stated directly in source read during this pass. diff --git a/docs/research/session-store/products/pi/vs-session-events.md b/docs/research/session-store/products/pi/vs-session-events.md new file mode 100644 index 000000000..2947d2545 --- /dev/null +++ b/docs/research/session-store/products/pi/vs-session-events.md @@ -0,0 +1,133 @@ +# Pi compared to our session event catalog + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT_COMPARISON](../../RESEARCH_PROMPT_COMPARISON.md). +Stage-one dossier: [Pi](./index.md). +Compared against `proto/trogonai/session/sessions/v1alpha1/` and [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) on 2026-08-04. + +**Store maturity: 7/12**: evolution scars 2/3 (a real, traced two-hop schema migration, `migrateV1ToV2`/`migrateV2ToV3` (`packages/coding-agent/src/core/session-manager.ts:230-291`), retrofits the tree structure onto flat entries and renames a field; it runs destructively and non-atomically on every load of an old file via `_rewriteFile` (`packages/coding-agent/src/core/session-manager.ts:917-918,979-989`); but the evolution is fragmented, not shared: the harness generation abandoned migration entirely for a hard version pin that rejects anything but `version === 3` (`packages/agent/src/harness/session/jsonl-repo.ts:88-93`)), operational age 1/3 (one source-confirmed historical bugfix, `packages/coding-agent/scripts/migrate-sessions.sh:1-6`, repairing a real v0.30.0 session-misplacement bug, but with no corroborating issue thread establishing scope or severity, and no other operational-failure evidence anywhere in the source), exposure 1/3 (a real, MIT-licensed, vendor-shipped CLI/TUI, but confined to a single-host, single-process surface with no adoption-scale evidence cited, zero cross-process or multi-host coordination anywhere in the storage layer, and the one implementation with genuine transactional durability, the SQLite backend, is a dependency of nothing shipped), design independence 3/3 (no evidence the storage code was forked from another product; the tree-of-entries model and both concurrency primitives read as original to this codebase). + +A score of 7/12 clears the "thin evidence" line of 6, so recommendations below are not blanket-labelled thin; individual sub-claims that go beyond what the dossier directly confirms are still flagged inline as inference. + +## The one structural difference everything else follows from + +Pi stores a session as a tree of entries in one JSONL file, addressed by `parentId` (`SessionEntryBase`, `packages/coding-agent/src/core/session-manager.ts:46-51`; `SessionTreeEntryBase`, `packages/agent/src/harness/types.ts:375-380`), with a movable "leaf" pointer naming which branch is currently active. Physical append order and logical tree order are two different things: an entry's position in the file says only when it was written, not where it sits in the conversation. Our design ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 1) makes a session one linear stream; there is exactly one line, and `SessionOrdinal` (`proto/trogonai/session/sessions/v1alpha1/session_ordinal.proto`) is that line's own fold-derived position, never a place in a branching structure. Divergence always mints a new stream (decision 5), it never becomes a second branch inside the same one. + +Everything else in this comparison is a consequence of that one choice. Because Pi's "current position" is a pointer into a tree rather than a stream's own tail, the pointer itself becomes a second, independently-durable piece of state that can drift from the entries it names: the CLI resolves it purely by physical last line on every reload (`_buildIndex()`, `packages/coding-agent/src/core/session-manager.ts:958-977`), so a `branch()` call that moves the pointer without a follow-up append is silently reverted the next time the file is read; the harness instead persists the pointer as a first-class `LeafEntry{type: "leaf", targetId}` (`packages/agent/src/harness/types.ts:448-451`), written through the same append path as everything else. Two implementations answering "where are we in this session" differently, from the same on-disk format, is the direct fallout of the tree model admitting more than one valid answer to that question in the first place. A linear stream has no equivalent failure mode: its tail is whatever the last successfully-appended event says it is, full stop. + +The tree model also explains Pi's four independently-evolved fork mechanisms (`/tree`, `createBranchedSession`, `SessionManager.forkFrom`, and the harness's selection-based `SessionRepository.fork()`), each solving a differently-scoped version of "which part of the tree becomes the new thing," where our single `SessionForked` event (decision 5) has exactly one scope: the whole prefix up to `context_prefix_boundary`, always into a new stream. + +## Mapping + +| Pi field / entry type | Our equivalent | Note | +| --- | --- | --- | +| `uuidv7()` session id, minted independently by CLI (`session-manager.ts:208-210`) and harness (`packages/agent/src/harness/session/repository.ts:14-16`) | Opaque `session_id`; one logical stream per session on `session.sessions.events.` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 1) | Same identity concept, but ours also doubles as the stream address; Pi's does not. | +| Path-encoded identity: `~/.pi/agent/sessions/----/_.jsonl`, the encoding duplicated verbatim in two places (`session-manager.ts:479-482`, `jsonl-repo.ts:186-188`) | No equivalent; addressing is the opaque `session_id` alone, workspace is a recorded fact, never part of addressing | Ours, decisively: see recommendation 1 and the mismatch row below. | +| `SessionHeader{version, id, timestamp, cwd, parentSession}` (`session-manager.ts:30-38`) | `SessionStarted{session_id, execution_plan, workspace}` (`session_started.proto`) | Semantic mismatch: Pi's `version` is a mutable, in-place-upgraded format version bumped by a destructive migration; we have no per-session format-version field, because decision 2 forbids editing already-persisted events, so schema evolution is additive at the proto layer, not a migration of stored bytes. | +| Entry envelope `{type, id, parentId, timestamp}` threaded into a tree by `parentId` (`packages/coding-agent/src/core/session-manager.ts:46-51`, `packages/agent/src/harness/types.ts:375-380`) | `SessionOrdinal{value}` (`session_ordinal.proto`), a strictly linear, 1-indexed fold-derived position; no event carries a parent-pointer field | Semantic mismatch, the deepest one here: Pi's "position" names a place in a tree with possibly many live branches; ours names a place on one line. This is the structural difference above. | +| CLI's `leafId`, resolved purely by physical last line on reload; harness's persisted `LeafEntry{type: "leaf", targetId}` | No equivalent needed: a linear stream's current position is always its own tail | Ours, decisively. | +| `SessionManager.branch(entryId)`/`branchWithSummary` ("rewind" as a leaf-pointer move, not durable until a follow-up append, `session-manager.ts:1360-1365`) | `SessionRewound{session_id, keep_through, reason}`, `WRITE_PRECONDITION = At(current_position)` (`session_rewound.proto`) | Semantic mismatch: both are called "rewind," but Pi's CLI rewind can be silently undone by a reload if nothing was appended afterward; ours is unconditionally durable the instant it is appended, because append is the only mutation primitive (decision 2). | +| Fork, four mechanisms: `/tree` (no new identity), `createBranchedSession` for `/clone`/`/fork` (copies only the active root-to-leaf path, `session-manager.ts:1412-1512`), `SessionManager.forkFrom` (copies the whole source file verbatim into a new cwd, `session-manager.ts:1579-1630`), harness `SessionRepository.fork()` (selection-based: `all`/`through_entry`/`before_user_message`, `repository.ts:51-56`) | `SessionForked{session_id, source_session_id, context_prefix_boundary, reason}`, second event in an atomic `[SessionStarted, SessionForked]` `NoStream` batch; inheritance by reference, never physical copy (decision 5) | Ours, decisively: one unambiguous operation with a closed `ForkReason` enum (`MANUAL_BRANCH`, `COMPACTION_CONTINUATION`, `RETRY`) versus four independently-evolved operations with overlapping names and different copy semantics. See recommendation 1 for the one use case (cross-workspace relocation) Pi's fourth mechanism names that ours does not yet address. | +| `CompactionEntry{summary, firstKeptEntryId, tokensBefore, details?, usage?, fromHook?}`, required-vs-optional field divergence between CLI (`packages/coding-agent/src/core/session-manager.ts:69-79`) and harness (`packages/agent/src/harness/types.ts:403-412`, adds `retainedTail`) | `Compacted{session_id, summary_id, summary_content, covers_from, covers_through, trigger, guidance, tokens_before, tokens_after, model, usage}` (`compacted.proto`), one schema, `WRITE_PRECONDITION = At` | Ours, decisively. | +| "Turn," informally defined the same way ours is, used only as a compaction cut-point rule, never stamped on any entry (`packages/coding-agent/docs/compaction.md`) | `turn_id`, stamped (never folded) on `UserMessageRecorded`, every `AssistantMessage*` event, and `ToolCallRequested`/`ToolCallCompleted` (`user_message_recorded.proto:12-20`, `tool_call_completed.proto:22`) | Ours, decisively: same concept, but Pi's is application logic, never a durable fact; ours survives compaction and re-identifies a turn's events without re-deriving message-role adjacency. | +| `Usage`/`tokensBefore` on a compaction summary (`packages/coding-agent/src/core/session-manager.ts:69-79`), no finality marker | `TokenUsage{input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cost, completeness}`, `completeness` is `UsageCompleteness{FINAL, PARTIAL}` (`token_usage.proto`) | Ours, decisively; the same gap the fx and Cline comparisons independently flagged. | +| No claim-check/content-addressing concept anywhere; `CompactionEntry.details` is arbitrary inlined JSON | `ArtifactRef{artifact_id, digest, size_bytes, mime, preview, truncated, untruncated_size_bytes}` (`artifact.proto`), `Digest{algorithm, value}` (`digest.proto`) | Ours, decisively. | +| Cumulative `readFiles`/`modifiedFiles` path lists carried in `CompactionEntry.details`, deduplicated across compactions | `ResourceObservation{uri, content_digest\|absent, range, complete}` on `ToolCallCompleted.observed` (`resource_observation.proto`, `tool_call_completed.proto:34`) | Trade-off, not a plain win; see below. | +| No operation-ledger concept beyond entry-id collision rejection at mint/append time (`generateId()`, 100-try loop, `session-manager.ts:221-227`; harness throws on a duplicate id already in its index, `jsonl-repo.ts:270`) | `OperationReserved{session_id, operation_id, request_digest, operation_kind}` / `OperationOutcomeRecorded{oneof succeeded, failed, cancelled, unknown}` / `OperationCancellationRequested{session_id, operation_id, reason}` | Ours, decisively: a typed, reconciled outcome ledger with a documented indeterminate state, versus Pi's much weaker guarantee. | +| Zero subagent/child-session concept: zero grep matches for "subagent" outside documentation; the only appearance is an example extension under `packages/coding-agent/examples/extensions/subagent/` | `DelegationDispatched`/`ParentLinked`/`ParentTerminated`/`ParentHistoryInvalidated`/`DelegationDetached`/`ParentDetached`/`CascadePolicy` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6) | Deliberate omission, not a defect; see "Subagent cascade" below. | +| `SessionManager.forkFrom`'s header `parentSession` field, used purely for lineage display in the picker's threaded sort mode, no reverse index, no lifecycle coupling (`session-selector.ts:209-278`) | Same fields as the row above, plus `parent_dispatched_at`/cross-stream `SessionOrdinal` reference (decision 2) | Not equivalents: Pi's field links two fully autonomous sessions for display; ours links a parent's live execution to a dependent child's lifecycle. Flagged so the two are not conflated. | +| No TTL, no retention policy, no tombstone; deletion is a manual, human-initiated action that calls `trash` then falls back to `unlink`, fully removing the file (`session-selector.ts:645-680`) | `SessionHidden{session_id, reason}` (`session_hidden.proto`), a visibility tombstone that deletes no bytes, plus `RedactionApplied`/`ArtifactErased` (decision 7) | Ours, decisively; see "Retention on an unbounded log" below. | +| `SessionInfoEntry`/`appendSessionInfo`, a rename expressed as a new tree entry, resolved by scanning entries in reverse for the latest one (`session-manager.ts:1135-1160`) | `SessionRenamed{session_id, display_name}`, a commuting fact, `WRITE_PRECONDITION = Any` | Equivalent design philosophy: both express a rename as an appended fact, not a header rewrite. One of the few places the two designs independently converged. | +| No archive/unarchive concept: a session either exists or is deleted | `SessionArchived`/`SessionUnarchived`, reversible organization state distinct from the terminal `SessionHidden`, `WRITE_PRECONDITION = Any` (`session_archived.proto`, `session_unarchived.proto`) | Looks incidental, not principled: the picker has sort/filter modes but no distinct archived state. | +| Per-file summary read-model rebuilt from scratch on every `list()`/`listAll()` call, scanning every byte of every session file (`buildSessionInfo`, `session-manager.ts:687-758`); harness's list is header-only and cheap by contrast (`jsonl-repo.ts:150-156`) | Continuously maintained, incrementally checkpointed `SessionProjection` (decision 8); listing never re-scans the log | Ours, decisively. | +| `packages/storage/sqlite-node`'s real transactional backend and FTS5 search index, built and shipped in the monorepo but wired into nothing (`packages/storage/sqlite-node/src/sqlite/search-backend.ts:38-52`) | No equivalent concern; exactly one store implementation exists behind `trogon-decider`/`trogon-decider-nats` today | Reverse-direction note: Pi's abandoned-but-present SQLite backend is direct evidence for recommendation 2 below. | +| No file-state/environment checkpoint tied to a turn anywhere in the dossier | `Checkpoint{reference, checkpoint_type, digest, implementation_version, checkpoint_id, producing_execution_attempt_id, covers_through, session_execution_plan_digest, capture_attestation_ref, capture_attestation_digest, effective_history_digest}`, carried by `CheckpointProduced`/`ExecutionAttemptStarted.restored_checkpoint` | Ours, decisively; also not a false-friend risk, since Pi has nothing playing this role at all, only a plain gap. | +| No `TodoUpdated`-equivalent found anywhere in the dossier | `TodoUpdated{session_id, items, revision}`, highest-revision-wins fold (`todo_updated.proto`) | Not established either way: the dossier does not report searching for a plan/task-list concept, so this is "no evidence found," not "confirmed absent." | +| No system-notice-equivalent found; the closest is a generic `custom` entry type, an application-defined extension point (`packages/coding-agent/src/core/messages.ts:69-77`) | `SystemNoticeRecorded{session_id, level, text, tool_call_id}`, leveled via `NoticeLevel` (`system_notice_recorded.proto`) | Loose equivalent at best: Pi's escape hatch is untyped; ours is a typed, leveled fact. | + +## What we should consider changing + +### 1. Decide explicitly whether `SessionForked` should ever cross a `WorkspaceRef` + +**The change**: either add a `ForkReason` value (or a new field) that names a workspace-relocation fork, or explicitly record in [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) that a session is never portable across `workspace_id` and any such move requires a brand-new `SessionStarted`, not a fork. + +**Evidence anchor**: Pi, store maturity 7/12. `SessionManager.forkFrom(sourcePath, targetCwd, ...)` is a fourth, distinct fork mechanism whose entire purpose is moving a session's history into a different working directory (`session-manager.ts:1579-1630`), something none of Pi's other three fork mechanisms do. It exists because Pi's identity is partly path-encoded, so relocating a working directory is itself an operation worth naming. Our `WorkspaceRef` states plainly that "a different workspace requires a new session or a fork" (`proto/trogonai/session/sessions/v1alpha1/workspace.proto:11-12`), which already anticipates the question but does not answer which of the two it is, or what happens to inherited context computed against the source workspace's `uri`. + +**Blast radius**: additive if a new `ForkReason` enum value is the chosen answer; breaking the decision if the ADR owner instead wants to forbid cross-workspace fork outright, since that would need to be stated as an explicit constraint decision 5 does not currently carry. + +**Why**: Pi shows this is a real, named use case in practice (moving a project directory, or handing a session to a teammate on a different machine), not a hypothetical. Leaving it unanswered means the first team to hit it will invent an ad hoc answer rather than a designed one. + +**Cost**: small if additive (one enum value, a validation rule for what `context_prefix_boundary` means when the workspace changes); the harder cost is deciding what "inherited by reference" means when the referent (`ResourceObservation.uri`, workspace-relative `ArtifactRef` state) no longer resolves the same way in the new workspace, which is a real design question, not just a schema one. + +### 2. Require a cross-implementation conformance suite before a second `trogon-decider` backend ships + +**The change**: add an explicit prerequisite, in [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) or in the crate's own contribution guidelines, that any second implementation of the `trogon-decider` trait (an in-memory test double, an embedded/offline mode) must pass a shared conformance suite exercising every `WRITE_PRECONDITION` class and every commuting-fact fold rule before it ships, rather than relying on documentation and code review to keep it aligned with `trogon-decider-nats`. + +**Evidence anchor**: Pi, store maturity 7/12. Pi's `SessionRepository`/`SessionStorage` interface has three concrete implementations (CLI's hand-rolled `SessionManager`, harness's JSONL/in-memory/SQLite backends), and two of them have already silently diverged on a single field: `CompactionEntry.firstKeptEntryId` is required in the CLI's type and optional in the harness's type, which additionally adds a field, `retainedTail`, that the CLI's type does not have at all (zero matches for `retainedTail` anywhere under `packages/coding-agent/src`, against five occurrences in `packages/coding-agent/docs/session-format.md`). The checked-in documentation then describes the harness-only `retainedTail` behavior as if it were the CLI's own, calling it "newer harness-generated compactions" (`packages/coding-agent/docs/session-format.md:245`), which means the divergence is now laundered into a doc that reads as authoritative for both. + +**Blast radius**: additive; a test suite is new tooling, not a schema change, and today only `trogon-decider-nats` exists ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) context, lines 37-38, 85), so there is no existing second implementation to reconcile. + +**Why**: this is exactly the failure mode a shared, typed proto catalog and a single documented decision record are supposed to prevent, but a schema alone does not prevent it; only a suite that runs against every implementation does. Pi is direct evidence that "we have one interface and one reference implementation" degrades quickly into "we have one interface and several implementations that quietly disagree" the moment a second implementation exists, with nothing but prose holding them together. + +**Cost**: engineering time to write and maintain the suite, and a new gate in the review process for any future backend; no cost to `trogon-decider-nats` today since it is the only implementation. + +### 3. Decide whether a session with no real turn needs its own recorded fact + +**The change**: either state explicitly that folding `SessionStarted` alone, with zero further events, is always sufficient to answer "was this session ever actually used," or add a distinguishing fact (or reuse `SessionHidden`'s `SESSION_HIDDEN_REASON_UNSPECIFIED`-adjacent space) for a session that was created but never received a real conversational turn. + +**Evidence anchor**: Pi, store maturity 7/12, inference. The dossier's own synthesis notes Pi's CLI defers the first disk write until the first real content exists (a `flushed` in-memory flag gates the initial file creation), specifically to avoid littering storage with empty session files from aborted starts. This is inference from the dossier's synthesis section, not a directly cited code path in this document, and is carried forward with that caveat. + +**Blast radius**: additive if the answer is a new optional marker; a no-op (documentation only) if the answer is that `SessionStarted` alone is already sufficient, since decision 1 already treats stream existence as the only signal needed. + +**Why**: our decision to always mint a real, addressable stream on `CreateSession` (rather than deferring the write) trades away Pi's "no empty files" property for a much simpler invariant: existence of a stream never needs a second signal to mean "used." That trade looks correct, but it has not been stated as a deliberate decision anywhere read for this comparison, only implied by decision 1's silence on the question. + +**Cost**: low either way; the main cost is the discipline of writing the decision down so it is not re-litigated per-implementation later. + +## What our design already does better + +- Server-enforced optimistic concurrency versus none at all: JetStream's `At(current_position)` precondition, enforced via the `Nats-Expected-Last-Subject-Sequence` header, rejects a stale writer at the broker ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2). Pi has zero equivalent in either implementation; the dossier's own synthesis states that two `SessionManager` instances opened on the same path "have no coordination whatsoever," and describes the harness's `KeyedOperationQueue` (`packages/agent/src/harness/session/keyed-operation-queue.ts:18-43`) as pure in-process promise-chain serialization, worthless the moment two processes touch the same file. +- Append-only-only mutation versus a destructive, non-atomic full-file rewrite as the migration mechanism: decision 2 makes rewind, revert, compaction, hide, and cancel all new appended events, "never an edit or a delete of stored messages. No command ever purges or trims the stream" (`docs/adr/0035-session-store-decider-aggregate.md:132-134`). Pi's version migration truncates and rewrites the entire file in place the first time an old file loads (`_rewriteFile`, `"w"` flag, no temp file, no fsync, `session-manager.ts:979-989`), a confirmed crash-safety gap. +- One compaction schema versus two incompatible ones under the same tag: `Compacted` (`compacted.proto`) is a single schema with a single producer path; Pi's `CompactionEntry` has a required-vs-optional field split between its CLI and harness types, with the checked-in doc describing only the harness variant as if it were universal. +- A continuously maintained projection versus a full-file rescan on every listing: `SessionProjection` (decision 8) versus `buildSessionInfo`'s from-scratch, every-byte read-model rebuild on every `list()`/`listAll()` call (`session-manager.ts:687-758`). +- Typed, per-entity terminal-outcome resolution versus none described: `ToolCallCompleted`/`ToolCallFailed` and `AssistantMessageCompleted`/`AssistantMessageFailed` resolve deterministically under first-terminal-outcome-wins (decision 2); the dossier describes no analogous competing-outcome concept anywhere in Pi. +- Workspace binding as a required, immutable, recorded fact versus a plain mutable header field: `SessionStarted.workspace` (`workspace.proto:11-12`) versus Pi's `cwd`, which the dossier reports has "no reconciliation path... beyond the one historical bugfix script" if the underlying directory moves. +- Redaction and erasure as named, typed events versus nothing at all: `RedactionApplied`/`ArtifactErased` (decision 7); the dossier finds "no TTL or lifecycle policy... anywhere in the source" for Pi. + +## Trade-offs, not gaps + +- Tree-of-entries-in-one-file, cheap in-place multi-branch navigation, versus one-fork-per-branch, an atomic, unambiguous new session per divergent branch. Pi's model lets a user cheaply explore many speculative branches from a single file with `/tree`, at the cost of the leaf-durability and cross-implementation-position ambiguity documented above; our model costs a new stream per branch via `SessionForked`, a cost [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md)'s own alternatives-considered discussion weighed against physical-copy and shared-snapshot models close to Pi's tree, and rejected deliberately, not by oversight. +- Cumulative, deduplicated file-touch bookkeeping (`readFiles`/`modifiedFiles`) versus a per-call `ResourceObservation` digest. Pi's approach is cheaper to read as "every file this session has ever touched" from one field; ours is more precise, a verifiable digest per read, at the cost that the same "ever touched" view must be built as a fold over every `ToolCallCompleted` rather than read off a single running list. +- Pi's deferred first write, avoiding empty session files from aborted starts, versus our immediate `SessionStarted` on every `CreateSession`. Pi's approach avoids on-disk clutter at the cost of an implicit, process-local `flushed` flag as the source of truth for "does this session exist yet"; ours never has an ambiguous "is this persisted" state, at the cost of always minting a real, addressable stream even for a session nobody used (see recommendation 3). + +## What not to copy + +- The truncating, non-atomic, destructive-in-place migration rewrite (`_rewriteFile`, `"w"` flag, no temp file, no fsync, `session-manager.ts:979-989`), invoked automatically the first time an old-format file is loaded. A crash mid-rewrite leaves a truncated file with no recovery path. +- Letting a pluggable interface and its reference implementation diverge silently on required-vs-optional fields, then documenting only one of the two behaviors as if it were universal (`docs/session-format.md:245`'s description of `retainedTail`). See recommendation 2. +- Deriving "the current position" from physical append order (the CLI's `leafId` equals the last physical line) instead of recording it as an explicit fact; a non-durable pointer move that a reload can silently undo. +- Zero cross-process write coordination in either implementation: no lock file, no CAS, no expected-version precondition on append. Real for a single process, worthless the moment two processes or hosts touch the same file, per the dossier's own synthesis. +- Cumulative, unbounded, recomputed-on-every-list summaries (`allMessagesText`, `buildSessionInfo`) as the entire search story outside an unused SQLite backend: cost scales linearly with total bytes across every session file, on every picker open. + +## The two gaps the industry has not closed + +### Subagent cascade + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6 already takes a position: a child session is its own logical stream linked by facts on each side (`DelegationDispatched`/`ParentLinked`), parent-first dispatch, acyclicity by construction, rewind-invalidation kept distinct from terminal cascade, and a two-fact detach saga. The question for this section is whether Pi's evidence validates, challenges, or refines that position, not whether we still need one. + +What Pi does: nothing. A targeted grep for the literal string "subagent" across `packages/coding-agent/src` and `packages/agent/src` returns zero matches outside documentation; the only appearance anywhere is a catalog entry in `packages/coding-agent/docs/extensions.md:2969` and an example extension under `packages/coding-agent/examples/extensions/subagent/`, explicitly sample code demonstrating how a third party could build subagent spawning on top of the public tool-registration API, not a shipped, store-level feature. There is consequently no durable parent-child link for subagents, no inheritance-versus-isolation answer, and no cascade, orphan, or reconcile behavior of any kind to report when a parent session is deleted, rewound, or crashes while a subagent-shaped child would be live, because no such child concept exists to crash or be orphaned. The one actually-implemented parent-child-shaped field, `forkFrom`'s header `parentSession`, links two independent, fully autonomous sessions for lineage display in the picker's threaded sort mode, with no lifecycle coupling; it answers a different question, fork provenance, and the dossier is careful not to conflate the two. + +Does this validate, challenge, or refine decision 6? Neither, cleanly, and that is itself the finding worth recording: Pi is not counter-evidence to decision 6's shape, since it has no cascade mechanism to compare against ours, but it is real evidence for an adjacent claim decision 6 does not currently weigh against, that a shipped, real coding-agent product can function with no store-level subagent concept at all, by leaving "spawn a subagent" entirely to the tool or extension layer, with the store never aware a delegation happened. Formalizing delegation at the store layer buys specific safety properties that only become necessary once a product actually tries to model subagents as sessions with independent lifecycles; Pi simply has not built the feature decision 6 hardens, so its zero-position status is one more data point that most of the corpus has not yet needed to pay this cost, not evidence that the cost is unnecessary. + +### Retention on an unbounded log + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7 already takes a position: keep-forever, `SessionHidden` as a visibility tombstone rather than a delete, `RedactionApplied` for read-time masking, `ArtifactErased` for out-of-band artifact-byte destruction, and aggregate snapshots that bound replay cost rather than storage size, so resume cost tracks snapshot cadence, not transcript length, relying on decision 8's resume mechanism (load the newest snapshot, replay only the tail after it). The question is whether Pi's evidence validates that design or exposes a cost the ADR does not bound. + +What Pi does: nothing bounds growth, and its exposure is structural on both the write and the read path. Appends are cheap, one `appendFileSync` call per entry (`session-manager.ts:1021,1040`), so Pi does not reproduce a whole-document-rewrite-per-turn cost. But every open of any session, on either implementation, is a full-file read into memory with no cursor and no size bound: `loadEntriesFromFile` (`session-manager.ts:511-553`) and the harness's `fs.readTextFile(path)` (`jsonl-repo.ts:159-174`) both buffer the entire file before any use, and there is no snapshot mechanism, no bounded-tail-replay path, and no size cap anywhere in either implementation. Deletion is the only bound on growth, and it is entirely manual, one session at a time, through the picker's `deleteSessionFile()` (`session-selector.ts:645-680`); there is no TTL, no scheduled cleanup, and no automatic eviction anywhere in the source. The dossier found no corroborating issue report of this becoming a user-visible problem, so the user-visible-harm half of this claim is source-confirmed only, not issue-confirmed; the structural exposure itself is not in question. + +Does this validate, challenge, or refine decision 7? It validates the core design, more sharply than a product with even a partial bounded-replay mechanism would, because Pi has no analogue for one at all: every resume in Pi costs O(total file size), by construction, on every implementation, for every session, regardless of how much of that history still matters to the current turn. Our read path was built to avoid exactly this failure mode from the start. The one caveat, carried forward rather than invented fresh for Pi: decision 7's bound is on how far back model-visible-context compilation reads, bounded by the latest `Compacted` marker (decision 8), not on how much content accumulates between the last compaction and the current turn; a long uncompacted run has no stated bound on that specific cost. This is the same open edge already surfaced when comparing against other products in this corpus, not a new one Pi's evidence adds, and it remains an agent-loop or compaction-policy question, not a store defect. + +## Open questions for the ADR + +1. Should `SessionForked` (or a new `ForkReason` value) address forking a session into a different `workspace_id` explicitly, given `WorkspaceRef` states that "a different workspace requires a new session or a fork" (`workspace.proto:11-12`), and if it is allowed, what happens to context inherited by reference (`ResourceObservation.uri`, `ArtifactRef`) that was computed against the source workspace? +2. If a second implementation is ever built behind `trogon-decider`, should a cross-implementation conformance suite be a stated prerequisite before it ships, given how quickly Pi's own reference-and-pluggable split diverged on required-versus-optional fields with nothing but documentation holding them together? +3. Does the platform need an explicit, recorded fact for a session that never receives a real conversational turn, or is folding `SessionStarted` alone, with no further events, always sufficient at the read-model layer to answer whether a session was ever really used? +4. Who is responsible for guaranteeing `Compacted` markers are emitted often enough that model-visible-context compilation never has to walk an unboundedly long uncompacted tail: the agent loop's compaction-trigger policy, or a store-side backstop? diff --git a/docs/research/session-store/products/qwen-code/index.md b/docs/research/session-store/products/qwen-code/index.md new file mode 100644 index 000000000..087f09c84 --- /dev/null +++ b/docs/research/session-store/products/qwen-code/index.md @@ -0,0 +1,298 @@ +# Qwen Code: what diverged from Gemini CLI's session store + +Part of Session Store Research. +Fork delta report; see [backlog](../../backlog.md) Wave 5 for why this is a delta rather than a dossier. +Qwen Code pinned at `06cc41ee3f50845c05f518d072e5175910b91f7e` (Apache-2.0), compared against the accepted [Gemini CLI dossier](../gemini-cli/index.md). Retrieved 2026-08-04. + +## Summary of divergence + +Qwen Code diverged substantially, more than a typical fork in this class. It +kept the shape "append-only JSONL per session, replayed on resume" but +rewrote almost everything under that shape: the storage root was renamed +(`~/.gemini` → `~/.qwen`) with no migration path for existing Gemini CLI +users; the on-disk record format was redesigned from Gemini's positional, +structurally-discriminated line kinds into an explicit `type`/`subtype` +tagged, `uuid`/`parentUuid` tree; project keying moved from an opaque +sha256-hash-plus-short-id-registry scheme to a human-readable +sanitized-cwd directory name; the shadow-git file-state checkpoint +mechanism was replaced by a file-copy-based history service; a +process-level write concurrency control was added where Gemini had none; +and the single directory-nested subagent model was replaced by three +different subagent/child-session mechanisms plus a first-class session +fork (`/branch`). None of this is a cosmetic rebrand -- each item changes +either on-disk layout, read/replay semantics, or what a session even *is*. + +## What diverged + +### Storage root renamed, no migration from Gemini CLI + +- The global root is `.qwen` (`qwen: packages/core/src/utils/paths.ts:14`, + `QWEN_DIR = '.qwen'`), resolved by `Storage.getGlobalQwenDir()` + (`qwen: packages/core/src/config/storage.ts:183-193`), versus Gemini's + `~/.gemini` (`GEMINI_DIR`) per the dossier's keying section. +- No migration of session/config data from a legacy `~/.gemini` tree was + found. The only "legacy dir" logic in Qwen concerns a user-configured + `QWEN_HOME` pointing somewhere other than the default `~/.qwen` -- it warns + that "OAuth tokens, settings, memory, extensions, and skills are not + auto-migrated" between two *Qwen* homes, not from Gemini + (`qwen: packages/cli/src/config/settings.ts:659-690`, + `detectQwenHomeRedirectWithoutMigration`). Targeted greps for `.gemini`, + `GEMINI_DIR`, and `legacyDir`-style names in `packages/core/src` and + `packages/cli/src` turned up nothing that reads or migrates a prior + Gemini CLI installation's session data. A user arriving from Gemini CLI + with sessions under `~/.gemini/tmp//chats/` gets a fresh, + empty `~/.qwen` tree; their old sessions are not orphaned by breakage, they + are simply never looked at. + +### Durable record format redesigned: tree of tagged records, not positional line kinds + +- Gemini's format (per the dossier) is untagged lines discriminated + structurally by the loader (`$rewindTo` / `$set` / message-with-`id` / + initial-metadata), with last-write-wins-by-message-`id` upserts and no + sequence number (dossier, "Entry/message structure and versioning"). +- Qwen's `ChatRecord` carries explicit `type: 'user' | 'assistant' | + 'tool_result' | 'system'` and an explicit `subtype` enumerating over a + dozen record kinds (`chat_compression`, `slash_command`, `ui_telemetry`, + `at_command`, `attribution_snapshot`, `notification`, `custom_title`, + `parent_session`, `session_source`, `rewind`, `agent_bootstrap`, + `file_history_snapshot`, `user_text_elements`, `session_artifact_event`, + `session_artifact_snapshot`, `goal_state`, `goal_runtime`, ...) + (`qwen: packages/core/src/services/chatRecordingService.ts:265-393`). + Every record has `uuid` and `parentUuid` -- "Forms a tree structure via + uuid/parentUuid for future conversation branching support" + (`qwen: packages/core/src/services/chatRecordingService.ts:230-238`). + Records are never re-appended/upserted; each is written once and is + self-contained. + +### Rewind reimplemented as branch re-rooting, not a positional truncation marker + +- Gemini appends `{ "$rewindTo": messageId }`; the replay reducer finds that + id and deletes it plus everything after it on load (dossier, "Rewind, + checkpoints, and fork"). +- Qwen's `rewindRecording` re-points the in-memory `lastRecordUuid` (the + parent pointer the *next* appended record will use) back to the record + before the target turn, appends a `system`/`rewind` record for the audit + trail, and lets subsequent writes form a new branch off that point + (`qwen: packages/core/src/services/chatRecordingService.ts:1686-1719`). + The rewound records are not deleted; they remain in the file as an + abandoned parentUuid branch that a tree-walk from the tail never visits. + Functionally similar end state (old turns invisible on replay, physically + retained) but the mechanism is graph re-rooting, not "scan forward from an + id and drop lines." + +### First-class fork (`/branch`), which Gemini's dossier says does not exist upstream + +- Gemini: "there is no first-class fork/branch operation on the chat + record... resume... continues the same file rather than branching a new + lineage" (dossier, "Rewind, checkpoints, and fork"). +- Qwen's `SessionService.forkSession` reads the full source transcript, + reconstructs only the *active* branch (explicitly excluding abandoned + post-rewind records -- "Rewind leaves old records in the JSONL as abandoned + parentUuid branches; copying raw records would resurrect them"), strips + `parent_session`/`session_source` records so the fork is attributed as a + fresh top-level session, and writes the result to a new session file + (`qwen: packages/core/src/services/sessionService.ts:1690-1740`). + +### Project keying scheme changed, with a new collision-handling code path as a direct consequence + +- Gemini keys the chats directory by an opaque `projectShortId` minted by a + `ProjectRegistry` (`projects.json`), separately from a `sha256(projectRoot)` + stored in the record, and migrates old hash-named directories to the new + short-id scheme (dossier, "Keying and identity"). +- Qwen has no equivalent registry. `Storage.getProjectDir()` derives the + directory name directly from the project root via `sanitizeCwd`, which + just replaces every non-alphanumeric character with a hyphen + (`qwen: packages/core/src/config/storage.ts:346-349`, + `qwen: packages/core/src/utils/paths.ts:385-389`; chats live at + `/chats`, `qwen: packages/core/src/services/chatRecordingService.ts:768-769`). + A separate `getProjectTempDir()` keyed by `sha256` still exists for + temp/debug output (`qwen: packages/core/src/config/storage.ts:352-357`) + but is not what backs `chats/`. + Because `sanitizeCwd` is a lossy many-to-one mapping (e.g. two absolute + paths that differ only in punctuation can sanitize to the same string), + `SessionService.listSessions` has to defend against directory collisions + that did not exist under Gemini's hash-based scheme: "Different projects + may share the same chats directory due to path sanitization, so we need to + filter by project hash and continue until we have enough items" + (`qwen: packages/core/src/services/sessionService.ts:982-984`, filtered via + `sessionBelongsToCurrentProject`). This is a new failure mode introduced by + the rename, not present in the scheme it replaced. + +### Session identity minting changed + +- Gemini mints the session id from the runtime `context.promptId` (dossier, + "Keying and identity"). +- Qwen mints an independent `randomUUID()` as `Config.sessionId` at config + construction (`qwen: packages/core/src/config/config.ts:2087`), and + `ChatRecordingService` uses that (or a `binding`-supplied override) as the + session id (`qwen: packages/core/src/services/chatRecordingService.ts:764-765`). + Session identity is decoupled from the prompt-id concept entirely. + +### File-state checkpointing swapped from a shadow-git repo to a file-copy history service + +- Gemini's `/restore` checkpointing is a hidden shadow git repository + (`GitService`) that commits the workspace before each restorable tool call + and restores via `git restore --source ` (dossier, "Rewind, + checkpoints, and fork"; `gitService.ts`, `checkpointUtils.ts`). +- No `GitService`, `gitService.ts`, or `checkpointUtils.ts` equivalent exists + in Qwen Code (targeted searches for `GitService`, `createFileSnapshot`, + `restorableToolCall`, and `shadow git` in `packages/core/src` and + `packages/cli/src` found nothing). Qwen instead has + `FileHistoryService` (`qwen: packages/core/src/services/fileHistoryService.ts`), + which makes per-file backup copies (via `copyFile`, hashed content, + diffed with the `diff` npm package) under a `file-history` directory + (`qwen: packages/core/src/services/fileHistoryService.ts:103-104`, + `MAX_SNAPSHOTS = 100`, `FILE_HISTORY_DIR = 'file-history'`), keyed by + `promptId` rather than by tool-call/commit hash, and recorded into the + transcript itself as `file_history_snapshot` system records + (`qwen: packages/core/src/services/chatRecordingService.ts:2041-2060`, + `recordFileHistorySnapshot`/`recordFileHistorySnapshotBatch`). This is a + different mechanism (file copies + diff, not content-addressed git + commits), not a rename of the same one. +- Gemini's legacy `Logger` (`logs.json`, `/chat save ` → + `checkpoint-.json`) *does* survive in Qwen essentially unchanged in + shape, just under `.qwen` instead of `.gemini` + (`qwen: packages/core/src/core/logger.ts:491-654`, + `_checkpointPath`/`saveCheckpoint`/`loadCheckpoint`/`checkpointExists`) -- + see "What did not diverge" below. + +### Concurrency control added where Gemini's dossier flags none + +- Gemini's dossier lists "No concurrency control -- single-writer assumption + with no lock or expected-version; unsafe for multi-writer/multi-host" as a + caution (dossier, "What this implies for our Session Store"). + Qwen added `SessionWriterLease` + (`qwen: packages/core/src/services/session-writer-lease.ts`), a PID-based + lock file with a schema version (`LOCK_SCHEMA_VERSION = 2`, + `qwen: packages/core/src/services/session-writer-lease.ts:17`), transcript + snapshot hashing to detect drift, and typed failure modes + (`SessionTranscriptChangedError`, `SessionWriterLostError`, + `SessionWriterUnavailableError`, + `qwen: packages/core/src/services/chatRecordingService.ts:40-45`). Writes + are serialized through a per-service `operationTail` promise chain and, when + a lease is held, appended via `lease.appendJsonLine` + (`qwen: packages/core/src/services/chatRecordingService.ts:943-971`) -- a + materially different durability/concurrency story than Gemini's bare + `fs.appendFileSync`. + +### Subagent/child-session model replaced with three distinct mechanisms; no cascade delete + +Gemini has one subagent mechanism: a child session file nested under +`chats//.jsonl`, whose deletion is designed to cascade -- +`deleteStoredSession` "finds and deletes all associated files (parent and +subagents)" (dossier, "Subagents and nested sessions"). Qwen has three, +none of which nest a child file under the parent's directory, and none of +which is cascade-deleted with the parent: + +1. **`create_sub_session`** spawns "a FRESH top-level sub-session (a sibling + of the current session, its own transcript)" + (`qwen: packages/core/src/tools/create-sub-session.ts:8-9`). It lives as + an ordinary file in the same `chats/` directory as its parent, linked only + by a soft `parent_session` system record + (`ParentSessionRecordPayload { parentSessionId }`, + `qwen: packages/core/src/services/chatRecordingService.ts:507-515`, + written at `qwen: packages/core/src/tools/agent/agent.ts:3280`, `:4081`) + -- a pointer, not directory nesting. +2. **Background subagents** get their own directory, + `/subagents//`, holding a canonical + `agent-.jsonl`, an `agent-.meta.json` sidecar (agentType, + description, parent ids, createdAt), and a transient + `agent-.jsonl.stream` for in-flight text + (`qwen: packages/core/src/agents/agent-transcript.ts:9-20`, + `getSubagentsRootDir`/`getSubagentSessionDir` at `:53-77`). This is the + closest analog to Gemini's nested-child model, but the directory is + `subagents/`, not `chats//`, and it carries a metadata + sidecar Gemini's model doesn't have. +3. **Inline sidechain records**: `ChatRecord` carries `isSidechain`, + `agentId`, `agentColor`, `agentRunId`, `agentRound` fields + (`qwen: packages/core/src/services/chatRecordingService.ts:361-374`), set + from `packages/core/src/tools/agent/agent.ts` (e.g. `agentId`/`agentColor` + at `:3261-3263`), letting some subagent activity live as tagged records + interleaved in the *parent's own* transcript rather than a separate file + at all. + +**Cascade on delete does not exist for either of the divergent mechanisms.** +`SessionService.removeSession` / `removeSessionFiles` deletes the session's +own transcript, worktree sidecars, and file-history backups +(`qwen: packages/core/src/services/sessionService.ts:1352-1417`, +`removeWorktreeSidecars` at `:554-562`, `removeFileHistoryBackups` at +`:563-569`), but never references `subagents/` or a `create_sub_session` +child. Background-subagent transcripts are instead reaped independently by +an age/TTL housekeeping job (`cleanupOldSubagentTranscripts`, scheduled at +`qwen: packages/cli/src/utils/housekeeping/scheduler.ts:175-194`), not tied +to parent-session deletion at all. Deleting a session that spawned +`create_sub_session` children leaves those children on disk indefinitely as +ordinary independently-listed sessions (they are full top-level sessions, so +this is arguably correct for that mechanism, but it means "delete cascades +to children" -- true in Gemini -- is false in Qwen for every mechanism except +possibly the fully-nested rewind-branch case, which isn't a parent/child +relationship at all). + +## What did not diverge + +- **The storage medium is still append-only JSONL**, one file per session, + read by streaming/parsing the file directly on resume -- same shape as + Gemini's `session-*.jsonl`, just with a redesigned record schema (see + above). Cosmetic-only in the sense that "it's still JSONL append" holds; + substantive in every other respect covered above. +- **Listing is still per-project**, a directory scan/cursor walk over one + project's `chats/` dir (`qwen: packages/core/src/services/sessionService.ts:943-1067`), + matching Gemini's "no global cross-project session enumeration" behavior + (dossier, "Listing, summaries, and search") -- Qwen added pagination + (`cursor`/`size`) and an archive/active split on top, but the scope (one + project) is unchanged. +- **Resume is still "read the durable store directly, no separate cache or + index"** -- same principle as Gemini's, just reconstructing a tree instead + of replaying line kinds. +- **The legacy `Logger` (`logs.json`, `/chat save`/`checkpoint-.json`) + survives essentially unchanged in shape** + (`qwen: packages/core/src/core/logger.ts:15-79`, `:491-654`) -- this is a + genuine cosmetic rename (`.gemini` → `.qwen` root only): same file names, + same tag-encoding scheme (`encodeTagName`/`decodeTagName`, + `qwen: packages/core/src/core/logger.ts:46-69`), same API shape + (`saveCheckpoint`/`loadCheckpoint`/`checkpointExists`/`deleteCheckpoint`). + It does not change on-disk layout beyond the root rename and does not + break reading existing data in place (there simply is no existing `.qwen` + data for a new user, per the migration gap above). +- **No multi-host / shared-filesystem coordination was added.** The new + `SessionWriterLease` is a local PID-based file lock, not a remote/shared + coordination protocol -- same single-host assumption as Gemini's model, just + with a lock where Gemini had none. + +## What this adds to the corpus + +Qwen Code is independent evidence, not a restatement of Gemini CLI's design. +It shares Gemini's original append-only-JSONL-with-projection lineage (the +file names, the `Storage`/`paths` module split, and the legacy `Logger` are +recognizably descended from the same code), but the actual session record +schema, project-keying scheme, concurrency model, file-checkpoint mechanism, +and subagent model have all been independently redesigned and now differ in +ways that matter for anyone building a store: an explicit tagged/tree +format instead of positional-line discrimination, a real write-lease instead +of none, and three competing subagent mechanisms instead of one consistent +one. A design survey should treat Qwen Code as its own data point on tree- +structured (`uuid`/`parentUuid`) session formats and on file-lease-based +single-writer enforcement, not as "Gemini CLI with a different folder name." + +## Open questions + +- **Whether upstream Gemini CLI has since added anything comparable** (a + write lease, a fork/branch operation, tree-structured records) after the + dossier's pinned commit (`87f785192c34067e4e8f26bda16cf9ce24014d83`, + 2026-07-23) could not be checked -- no local Gemini CLI clone was available + for this delta, per instructions. Everything above is stated relative to + that pinned dossier, not to Gemini CLI's current `main`. +- **Whether any code path reads a legacy `~/.gemini` tree as a fallback** + (e.g. a first-run importer not wired into the modules searched) was not + exhaustively ruled out -- the searches covered `packages/core/src` and + `packages/cli/src` for `.gemini`, `GEMINI_DIR`, and `legacyDir`-shaped + names and found nothing, but Qwen Code is a large multi-package monorepo + (it also contains `packages/desktop`, `packages/vscode-ide-companion`, + `packages/channels`, etc.) that was not fully swept. +- **Retention/TTL default for the background-subagent housekeeping job** + (`qwen: packages/cli/src/utils/housekeeping/scheduler.ts:175-194`) was not + traced to its configured cutoff value. +- **Collision probability/handling correctness of `sanitizeCwd`** beyond the + defensive filter in `listSessions` -- e.g. whether two genuinely different, + colliding project roots can have their sessions cross-contaminate outside + the listing path (writes, not just reads) -- was not traced end to end. diff --git a/docs/research/session-store/products/roo-code/index.md b/docs/research/session-store/products/roo-code/index.md new file mode 100644 index 000000000..dcd8cdb01 --- /dev/null +++ b/docs/research/session-store/products/roo-code/index.md @@ -0,0 +1,328 @@ +# Roo Code: what diverged from Cline's session store + +Part of Session Store Research. +Fork delta report; see [backlog](../../backlog.md) Wave 5 for why this is a delta rather than a dossier. +Roo Code pinned at `b867ec9145750d0ae1ff7f02d35406e9bf2a0b16` (Apache-2.0, committed +2026-05-15), compared against Cline at `5ec2d47b21b3a09aa7a094bfbbe0c7e8f7ddd3fa` +(Apache-2.0, committed 2026-08-03). Retrieved 2026-08-04. +Upstream reference: [Cline](../cline/index.md). + +## Summary of divergence + +Roo Code forked before Cline's `sdk/packages/core` rewrite existed, and never +adopted it: at this pin, Roo Code is still entirely on the architecture Cline's +dossier calls "Generation 1" -- two per-task flat files +(`api_conversation_history.json`, `ui_messages.json`), no database, full-file +rewrite on every save, one JSON file per concern. That alone is the headline +finding and it dates the fork to before Cline's SQLite/manifest/messages-file +generation. Within that shared generation-1 skeleton, however, Roo Code has +independently built real infrastructure Cline's generation 1 never had: a +crash-safe write path (temp-file-plus-rename with cross-process locking), a +`history_item.json` + `_index.json` indexing layer with filesystem-watch-driven +reconciliation, a non-destructive tag-and-filter compaction scheme, and a +recursive (not one-level) cascade delete for its parent/child task tree. +Checkpoints are a real second git repository per task (`git init` + `core.worktree` ++ ordinary commits), not Cline's private-ref-plus-`git stash create` mechanism +inside the user's own repo, and not Cline's docs' claimed "shadow repository" +either -- Roo Code is the product that actually does what Cline's docs describe. +Net: the transcript *format* did not diverge (same generation, same file +names), but nearly every mechanism built around that format did. + +## What diverged + +### 1. Checkpoints: a real second git repository, not refs-in-the-real-repo + +Cline (current generation) stores checkpoints as private refs +(`refs/cline/checkpoints/{sessionId}/{runCount}`) created via `git stash create` +directly inside the user's own repository -- no second `.git` directory +(`cline: sdk/packages/core/src/hooks/checkpoint-hooks.ts`, cited in the Cline +dossier's Rewind/checkpoints section). + +Roo Code instead creates an actual second git repository per task, with a +separate `.git` directory living under the extension's global storage and +`core.worktree` pointed at the real workspace: + +- `roo: src/services/checkpoints/ShadowCheckpointService.ts:125` -- + `this.dotGitDir = path.join(this.checkpointsDir, ".git")`. +- `roo: src/services/checkpoints/ShadowCheckpointService.ts:175-186` -- + `git.init(...)`, `git.addConfig("core.worktree", this.workspaceDir)`, + then an ordinary `git.commit("initial commit", { "--allow-empty": null })`. +- `roo: src/services/checkpoints/ShadowCheckpointService.ts:295-341` -- + `saveCheckpoint()` does `stageAll` (`git add . --ignore-errors`) followed by + a normal `git.commit(message)` -- full commits via the `simple-git` package, + not `git stash create`. +- `roo: src/services/checkpoints/RepoPerTaskCheckpointService.ts:6-15` -- the + concrete class used in production wires the shadow repo's directory to + `{shadowDir}/tasks/{taskId}/checkpoints`, where `shadowDir` is the + extension's `globalStorageUri.fsPath` + (`roo: src/core/checkpoints/index.ts:62-73`, `getCheckpointService()` + passes `shadowDir: globalStorageDir`). So the shadow repo's own `.git` + physically lives at + `{globalStorage}/tasks/{taskId}/checkpoints/.git`, nested inside the same + per-task directory as the transcript files. +- Restore is `git clean -f -d` + `git reset --hard ` + (`roo: src/services/checkpoints/ShadowCheckpointService.ts:344-372`) -- no + safety-net stash/ref is taken first, unlike Cline's + `beginWorktreeRestoreTransaction` (noted in the Cline dossier as a + pre-restore safety net Cline takes that Roo Code has no counterpart for, as + far as this pass found). + +This means Roo Code is closer to what Cline's own user-facing docs describe +("Cline maintains a shadow Git repository separate from your project's actual +Git history") than Cline's actual current code is -- the Cline dossier flags +that doc passage as contradicted by Cline's source; it is an accurate +description of Roo Code's mechanism instead. + +A secondary, apparently vestigial code path exists for a *shared-per-workspace* +shadow repo with one git branch per task (`roo-${taskId}`): +`roo: src/services/checkpoints/ShadowCheckpointService.ts:440-516` +(`workspaceRepoDir()`, `deleteTask()`, `deleteBranch()`). `deleteTask()` is +still called from the task-deletion path +(`roo: src/core/webview/ClineProvider.ts:1786`), but it targets +`workspaceRepoDir()` (`{globalStorage}/checkpoints/{sha256(workspaceDir).slice(0,8)}`), +a different path than the repo-per-task directory `RepoPerTaskCheckpointService` +actually uses for live checkpoints. It is plausible this is a best-effort +cleanup for an older layout that a prior Roo Code version used, now dead in +practice because the directory it targets is never created by the live +`RepoPerTaskCheckpointService` path -- not confirmed by tracing git blame; flagged +under Open questions rather than asserted. + +### 2. Compaction: non-destructive in-band tagging, not a separate sidecar file + +Cline's compaction writes a separate sidecar artifact +(`{sessionId}.compaction.json`) and leaves the messages file untouched +(Cline dossier, Compaction and history management). + +Roo Code has no sidecar file. Its condense step rewrites the *same* +`api_conversation_history.json` in place, but non-destructively: it does not +delete the original messages, it tags them: + +- `roo: src/core/condense/index.ts:445-474` -- `summarizeConversation()` + mints a `condenseId = crypto.randomUUID()`, appends a new message with + `isSummary: true, condenseId`, and tags every pre-existing message with + `condenseParent: condenseId` (skipping any that already carry one). +- `roo: src/core/task/Task.ts:1686` -- `condenseContext()` calls + `await this.overwriteApiConversationHistory(messages)`, and + `roo: src/core/task/Task.ts:1016-1019` shows `overwriteApiConversationHistory` + writes the *entire* returned array (original tagged messages + new summary) + back to disk via `saveApiConversationHistory` -- a full-file rewrite of the + one durable transcript, not a second file. +- `roo: src/core/condense/index.ts:602-682` -- read-side filtering + (`condenseParent`/`truncationParent` pointing at a still-existing + summary/marker) decides what actually gets sent to the model API; orphaned + tags (e.g. after a rewind deletes the summary message itself) are cleaned up + on the next pass. +- `roo: src/core/task-persistence/apiMessages.ts:26-37` -- the `ApiMessage` + type itself carries `condenseId`/`condenseParent`/`truncationId`/ + `truncationParent`/`isTruncationMarker` fields, i.e. this is a persisted + part of the message schema, not a runtime-only concept. + +Both products claim "non-destructive" compaction, but the mechanism differs: +Cline keeps the full transcript in one file and shrinks the *model-visible* +view in a second, independently-versioned file; Roo Code keeps everything in +one file and filters at send-time using tags baked into the persisted +records. Roo Code additionally supports a separate, older sliding-window +*truncation* path alongside condensing (`truncationId`/`truncationParent`/ +`isTruncationMarker` fields, used at +`roo: src/core/task/Task.ts:3797`, `roo: src/core/task/Task.ts:4024`) -- a +second context-shrinking mechanism with no clear Cline counterpart identified +in this pass. + +### 3. A history-index layer Cline's generation 1 never built (and generation 2 built differently) + +Cline gen 1 kept task history in extension global state, and its own code +contains an abandoned stub for migrating that into a per-task file +(`cline: apps/vscode/src/core/storage/state-migrations.ts:65-67`, +`migrateTaskHistoryToFile`, body is `// TODO migrate to sdk location`, per the +Cline dossier's Entry/message structure and versioning section -- never +implemented at Cline's pinned commit). + +Roo Code implemented this migration and the file layout it stubs out never got +around Cline's own gen-1 code: + +- `roo: src/core/task-persistence/TaskHistoryStore.ts:1-72` -- each task's + `HistoryItem` is now its own file, + `{globalStorage}/tasks/{taskId}/history_item.json` + (`roo: src/shared/globalFileNames.ts:7`), with a single cache/index file + `{globalStorage}/tasks/_index.json` + (`roo: src/shared/globalFileNames.ts:8`) for fast startup listing. +- `roo: src/core/task-persistence/TaskHistoryStore.ts:325-360` -- + `migrateFromGlobalState()` walks a legacy globalState `taskHistory` array + and writes a `history_item.json` for any entry whose task directory still + exists on disk, then rewrites the index -- an idempotent one-time migration, + explicitly the migration Cline's own stub never implemented. + `roo: src/core/webview/ClineProvider.ts:172` wires + `new TaskHistoryStore(...)` into the live provider, so this is active code, + not a dead utility. +- `roo: src/core/task-persistence/TaskHistoryStore.ts:465-508` -- cross-window + reactivity via `fs.watch` on the tasks directory (debounced reconcile), plus + a 5-minute periodic reconciliation + (`roo: src/core/task-persistence/TaskHistoryStore.ts:514-529`) as a + defensive fallback where `fs.watch` is unreliable. Cline's dossier records + no equivalent watch-based cross-instance mechanism for its gen-1 files; its + gen-2 manifest/index staleness detection instead relies on lazy + reconciliation at listing time (`reconcileDeadSessions`, PID-liveness based) + -- a different mechanism solving a related but not identical problem + (crash/staleness detection vs. cross-window index freshness). + +This is convergent evolution on the general shape ("small index file plus +per-item source-of-truth plus reconciliation"), not a copy of Cline's +SQLite/manifest design -- no schema validation library (Zod or otherwise), no +database, and a different trigger (`fs.watch` + timer vs. lazy +listing-time PID check). + +### 4. Write durability: temp-file-plus-rename and cross-process locking, where Cline gen 1 has neither + +Cline's classic generation-1 writes (`saveTaskMetadata` and siblings) are +plain `fs.writeFile`/`writeFileSync` calls with no atomic rename, per the +Cline dossier's storage-model section. + +Roo Code's equivalent writes for the *same-named* files +(`api_conversation_history.json`, `ui_messages.json`, `history_item.json`, +`_index.json`) all go through a shared helper that is meaningfully more +durable: + +- `roo: src/utils/safeWriteJson.ts:33-79` -- acquires an inter-process + advisory lock via `proper-lockfile` (stale-after-31s, exponential-backoff + retries) before touching the file. +- `roo: src/utils/safeWriteJson.ts:86-115` -- writes to a temp file, renames + any existing target to a backup path, then renames the temp file onto the + target (`fs.rename` as the commit step) -- a real temp-write-then-rename + pattern, with the old version preserved as a `.bak` file until cleanup + succeeds. +- Used by `roo: src/core/task-persistence/apiMessages.ts:120` + (`saveApiMessages`), `roo: src/core/task-persistence/taskMessages.ts:55` + (`saveTaskMessages`), and `roo: src/core/task-persistence/TaskHistoryStore.ts:442` + (`writeTaskFile`). + +This closes (for these specific files) the torn-write risk the Cline dossier +calls out as a genuine gap in Cline's own messages/manifest files -- a +divergence in the safe direction, though it does not change the shared +finding below that both products still rewrite the *entire* file on every +save (no append-only log, no bounded per-write cost). + +### 5. Cascade delete on parent task removal: recursive, not one level deep + +Cline's cascade delete only looks one level down from a root session and +does not recurse into a deleted child's own children +(`cline: sdk/packages/core/src/session/services/persistence-service.ts:557-609`, +gated on `if (!row.isSubagent)`, per the Cline dossier's Subagents section, +which calls this "a latent orphan path" for graphs deeper than one level). + +Roo Code's task-history deletion walks the full child tree recursively before +deleting anything: + +- `roo: src/core/webview/ClineProvider.ts:1737-1763` -- + `deleteTaskWithId()`'s `collectChildIds()` recurses through + `historyItem.childIds` at every depth, building the full set of descendant + task ids before any deletion happens (not gated on the target task being a + root -- it recurses from whichever id is passed in). +- `roo: src/core/webview/ClineProvider.ts:1774-1803` -- all collected ids are + removed from the history store in one batch (`taskHistoryStore.deleteMany`), + then each id's shadow-checkpoint repo and task directory are individually + removed. + +Roo Code's parent/child linkage is `rootTask`/`parentTask` object references +on the in-memory `Task` (`roo: src/core/task/Task.ts:148-149`) plus +`childIds: z.array(z.string()).optional()` on the persisted `HistoryItem` +(`roo: packages/types/src/history.ts:25`), a "boomerang task"/delegation model +(`delegateParentAndOpenChild`, `roo: src/core/webview/ClineProvider.ts:2780-2907`) +rather than Cline's deterministic-subagent-id model -- conceptually adjacent +(both are relational parent-child links on the task/session record, not +path-nesting) but the delegation semantics and id-minting are unrelated +enough that this is a genuine design difference, not a rename. + +## What did not diverge + +- **Transcript generation and file names.** Roo Code is squarely on the same + generation Cline calls "Generation 1": `api_conversation_history.json` + (model-facing) and `ui_messages.json` (display-facing) are both still + actively read and written, not a fallback for old data + (`roo: src/core/task-persistence/apiMessages.ts:109-121`, + `roo: src/core/task-persistence/taskMessages.ts:52-56`, contrast with + Cline's SDK generation where these same two file names are read-only + fallback paths per the Cline dossier). Roo Code also still tolerates one + file name older than either: `claude_messages.json` + (`roo: src/core/task-persistence/apiMessages.ts:73-99`), a one-time + read-and-delete fallback -- an extra rung on the same ladder, not a new + generation. +- **Directory layout convention.** `{globalStoragePath}/tasks/{taskId}/` as + the per-task root (`roo: src/utils/storage.ts:53-58`, + `getTaskDirectoryPath`) is the same flat, non-nested layout Cline's + generation 1 uses (`{extensionGlobalStoragePath}/tasks/{taskId}/` per the + Cline dossier). This is inherited structure from before the fork, not an + independent convergence -- say so plainly rather than counting it as + evidence of anything. +- **No database.** Neither Cline generation 1 nor Roo Code has one. Checked: + no `sqlite`/`better-sqlite3` dependency in Roo Code's `package.json`, and no + source file constructs a SQL connection or table for session/task data (the + only two source hits for the string "sqlite" are an unrelated exclude-glob + list and an unrelated custom-instructions string, not session storage). + This is not a divergence from Cline gen 1, but it is worth restating plainly + because Cline's *current* generation (2) does have SQLite as its primary + backend -- Roo Code simply never made that jump, consistent with the + Summary above. +- **Unbounded growth / full-file rewrite cost.** Every write to + `api_conversation_history.json` or `ui_messages.json` re-serializes the + entire array (`roo: src/core/task-persistence/apiMessages.ts:120`, + `roo: src/core/task-persistence/taskMessages.ts:55`), and reads parse the + whole file (`roo: src/core/task-persistence/apiMessages.ts:50-53`). No + pagination, cursor, or size cap was found -- the same shape of unbounded + linear-cost growth the Cline dossier documents as a proven, user-visible + failure mode (issue cline/cline#9011) for Cline's own gen-1 files. The + atomic-write improvement (item 4 above) makes individual writes safer, not + smaller or cheaper. +- **No explicit subagent/child nesting-depth cap.** Grepped + `roo: src/core/task/Task.ts` and `roo: src/core/webview/ClineProvider.ts` + for a maximum-depth constant/guard; none was found -- matching the Cline + dossier's own "none found" conclusion for Cline. Absence confirmed in the + same places searched in both trees, not merely assumed. + +## What this adds to the corpus + +Independent evidence, not a restatement -- but only for the mechanisms layered +around the shared generation-1 skeleton, not for the skeleton itself. Roo Code +answers "what does a long-lived fork of the *old* Cline format actually look +like once it grows its own infrastructure for years" -- a question Cline's own +repository cannot answer about itself, since Cline replaced that generation +with a structurally different one rather than hardening it in place. The +checkpoint mechanism in particular is useful corpus evidence in its own right: +it is the second real-git-repo-based design in this research (as opposed to +refs-in-the-real-repo), and it happens to be the one that actually matches +Cline's own (otherwise inaccurate-for-Cline) documentation. The +history-index layer and the non-destructive condense-by-tagging scheme are +both independently engineered solutions to problems Cline also has, built +without sharing code with Cline's gen-2 answers to the same problems -- useful +as a second data point on "index file plus per-item source of truth" and +"non-destructive compaction" as recurring shapes across unrelated +implementations, not as confirmation of Cline's specific design. + +## Open questions + +- Is `ShadowCheckpointService.deleteTask()`'s branch-based cleanup + (`roo: src/services/checkpoints/ShadowCheckpointService.ts:450-469`, + targeting `workspaceRepoDir()`) dead code left over from an earlier + shared-shadow-repo-per-workspace design, or does some other code path still + create checkpoints there? I did not find any call site that constructs a + `workspaceRepoDir()`-rooted service for live checkpoint creation -- only + `RepoPerTaskCheckpointService`, which uses a per-task directory instead -- + but did not trace git history to confirm this is vestigial rather than + reachable through a path this pass missed. + `roo: src/core/webview/ClineProvider.ts:1786` still calls it unconditionally + on every task delete, so if it is dead it is at least harmless (best-effort, + caught and logged). +- Does Roo Code's CLI surface (`apps/cli/src/lib/task-history`, + `apps/cli/src/lib/storage`) share the same per-task file format and + `TaskHistoryStore`, or does it have its own independent storage path? Not + investigated in this pass -- flagged rather than assumed, since Cline's own + CLI (`apps/cli`) does share its store with the VS Code extension via + `@cline/core`, and it would be a real divergence if Roo Code's CLI does not. + `apps/cli/src/lib/storage/history.ts` was located but not read. + (roo, path only, not opened) +- Whether Roo Code's `git add . --ignore-errors` / full-worktree-commit + checkpoint strategy has the same "cost scales with working-tree diff since + last checkpoint" property the Cline dossier documents for `git stash + create`, or whether committing the full worktree via a real branch/HEAD + history (rather than dangling stash-created commits with no branch) grows + the shadow repo's `.git` directory differently over a long task, was not + measured or benchmarked in this pass. diff --git a/docs/research/session-store/products/swe-agent/index.md b/docs/research/session-store/products/swe-agent/index.md new file mode 100644 index 000000000..ea2033498 --- /dev/null +++ b/docs/research/session-store/products/swe-agent/index.md @@ -0,0 +1,647 @@ +# SWE-agent: how session transcripts are stored and resumed + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT](../../RESEARCH_PROMPT.md). +Evidence snapshot retrieved 2026-08-04. Source: `SWE-agent/SWE-agent`, pinned at commit `3ea751c087f32b16e039a2233dd6eefecef325d5` +(`fix: map multimodal subset to sb-cli's swe-bench-m (#1458)`, 2026-07-16), +MIT license. All paths below are relative to that repository root, not to +this platform's REPO. + +- Primary anchor: `sweagent/agent/agents.py` (trajectory writer, `DefaultAgent`, + `RetryAgent`). +- Secondary anchors: `sweagent/types.py` (wire types), `sweagent/run/run_batch.py`, + `sweagent/run/run_replay.py`, `sweagent/run/run_single.py`, + `sweagent/run/common.py`, `sweagent/agent/history_processors.py`, + `sweagent/agent/reviewer.py`, `sweagent/inspector/server.py`, + `docs/usage/trajectories.md`, `docs/usage/inspector.md`. + +## Framing: is a trajectory a session store, or an output artifact? + +**It is an output artifact that happens to contain a full transcript, not a +session store in the resume-a-conversation-tomorrow sense.** The docs say so +themselves: "Trajectories are the main output of SWE-agent. They are the best +way to understand what SWE-agent does" (`docs/usage/inspector.md:4`), and the +`.traj` file is introduced as "the main output file" of a run +(`docs/usage/trajectories.md:7`), not as a session record. + +The four decisive tests, each answered from the code: + +1. **Is a `.traj` file ever read back by the program to continue a run?** No. + The only two readers of a `.traj` file's `history`/`trajectory` fields at + runtime are `sweagent/run/run_replay.py` and + `sweagent/run/run_traj_to_demo.py`, and neither continues the run that + produced the file: `run_replay.py` starts a **brand-new** `SWEEnv` and a + **brand-new** `DefaultAgent` (`sweagent/run/run_replay.py:186-192`, + `_get_env`/`_get_agent`/`_get_run_single`) and re-executes the stored + assistant actions through a `ReplayModel` that just plays back the + `history` list instead of querying a real LLM + (`sweagent/agent/models.py:464-481`). `run_traj_to_demo.py` extracts a + filtered `history` into a YAML demo file for a human to hand-edit + (`sweagent/run/run_traj_to_demo.py:39-58`). Neither reconstructs `self.history`, + `self.trajectory`, `self.info`, or any other live agent attribute; they + consume the JSON as data, not as agent state. +2. **Is there a resume-by-id path?** No. What exists instead is + `RunBatch.should_skip` (`sweagent/run/run_batch.py:376-409`): if + `//.traj` already exists and its + `info.exit_status` is a completed status, the instance is skipped entirely + (never re-run, never continued). If the file is empty, unparsable, or has + `exit_status in (None, "early_exit")`, the code calls + `log_path.unlink()` (`sweagent/run/run_batch.py:391,400,405`) and the + instance is **re-run from scratch** -- a fresh `output_dir.mkdir`, fresh + `agent.setup()`, fresh environment, fresh `.traj`, overwriting the old + path. This is "already done, skip" logic, explicitly not resume: nothing in + this path loads `history` back into an agent, and an incomplete run is + deleted, not continued. +3. **Append-as-you-go or written once at the end?** Append-as-you-go, but by + whole-file rewrite, not by appending bytes. `DefaultAgent.run`'s main loop + is `while not step_output.done: step_output = self.step(); self.save_trajectory()` + (`sweagent/agent/agents.py:1284-1286`), so the trajectory file is rewritten + to disk after **every** step, not just at the end. `save_trajectory` + (`sweagent/agent/agents.py:779-787`) calls + `self.traj_path.write_text(json.dumps(data, indent=2))` -- a single + `write_text` call that serializes the entire accumulated `history` + + `trajectory` + `info` from scratch each time. So a crash between steps + loses at most the in-flight step, not the run so far; a crash **during** + that `write_text` call can leave a torn/partial JSON file, because there is + no temp-file-and-rename and no fsync anywhere in this path (none found in + `sweagent/agent/agents.py` or `sweagent/utils/`). +4. **Is there per-run replay tooling, and does it reconstruct agent state or + only display it?** Both kinds exist, and neither reconstructs agent state. + The **inspector** (`sweagent/inspector/server.py`, + `sweagent/inspector/static.py`) is read-only: it globs `**/*.traj` + (`sweagent/inspector/server.py:274`, `sweagent/inspector/static.py:158`), + loads each file with `json.load` (`sweagent/inspector/server.py:170`), and + serves it to a browser-side viewer (`fileViewer.js`) or bakes it into a + static HTML page (`sweagent/inspector/static.py:96-124`) -- display only, + confirmed by `docs/usage/inspector.md:1-8`. **`run-replay`** + (`sweagent/run/run_replay.py`) does execute actions again, but through a + fresh environment and a fresh agent, as described in point 1: it + reconstructs environment **output**, not agent **state**. + +Taken together: a `.traj` file is scored by evaluation harnesses (SWE-bench's +`preds.json`), displayed by humans (inspector), and occasionally re-executed +for a different purpose (demo creation, debugging), but the running program +that produced it never reads it back to pick up where it left off. That is +the operational definition of an output artifact, not a session store. + +## The storage model + +The durable record for one instance-run is a single JSON file, +`.traj`, produced by +`DefaultAgent.get_trajectory_data`/`save_trajectory` +(`sweagent/agent/agents.py:762-787`). Its top-level shape, built at +`sweagent/agent/agents.py:768-777`: + +```python +attempt_data = { + "trajectory": self.trajectory, # list[TrajectoryStep] -- post-hoc, per-step summary + "history": self.history, # list[HistoryItem] -- the literal LM conversation + "info": self.info, # AgentInfo -- exit status, submission, cost stats +} +attempt_data["replay_config"] = self.replay_config.model_dump_json() if ... else None +attempt_data["environment"] = self._env.name +``` + +There is no separate index, no sidecar summary file, no cache distinct from +the trajectory itself, and no derived/authoritative split: the one file is +computed fresh from in-memory Python objects (`self.trajectory`, +`self.history`, `self.info`) every time it is written, and nothing is ever +read back from it to reconstruct those objects (see Framing, above). It is +closest to **session-as-document**: a single mutable JSON document, +rewritten wholesale on every step, not an append-only log and not a +directory of separate records. + +`RetryAgent` (the multi-attempt driver, see Subagents section) wraps this in +one more layer: its own `.traj` file is `{"attempts": [, ...]}`, +optionally overlaid with a full copy of the chosen attempt's fields at the +top level (`sweagent/agent/agents.py:358-388`). + +## Keying and identity + +- **Instance id** (`ProblemStatement.id`) is the primary key of a run, and it + is content-derived, not randomly minted, for the built-in problem statement + types: + - `TextProblemStatement.id` and `SWEBenchMultimodalProblemStatement.id` + default to `hashlib.sha256(text).hexdigest()[:6]` + (`sweagent/agent/problem_statement.py:84-86,183-185`). + - `FileProblemStatement.id` is the sha256[:6] of the loaded file content + (`sweagent/agent/problem_statement.py:117-119`). + - `GithubIssue.id` is `f"{owner}__{repo}-i{issue_number}"` + (`sweagent/agent/problem_statement.py:144-147`). + - `EmptyProblemStatement.id` defaults to `str(uuid.uuid4())` + (`sweagent/agent/problem_statement.py:58`) -- the one case that is + randomly minted, used when there is no real problem statement (e.g. shell + mode). + - SWE-bench-sourced instances use the dataset's own `instance_id` string + verbatim (`sweagent/run/batch_instances.py:97,166-167,408,429`). +- **Output path is the true identity for collision purposes.** The instance + id becomes both a subdirectory name and the `.traj` file stem: + `traj_path = output_dir / (self._problem_statement.id + ".traj")` + (`sweagent/agent/agents.py:589`, and identically for `RetryAgent` at + `sweagent/agent/agents.py:298`). `output_dir` for `run-batch` is + `TRAJECTORY_DIR / user_id / f"{config_file}__{model_id}___{source_id}{suffix}"` + (`sweagent/run/run_batch.py:103-117`) and for `run` (single) is + `Path.cwd() / "trajectories" / user_id / f"{config_file}__{model_id}___{problem_id}"` + (`sweagent/run/run_single.py:68-80`). `TRAJECTORY_DIR` itself defaults to + `/../trajectories`, overridable by the + `SWE_AGENT_TRAJECTORY_DIR` env var (`sweagent/__init__.py:46-47`). +- **Two runs of the same instance under the same experiment directory do not + coexist; they collide at the same path.** Same user, same config file + name, same model id, same instance id -> same `output_dir` -> + same `.traj` path. `RunBatch.should_skip` + (`sweagent/run/run_batch.py:376-409`) is the only guard against this, and it + guards by **skip-or-delete-and-redo**, not by identity disambiguation: a + second run either does nothing (existing run looked complete) or unlinks + the old file and overwrites it in place. The only way to get a + non-colliding second copy is to change `output_dir`, `suffix`, the config + file name, or the model id (all of which feed the path formula above), or + to pass `--redo_existing` (`sweagent/run/run_batch.py:83-84`), which + explicitly does not protect against the collision either -- it just accepts + it. +- **Listing is directory-scoped, not indexed.** There is no session/run index + file anywhere in the codebase; every consumer (`should_skip`, the + inspector, `merge_predictions`) enumerates trajectories with a filesystem + glob: `directory.glob("*.traj")` (`sweagent/run/remove_unfinished.py:20`), + `directory.rglob("*.pred")` (`sweagent/run/merge_predictions.py:22`), + `Path(self.traj_dir).glob("**/*.traj")` + (`sweagent/inspector/server.py:274`, `sweagent/inspector/static.py:158`). +- **Relocation/rename:** there is no concept of a moved or renamed session. + The identity is the file path itself; move the file and, from the program's + point of view, that trajectory no longer exists at its old identity (there + is no separate id field inside the JSON that a mover would need to keep in + sync -- `info` and `trajectory`/`history` carry no `instance_id` field of + their own; the id lives only in the filename and the parent problem + statement object in memory). + +## The store interface + +There is no pluggable store adapter, no interface class, and no protocol for +the trajectory store. The interface below is **reconstructed** from the call +sites; every operation is a plain method on `DefaultAgent` or a module-level +function, and every "store" is just the local filesystem. + +| Operation | Signature / call site | Effect and guarantee | +| --- | --- | --- | +| set traj path | `self.traj_path = output_dir / (id + ".traj")` -- `sweagent/agent/agents.py:589` (`DefaultAgent.setup`), `:298` (`RetryAgent.setup`) | Pure path computation; no I/O. Called once per instance/attempt setup. | +| write (full rewrite) | `DefaultAgent.save_trajectory()` -- `sweagent/agent/agents.py:779-787` | `traj_path.write_text(json.dumps(get_trajectory_data(), indent=2))`. Whole-file overwrite, called after every step (`agents.py:1286`) and again at run end. No lock, no temp file, no fsync. | +| write (retry rollup) | `RetryAgent.save_trajectory(choose)` -- `sweagent/agent/agents.py:385-388` | Same whole-file-overwrite mechanics, over the `{"attempts": [...]}` shape. | +| append in-memory step | `DefaultAgent.add_step_to_trajectory(step)` -- `sweagent/agent/agents.py:1220-1233` | Appends one `TrajectoryStep` dict to `self.trajectory` (an in-process Python list). Not itself durable; durability only happens on the next `save_trajectory()` call. | +| append in-memory history | `DefaultAgent._append_history(item)` -- `sweagent/agent/agents.py:556-559` | Appends one `HistoryItem` to `self.history`. Same non-durability caveat. | +| read for replay | `RunReplay.__init__` -- `sweagent/run/run_replay.py:85-88` | `json.loads(traj_path.read_text())` (or `yaml.safe_load` for a `.yaml` demo). One-shot full read; no cursor, no pagination. | +| read for skip-check | `RunBatch.should_skip` -- `sweagent/run/run_batch.py:384-409` | `json.loads(log_path.read_text())`, inspects only `info.exit_status`. Deletes the file (`log_path.unlink()`) on empty/invalid/incomplete content. | +| read for prediction extraction | `sweagent/run/extract_pred.py:11-19` | `json.loads(traj_path.read_text())`, pulls `info["submission"]`, writes a sibling `.pred` file. Manual/offline recovery tool ("If for some reason the .pred file isn't saved..."). | +| read for demo conversion | `convert_traj_to_action_demo` -- `sweagent/run/run_traj_to_demo.py:39-58` | Reads `history` + `replay_config`, filters to assistant/user/tool roles, writes a `.demo.yaml`. | +| read for listing/viewing | `sweagent/inspector/server.py:168-212`, `sweagent/inspector/static.py:49-124` | `json.load` per file for display; a `check_for_updates` poll (`sweagent/inspector/server.py:281-282`) diffs `st_mtime` across the glob to detect new/changed files for the live web UI. | +| delete (manual, bulk) | `remove_unfinished(base_dir, dry_run)` -- `sweagent/run/remove_unfinished.py:14-41` | Offline CLI tool. For every experiment directory with exactly one `.traj`, if `info.submission` is `None`, `shutil.rmtree(directory)`. Not invoked automatically by any run path. | + +There is no versioned/expected-position precondition on the write operation +at all: `write_text` has no compare-and-swap, no ETag, no sequence check. The +only concurrency control is convention (one `DefaultAgent` per instance +directory, one directory per instance id), not an enforced lock. + +## Write and append path + +- **Mechanism:** whole-document rewrite via `Path.write_text`, once per + agent step, at `sweagent/agent/agents.py:786-787`: + `self.traj_path.write_text(json.dumps(data, indent=2))`. This is called + from the run loop after every `self.step()` + (`sweagent/agent/agents.py:1284-1286`), so functionally the file is + "appended to" at step granularity even though the write is a full rewrite, + not a byte-range append. +- **Ordering:** positional. `self.trajectory` and `self.history` are Python + lists; order is list order, with no explicit sequence number or timestamp + field in either `TrajectoryStep` (`sweagent/types.py:44-52`) or + `HistoryItem` (`sweagent/types.py:62-73`). There is no `seq`, no + `event_id`, no server timestamp anywhere in these types. +- **Durability/atomicity:** none beyond the OS's own write-syscall semantics. + No temp-file-and-rename pattern, no advisory lock file, no fsync call + appear anywhere in `sweagent/agent/agents.py` or the `sweagent/utils/` + package (checked by grep across the tree; none found). A process killed + mid-`write_text` can leave a truncated/invalid JSON file; `should_skip`'s + `try: data = json.loads(content) except Exception: ... log_path.unlink()` + (`sweagent/run/run_batch.py:394-406`) is the only place that anticipates + and heals a torn file, and it heals by deletion, not repair. +- **Concurrency:** single-writer-per-instance by construction, not by lock. + `run-batch`'s multi-worker mode (`ThreadPoolExecutor`, + `sweagent/run/run_batch.py:268-289`) parallelizes across **different** + instance ids, each with its own `output_dir / instance_id` path + (`sweagent/run/run_batch.py:334`), so there is no observed multi-writer + contention on one file in the normal flow. Nothing in the code would + prevent two processes from racing on the same instance id's `.traj` path if + invoked concurrently by hand; there is no lock file guarding it. +- **Delivery semantics:** best-effort, at-most-once from the store's point of + view -- there is no retry-on-write-failure and no acknowledgement channel. + If `write_text` raises, the exception propagates up through the agent loop + like any other Python exception; there is no dedicated handling for a + failed trajectory write in `sweagent/agent/agents.py`. + +## Read and resume path + +There is no resume path in the sense of "reconstruct a live agent from a +stored session" (see Framing). The reads that exist are all one-shot, whole +document loads for a different purpose than resuming: + +- `run-replay` reads the whole file once at construction + (`sweagent/run/run_replay.py:85-88`) and starts a new `RunSingle` / + `DefaultAgent` / `SWEEnv` from scratch (`sweagent/run/run_replay.py:173-202`). + It materializes the entire `history` array eagerly to build the replay + actions file (`_create_actions_file`, `sweagent/run/run_replay.py:138-171`); + there is no lazy/partial load. +- `should_skip` reads the whole file once, looks at one field + (`info.exit_status`), and either returns a skip signal or deletes the file + (`sweagent/run/run_batch.py:384-409`). It never loads `history` back into an + agent. +- The inspector reads the whole file once per view/poll cycle + (`sweagent/inspector/server.py:168-212`); `check_for_updates` + (`sweagent/inspector/server.py:281-289`) re-scans `st_mtime` across the glob + on each poll rather than tailing a log, so its "resume" of the view after a + reload is just "re-read the file from disk," not incremental. + +There is no pagination, no cursor, no offset, and no bound on transcript size +anywhere in these paths; `max_observation_length` +(`sweagent/agent/agents.py:79` in `TemplateConfig`, default 100,000 chars) +bounds what the **model sees** per observation, not what is stored or read +back from the trajectory file. + +## Listing, summaries, and search + +- **Enumeration is directory glob, always**, never an index: `*.traj` + (`sweagent/run/remove_unfinished.py:20`), `**/*.traj` + (`sweagent/inspector/server.py:274`, `sweagent/inspector/static.py:158`), + `*.pred` (`sweagent/run/merge_predictions.py:22`). No cost figures for this + at scale are stated anywhere in the docs or code; the whole design assumes + a batch of hundreds to a few thousand instances (SWE-bench scale) processed + once, not a live, growing store enumerated repeatedly. +- **No write-time summary sidecar exists** for a single trajectory. The + closest thing is `preds.json`, but that is a **derived rollup across many + instances**, not a per-trajectory metadata cache: `merge_predictions` + (`sweagent/run/merge_predictions.py:14-45`) globs every `.pred` file (itself + written per-instance by `save_predictions`, + `sweagent/run/common.py:370-379`) and writes one JSON object keyed by + `instance_id`, each value `{"model_name_or_path", "instance_id", + "model_patch"}`. `run_batch_exit_statuses.yaml` + (`sweagent/run/_progress.py` via + `RunBatchProgressManager(..., yaml_report_path=output_dir / + "run_batch_exit_statuses.yaml")`, `sweagent/run/run_batch.py:182-184`) is + the nearest thing to a listing view: one exit status per instance for the + current `run-batch` invocation, not a durable index rebuilt across runs. +- **No search subsystem** of any kind (no FTS, no vector index, no grep + helper) exists over trajectory content in this codebase. Finding something + in a trajectory means opening the JSON (in the inspector, a text editor, or + `jsoneditoronline.org`, per `docs/usage/trajectories.md:44-47`). + +## Entry/message structure and versioning + +Two parallel records are kept per run, and the docs are explicit that they +serve different purposes: `history` is "all messages that were shown to the +LM" (`docs/usage/trajectories.md:22`, i.e. the literal LM conversation +including system/demo/observation turns) and `trajectory` is the +(thought, action, observation) summary "for every step of the agent" +(`docs/usage/trajectories.md:7-9`). + +### `TrajectoryStep` (the `trajectory` array) + +Defined as a `TypedDict` at `sweagent/types.py:44-52`: + +```python +class TrajectoryStep(TypedDict): + action: str + observation: str + response: str + state: dict[str, str] + thought: str + execution_time: float + query: list[dict[str, Any]] + extra_info: dict[str, Any] +``` + +Populated verbatim at `add_step_to_trajectory` +(`sweagent/agent/agents.py:1220-1233`): + +```python +trajectory_step = TrajectoryStep({ + "action": step.action, "observation": step.observation, + "response": step.output, "thought": step.thought, + "execution_time": step.execution_time, "state": step.state, + "query": step.query, "extra_info": step.extra_info, +}) +``` + +- `query` is "the exact input at the current step," replacing an older + `message` field that meant "the input for the LM for the _next_ step" prior + to SWE-agent 1.1.0 (`docs/usage/trajectories.md:27-30`) -- the one place the + format's own docs mark a breaking, named schema change. +- `state` is the environment state dict returned by + `ToolHandler.get_state` (`sweagent/tools/tools.py:337-348`), sourced from + `/root/state.json` inside the sandboxed environment + (`sweagent/tools/tools.py:317-335`). Its keys depend entirely on which tool + bundles are enabled; the `diff_state` bundle + (`tools/diff_state/config.yaml`, `tools/diff_state/bin/_state_diff_state`) + adds a `diff` key holding a full `git diff --cached` at that step + (`tools/diff_state/bin/_state_diff_state:17-30`), used later as the last + resort for autosubmission (see Rewind/checkpoints, below). +- `extra_info` is a grab-bag populated by optional action-sampling + strategies: when `action_sampler_config` is set, + `step.extra_info.update(best.extra_info)` + (`sweagent/agent/agents.py:1040`) folds in whatever the sampler's chosen + candidate carried, including -- for samplers like + `BinaryTrajectoryComparison` -- formatted text of the **rejected** + candidates (`sweagent/agent/action_sampler.py:96-183`). Rejected candidates + are not separately persisted; they exist only inside this one field of the + single committed step, then are gone once the sampler's process ends + (nothing durable references them beyond that step's JSON). + +### `HistoryItem` (the `history` array) + +Required fields via `_HistoryItem` and optional fields via `HistoryItem` +(`sweagent/types.py:56-73`): + +```python +class _HistoryItem(TypedDict): + role: str + content: str | list[dict[str, Any]] + message_type: Literal["thought", "action", "observation"] + +class HistoryItem(_HistoryItem, total=False): + agent: str + is_demo: bool + thought: str + action: str | None + tool_calls: list[dict[str, str]] | None + tool_call_ids: list[str] | None + tags: list[str] + cache_control: dict[str, Any] | None + thinking_blocks: list[dict[str, Any]] | None +``` + +Model messages are stored **verbatim as sent/received**: assistant turns are +appended with the raw model `content` and `tool_calls` +(`add_step_to_history`, `sweagent/agent/agents.py:714-727`), and templated +observation/user turns are appended with their rendered text +(`_add_templated_messages_to_history`, +`sweagent/agent/agents.py:675-712`). There is no separate "raw provider +response" versus "normalized" pair -- `history` **is** the wire content, save +for the history-processor view described below, which operates on a copy at +read time, not on the stored list. + +`agent` (which named agent produced/owns the entry, `"main"` by default) is +the field that would let a reader separate multiple agents' turns in one +`history` array; see Subagents, below, for where this matters (or, per the +evidence, does not -- see that section). + +### Identity/dedup + +The store relies on no identity or dedup key for entries: no entry carries a +uuid, hash, or sequence number. Ordering is purely array position; there is +no defined behavior for detecting or dropping a duplicate entry, because +nothing ever appends to an existing on-disk trajectory incrementally (see +Framing) -- the whole array is rebuilt in memory and rewritten each time. + +### Versioning + +The only explicit, named schema-version marker in the whole system is the +`query`-replaces-`message` note in the docs +(`docs/usage/trajectories.md:27-30`), tied to product version "SWE-agent +1.1.0," not to a field inside the JSON itself. There is no `schema_version` +field anywhere in `TrajectoryStep`, `HistoryItem`, or the top-level +`get_trajectory_data()` dict. Two `swe_agent_hash`/`swe_agent_version` fields +do ride along in `AgentInfo` (`sweagent/types.py:94-95`, set at +`sweagent/agent/agents.py:596-599` from `get_agent_commit_hash()` / +`__version__`), which stamps *which build* produced the file, but this is a +provenance stamp, not a format-version field a reader is expected to branch +on. `sweagent/run/run_replay.py:96-103` is the one place that reacts to an +old format, and it does so by a `KeyError` on `replay_config` being absent, +raising `"Replay config not found in trajectory. Are you running on an old +trajectory?"` -- sniffing by absence, not by a version tag. + +## Compaction and history management + +SWE-agent's `history_processors` are explicitly a **model-view-only** +mechanism; they never touch the durable `history` list. + +The chain runs inside the `messages` property, not against `self.history` +itself: + +```python +@property +def messages(self) -> list[dict[str, Any]]: + filtered_history = [entry for entry in self.history if entry["agent"] == self.name] + messages = filtered_history + for processor in self.history_processors: + messages = processor(messages) + return messages +``` +(`sweagent/agent/agents.py:539-551`) + +`self.history` (the thing persisted into `.traj` at +`sweagent/agent/agents.py:771`) is built by `messages` at +`filtered_history = [entry for entry in self.history if entry["agent"] == +self.name]` (`sweagent/agent/agents.py:544`) -- a **shallow** list +comprehension: it is a new list, but its elements are the exact same dict +objects that live inside `self.history`. Whether a processor is safe for the +durable record therefore depends on whether it mutates `entry` in place or +copies it first, and the processors in +`sweagent/agent/history_processors.py` split on exactly this line: + +- **Content-eliding processors copy first, so they cannot touch the durable + record.** `LastNObservations.__call__` + (`sweagent/agent/history_processors.py:157-176`) does + `data = entry.copy()` (`:167`) before rewriting `data["content"]` to the + "N lines omitted" placeholder; the original `entry` inside `self.history` + is untouched. `ClosedWindowHistoryProcessor.__call__` + (`sweagent/agent/history_processors.py:230-258`) likewise copies + (`data = entry.copy()`, `:234`) before truncating a stale file-window. + `RemoveRegex.__call__` (`sweagent/agent/history_processors.py:320-336`) uses + `entry = copy.deepcopy(entry)` (`:322`) before stripping regex matches. + `ImageParsingHistoryProcessor._process_entry` + (`sweagent/agent/history_processors.py:352-360`) uses `entry = + copy.deepcopy(entry)` (`:354`) before splicing in parsed image segments. +- **`CacheControlHistoryProcessor` is the one exception, and it does mutate + the durable record.** Its `__call__` + (`sweagent/agent/history_processors.py:287-303`) calls + `_clear_cache_control(entry)` (`:293`) and, conditionally, + `_set_cache_control(entry)` (`:299`) directly on `entry`, with no copy + anywhere in the function. Both helpers mutate their argument's dict in + place -- `_clear_cache_control` pops `cache_control` keys + (`sweagent/agent/history_processors.py:46-51`), `_set_cache_control` + assigns into `entry["content"]`/`entry["cache_control"]` + (`:53-65`) -- so every call to `self.messages` that includes this processor + in the chain **writes `cache_control` markers into the same dict objects + stored in `self.history`**, which is exactly what gets serialized into + `history` in the next `.traj` write. This is metadata (an + Anthropic prompt-caching hint), not conversational content, but it is a + concrete instance of a "model-view" processor leaking a side effect into + the durable record -- the one place in this codebase where the + clean split between "durable log" and "model-visible view" does not + fully hold. + +So, with that one metadata-only exception: **compaction shrinks only what the +next model call sees; the durable `history` array in the `.traj` file is +never shortened or content-redacted by a history processor.** There is no +marker, no external snapshot, and no in-place content rewrite left in the +log for elided observations or closed windows, because there is no separate +"log" from the model-visible view to begin with for those processors -- they +compute a filtered copy from the same `self.history` list that gets +persisted directly. + +There is no replay/resume behavior that "crosses a compaction boundary," +because nothing ever resumes through the durable record in the first place +(see Framing); `run-replay`'s reconstructed run applies whatever +`history_processors` the **replay config** specifies to the **replayed** +conversation as it is built turn by turn, independent of whatever compaction +the original run applied. + +## Rewind, checkpoints, and fork + +- **No rewind, undo, or branch operation exists.** Nothing in + `sweagent/agent/agents.py`, `sweagent/run/`, or the CLI surface lets a user + roll the trajectory back to an earlier step and continue differently; the + `while not step_output.done` loop (`sweagent/agent/agents.py:1284`) only + moves forward. +- **The closest thing to a checkpoint is the per-step `state.diff` value** + from the optional `diff_state` tool bundle + (`tools/diff_state/bin/_state_diff_state:17-30`): a full, non-deduplicated + `git diff --cached` computed fresh every step and stored under + `TrajectoryStep["state"]["diff"]`. It exists purely as a fallback for + crash/error recovery, not for user-facing rewind: + `attempt_autosubmission_after_error` + (`sweagent/agent/agents.py:823-851`) reaches into + `self.trajectory[-1]["state"]["diff"]` (`:836,840,843`) when the runtime has + died, to autosubmit whatever patch was last captured. There is no + content-addressing or diffing between steps -- each step's `diff` is a + complete snapshot of the working tree's staged changes at that instant. +- **`RetryAgent` is retry, not fork.** `_next_attempt` + (`sweagent/agent/agents.py:321-326`) calls `self._env.hard_reset()` and sets + up a brand-new `DefaultAgent`; there is no shared-prefix history between + attempt N and attempt N+1 -- each attempt starts from the same original + problem statement in a reset environment, not from a point partway through + a previous attempt. Lineage between attempts is recorded only by the + `"attempts"` list index in the parent's `.traj` + (`sweagent/agent/agents.py:362-383`), not by any explicit parent/child + pointer inside a child's own trajectory file. +- The one appearance of the word "fork" in the codebase is git-repository + forking for opening a pull request + (`sweagent/run/hooks/open_pr.py:23,77-89`), unrelated to session/trajectory + forking. + +## Subagents and nested sessions + +There is no live multi-agent or delegate-to-subagent path in this codebase +(confirmed by grep across `sweagent/` and `tools/` for "subagent"/"sub_agent", +which returns only comments describing `RetryAgent`'s attempts as +"sub-agent," at `sweagent/agent/agents.py:267,333,335`). What does exist, +and is the closest analogue: + +- **`RetryAgent` attempts** (`sweagent/agent/agents.py:257-440`). Each attempt + is a full, independent `DefaultAgent` instance + (`_setup_agent`, `sweagent/agent/agents.py:303-319`), given its own output + directory `output_dir / f"attempt_{self._i_attempt}"` + (`:315`) and therefore its own, separately durable `.traj` + file at that path (via the normal `DefaultAgent.setup` path, + `sweagent/agent/agents.py:589`). This is a durable, separately identified + child record (one file per attempt directory), **and** it is folded whole + into the parent's own `.traj` under `"attempts": [...]` + (`:358-364,385-388`) -- so the child transcript exists both as its own file + and duplicated inside the parent's file. Nesting is bounded by + `RetryLoopConfig.max_attempts` and a `cost_limit` + (`sweagent/agent/reviewer.py:184-216`); there is no crash/rewind cascade to + reconcile because each attempt is independent from setup. +- **The `name`/`agent` field on `HistoryItem`** (`sweagent/types.py:63`, + filtered on in `messages`, `sweagent/agent/agents.py:544`) is designed to + let multiple named agents share one `history` array (`ShellAgent` and its + config support this generality, `sweagent/agent/agents.py:170-186`), but no + code path in this codebase actually runs two agents concurrently against + one shared environment/history; the mechanism exists for future/other + configurations more than for an active subagent feature here. +- **Action samplers are not subagents.** `AbstractActionSampler` + implementations (e.g. `AskColleagues`, + `sweagent/agent/action_sampler.py:49-94`) query one or more model candidates + for the *next single action* and choose the best one inline within + `forward()` (`sweagent/agent/agents.py:1031-1040`); rejected candidates' + text lives only in that one step's `extra_info` (see Entry structure, + above), not as separate durable trajectories or identified child sessions. + +## Retention, deletion, and multi-host + +- **No TTL, no scheduled cleanup, no automatic retention policy** exists + anywhere in the codebase or docs. Trajectory directories accumulate under + `trajectories///` (`docs/usage/trajectories.md:59-83`) + indefinitely unless a human intervenes. +- **The only cleanup tool is manual and opt-in:** `remove_unfinished` + (`sweagent/run/remove_unfinished.py:14-41`), a separate CLI invocation that + defaults to `dry_run=True` and only deletes a whole instance directory + (`shutil.rmtree`) when it finds exactly one `.traj` with no + `info.submission`. It is never called from `run`, `run-batch`, or any hook. +- **Deletion in the running-program path is incidental, not policy-driven:** + the only automatic delete is `should_skip`'s `log_path.unlink()` + (`sweagent/run/run_batch.py:391,400,405`) for empty/corrupt/incomplete + files, which exists to enable a clean redo, not to reclaim space or enforce + a lifecycle. +- **Multi-host is not addressed as a first-class concern.** The whole design + assumes a single local filesystem the run process can write to directly: + `output_dir.mkdir(parents=True, exist_ok=True)` + (e.g. `sweagent/agent/agents.py:572`) and plain `Path.write_text` calls + throughout, no remote-writeback, no network-filesystem handling, no + crash-detection heartbeat file. `run-batch`'s only concession to + concurrent writers is a small random start delay to avoid thundering-herd + container startup (`sweagent/run/run_batch.py:295-297`), not a + cross-process or cross-host coordination mechanism. + +## Interop with foreign session stores + +None found. SWE-agent reads only its own `.traj`/`.demo.yaml` files +(`run-replay`, `traj-to-demo`) and its own `.pred` files +(`merge-preds`); no code path in `sweagent/` reads another agent product's +session or transcript format. + +## What this implies for our Session Store (our inference) + +SWE-agent is the corpus's clearest **negative case**: a product that produces +a rich, complete, well-documented transcript file, yet has no notion of a +session as a resumable, addressable, evolving record. Three points are worth +carrying into our design as contrast, not as pattern to copy: + +- **A transcript is not a session merely because it is complete and replayable.** + The decisive test we used here -- is the artifact ever read back by the + *producing* program to continue, versus read only by a *different* + consumer (evaluator, viewer, demo tool) -- is a clean litmus test we should + keep applying to every other product in this corpus, including our own + design: our Session Store must be distinguishable from "just a good log + file" by having an actual resume/read path that the same runtime uses. +- **"Skip if already done" is not resume, and conflating them is an easy + mistake.** `should_skip`'s behavior (skip a complete run, delete-and-redo an + incomplete one) looks superficially like idempotent resume but is neither + incremental nor state-preserving; our platform's language for these two + concepts (dedup-on-completion vs. resume-from-position) needs to stay + sharply distinct in the ADRs, because a store implementation could easily + drift toward SWE-agent's model by accident if "idempotent retry" and + "resume" are not kept conceptually separate from day one. +- **Whole-document rewrite-per-step, with no lock/fsync/atomic-rename, is the + failure mode our append-only log design is explicitly meant to avoid.** + SWE-agent's crash exposure (a torn `write_text` mid-run) is exactly the + scenario an append-only event log with a separate durable commit boundary + (as documented for other products in this corpus) is built to eliminate; + it is useful as a concrete "what we are not doing" example when justifying + that architecture choice. + +## Open questions + +- Whether a torn/partial `.traj` write (process killed mid-`write_text`) has + ever been observed to corrupt a file such that even `should_skip`'s + `json.loads` fails silently in some encoding edge case; the code path + exists (`sweagent/run/run_batch.py:394-406`) but no test fixture exercising + the truncation scenario itself was found in `tests/`. +- Whether any out-of-tree fork or downstream consumer (e.g. a hosted + SWE-agent service) adds an index, database, or resume layer on top of the + `.traj` file convention documented here; this dossier is scoped to what + ships in this commit of the open-source repository only. +- The exact production conditions under which `RunBatch.main_multi_worker` + (`sweagent/run/run_batch.py:268-289`) could produce two threads writing the + same instance id's path concurrently (e.g. a caller passing duplicate + instance ids into one `run-batch` invocation) were not traced end to end; + the instance-loading paths (`sweagent/run/batch_instances.py`) were not + audited for duplicate-id guarantees. +- Whether `sweagent/inspector/server.py`'s live "check for updates" polling + (`:281-289`) is used by anything beyond the bundled web viewer, or whether + any other internal tooling treats a growing trajectory directory as a + quasi-live feed. diff --git a/docs/research/session-store/products/swe-agent/vs-session-events.md b/docs/research/session-store/products/swe-agent/vs-session-events.md new file mode 100644 index 000000000..c2f5e49fd --- /dev/null +++ b/docs/research/session-store/products/swe-agent/vs-session-events.md @@ -0,0 +1,390 @@ +# SWE-agent compared to our session event catalog + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT_COMPARISON](../../RESEARCH_PROMPT_COMPARISON.md). +Stage-one dossier: [SWE-agent](./index.md). +Compared against `proto/trogonai/session/sessions/v1alpha1/` and [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) on 2026-08-04. + +**Store maturity: 3/12**, evolution scars 0/3 (the dossier finds exactly one +named schema break in the whole system: `query` replaced an older `message` +field at product version 1.1.0, documented in prose only, with no +`schema_version` field anywhere in `TrajectoryStep`, `HistoryItem`, or the +top-level trajectory dict, `docs/usage/trajectories.md:27-30`; one prose-only +rename is not evolution scarring), operational age 1/3 (the dossier's own open +questions admit no test fixture exercises the torn-write scenario and no issue +history was surveyed for the trajectory format specifically; the format has +shipped long enough to acquire one documented breaking rename, which is weak +but non-zero signal of contact with real use), exposure 1/3 (SWE-agent is an +academic/benchmark harness, not a vendor-shipped product a user resumes work +in; its own docs call the `.traj` file "the main output," never a session a +person returns to, `docs/usage/inspector.md:4`, so the store has essentially +no exposure to the failure modes that matter for resumption: crash-then- +continue, multi-host, upgrade-across-versions, because nothing in the +product's design asks it to survive them), design independence 3/3 (no fork +parent; the dossier finds no evidence the trajectory format was inherited from +another product). This is thin evidence per the Method's rule: **do not read +any SWE-agent recommendation below as an industry norm.** Its value here is +not as a peer store to weigh against ours, but as a clean negative control +for a different question: what does a benchmark harness look like when it +never needs to resume, and which of the properties we build for resumption +turn out to be properties resumption specifically requires, versus properties +any complete transcript would benefit from regardless. + +## The one structural difference everything else follows from + +**A `.traj` file is an output artifact, not a session store.** The dossier's +own framing section states the operational test plainly: is the file ever +read back by the *producing* program to continue, versus read only by a +*different* consumer (an evaluator, a viewer, a demo tool)? For SWE-agent the +answer is no on every path traced. `run_replay.py` starts a brand-new +`SWEEnv` and a brand-new `DefaultAgent` and re-executes stored actions through +a `ReplayModel` that plays back the `history` list instead of querying a real +LLM (`sweagent/agent/agents.py:1284-1286`, `sweagent/run/run_replay.py:186-192`, +`sweagent/agent/models.py:464-481`, per the dossier). `RunBatch.should_skip` +either skips a completed instance outright or deletes an incomplete one and +reruns from scratch (`sweagent/run/run_batch.py:376-409`); nothing in that +path loads `history` back into a live agent. The inspector is read-only +display (`sweagent/inspector/server.py`, `sweagent/inspector/static.py`). + +Every other divergence in this document is downstream of that one fact. We +are comparing a durable, resumable, addressable record (ours) against a +complete, replayable, but never-resumed document (theirs). Where the dossier +and this comparison therefore diverge from the shape of every other +comparison in this corpus: there is no meaningful "fact-by-fact mapping" to +draw for most of our catalog, because most of what we record exists to make +resumption possible, and SWE-agent has no resumption to support. The mapping +below is consequently short and, per the Method, does not manufacture +equivalents where none exist. + +## Mapping + +| SWE-agent | Ours | Verdict | +| --- | --- | --- | +| `.traj` file, rewritten whole every step (`get_trajectory_data`/`save_trajectory`, `sweagent/agent/agents.py:762-787`) | One append-only stream per session, `SessionEvent` oneof (`proto/trogonai/session/sessions/v1alpha1/events.proto:58-114`) | Structural mismatch, not an equivalence; see above | +| `trajectory: list[TrajectoryStep]`, a post-hoc per-step summary (`sweagent/types.py:44-52`) | `ToolCallRequested`/`Started`/`Completed`/`Failed` as separate durable facts (`tool_call_requested.proto`, `tool_call_completed.proto`, `tool_call_failed.proto`) | Ours, decisively; SWE-agent's step summary exists only inside a document that is rebuilt every write; ours is durable the instant it is appended | +| `history: list[HistoryItem]`, the literal LM conversation, "all messages that were shown to the LM" (`docs/usage/trajectories.md:22`) | `CanonicalMessage` on `UserMessageRecorded`/`AssistantMessageCompleted` (`proto/trogonai/session/sessions/v1alpha1/message.proto:14-28`) | Equivalent in intent (both are the provider-visible transcript form); ours is append-only per message, theirs is one array inside a rewritten whole document | +| `info: AgentInfo` (exit status, submission, cost stats, `swe_agent_hash`/`swe_agent_version`; `sweagent/types.py:94-95`) | `SessionClosed`/`SessionFailed`/`SessionCancelled` + `TokenUsage`/`Cost` spread across message events (`session_closed.proto`, `token_usage.proto`) | Ours, decisively; no single denormalized rollup object that the whole write path recomputes and re-serializes on every step | +| Instance id: content-derived sha256[:6] of the problem text for most problem-statement types, `uuid.uuid4()` only for the no-problem-statement case (`sweagent/agent/problem_statement.py:84-86,117-119,144-147,58`) | Opaque `SessionId`, one logical stream per session ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 1) | Semantic mismatch; see below | +| `output_dir`/file path as the true identity for collision purposes; no id field inside the JSON itself (`sweagent/agent/agents.py:589`, per the dossier's Keying section) | `SessionId` addresses a JetStream subject `session.sessions.events.`, independent of any storage path ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 1) | Ours, decisively; identity survives relocation; SWE-agent's does not, by the dossier's own account ("move the file and... that trajectory no longer exists at its old identity") | +| `RunBatch.should_skip`: skip a complete run, delete-and-rerun an incomplete one (`sweagent/run/run_batch.py:376-409`) | No equivalent concept; dedup-on-completion is not a primitive our catalog needs, because we never delete-and-redo a session | Deliberate divergence, see "Skip is not resume" below | +| `RetryAgent` attempts, each a full independent `DefaultAgent` with its own `.traj` at `output_dir / f"attempt_{i}"`, folded whole into the parent's `{"attempts": [...]}` (`sweagent/agent/agents.py:257-440, 358-388`) | `DelegationDispatched`/`ParentLinked` linking two independently durable streams, never a copy of one inside the other (`delegation_dispatched.proto`, `parent_linked.proto`) | Ours, decisively; see the subagent-cascade section below | +| No rewind/undo/branch operation exists at all (per dossier, "Rewind, checkpoints, and fork") | `SessionRewound.keep_through`, `SessionForked` (`session_rewound.proto`, `session_forked.proto`) | Ours; SWE-agent's `while not step_output.done` loop only ever moves forward | +| `state.diff` (`diff_state` tool bundle): a full, non-deduplicated `git diff --cached` recomputed every step, used only as a crash-recovery autosubmission fallback (`attempt_autosubmission_after_error`, `sweagent/agent/agents.py:823-851`) | `FileChanged.before_ref`/`after_ref` (`ArtifactRef`, content-addressed, deduplicated) plus `DiffSummary` (`file_changed.proto`, `diff_summary.proto`) | Ours, decisively; see below | +| No compaction/history-summarization concept found; `history_processors` only ever produce a model-visible view (`sweagent/agent/agents.py:539-551`) | `Compacted{covers_from, covers_through, summary_content}` (`compacted.proto`) | Ours; no equivalent exists on their side to compare against; not a gap, since a benchmark run's transcript never needs to be shortened for context-window reasons across resumption | +| No retention/TTL policy; the only cleanup is `remove_unfinished`, a manual, opt-in, `dry_run=True`-by-default offline CLI tool (`sweagent/run/remove_unfinished.py:14-41`) | `SessionHidden`, `RedactionApplied`, `ArtifactErased` (`session_hidden.proto`, `redaction_applied.proto`, `artifact_erased.proto`); keep-forever with a typed masking contract ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7) | Ours, decisively; see the retention section below | +| No index; every consumer globs `*.traj`/`**/*.traj` (`sweagent/run/remove_unfinished.py:20`, `sweagent/inspector/server.py:274`) | `SessionProjection`, a rebuildable read model checkpointing `last_applied_stream_position` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8) | Ours, decisively | +| No search subsystem of any kind over trajectory content (per dossier) | Out of scope for the core catalog too; "any full-text or vector search subsystem is a separate, independently bootstrapped projection off the same log" ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8) | Trade-off/parity; neither side builds this into the store proper | + +## What we should consider changing + +None. Every recommendation in this section had to clear one bar: does +SWE-agent's evidence say something about our design that a *stronger* store +in this corpus (fx, Cline, or another product scoring above 6/12) has not +already said more strongly? For every candidate change that came up while +writing this comparison, the answer was no, and the reason is the structural +difference itself: SWE-agent's design choices are optimized for a batch +harness that recomputes everything from scratch and never resumes, and the +properties that follow from that (whole-file rewrite, no lock, no rewind, no +retention policy) are absent *because resumption isn't attempted*, not +because SWE-agent found a cheaper way to get resumption's benefits. A store +whose maturity score is 3/12, and whose exposure axis is capped precisely +because it never faces the failure modes ours must survive, cannot anchor a +schema or ADR-decision change on its own. Where SWE-agent's evidence does +sharpen something, it sharpens the *rationale* for a decision we already made, +which is what the sections below record instead of a numbered recommendation +list. + +If a reader wants the one thing closest to a recommendation: confirm, in the +ADR or an implementation note, that `should_skip`-style "dedup on completion" +and "resume from position" remain two different vocabulary items in our own +design language, never conflated even informally. That costs nothing to +write down (**blast radius: additive**, a documentation clarification only) +and the evidence anchor is exactly the confusion SWE-agent's own code +invites, addressed in "What not to copy" below. + +## What our design already does better + +**Content addressing instead of full non-deduplicated snapshots.** SWE-agent's +`diff_state` bundle recomputes a full `git diff --cached` at *every step* and +stores it inline in `TrajectoryStep["state"]["diff"]`, with "no +content-addressing or diffing between steps; each step's `diff` is a +complete snapshot of the working tree's staged changes at that instant" (per +the dossier's Rewind/checkpoints section). Our `FileChanged.before_ref`/ +`after_ref` are `ArtifactRef`s keyed by `Digest`, deduplicated globally, with +a `DiffSummary` carrying exact line counts and a claim-checked rendered form +(`file_changed.proto:33-46`, `diff_summary.proto`). SWE-agent's mechanism +exists purely as an autosubmission fallback for a dying process, not as a +first-class change record, and it pays for that with an unbounded, +non-deduplicated snapshot on every single step it is enabled for. + +**Durable identity independent of storage location.** SWE-agent's identity is +the file path itself; per the dossier, "move the file and, from the program's +point of view, that trajectory no longer exists at its old identity," because +neither `info` nor `trajectory`/`history` carries an `instance_id` field of +its own. Our `SessionId` addresses a JetStream subject +(`session.sessions.events.`, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 1) independent of +any physical storage location, so relocation, cold-tiering, or backup/restore +never breaks identity. + +**Typed, durable outcome record instead of a pair of parsed fields.** +SWE-agent's `AgentInfo.exit_status` is the sole signal `should_skip` inspects +to decide completion, and the dossier notes there is no compare-and-swap, no +ETag, no sequence check on any write to the file at all. Our terminal markers +(`SessionClosed`, `SessionCancelled`, `SessionFailed`, `SessionHidden`) are +each their own typed, `At`-guarded event with a typed reason enum +(`session_cancelled.proto:19-33`, `session_failed.proto:16-28`), so "why did +this session end" is a durable fact rather than a string parsed out of an +exit-status field designed for a different purpose (skip-or-rerun logic). + +**Rewind and fork exist at all.** SWE-agent's `while not step_output.done` +loop "only moves forward" (per dossier); there is no rewind, undo, or branch +operation anywhere in the codebase, and the one appearance of "fork" in the +source is git-repository forking for opening a pull request, unrelated to +session forking. `SessionRewound.keep_through` and `SessionForked` with +`context_prefix_boundary` (`session_rewound.proto`, `session_forked.proto`) +give us both, atomically and without touching prior events. + +## Trade-offs, not gaps + +**Whole-document rewrite is a coherent choice for a system that never needs +partial recovery of its own record; and stops being coherent the moment +recomputing from scratch is not free.** SWE-agent's `save_trajectory` +overwrites the entire accumulated `history` + `trajectory` + `info` from +scratch on every step via a single `write_text` call, with "no temp file, no +fsync anywhere in this path" (`sweagent/agent/agents.py:786-787`, confirmed by +grep across `sweagent/agent/agents.py` and `sweagent/utils/`, per the +dossier). A crash mid-`write_text` can leave a torn JSON file. The batch +runner's answer to that torn file is not repair; it is deletion and rerun +(`RunBatch.should_skip`, `sweagent/run/run_batch.py:391-406`). This is a +genuinely defensible design *for SWE-agent specifically*: an instance run is +a pure function of a public benchmark problem statement plus a deterministic +harness plus (for the non-`ReplayModel` case) a model API call, so "delete +and recompute" costs one more model-call budget, not lost work. The whole +system is built around the assumption that recomputation is cheap and the +inputs are reproducible. + +That assumption is exactly what breaks for a session store whose sessions are +user-owned and not reproducible. A user's session cannot be regenerated from +a public problem statement; the "input" is an unrepeatable sequence of user +messages, tool executions against a live filesystem and network, and +model responses that are not deterministic even given the same prompt. Once a +session is a record of something that happened rather than a cached answer to +a reproducible question, "delete the torn file and start over" stops being a +recovery strategy and becomes a bill for a user's genuinely irrecoverable +work. This is the sharpest available argument, in this whole corpus, for why +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2's append-only mutation with a server-enforced +`WRITE_PRECONDITION` matters: our design never has a "whole document" to tear +in the first place, because "append one small fact, guarded where it needs +guarding" replaces "rewrite everything and hope the write finishes" as the +unit of durability. SWE-agent is not a counterexample to that design; it is +the clearest illustration of why a benchmark harness can get away with the +thing our design is built to rule out, and why the same shortcut would be a +user-hostile failure mode for us. + +**"Skip if already done" is not resume, and SWE-agent shows how easily the two +get confused.** `should_skip`'s behavior (skip a complete run, delete-and- +redo an incomplete one) looks superficially like idempotent resume but is +neither incremental nor state-preserving: nothing in that path ever loads +`history` back into a live agent (per dossier, Framing section, point 2). Our +platform keeps these concepts sharply separate by construction: `NoStream` on +`CreateSession` makes creation idempotent-by-rejection (a second create simply +fails), while resumption is `StartExecutionAttempt` replaying the effective +tail after the newest admitted checkpoint ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 8, facet 3). The +value of SWE-agent's evidence here is not that it proposes a change to our +schema, it proposes nothing because it has no resume path to compare, but +that it is the cleanest available demonstration of a category error a future +implementer could make by accident: treating "the file already exists and +looks done" as equivalent to "we know how to pick this session back up," +when the two require entirely different guarantees (idempotent rejection +versus checkpoint-verified replay). + +## What not to copy + +- **Whole-file rewrite with no temp-file-and-rename and no fsync.** + `save_trajectory`'s single `write_text` call, invoked after every agent step + (`sweagent/agent/agents.py:779-787,1284-1286`), is the exact failure mode our + append-only log with server-enforced write preconditions ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision + 2) exists to rule out. Even setting aside resumption, this is a pattern to + reject on durability grounds alone: a crash mid-write can silently corrupt + the entire record, not just the in-flight step. +- **Healing a torn record by deletion rather than repair.** + `should_skip`'s response to an unparsable or incomplete `.traj` file is + `log_path.unlink()` followed by a full rerun (`sweagent/run/run_batch.py: + 391,400,405`). Coherent when the record is a cached, reproducible answer; + actively harmful as a pattern for a store whose records are the only copy + of something that happened and cannot be regenerated. +- **A processor that mutates the durable record while claiming to be a + view-only mechanism.** SWE-agent's `history_processors` are documented and, + for four of five processors, actually implemented as read-time, + copy-first transformations that never touch `self.history` + (`LastNObservations`, `ClosedWindowHistoryProcessor`, `RemoveRegex`, + `ImageParsingHistoryProcessor`, all copying or deep-copying `entry` before + mutating it, `sweagent/agent/history_processors.py:157-176,230-258, + 320-336,352-360`). `CacheControlHistoryProcessor` is the one exception: its + `__call__` mutates `entry` in place with no copy anywhere in the function + (`sweagent/agent/history_processors.py:287-303`), so a prompt-caching + metadata hint leaks into the exact dict objects that `self.history` holds + and gets serialized into the next `.traj` write. The dossier is careful to + call this "metadata, not conversational content," and that is true; but it + is still a concrete instance of a model-view-only mechanism eroding the + boundary between "what the model sees" and "what the durable record + contains," inside a codebase whose other four processors got that boundary + right. This is exactly the boundary [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 8 keeps explicit by + construction: the model-visible context is *compiled* deterministically + from the event log, never mutated back into it, and `ProviderBlock`/ + `ThinkingBlock.signature` are the two places we deliberately let + provider-specific data ride along, both write-verbatim-read-never, never a + silent in-place mutation of an already-durable payload. The lesson is not + "audit history_processors code we don't have"; it's "a documented + view/record split is only as good as its least-audited implementation, and + a single mutating branch is enough to erode it invisibly." Our own design + has no equivalent mutation path today (compaction and redaction are both + new appended events, never edits, per [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2), and this + comparison is the reason to keep it that way rather than to introduce a + "cheap" in-place metadata patch later. +- **An identity that lives only in a file path, with no id inside the + payload.** Neither `info` nor any `TrajectoryStep`/`HistoryItem` carries an + `instance_id` field; the id lives only in the filename and the in-memory + problem-statement object (per dossier, Keying section). This makes the + record itself non-self-describing: read the JSON with no path context and + you cannot say whose trajectory it is. Every one of our events carries + `session_id` as a `LEGACY_REQUIRED` field for exactly this reason. + +## The two gaps the industry has not closed + +### Subagent cascade + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6 already takes a position: a child session is its own +logical stream, linked by facts recorded on each side +(`DelegationDispatched`/`ParentLinked`), acyclic by construction (a fresh +`child_session_id` every dispatch, `ParentLinked` valid only inside a +`NoStream` creation batch), with terminal cascade driven by a reconciler +reacting to Session-level terminal markers and rewind-invalidation kept a +distinct saga from terminal cascade. The question here is whether +`RetryAgent`'s evidence validates, challenges, or refines that position. + +**What `RetryAgent` does.** Each attempt is a full, independent `DefaultAgent` +instance with its own output directory (`output_dir / f"attempt_{i}"`, +`sweagent/agent/agents.py:303-319`) and therefore its own, separately durable +`.traj` at that path; the closest thing in this codebase to a +subagent: an independently durable child run. It is *also* folded whole into +the parent's own `.traj` under `"attempts": [...]` (`sweagent/agent/agents.py: +358-364,385-388`), so the child transcript exists twice: once as its own +file, and once duplicated inside the parent's file. `_next_attempt` calls +`self._env.hard_reset()` and sets up a brand-new `DefaultAgent` +(`sweagent/agent/agents.py:321-326`); there is no shared-prefix history +between attempts, and lineage is recorded only by the attempt's list index in +the parent's `.traj`, not by any parent/child pointer inside the child's own +file. Nesting is bounded only by `RetryLoopConfig.max_attempts` and a cost +limit (`sweagent/agent/reviewer.py:184-216`); per the dossier, "there is no +crash/rewind cascade to reconcile because each attempt is independent from +setup." + +**Does this validate, challenge, or refine decision 6?** It is the honest +"this product has no position" case the Method anticipates, and it is worth +stating precisely why, rather than treating the absence as silence. A +`RetryAgent` never faces the cascade problem decision 6 solves, for a reason +that is structural, not incidental: there is no live parent process for a +crashed or rewound attempt to cascade *from*. `_next_attempt` runs only after +the *current* process has already decided the prior attempt is done (an +in-process, synchronous decision, not a fact discovered later by a reconciler +watching for a terminal marker); there is no notion of an attempt continuing +to run unsupervised while another part of the system decides its parent's +fate. Decision 6's entire cascade machinery; the reconciler reacting to +`ParentTerminated`/`ParentHistoryInvalidated`, the crash-repair path for a +dispatch that acked but whose child creation never happened, the eventually- +consistent O(depth) cascade; exists to solve a problem that only exists once +parent and child are running as independent, potentially crashable, +potentially concurrently-progressing processes linked by durable facts rather +than by being steps in the same call stack. `RetryAgent`'s attempts are +steps in the same call stack. This does not challenge decision 6; it +sharpens exactly what decision 6 is *for*, by showing the one case where +the machinery would be pure overhead: a supervisor and worker with no +independent liveness, no possibility of the worker outliving a decision about +it, and therefore nothing to reconcile. That case does not describe our +child sessions (which are dispatched to run independently and *can* outlive +or crash independently of the parent), so it is not evidence for simplifying +decision 6; it is evidence that decision 6 correctly targets a harder +problem than SWE-agent ever has to solve. + +One thing worth naming as a genuine, if minor, point of comparison: `RetryAgent` +duplicating the full child transcript inside the parent's own file +(`"attempts": [...]`) is the pattern [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md)'s record-once rule +([ADR#0024](../../../../adr/0024-agent-platform-stream-topology.md), cited throughout [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 6) explicitly forecloses for us: +our parent never carries a copy of a child's events; it carries only the +linking facts (`DelegationDispatched`, and later the delegation's own +`OperationOutcomeRecorded`). SWE-agent's duplication is affordable because a +`.traj` file is rewritten from a small, bounded, in-memory Python list every +time anyway; it would not be affordable, and would violate our own stream- +placement rule, if attempted at the scale and independence our child sessions +operate at. + +### Retention on an unbounded log + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7 already takes a position: keep-forever, with +`SessionHidden` as a visibility tombstone, `RedactionApplied` for read-time +masking, `ArtifactErased` for out-of-band artifact-byte destruction, and +aggregate snapshots bounding replay cost rather than storage. The question +here is whether SWE-agent's evidence validates that design or exposes a cost +the ADR does not bound. + +**What SWE-agent does.** There is no TTL, no scheduled cleanup, and no +automatic retention policy anywhere in the codebase or docs; trajectory +directories accumulate under `trajectories///` indefinitely +unless a human intervenes (per dossier, Retention section). The only cleanup +tool is `remove_unfinished` +(`sweagent/run/remove_unfinished.py:14-41`): a *separate, manual CLI +invocation*, `dry_run=True` by default, that deletes a whole instance +directory only when it finds exactly one `.traj` with no `info.submission`. +It is never called from `run`, `run-batch`, or any hook; a human has to +remember it exists and choose to run it. The dossier found no issue reports +naming this a user-visible problem, which it explicitly attributes to scale: +"the whole design assumes a batch of hundreds to a few thousand instances... +processed once, not a live, growing store enumerated repeatedly." + +**Does this validate, challenge, or refine decision 7?** It validates the +core shape (keep-forever, no automatic purge) while being far too thin an +evidence base to validate the *rest* of decision 7's contract, and the gap +between the two is itself informative. SWE-agent's "retention policy" is not +really a retention policy in the sense decision 7 means the term: it is an +offline maintenance script a human runs by hand, with no automatic trigger, +no typed reason, no visibility tombstone, no read-time masking, and no +distinction between "hidden from listing" and "bytes destroyed"; `rmtree` +deletes the whole directory outright, an irreversible physical purge, not the +graduated masking-then-erasure story decision 7 builds (`SessionHidden` never +deletes bytes; `RedactionApplied` masks at read time over a keep-forever log; +`ArtifactErased` is the one place bytes actually go, and only for claim- +checked artifacts, never for the event log itself). SWE-agent's approach is +coherent for its own scale and purpose (a bounded batch of instances, run +once, where an incomplete/unsubmitted directory really is disposable junk, +not a record anyone needs to audit later) but it offers no evidence at all +about the harder question decision 7 actually answers: what happens when a +log *cannot* be treated as disposable and must instead support redaction, +audit, and eventual legal erasure while never losing the ability to replay. +The dossier is explicit that no growth-related issue was found for this +product, which is the expected result of "batch, not live" scale, not +evidence the pattern would hold at session-store scale; this is exactly the +maturity-weighting the Method calls for: a 3/12 store's silence on a failure +mode is not evidence the failure mode doesn't matter, it's evidence the store +was never big enough or long-lived enough to hit it. Decision 7's own harder +cases (fx's usage-ledger-dominated log, Cline's confirmed `#9011` growth +failure, both discussed in those products' comparisons) remain the load- +bearing evidence for this gap; SWE-agent adds nothing to that case beyond +confirming, once more, that "no automatic policy, manual cleanup only" is +the default an unconstrained system drifts toward absent a deliberate +decision like ours. + +## Open questions for the ADR + +None. Every question this comparison could plausibly raise is already +answered more sharply by a stronger store elsewhere in the corpus (fx or +Cline), and manufacturing an ADR question from a 3/12-maturity, non-resuming +benchmark harness would misrepresent how much weight its evidence can bear. +The one item worth flagging is not a question for the ADR owner so much as a +note for whoever writes the ADR's prose on idempotency: SWE-agent's +`should_skip` is a good citable example, in ADR text or an implementation +note, of the specific "skip-vs-resume" conflation [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md)'s `NoStream` +(idempotent-by-rejection creation) versus `StartExecutionAttempt` +(checkpoint-verified replay) split already prevents by construction; worth +keeping in mind as a concrete illustration if that distinction ever needs +re-explaining to a new implementer, not as an open design question. diff --git a/docs/research/session-store/products/t3code.md b/docs/research/session-store/products/t3code/index.md similarity index 96% rename from docs/research/session-store/products/t3code.md rename to docs/research/session-store/products/t3code/index.md index ee0c6095e..c0497a3cb 100644 --- a/docs/research/session-store/products/t3code.md +++ b/docs/research/session-store/products/t3code/index.md @@ -1,7 +1,7 @@ # T3 Code: how session transcripts are stored and resumed Part of Session Store Research. -Produced by running [RESEARCH_PROMPT](../RESEARCH_PROMPT.md). +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 @@ -55,8 +55,8 @@ CREATE TABLE IF NOT EXISTS orchestration_events ( ``` 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 +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`. @@ -100,7 +100,7 @@ What is authoritative vs. derived: (`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 + 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 @@ -142,12 +142,12 @@ 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 +- `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 +- `readEvents(fromSequenceExclusive, limit) -> Stream` -- thin pass-through to `eventStore.readFromSequence` (`Layers/OrchestrationEngine.ts:315-316`). -- `streamDomainEvents -> Stream` — a fresh PubSub +- `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): @@ -198,7 +198,7 @@ The client-facing RPC surface over WebSocket is - **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 + 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 @@ -287,14 +287,14 @@ The client-facing RPC surface over WebSocket is 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 — +- **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 — + 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 @@ -322,13 +322,13 @@ The client-facing RPC surface over WebSocket is ## Rewind, checkpoints, and fork -- **Rewind/revert is expressed as appended events, interpreted at fold time — +- **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, + `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). @@ -343,7 +343,7 @@ The client-facing RPC surface over WebSocket is 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 + `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, @@ -376,7 +376,7 @@ The client-facing RPC surface over WebSocket is - **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 + 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). @@ -399,8 +399,8 @@ The client-facing RPC surface over WebSocket is - **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 + 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 @@ -433,7 +433,7 @@ several specific choices: 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 + 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, @@ -448,18 +448,18 @@ several specific choices: 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 + 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 +- **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 + 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 + present in the log -- a privacy/retention consideration we must decide deliberately. ## Open questions diff --git a/docs/research/session-store/products/void/index.md b/docs/research/session-store/products/void/index.md new file mode 100644 index 000000000..ef091c150 --- /dev/null +++ b/docs/research/session-store/products/void/index.md @@ -0,0 +1,354 @@ +# Void: how session transcripts are stored and resumed + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT](../../RESEARCH_PROMPT.md). +Evidence snapshot retrieved 2026-08-04. Void pinned at commit +`b3166e7ef2aefbdfeb139445fdf248a561b85d4d` (Apache-2.0). +Version-sensitive claims were checked against these +authoritative anchors: + +- `src/vs/workbench/contrib/void/browser/chatThreadService.ts` (Void's own chat/thread service, ~1884 lines) +- `src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts` (message/entry types) +- `src/vs/workbench/contrib/void/common/storageKeys.ts` (storage key definitions) +- `src/vs/platform/storage/common/storage.ts` and `src/vs/platform/storage/electron-main/storageMain.ts` (upstream VS Code storage machinery Void reuses, not Void's own code) + +## The storage model + +Void is a VS Code fork. Its chat feature does have a durable, coherent +record -- but it is a thin layer built entirely on top of stock VS Code +machinery, not a bespoke store. Void's own contribution is: one TypeScript +service (`ChatThreadService`), one storage key, and one JSON blob. + +The durable session record is a single `ChatThreads` value -- a +`{ [id: string]: ThreadType }` map holding **every thread the user has ever +had**, plus every message in each -- serialized with `JSON.stringify` and +written to one key in VS Code's built-in key-value `IStorageService` +(`src/vs/workbench/contrib/void/browser/chatThreadService.ts:415-423`): + +```ts +private _storeAllThreads(threads: ChatThreads) { + const serializedThreads = JSON.stringify(threads); + this._storageService.store( + THREAD_STORAGE_KEY, + serializedThreads, + StorageScope.APPLICATION, + StorageTarget.USER + ); +} +``` + +`THREAD_STORAGE_KEY` is `'void.chatThreadStorageII'` +(`src/vs/workbench/contrib/void/common/storageKeys.ts:19`). `IStorageService` +is stock VS Code: on desktop it is backed by a SQLite database file named +`state.vscdb` (`src/vs/platform/storage/electron-main/storageMain.ts:285`, +`:361`), one key-value row per storage key. Void never touches SQLite, files, +or IndexedDB directly -- it calls the generic `.get()`/`.store()` API that +every VS Code extension/contribution uses for its own settings and UI state. + +So: the source of truth is a **mutable JSON document under a single storage +key**, not an append-only log. Every mutation (new message, edit, delete, +checkpoint jump) reads the current in-memory `ThreadsState`, computes a new +whole `ChatThreads` object, and calls `_storeAllThreads()` with the entire +map -- confirmed at every call site +(`src/vs/workbench/contrib/void/browser/chatThreadService.ts:942`, `:1289`, +`:1646`, `:1659`, `:1675`, `:1696`). There is no derived index, cache, or +summary distinct from this blob -- the same object is both the record and the +thing the sidebar UI renders from directly. + +Closest fit: **session-as-document** (a single mutable JSON document per +installation, keyed by thread id inside it), not session-as-log. + +## Keying and identity + +- A thread's id is a client-generated UUID via `generateUuid()` + (`src/vs/workbench/contrib/void/browser/chatThreadService.ts:211`, inside + `newThreadObject()`). There is no server assignment; it is minted locally + when a thread is created and does not encode ordering (plain UUID, not + UUIDv7). +- `ThreadType` carries `createdAt` and `lastModified` ISO-string fields + (`src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts` is + message types; the thread shape itself is + `src/vs/workbench/contrib/void/browser/chatThreadService.ts:114-144`). + Ordering for the sidebar list is done by sorting on `lastModified`, not by + id (`src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx:37-38`): + `.sort((a, b) => (allThreads[a]?.lastModified ?? 0) > (allThreads[b]?.lastModified ?? 0) ? -1 : 1)`. +- Listing is **global, not scoped per project/workspace**. The storage scope + is `StorageScope.APPLICATION` + (`src/vs/workbench/contrib/void/browser/chatThreadService.ts:406`, `:420`), + which upstream VS Code documents as "scoped to all workspaces across all + profiles" (`src/vs/platform/storage/common/storage.ts:225-228`). Every + chat thread ever created in that VS Code installation lives in one blob and + is enumerable regardless of which folder/workspace is currently open. There + is no per-workspace or per-project partitioning of thread ids at all. +- `currentThreadId` is explicitly **not** persisted -- the comment at + `src/vs/workbench/contrib/void/browser/chatThreadService.ts:308` says + "allThreads is persisted, currentThread is not." On restart, `openNewThread()` + runs (`src/vs/workbench/contrib/void/browser/chatThreadService.ts:1628-1648`): + it looks for any existing empty thread and switches to it, otherwise mints a + brand-new UUID thread. There is no "reopen where you left off" behavior for + which thread was active; only the historical list survives, and the user + must manually pick a thread to resume it via the sidebar selector. +- No relocation/rename reconciliation exists because there is nothing to + reconcile against (no path/cwd component in the identity at all). + +## The store interface + +No pluggable interface -- this is a private module inside one workbench +contribution, called directly by `ChatThreadService`'s own methods and by +React UI code. Reconstructed effective operations, all in +`src/vs/workbench/contrib/void/browser/chatThreadService.ts`: + +| Operation | Signature (reconstructed) | Location | +|---|---|---| +| Read all threads on boot | `_readAllThreads(): ChatThreads \| null` | `:405-413` | +| Write all threads (full rewrite) | `_storeAllThreads(threads: ChatThreads): void` | `:415-423` | +| Create thread (or reuse an empty one) | `openNewThread(): void` | `:1628-1648` | +| Switch active thread (not persisted) | `switchToThread(threadId: string): void` | `:1623-1625` | +| Delete a thread | `deleteThread(threadId: string): void` | `:1651-1661` | +| Duplicate a thread (new id, deep-cloned contents) | `duplicateThread(threadId: string): void` | `:1663-1677` | +| Append a message to a thread | `_addMessageToThread(threadId: string, message: ChatMessage): void` | `:1680-1697` | +| Replace one message in place | `_editMessageInThread(threadId, messageIdx, newMessage): void` | `:925-944` | +| Reset entire store (dev/debug) | `resetState(): void` / `dangerousSetState(newState): void` | `:384-392` | +| Rewind (checkpoint pointer, then truncate on next write) | `jumpToCheckpointBeforeMessageIdx(opts): void` | `:1080-...` (truncation at `:1272-1290`) | + +Every one of these that mutates state ends by calling `_storeAllThreads()` +with the complete map (see call sites listed in "The storage model" above), +then firing `_onDidChangeCurrentThread` so the React sidebar re-renders. There +is no partial write, offset, or incremental append at the storage layer -- +"append a message" is implemented as "compute a new whole thread map with one +more message in one thread's array, then persist the whole map." + +## Write and append path (ordering, durability, concurrency, delivery) + +- **Ordering**: positional -- messages are plain array elements + (`ChatMessage[]` in `ThreadType.messages`, + `src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts` for the + `ChatMessage` union; array field at + `src/vs/workbench/contrib/void/browser/chatThreadService.ts:119`). No + sequence numbers, timestamps per message, or server-assigned positions -- + order is JS array order. +- **Durability/atomicity**: entirely inherited from VS Code's + `IStorageService`, which is a SQLite-backed key-value store + (`src/vs/platform/storage/electron-main/storageMain.ts:285`). Void does no + temp-file-and-rename or explicit fsync of its own; whatever atomicity + SQLite's transaction commit gives you is what you get. A crash mid-write + would be governed by SQLite/VS Code's storage flush behavior, not by + anything in Void's code. +- **Concurrency model**: effectively single-writer -- one renderer process + reading and rewriting one in-memory `ThreadsState` object, with no + optimistic-concurrency check (no expected-version precondition; last + `_setState` call simply wins). This is fine for a single-user desktop editor + but there is no compare-and-swap or lock around `_storeAllThreads`. +- **Delivery semantics**: best-effort, synchronous within the renderer; + no retry/queue logic was found around `_storageService.store()`. +- **Idempotence**: none needed/observed -- mutations are local function calls, + not messages that could be delivered twice. + +## Read and resume path + +On construction, `ChatThreadService` calls `_readAllThreads()` +(`src/vs/workbench/contrib/void/browser/chatThreadService.ts:334`), which +does a single synchronous `_storageService.get(THREAD_STORAGE_KEY, +StorageScope.APPLICATION)` and a full `JSON.parse` with a URI-reviving +reviver function (`:396-403`, `:405-413`). This is a **full ordered read of +the entire history in one shot** -- every thread and every message for the +whole installation is loaded into memory at startup, every time. There is no +cursor, no pagination, no lazy per-thread loading, and no size bound. What +gets read back is the raw record itself, not a cached/derived view -- Void +does not maintain any local cache distinct from this blob. + +`openNewThread()` then runs unconditionally (`:343`), so "resume" for the +*active* editing session never happens automatically; the user resumes a +specific past thread only by clicking it in the sidebar +(`switchToThread`, `:1623-1625`), which simply repoints `currentThreadId` at +an already-loaded thread -- no additional storage read occurs at that point, +since everything was already pulled in at boot. + +## Listing, summaries, and search + +Listing is a direct iteration of the in-memory `allThreads` map, sorted by +`lastModified` +(`src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx:37-38`), +with an initial-page cap in the UI only (`numInitialThreads`, "Show N more..." +at `:42-73` of the same file) -- that is a rendering limit, not a storage +limit; all threads are already in memory regardless. There is no separate +metadata sidecar, no denormalized summary record, and no search index (full +text, vector, or otherwise) of any kind found in +`src/vs/workbench/contrib/void/` -- a targeted grep for `indexedDB`/`IndexedDB` +across that directory tree returned no matches. + +## Entry/message structure and versioning + +The full `ChatMessage` union is defined in +`src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts:50-69`: + +```ts +export type ChatMessage = + | { + role: 'user'; + content: string; + displayContent: string; + selections: StagingSelectionItem[] | null; + state: { stagingSelections: StagingSelectionItem[]; isBeingEdited: boolean; } + } | { + role: 'assistant'; + displayContent: string; + reasoning: string; + anthropicReasoning: AnthropicReasoning[] | null; + } + | ToolMessage + | DecorativeCanceledTool + | CheckpointEntry +``` + +`ToolMessage` (`:11-28`) tags each tool call with `role: 'tool'`, an `id`, +`rawParams`, `mcpServerName`, and a discriminated `type` field walking through +its lifecycle (`invalid_params` → `tool_request` → `running_now` → +`tool_error`/`success`/`rejected`). `CheckpointEntry` (`:38-46`) has +`role: 'checkpoint'` and embeds, per file path, a `VoidFileSnapshot` -- +defined in `src/vs/workbench/contrib/void/common/editCodeServiceTypes.ts:115-118` +as `{ snapshottedDiffAreaOfId: ...; entireFileCode: string }`. Note +`entireFileCode`: checkpoints store the **full file content**, not a diff. + +Entries are not opaque to the store -- `ChatThreadService` reads and mutates +specific fields (`messages`, `state.currCheckpointIdx`, etc.) directly; there +is no black-box blob-passthrough. + +Versioning is a manual, ad hoc convention, not a schema-version field on the +data itself. `src/vs/workbench/contrib/void/common/storageKeys.ts:6-19` +tracks it entirely through the **storage key name**: + +```ts +// past values: +// 'void.chatThreadStorage' +// 'void.chatThreadStorageI' // 1.0.2 +// 1.0.3 +export const THREAD_STORAGE_KEY = 'void.chatThreadStorageII' +``` + +and a standing warning at +`src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts:49`: +"WARNING: changing this format is a big deal!!!!!! need to migrate old format +to new format on users' computers so people don't get errors." No migration +*code* implementing that warning was found anywhere under +`src/vs/workbench/contrib/void/` (a repo-wide grep for `migrat` in that tree +matched only an unrelated VSCodium doc link). In other words: the project has +bumped the key name twice as a de facto "start over" migration strategy +(old data under the old key becomes inaccessible, not converted), and +otherwise relies on the warning comment rather than enforced versioning. + +## Compaction and history management + +None found. No summarization, truncation-with-marker, or context-window +compaction logic exists in `chatThreadService.ts` or the LLM-message +conversion path -- a grep for `compact`/`summariz` across +`src/vs/workbench/contrib/void/browser/chatThreadService.ts` and +`convertToLLMMessageService.ts` returned nothing. Whatever context-window +management exists is presumably left to the model API itself; the durable +message array simply keeps growing. + +## Rewind, checkpoints, and fork + +- **Checkpoints**: each turn can append a `CheckpointEntry` message + (`role: 'checkpoint'`, `type: 'user_edit' | 'tool_edit'`) carrying a full + `entireFileCode` snapshot per touched file + (`src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts:38-46`, + `src/vs/workbench/contrib/void/common/editCodeServiceTypes.ts:115-118`). + These are full-content snapshots, not diffs, and they live inline in the + same `messages` array that gets wholesale-rewritten on every mutation -- + there is no separate snapshot store or deduplication observed. +- **Rewind ("jump to checkpoint")**: `jumpToCheckpointBeforeMessageIdx` + (`src/vs/workbench/contrib/void/browser/chatThreadService.ts:1080` onward) + moves a `currCheckpointIdx` pointer and re-applies prior file snapshots to + the working tree; by itself this does **not** truncate the message array. + Truncation is destructive and happens lazily, on the *next* user message: in + `addUserMessageAndStreamResponse` + (`src/vs/workbench/contrib/void/browser/chatThreadService.ts:1272-1290`), + if `currCheckpointIdx !== null`, the code does + `thread.messages.slice(0, checkpointIdx + 1)` and persists that truncated + array -- an in-place, irreversible rewrite of history, not an appended + branch marker. Anything after the checkpoint is permanently gone from the + stored record once you type a new message. +- **Fork**: `duplicateThread(threadId)` + (`src/vs/workbench/contrib/void/browser/chatThreadService.ts:1663-1677`) is + the only fork-like operation -- a full `deepClone` of the thread object with + a freshly minted `id`. It is copy-plus-new-identity with no lineage field + recorded (no `parentThreadId`/`forkedFrom` anywhere in `ThreadType`); the + two threads become entirely independent records with no durable link back + to each other. + +## Subagents and nested sessions + +No concept found. `ChatThreads` is a flat `{ [id]: ThreadType }` map with no +parent/child relationship anywhere in `ThreadType` +(`src/vs/workbench/contrib/void/browser/chatThreadService.ts:114-154`). +Targeted greps for `subagent`, `childThread`, and `parentThread` across +`src/vs/workbench/contrib/void/` (excluding the React UI tree) returned no +matches. Tool calls (including MCP tool calls, tagged via `mcpServerName` on +`ToolMessage`) are recorded as ordinary entries in the same thread's message +array -- there is no sub-thread spun up for a tool or agent call, and +therefore no cascade-on-delete question to answer: `deleteThread` simply +removes one key from the flat map +(`src/vs/workbench/contrib/void/browser/chatThreadService.ts:1651-1661`) with +nothing else to cascade to. + +## Retention, deletion, and multi-host + +- **Retention**: none. No TTL, lifecycle policy, or scheduled cleanup was + found. Threads persist until a user manually calls `deleteThread`. +- **Deletion**: `delete newThreads[threadId]` followed by a full + `_storeAllThreads` rewrite + (`src/vs/workbench/contrib/void/browser/chatThreadService.ts:1651-1661`) -- + a hard, immediate delete of that key from the in-memory map and the + persisted blob. No soft-delete/tombstone, no cascade (nothing to cascade + to, per above). +- **Growth bound**: none found. Every thread and every message (including + full-file-content checkpoints) accumulates forever in one JSON value under + one storage key, re-serialized and rewritten on every single mutation. This + is architecturally the most notable risk surfaced by this investigation: + there is no per-thread size limit, no total-thread cap, and no eviction -- + marked here as **inference**, since no explicit cap was found rather than + proven never to exist by exhaustive testing. +- **Multi-host**: not applicable/not addressed. `StorageScope.APPLICATION` is + a single local installation's storage; there is no remote writeback, shared + filesystem handling, or cross-host reconciliation in this code path -- it is + a single-process, single-machine, Electron-local design end to end. + +## Interop with foreign session stores + +Not applicable. No code was found that reads or imports session data from +other chat/agent products (Claude Code, Cursor, Continue, etc.) into Void's +thread store, and none was expected given Void's storage is entirely +internal to one VS Code installation's key-value store. + +## What this implies for our Session Store (our inference) + +Void is **not** independent evidence of an append-only, event-sourced session +design -- it is close to the opposite pole. The entire chat history for an +installation is one mutable JSON document, rewritten in full on every +mutation, with no positional dedup key, no expected-version precondition, and +no bound on size. It inherits durability entirely from VS Code's generic +key-value storage (itself SQLite-backed) rather than building anything +resembling a log. The one design choice worth carrying forward as a +cautionary data point: destructive, in-place history rewrite on +rewind-then-continue (checkpoint truncation, +`src/vs/workbench/contrib/void/browser/chatThreadService.ts:1272-1290`) is +exactly the failure mode an append-only log with branch markers is meant to +avoid -- Void's approach loses the "future" branch permanently the moment you +type past a rewind point, whereas an event-sourced design could retain it as +an orphaned but recoverable branch. + +## Open questions + +- Whether VS Code's `IStorageService` applies any internal row-size limit or + compression for large values (e.g. this specific SQLite-backed store) was + not verified beyond confirming the backing file name + (`src/vs/platform/storage/electron-main/storageMain.ts:285`); if there is a + practical ceiling, very active Void users could hit it. +- Whether the browser (non-Electron/web) build of Void backs + `IStorageService` with IndexedDB instead of SQLite was not traced; only the + Electron path (`storageMain.ts`) was confirmed. If Void ships a web/browser + target, its physical storage substrate there is unconfirmed by this pass. +- No test file for `chatThreadService.ts` was located/reviewed in this pass; + whether there is any test coverage asserting the migration warning's + intent is unknown. diff --git a/docs/research/session-store/products/void/vs-session-events.md b/docs/research/session-store/products/void/vs-session-events.md new file mode 100644 index 000000000..f09b1e266 --- /dev/null +++ b/docs/research/session-store/products/void/vs-session-events.md @@ -0,0 +1,322 @@ +# Void compared to our session event catalog + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT_COMPARISON](../../RESEARCH_PROMPT_COMPARISON.md). +Stage-one dossier: [Void](./index.md). +Compared against `proto/trogonai/session/sessions/v1alpha1/` and [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) on 2026-08-04. + +**Store maturity: 1/12**: evolution scars 0/3 (the storage key was renamed +twice, `void.chatThreadStorage` → `...StorageI` → `...StorageII` +(`src/vs/workbench/contrib/void/common/storageKeys.ts:14-19`), but the +dossier found no migration code anywhere in the tree carrying old data +forward; the axis rewards a format change that *carries data forward*, and +this is the opposite: a rename that abandons it, so it earns nothing here +even though it is real evidence of *something*), operational age 0/3 (no +first-commit date and no issue reports of corruption, growth, or lock +contention were found or cited; the only age signal is inline version +comments, "1.0.2" / "1.0.3", which date the renames but not a discovered +failure), exposure 1/3 (Void is a real, vendor-shipped VS Code fork, but the +dossier cites no adoption-scale or multi-host evidence, and the storage +model is explicitly single-installation, single-process, single-machine +with "no remote writeback, shared filesystem handling, or cross-host +reconciliation" (see the dossier's [Retention, deletion, and multi-host](./index.md#retention-deletion-and-multi-host) section), +so the exposure this axis rewards, resume surviving crashes/upgrades/hosts at +scale, is simply not exercised), design independence 0/3 (the store itself, +durability, atomicity, the key-value substrate, is unmodified upstream VS +Code `IStorageService`/SQLite code Void does not touch directly +(`src/vs/platform/storage/electron-main/storageMain.ts:285`, `:361`); Void's own +code contributes the domain shape serialized into it, not the persistence +mechanism, and this axis scores the store, not the schema on top of it). + +This score is low on purpose: Void's answers below carry little independent +weight and should not be read as an industry norm on any axis where a +higher-scoring store (Cline at 10/12, fx) disagrees. + +## The one structural difference everything else follows from + +Void has no event log. The durable record for an entire installation is one +`ChatThreads` map, holding every thread and every message ever created, +serialized with `JSON.stringify` and written to a single VS Code storage key, +`THREAD_STORAGE_KEY = 'void.chatThreadStorageII'` +(`src/vs/workbench/contrib/void/browser/chatThreadService.ts:415-423`, +`src/vs/workbench/contrib/void/common/storageKeys.ts:19`). Every mutation +computes a new whole map and calls `_storeAllThreads()` with the complete +object (`src/vs/workbench/contrib/void/browser/chatThreadService.ts:415-423`, +called at `:942`, `:1289`, `:1646`, `:1659`, `:1675`, `:1696`). There is no append operation, no positional ordinal, and no +schema-version field: versioning is done by renaming the key itself +(`src/vs/workbench/contrib/void/common/storageKeys.ts:14-19`). + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2 makes append the only mutation primitive: "rewind, +revert, compaction, and hide are all new appended events, never edits or +deletes to old ones." Void sits at the pole this decision was written to +avoid: not "append-only with looser discipline" like the JSONL products in +the corpus, but whole-document-rewrite with no discipline at all. Every +other difference below (no subagent concept, no growth bound, destructive +rewind-then-continue, ad hoc versioning) is a direct consequence of this one +fact: there is no concept of an event, only a snapshot of current state that +gets replaced in full. + +## Mapping + +**Semantic mismatch: "checkpoint."** Void's `CheckpointEntry` +(`role: 'checkpoint'`) is a message-array entry carrying, per touched file, a +full `entireFileCode` snapshot inline in the transcript itself +(`src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts:38-46`, +`src/vs/workbench/contrib/void/common/editCodeServiceTypes.ts:115-118`): it is an undo point living in the same mutable blob as the +conversation. Our `Checkpoint` (`proto/trogonai/session/sessions/v1alpha1/checkpoint.proto:17-38`) +is an opaque, out-of-line, digest-verified artifact reference used only for +harness process-state recovery, one of four records [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 3 +deliberately keeps separate authority for ("harness recovery checkpoint," +"aggregate snapshot," "read-side checkpoint," and the typed event log +itself). A reader mapping the two terms naively would assume equivalence; +they solve different problems and share almost nothing but the English word. + +| Void | Ours | Verdict | +| --- | --- | --- | +| Single `ChatThreads` map under one `StorageScope.APPLICATION` key, all threads for the installation (`src/vs/workbench/contrib/void/browser/chatThreadService.ts:415-423`) | One logical stream per session, subject `session.sessions.events.` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 1) | Ours, decisively: no shared blob whose size is every thread ever created | +| Thread id: client-generated `generateUuid()`, plain UUID, no ordering semantics (`src/vs/workbench/contrib/void/browser/chatThreadService.ts:211`) | Opaque `SessionId`, time-sortable by construction but sort order never load-bearing ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 1) | Equivalent identity concept, addressed differently | +| `createdAt`/`lastModified` ISO strings on `ThreadType`, sidebar sorts on `lastModified` (`src/vs/workbench/contrib/void/browser/chatThreadService.ts:114-144`, `src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx:37-38`) | No mutable "last modified" field; order is fold-derived `SessionOrdinal` (`proto/trogonai/session/sessions/v1alpha1/session_ordinal.proto`) | Ours, since there is nothing to keep in sync with the log it summarizes | +| `_storeAllThreads()`: full rewrite of the whole map on every mutation (`src/vs/workbench/contrib/void/browser/chatThreadService.ts:415-423`) | Append is the only mutation primitive ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2) | Ours, decisively | +| No positional dedup key, no expected-version precondition, "last `_setState` call simply wins" (see the dossier's [Write and append path (ordering, durability, concurrency, delivery)](./index.md#write-and-append-path-ordering-durability-concurrency-delivery) section) | Per-command `WRITE_PRECONDITION` (`NoStream`/`At`/`Any`), server-enforced ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2) | Ours, decisively | +| `ToolMessage` mutates a `type` field in place through a lifecycle (`invalid_params → tool_request → running_now → tool_error/success/rejected`), one object overwritten (`src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts:11-28`) | Separate immutable facts: `ToolCallRequested`/`Started`/`Completed`/`Failed` (`proto/trogonai/session/sessions/v1alpha1/tool_call_requested.proto`, `tool_call_completed.proto`) | Ours, decisively: no single mutable record whose history is only its current value | +| `CheckpointEntry.entireFileCode`: full file content inlined per touched file, no dedup (`src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts:38-46`, `src/vs/workbench/contrib/void/common/editCodeServiceTypes.ts:115-118`) | `FileChanged.before_ref`/`after_ref`, content-addressed `ArtifactRef` claim-checks (`proto/trogonai/session/sessions/v1alpha1/file_changed.proto:30-36`, `artifact.proto:14-34`) | Ours, decisively | +| `duplicateThread(threadId)`: deep clone, fresh id, no lineage field on either copy (`src/vs/workbench/contrib/void/browser/chatThreadService.ts:1663-1677`) | `SessionForked{source_session_id, context_prefix_boundary}` (`proto/trogonai/session/sessions/v1alpha1/session_forked.proto:17-27`) | Ours, decisively: typed, durable lineage vs. two independent records with no link back | +| Rewind: `jumpToCheckpointBeforeMessageIdx` moves a pointer only; the *next* user message calls `thread.messages.slice(0, checkpointIdx + 1)` and persists the truncated array (`src/vs/workbench/contrib/void/browser/chatThreadService.ts:1080`, `:1272-1290`) | `SessionRewound{keep_through}`, a pure marker (`proto/trogonai/session/sessions/v1alpha1/session_rewound.proto:16-22`); nothing is ever sliced or deleted ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2) | Ours, decisively: this is the calibration point below | +| Versioning: rename `THREAD_STORAGE_KEY`, no migration code, old data under the old key becomes inaccessible (`src/vs/workbench/contrib/void/common/storageKeys.ts:14-19`; see the dossier's [Entry/message structure and versioning](./index.md#entrymessage-structure-and-versioning) section for the migration-code absence) | Schema evolution is additive only: new optional fields, reserved retired numbers, never a per-event version branch ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 3) | Ours, decisively: this is the other calibration point below | +| `currentThreadId` explicitly not persisted; no "resume where you left off" (`src/vs/workbench/contrib/void/browser/chatThreadService.ts:308`, `:1628-1648`) | No equivalent: which session a client last had open is a client/UI concern, not a store fact | Neither, out of scope for both, noted for completeness | +| Listing is global across every workspace ever opened, `StorageScope.APPLICATION` (`src/vs/workbench/contrib/void/browser/chatThreadService.ts:406`, `:420`, `src/vs/platform/storage/common/storage.ts:225-228`) | `SessionStarted.workspace`, a required `WorkspaceRef` (`proto/trogonai/session/sessions/v1alpha1/session_started.proto:19-23`, `workspace.proto`) | Trade-off, not a plain gap: see below | +| No subagent/child-session concept anywhere; tool calls are ordinary entries in the same thread (see the dossier's [Subagents and nested sessions](./index.md#subagents-and-nested-sessions) section) | `DelegationDispatched`/`ParentLinked`/`CascadePolicy` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6) | Ours; see "Subagent cascade" below, this is not evidence either way, it is absence | +| No retention, no TTL, no growth bound; every thread and message accumulates forever, re-serialized whole on every mutation (see the dossier's [Retention, deletion, and multi-host](./index.md#retention-deletion-and-multi-host) section) | Keep-forever log with snapshot-bounded replay, `SessionHidden`/`RedactionApplied`/`ArtifactErased` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7) | Ours; see "Retention" below | + +## What we should consider changing + +Given the store maturity score, this section is short by design. Void +supports one clarification, not a schema change. + +### 1. State explicitly, in [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 2, that "non-destructive rewind" is a property of the write path following it, not of the rewind event alone + +**The change.** Add one sentence to [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2's `SessionRewound` +discussion (or to `proto/trogonai/session/sessions/v1alpha1/session_rewound.proto`'s +comment) naming the exact failure this store is designed to avoid: a rewind +marker by itself guarantees nothing if a *later* command is allowed to +delete or truncate anything. + +**Evidence anchor.** Void, store maturity 1/12 (thin evidence, cited only +as a cautionary counterexample, not an industry norm): +`jumpToCheckpointBeforeMessageIdx` moves a pointer and by itself does *not* +truncate the message array; truncation happens lazily, on the next user +message, via `thread.messages.slice(0, checkpointIdx + 1)`, an in-place, +irreversible rewrite (`src/vs/workbench/contrib/void/browser/chatThreadService.ts:1080`, `:1272-1290`). +The dossier's own conclusion: "'non-destructive rewind' is not a property of +the rewind operation alone, it is a property of what the next write does" +(paraphrasing the dossier's [What this implies for our Session Store (our inference)](./index.md#what-this-implies-for-our-session-store-our-inference) section). + +**Blast radius.** Additive: this is a documentation clarification. Our +`SessionRewound.keep_through` (`session_rewound.proto:16-22`) already is +what Void's rewind pointer is not: `RewindSession` never deletes, slices, or +edits an event, and there is no "next write" in our design that could later +discard the abandoned branch, because there is no write primitive other than +append. The recommendation costs nothing to implement; it exists only to +name the failure mode explicitly so a future implementer of `decide`/`evolve` +for `RewindSession` cannot accidentally reintroduce Void's pattern (for +example, by having a follow-on compaction or cleanup pass physically drop +events after `keep_through` as an "optimization"). + +**Why.** Void is a real, shipped instance of exactly the failure mode an +append-only rewind exists to prevent: the abandoned branch is not +recoverable, not auditable, and not distinguishable after the fact from a +branch that was never taken. It is worth one sentence in the ADR precisely +because the two halves of "non-destructive rewind" (a marker, and a write +path that respects it) are easy to build correctly in isolation and easy to +violate by adding one destructive step later; Void shows what that looks +like once it happens. + +**Cost.** None beyond the sentence. No new field, no new event, no new +projection. + +No further changes are recommended. A 1/12 store does not support +recommendations beyond a single, narrowly-scoped documentation note; every +other observation about Void surfaces either as something our design already +does better (below) or as a pattern to explicitly reject (also below). + +## What our design already does better + +- **Append-only mutation vs. whole-document rewrite.** Every Void mutation, + a new message, an edit, a delete, a checkpoint jump, reads the entire + `ChatThreads` map and rewrites the entire thing + (`src/vs/workbench/contrib/void/browser/chatThreadService.ts:415-423`). Our append + is O(1) in total session size regardless of how large the log has grown + ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2); a session's history is never re-read and + re-written in full to record one more fact. +- **Content-addressed artifacts vs. inlined full-file checkpoints.** Void's + `CheckpointEntry` embeds `entireFileCode`, the whole file, per touched + path, inline in the same array that gets rewritten on every mutation + (`src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts:38-46`, + `src/vs/workbench/contrib/void/common/editCodeServiceTypes.ts:115-118`). Our + `FileChanged.before_ref`/`after_ref` `ArtifactRef` pair deduplicates + identical content globally by digest (`artifact.proto:14-34`) and keeps + the event itself small. +- **Typed, durable fork lineage vs. an unlinked deep clone.** `duplicateThread` + produces two threads with no durable record that either came from the + other (`src/vs/workbench/contrib/void/browser/chatThreadService.ts:1663-1677`). + `SessionForked` names `source_session_id` and a `context_prefix_boundary` + permanently (`session_forked.proto:17-27`). +- **Real optimistic concurrency vs. "last call wins."** Void has no + compare-and-swap of any kind; concurrent writers simply overwrite each + other (see the dossier's [Write and append path (ordering, durability, concurrency, delivery)](./index.md#write-and-append-path-ordering-durability-concurrency-delivery) section). Our + guarded commands use a server-enforced `WRITE_PRECONDITION` + ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2). +- **Workspace binding as a recorded fact vs. no binding at all.** Void's + threads carry no path/cwd component and are listed globally regardless of + which folder is open (`src/vs/workbench/contrib/void/browser/chatThreadService.ts:406`, `:420`, + `:114-144`). `SessionStarted.workspace` is a required `WorkspaceRef` + (`session_started.proto:19-23`). + +## Trade-offs, not gaps + +- **Global, workspace-agnostic listing vs. workspace-scoped sessions.** + Void's `StorageScope.APPLICATION` means every chat thread ever created in + one VS Code installation is enumerable from any project + (`src/vs/workbench/contrib/void/browser/chatThreadService.ts:406`, `:420`, + `src/vs/platform/storage/common/storage.ts:225-228`). This may be + a deliberate product choice, "see every chat you've ever had, regardless + of which repo you have open," not an oversight; the dossier does not + establish intent either way. Our design requires every session to carry a + `WorkspaceRef` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 1), which buys workspace-scoped audit and + a queryable binding at the cost of making "all my chats across every + project" a cross-workspace query rather than one map iteration. Neither + side is wrong; they are answering different questions about what a + session list is for. +- **Single-writer simplicity vs. multi-writer/multi-host correctness.** + Void's "last `_setState` call simply wins" concurrency model + (see the dossier's [Write and append path (ordering, durability, concurrency, delivery)](./index.md#write-and-append-path-ordering-durability-concurrency-delivery) section) is + adequate and cheap for a single-user, single-process desktop editor. Our + server-enforced OCC ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2) exists to survive multi-writer + and multi-host correctness (decision 8's forced answer), which Void's + problem domain never has to solve. + +## What not to copy + +- **Whole-document rewrite as the only write primitive.** Every mutation, + regardless of size, rewrites the complete `ChatThreads` map + (`src/vs/workbench/contrib/void/browser/chatThreadService.ts:415-423`). This is the + direct structural cause of every other item on this list. +- **Unbounded single-key growth with no retention story.** Every thread and + every message, including full-file checkpoints, accumulates forever under + one storage key with no TTL, no cap, and no eviction + (see the dossier's [Retention, deletion, and multi-host](./index.md#retention-deletion-and-multi-host) section, flagged in + the dossier itself as inference, since no explicit cap was found rather + than proven absent by exhaustive testing). +- **Destructive truncation as the second half of "rewind."** `slice(0, + checkpointIdx + 1)` permanently discards everything after a rewind point + the moment the user sends the next message + (`src/vs/workbench/contrib/void/browser/chatThreadService.ts:1272-1290`). Recorded + here explicitly as the failure mode recommendation 1 above names. +- **Versioning by renaming the storage key, with no migration code.** + `void.chatThreadStorage` → `...StorageI` → `...StorageII` + (`src/vs/workbench/contrib/void/common/storageKeys.ts:14-19`), with a + standing comment warning that "changing this format is a big deal" but no + migration logic anywhere under `src/vs/workbench/contrib/void/` + implementing that warning + (`src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts:49`; see the + dossier's [Entry/message structure and versioning](./index.md#entrymessage-structure-and-versioning) + section for the absence of migration code). Every + version bump silently abandons every user's prior thread history rather + than carrying it forward. This is not a hypothetical risk of an + unversioned format; it is a concrete, shipped instance of exactly the + failure mode a migration ratchet exists to prevent. Contrast Zed's + `sqlez::Connection::migrate`, which compares each shipped migration's + stored SQL text against the compiled `Domain::MIGRATIONS` array and + **panics** on any mismatch unless a step explicitly opts into + `should_allow_migration_change` + (`crates/sqlez/src/migrations.rs:37-104` in the Zed repo): a hard + fail loudly, at connection time, rather than a silent, unannounced loss of + every prior thread. Void demonstrates the two ends of the same axis in one + corpus: fail loud and refuse to proceed (Zed), or rename the key and quietly + orphan the old data (Void). Our own schema evolution is additive-only in + `v1alpha1` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 3), which sidesteps the choice entirely for + now; it does not yet say what happens the day an additive change is not + enough (see Open questions). + +## The two gaps the industry has not closed + +### Subagent cascade + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6 already takes a position here: a child session is its +own logical stream, linked by `DelegationDispatched`/`ParentLinked`, with an +explicit `CascadePolicy` (`CASCADE_ON_PARENT_TERMINAL` or `INDEPENDENT`) and +a reconciler that cascades terminal state and rewind-invalidation as +distinct, typed batches. Void's evidence does not validate, challenge, or +refine that position; it simply has none to compare against. `ChatThreads` +is a flat `{ [id]: ThreadType }` map with no parent/child field anywhere in +`ThreadType`, and targeted greps for `subagent`, `childThread`, and +`parentThread` returned no matches +(see the dossier's [Subagents and nested sessions](./index.md#subagents-and-nested-sessions) section). Tool calls, +including MCP calls tagged via `mcpServerName`, are recorded as ordinary +entries in the same thread's own message array; there is no sub-thread, no +delegation, and therefore no cascade-on-delete question to answer at all: +`deleteThread` removes exactly one key from the flat map +(`src/vs/workbench/contrib/void/browser/chatThreadService.ts:1651-1661`), with +nothing else to cascade to, invalidate, or orphan. Stated plainly, as the +research prompt requires even when the honest answer is absence: **Void has +no position on subagent cascade**, because Void has no subagent concept. +This is thin, uninformative evidence on this axis, not a data point that +moves decision 6 in any direction. + +### Retention on an unbounded log + +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7 already takes a position: keep-forever, with +`SessionHidden` as a visibility tombstone, `RedactionApplied` for read-time +masking, `ArtifactErased` for out-of-band artifact-byte destruction, and +"aggregate snapshots bound replay, not storage" so an append stays O(1) +regardless of log length. Void's evidence sharpens, rather than +contradicts, this design, though it is thin evidence (no confirmed field +failure, only source-level inference): the dossier found **no** retention +policy, no TTL, no scheduled cleanup, and no growth bound anywhere: "every +thread and every message (including full-file-content checkpoints) +accumulates forever in one JSON value under one storage key, re-serialized +and rewritten on every single mutation" +(see the dossier's [Retention, deletion, and multi-host](./index.md#retention-deletion-and-multi-host) section, marked in +the dossier itself as inference, since no explicit cap was found rather than +proven never to exist). + +Void's failure mode is structurally worse than "an unbounded log with no +retention policy," which is what decision 7 accepts as its trade-off: Void +has no append primitive at all, so *every* mutation, not only growth past +some threshold, already costs O(total installation history): reading and +re-serializing every thread and every message the user has ever had, on +every keystroke's worth of state change. Decision 7's snapshot-bounded +replay exists specifically so growth without bound does not also mean cost +without bound; Void demonstrates the naive alternative directly, with no +event log underneath it at all, rather than merely lacking a retention +policy on top of one. This validates the structural bet in decision 7 (keep +the log, bound replay cost with snapshots) more sharply than a product that +has an event log but simply has not designed retention for it, but it says +nothing new about the retention *policy* question itself (keep-forever vs. +a TTL), because Void has no policy to compare and, per the dossier's own +flagged uncertainty, no confirmed user-facing failure report to point to +either. Carried forward as inference, not hardened into a claim: if Void +has ever caused a user-visible slowdown or freeze from installation-wide +blob size, no issue report documenting it was found in this pass. + +## Open questions for the ADR + +- [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 3 makes `v1alpha1` schema evolution additive-only + (new optional fields, reserved retired numbers, never a version branch). + Void's storage-key-rename pattern is a concrete example of what happens + when a product needs a genuinely breaking change and has no migration + story: history is silently abandoned. The ADR states that promotion from + `v1alpha1` to `v1` is "a later, separate decision" but does not yet say + what happens to already-persisted `v1alpha1` events at that boundary, or + whether a future breaking change to an accepted `v1` would follow Zed's + fail-loud ratchet, a replay/rewrite migration, or something else. This is + not urgent, nothing in `v1alpha1` has needed a breaking change yet, but + Void and Zed together show the two ends of the outcome space if the + question is left unanswered until the day it is forced. +- Is a client-facing "which session was I last looking at" pointer (Void's + `currentThreadId`, deliberately *not* persisted, + `src/vs/workbench/contrib/void/browser/chatThreadService.ts:308`, `:1628-1648`) something the + Session Store should ever record, or is it correctly out of scope as a + pure client/UI concern with no session-store fact backing it? Void answers + "out of scope" by omission rather than by a stated design choice; worth + confirming our own design agrees for the same reason, not by default. diff --git a/docs/research/session-store/products/zed/index.md b/docs/research/session-store/products/zed/index.md new file mode 100644 index 000000000..c73f5f925 --- /dev/null +++ b/docs/research/session-store/products/zed/index.md @@ -0,0 +1,627 @@ +# Zed: how session transcripts are stored and resumed + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT](../../RESEARCH_PROMPT.md). +Evidence snapshot retrieved 2026-08-04. Version-sensitive claims were checked +against a local clone of +[zed-industries/zed](https://github.com/zed-industries/zed) pinned at commit +`4aad57fd1f002f9feeea2b7fb6229ccbcd576cb1` ("workspace: Return the created +workspace when opening a remote project (#62028)", Aug 3 2026). Authoritative +anchors: `crates/agent_ui/src/thread_metadata_store.rs` (sidebar metadata +store and its `sqlez::Domain` migrations), `crates/agent/src/db.rs` and +`crates/agent/src/thread_store.rs` (thread content store), `crates/agent/src/thread.rs` +(in-memory thread model and ACP boundary conversion), `crates/sqlez/src/{domain,migrations}.rs` +(the migration ratchet), `crates/remote/src/remote_identity.rs` and +`crates/util/src/path_list.rs` (identity/relocation), and +`agent_client_protocol::schema::v1` as `acp` (the wire schema Zed's session +identity is drawn from). Zed is licensed per-crate; every crate cited below is +`GPL-3.0-or-later` except `gpui`, `util`, and `collections`, which are +`Apache-2.0` (checked against each crate's `Cargo.toml` `license` field at this +commit). This dossier is orthogonal to, and cross-references rather than +re-derives, the existing ACP corpus at [../../../acp/index.md](../../../acp/index.md) +and [../../../acp/products/zed.md](../../../acp/products/zed.md), which covers +Zed as an ACP *client* wiring external agents; this dossier covers Zed's own +built-in agent panel as a session-storage system. + +## The storage model + +Zed's agent panel keeps thread data in **two structurally distinct SQLite +databases**, migrated by two different mechanisms, which is the single most +important fact to get right before anything else: + +1. **`ThreadMetadataDb`** -- table `sidebar_threads`, living inside the single + shared `db.sqlite` file that the whole Zed application (not just the agent + panel) uses for workspace state, key-value settings, and everything else + registered as a `sqlez::Domain` + (`crates/agent_ui/src/thread_metadata_store.rs:1370`, + `impl Domain for ThreadMetadataDb`). This is *metadata only*: title, + timestamps, project paths, remote-connection scoping, archival state -- not + message content. It is migrated by `sqlez`'s strict, stored-text-compared, + one-way ratchet (see Entry/message structure and versioning). +2. **`ThreadsDatabase`** -- table `threads`, in its own file at + `paths::data_dir().join("threads").join("threads.db")` + (`crates/agent/src/db.rs:438-440`), holding the actual message content as a + single compressed JSON blob per row. It is migrated by ad hoc, + best-effort `ALTER TABLE ... ADD COLUMN` statements run at startup with + errors swallowed if the column already exists + (`crates/agent/src/db.rs:456-471`) -- there is no ratchet, no drift + detection, and no migrations ledger for this database. + +Both databases are row-based mutable-document stores, not append-only logs. +`ThreadsDatabase::save_thread_sync` (`crates/agent/src/db.rs:489`) does a full +upsert of one JSON+zstd blob per thread on every save; there is no per-turn +append record anywhere in the durable content path. `ThreadMetadataDb`'s +`sidebar_threads` rows are likewise fully replaced on each save +(`crates/agent_ui/src/thread_metadata_store.rs:741`, `save_internal`, and the +domain's `save`/`delete`/`list` SQL at `:1370` onward). The closest thing to +an append-only structure anywhere in this system is the `migrations` ledger +table that `sqlez::Connection::migrate` writes to +(`crates/sqlez/src/migrations.rs:46-60`) -- but that records *schema* history, +not application data. + +Session identity, however, is drawn directly from the Agent Client Protocol: +`ThreadMetadata.session_id: Option` +(`crates/agent_ui/src/thread_metadata_store.rs:311`) and +`ThreadsDatabase::load_thread`/`delete_thread` are keyed by `acp::SessionId` +(`crates/agent/src/db.rs:607`, `:671`). So identity is ACP-shaped, but content +is not: see "Entry/message structure and versioning" below for exactly where +the wire-format boundary sits. The best-fitting conceptual model is +**session-as-row across two independently-keyed tables** -- a metadata row +keyed loosely (thread id, optionally linked to a session id) for listing, and +a content row keyed strictly by ACP session id for the full document -- with +neither table backed by an append log. + +## Keying and identity + +- The canonical metadata-store key is `ThreadId`, a newtype over `uuid::Uuid` + (`crates/agent_ui/src/thread_metadata_store.rs:34`, + `pub struct ThreadId(uuid::Uuid);`). This exists independently of ACP: a + thread can exist as a metadata row (e.g. a draft) before it has ever been + assigned a session. Both of Zed's identifiers are random, not + time-ordered: `ThreadId::new` is `Self(uuid::Uuid::new_v4())` + (`crates/agent_ui/src/thread_metadata_store.rs:36-39`), and the two sites + that mint a fresh `acp::SessionId` for Zed's own agent both wrap + `uuid::Uuid::new_v4()` (`crates/agent/src/thread.rs:1380`, + `crates/agent_ui/src/agent_panel.rs:3819`). Nothing in the key carries + ordering, which is why every listing path has to sort on a stored timestamp + column instead. +- `ThreadMetadata.session_id: Option` + (`:311`) links a metadata row to its ACP session once one exists; the + content store (`ThreadsDatabase`) is keyed purely by `acp::SessionId` + (`crates/agent/src/db.rs:607,671`). `ThreadMetadataStore` keeps an in-memory + reverse index, `threads_by_session: HashMap` + (`:505`), to translate between the two keyspaces. +- Comment evidence confirms drafts are the reason `session_id` is optional: + "Drafts may not have a session_id yet; only index by session" (grepped + comment text in `thread_metadata_store.rs`), consistent with the + `Option` field. +- Beyond thread id and session id, `ThreadMetadataStore` maintains two more + in-memory indexes for scoping: `threads_by_paths: HashMap>` and `threads_by_main_paths: HashMap>` (`:500-509`) -- i.e. listing is scoped per-project by + literal worktree path set, not global. `entries_for_path` + (`:621`) is the per-project listing entry point. +- `PathList` (`crates/util/src/path_list.rs`, Apache-2.0) is the path-identity + primitive: an ordered list of absolute paths whose `PartialEq`/`Hash` + compare only the *set* of paths (lexicographically sorted internally), + ignoring display order (`path_list.rs:27-39`). This means two projects with + the same folders opened in a different order are the same identity key; + renaming or moving a folder is a *different* key with no automatic + reconciliation baked into `PathList` itself -- reconciliation, where it + exists, is bolted on elsewhere (next point). +- Live relocation reconciliation happens at the workspace layer, not inside + the store: `crates/sidebar/src/sidebar.rs` subscribes to + `ProjectEvent::WorktreePathsChanged { old_worktree_paths }` + (`subscribe_to_workspace`, `sidebar.rs:992-1012`) and calls + `move_entry_paths` (`sidebar.rs:1062`), which in turn calls + `ThreadMetadataStore::change_worktree_paths` + (`crates/agent_ui/src/thread_metadata_store.rs:978`, via + `mutate_thread_paths` at `:1005`) to rewrite the path-list key for every + thread matching the *old* path set -- this is a broad, all-matching-threads + rewrite, not scoped to only the currently active/open threads. A narrower, + retained/active-threads-specific reconciliation also exists at + `crates/agent_ui/src/agent_panel.rs:4113` (`update_thread_work_dirs`). Both + paths only fire while Zed is running and the affected project is open in a + workspace at the moment of the rename/move; **we could not find any code + path that reconciles a worktree rename that happened while Zed was not + running** -- this is listed under Open Questions rather than asserted as a + gap, since we did not find positive evidence either way for an + offline-reconciliation attempt (e.g. a path-existence check at load time). +- Remote/multi-host identity is layered independently of path identity. + `RemoteConnectionOptions` (`crates/remote/src/remote_client.rs:1320-1327`) + is an enum over `Ssh`/`Wsl`/`Docker` (plus test-only `Mock`) variants + carrying full connection detail (including runtime-only fields like SSH + passwords or Docker env overrides). Matching a live connection against + persisted thread metadata does **not** use `RemoteConnectionOptions` + equality directly; it goes through `RemoteConnectionIdentity` + (`crates/remote/src/remote_identity.rs:10-27`), a normalized projection + (host/username/port for SSH, distro/user for WSL, container id/name/user for + Docker) with an explicit doc comment: "so runtime-only fields like SSH + nicknames or Docker environment overrides do not affect matching" + (`remote_identity.rs:6-8`). `same_remote_connection_identity` + (`:87-98`) is the comparison entry point, and its own test suite + (`:107-198`) confirms password/nickname/upload-flag changes do not break + identity while host/port/username changes do. +- Session ids: we did not find the exact minting call site for + `acp::SessionId` inside this codebase within our reading budget (Zed is + the ACP host generating ids for its own built-in agent, not just a client + consuming ids from an external one) -- flagged under Open Questions rather + than guessed. + +## The store interface + +There is no pluggable store trait for agent threads (unlike, say, a +`LanguageModel` provider trait elsewhere in Zed) -- the store is two internal +modules with ad hoc call sites. Reconstructed operation contract, split by +which of the two databases each call touches: + +**`ThreadMetadataStore`** (`crates/agent_ui/src/thread_metadata_store.rs`, +wraps `ThreadMetadataDb`): + +| Operation | Signature / entry point | Notes | +| --- | --- | --- | +| List (scoped) | `entries_for_path<'a>(...)` (`:621`) | In-memory, filtered by `PathList` key; no DB hit per call | +| Reload (bootstrap) | `fn reload(&mut self, cx) -> Shared>` (`:657`) | Full-table load into the in-memory `threads`/`threads_by_*` maps | +| Save (upsert) | `fn save_internal(&mut self, metadata: ThreadMetadata)` (`:741`) | Enqueues onto `pending_thread_ops_tx`, not a direct write | +| Archive / unarchive | `fn archive(...)` (`:858`), `fn unarchive(&mut self, thread_id, cx)` (`:873`) | Archival is a metadata-store concept tied to `ArchivedGitWorktree` | +| Relocate | `fn change_worktree_paths(...)` (`:978`), `fn mutate_thread_paths(...)` (`:1005`) | Broad rewrite across all matching threads | +| Delete | `fn delete(&mut self, thread_id, cx)` (`:1140`); `fn delete_all(...)` (`:1175`) | Enqueued the same way as save | +| Draft matching | `fn unarchived_draft_ids_matching(...)` (`:1165`) | For dedup/lookup of not-yet-sessioned drafts | +| Conversation event intake | `fn handle_conversation_event(...)` (`:1265`) | Where live thread events (title changes, etc.) become metadata writes | + +Writes to `ThreadMetadataDb` are **not synchronous**: `save`/`delete` push a +`DbOperation::Upsert`/`Delete` (`:513-517`) onto an `async_channel`, consumed +by a background task (`_db_operations_task`, `:509`) that de-duplicates +pending operations per thread id before hitting SQLite -- an eventual-write, +coalesced/debounced design, not one write per call. + +**`ThreadsDatabase`** (`crates/agent/src/db.rs:392`, wraps a single +`Arc>`, `:394` -- one physical connection, no pool): + +| Operation | Signature / entry point | Notes | +| --- | --- | --- | +| Save (full overwrite) | `fn save_thread_sync(...)` (`:489`) | Full JSON+zstd blob upsert; called from async `Task` wrappers | +| List | `fn list_threads(&self) -> Task>>` (`:564`) | Full-table scan, no pagination | +| Load | `fn load_thread(&self, id: acp::SessionId) -> Task>>` (`:607`) | Single-row fetch + decompress + version-sniffed deserialize | +| Delete (cascading) | `fn delete_thread(&self, id: acp::SessionId) -> Task>` (`:671`) | Stack-based transitive walk over `parent_id` finds and deletes all transitive subagent children too | +| Delete all | `fn delete_threads(&self) -> Task>` (`:724`) | Wipes the whole table | + +`ThreadStore` (`crates/agent/src/thread_store.rs`) is a thin GPUI-entity +wrapper over `ThreadsDatabase` that additionally filters subagent threads out +of the main listing: `if thread.parent_session_id.is_some() { continue; }` +inside `entries()` (`thread_store.rs:115,131`). + +## Write and append path (ordering, durability, concurrency, delivery) + +- **Trigger**: writes to `ThreadsDatabase` are reactive, not batched by turn. + `crates/agent/src/agent.rs:820` registers `cx.observe(&thread_handle, ...)` + on every `Thread` entity, and that observer calls `save_thread` + (`crates/agent/src/agent.rs:1736`) on essentially every GPUI change notification -- i.e. any + mutation to the in-memory `Thread` (new message, tool-call update, title + change) can trigger a full-document re-save, not just message-boundary + commits. +- **Ordering / durability**: there is no positional-append guarantee to + reason about because the unit of write is "the whole document," not a line + or row per turn. Durability is whatever a single SQLite `UPDATE`/`INSERT` + gives you under `PRAGMA journal_mode=WAL; PRAGMA busy_timeout=500;` + (`crates/db/src/db.rs:130-131`, the initialize pragmas run on every opened + connection). `cx.on_app_quit(Self::flush_threads_on_quit)` + (`crates/agent/src/agent.rs:575`, implementation at `crates/agent/src/agent.rs:1795`) registers a final flush + specifically to race-proof shutdown against the reactive/async save path -- + i.e. the authors were aware that reactive saves can lag behind quit and + added an explicit drain-on-quit safeguard. +- **Concurrency**: single-writer-per-process via one shared + `Arc>` (`crates/agent/src/db.rs:394`) -- no expected-version/CAS precondition + anywhere in `save_thread_sync`; it is a last-write-wins full overwrite. + Concurrent Zed processes against the same `db.sqlite`/`threads.db` are + handled only by SQLite's own WAL + busy-timeout, not by any + application-level optimistic-concurrency check. + `ThreadMetadataDb` writes are similarly last-write-wins per the coalescing + background task described above (last enqueued mutation per thread id wins + within a debounce window). +- **Delivery semantics**: best-effort, at-most-once-per-save-attempt from the + app's perspective -- there is no retry/backoff visible around + `save_thread_sync` or the metadata store's background op-processing task + beyond what `anyhow::Result` propagation and logging provide. There is no + idempotence key on individual "entries" because there are no discrete + entries at the storage layer -- the whole thread is the unit. + +## Read and resume path + +- Resume reads directly from `ThreadsDatabase::load_thread(id)` + (`crates/agent/src/db.rs:607`): a single-row SQLite `SELECT`, zstd-decompress, then a + version-sniffed `serde_json` deserialize (`DbThread::from_json`, `db.rs` + around the `VERSION`/`from_json` block near `:189` onward) -- full ordered + read of the entire message vector in one shot, not incremental and not + cursor-based. There is no entry-level pagination or transcript-size bound + found anywhere in the load path. +- Listing/metadata, by contrast, is **eagerly materialized and kept resident** + rather than loaded lazily per view: `ThreadMetadataStore::reload` + (`thread_metadata_store.rs:657`) does a full-table load into the in-memory + `threads: HashMap` and its path/session indexes at + startup, and `ThreadsDatabase::list_threads` (`crates/agent/src/db.rs:564`) similarly does a + full-table scan with no pagination. Sidebar listing/searching afterward is + pure in-memory work against this resident cache (see next section) -- no + incremental disk reads for browsing. +- Full message content is loaded lazily, on demand, only when a specific + thread is opened (`load_thread(id)`), not prefetched for every row the + sidebar shows. + +## Listing, summaries, and search + +- The sidebar list view is backed by `ThreadMetadata` rows (title, timestamps, + path scoping, archival flag), which is exactly the "metadata sidecar/summary + maintained at write time" pattern -- `ThreadMetadataDb`/`sidebar_threads` is + a read model, denormalized away from the full message content that lives in + `ThreadsDatabase`. +- Filtering/search over the sidebar list is **in-memory fuzzy string + matching**, not a separate indexed subsystem: `crates/sidebar/src/sidebar.rs` + calls `fuzzy_match_positions(&query, ...)` against already-loaded thread + titles (and terminal-session titles) at multiple call sites (grepped hits + at `sidebar.rs:1851,1860,1867,1885,1895`). We did not find any FTS table, + vector index, or external search service anywhere in `agent_ui`, `agent`, + or `sidebar` for cross-thread content search -- search appears to be + title-only, over the resident `ThreadMetadata` cache, with no persisted + index to bootstrap or keep consistent. We treat this as a well-supported + but not fully exhaustive finding (see Open Questions): we grepped the + relevant files for FTS/fuzzy/index keywords rather than reading every + sidebar/search file end-to-end. +- Cross-listing at scale: because both `list_threads` and metadata `reload` + are full-table scans loaded once into memory and then diffed/patched by the + background op-processing task, there is no stated cost number (no + benchmark or scale comment found), unlike grok-build's explicit ~12K-session + full-scan pain point. + +## Entry/message structure and versioning + +- The durable content type is `DbThread` + (`crates/agent/src/db.rs`, struct beginning near `:54`): `title: + SharedString`, `messages: Vec>`, `updated_at: DateTime`, + plus a long tail of `#[serde(default)]` optional fields -- `detailed_summary`, + `initial_project_snapshot`, `cumulative_token_usage`, + `request_token_usage: HashMap`, `model`, `profile`, `subagent_context: + Option`, `speed`, `thinking_enabled`, + `thinking_effort`, `draft_prompt: Option>`, + `ui_scroll_position`, `sandboxed_terminal_temp_dir`, `sandbox_grants`. The + additive-`#[serde(default)]` pattern on nearly every field after the core + three is the schema-evolution mechanism for this table: new fields default + in for old rows rather than requiring a migration. +- `DbThread::VERSION = "0.3.0"` (`crates/agent/src/db.rs:189`) is checked explicitly in + `from_json`: it matches on the JSON `"version"` field and, for any value + other than the current constant, calls `Self::upgrade_from_agent_1(...)` + against `crate::legacy_thread::SerializedThread` -- a one-way upgrade path + from an older, structurally different schema (file not read in full; the + call site alone confirms a legacy-format-sniffing upgrade exists). + Structurally this is version-tagged additive evolution plus a one-shot + legacy-format bridge, not a numbered migration ratchet -- quite different + from the `sqlez::Domain` mechanism described next. +- The **metadata table**, by contrast, evolves through `sqlez`'s strict + migration ratchet. `Connection::migrate` + (`crates/sqlez/src/migrations.rs:37-104`) stores every applied migration's + formatted SQL text in a `migrations (domain, step, migration)` table + (`:45-50`) and, on every subsequent boot, re-diffs the compiled-in + `Domain::MIGRATIONS` array against what's stored: if step *n*'s text + matches, it's skipped (already applied); if it differs, the connection call + panics (`anyhow::bail!`) unless `Domain::should_allow_migration_change` + explicitly opts in for that step (`domain.rs:7-9`, `crates/sqlez/src/migrations.rs:66-89`). + `ThreadMetadataDb`'s own `impl Domain` (`thread_metadata_store.rs:1370-1373`) + does not override `should_allow_migration_change`, so it uses the strict + default (`false`) -- **any edit to an already-shipped migration string is a + hard startup failure**, not a silent skip. This is confirmed by the crate's + own test suite (`sqlez/src/migrations.rs:311-346`, + `changed_migration_fails`). Practically: an **old Zed binary opening a + newer database** will simply not have run the newer migrations (they don't + exist in its compiled `MIGRATIONS` array), so it operates against a schema + ahead of what it expects -- we did not find explicit downgrade-safety + handling for this case and list it under Open Questions. A **new Zed binary + opening an older database** runs exactly the not-yet-applied migration + steps in order, ratcheting the schema forward one time only; this direction + is squarely what the mechanism is built for. +- Backfill migrations layered on top of the schema ratchet use one-shot KVP + guards rather than schema versioning: `THREAD_REMOTE_CONNECTION_MIGRATION_KEY` + and `THREAD_ID_MIGRATION_KEY` + (`thread_metadata_store.rs:60-61`), read/written via `read_kvp`/`write_kvp` + (`:203,262,280,294`) against `KeyValueStore`, which is itself a + `sqlez::Domain` (`crates/db/src/kvp.rs:20-21`) sharing the very same + `db.sqlite` file. This is a distinct mechanism from the schema-text ratchet: + it guards a one-time *data backfill* (e.g. populating a newly-added remote + identity column from existing rows), not a `CREATE TABLE`/`ALTER TABLE` + step. +- Message structure inside `DbThread.messages`: the persisted `Message` enum + (`crates/agent/src/thread.rs`) has `User`/`Agent`/`Resume`/`Compaction` + variants (evidenced throughout, e.g. match arms at `thread.rs:2379,3966,4809`). + `UserMessage`/`AgentMessage` and their content enums + (`UserMessageContent`/`AgentMessageContent`) are Zed's **own internal** + representation -- not literally serialized ACP wire types. The conversion + to/from the ACP wire schema happens at two explicit boundary functions: + `UserMessageContent::from_content_block(value: acp::ContentBlock, ...)` + (`thread.rs:6717`) and `impl From for acp::ContentBlock` + (`thread.rs:6773`). So: **session identity is literally ACP** (`acp::SessionId` + used as the storage key throughout both databases), but **persisted message + content is Zed's internal model**, converted at these two named functions -- + the stored thread is not a serialized ACP session. + +## Compaction and history management + +- Compaction is a **store-visible, in-place marker**, not a destructive + rewrite and not an external snapshot file. `Thread::compact` + (`thread.rs:2526`) and the automatic path both eventually call + `stream_compaction` (`thread.rs:3135`), which on success inserts + `Arc::new(Message::Compaction(CompactionInfo::Summary(summary.into())))` + directly into `self.messages` (`thread.rs:3216-3234`): for the automatic + case it is `messages.insert(insertion_ix, compaction)`, for the manual case + it is appended after a marker `UserMessage` carrying the triggering + `ClientUserMessageId`. Prior messages are **not removed** from the vector -- + the full raw history remains in `self.messages` (and therefore in the next + full-document save to `ThreadsDatabase`). +- The model-visible view shrinks only at *request-building* time, not at + storage time: `latest_compaction_message_ix_before` + (`thread.rs:4844-4847`, `rposition` over `Message::Compaction` variants) + finds the most recent compaction marker before a given point, and the + request-assembly helper around it (`thread.rs:4820-4839`) skips messages + before that marker except for a small retained-user-messages budget + (`retained_user_request_messages_before`, + governed by `COMPACTION_RETAINED_USER_MESSAGES_BYTE_BUDGET`). So: **the + durable record keeps everything; only the LLM-facing request view is + truncated at replay time**, evaluated fresh from the marker position on + every turn rather than persisted as a separate compacted artifact. +- Resume crossing a compaction boundary therefore requires no special + handling: `load_thread` loads the full `messages` vector including every + `Message::Compaction` marker ever inserted, and the same + marker-position-scan logic re-derives the model-visible view on the next + turn. +- This is a clean point of contrast with `truncate()` (next section), which + *is* destructive -- compaction leaves everything in place; truncate removes + it. + +## Rewind, checkpoints, and fork + +- **Rewind/truncate is destructive, not an appended marker.** `Thread::truncate` + (`thread.rs:2359-2383`) finds the position of a target user message by id + and then does `for message in self.messages.drain(position..)` -- a genuine + in-memory `Vec::drain`, permanently discarding every message from that + point forward (net of per-message token-usage bookkeeping cleanup). Because + every subsequent save is a full-document overwrite (`save_thread_sync`), + the very next save after a truncate **permanently erases** the truncated + tail from `threads.db` -- there is no tombstone, no soft-delete, and no + server-side ability to "un-rewind" once a save has landed. +- **File-state checkpoints do not survive a restart.** The client-rendering + type `acp_thread::UserMessage` carries `checkpoint: Option` + (`crates/acp_thread/src/acp_thread.rs:294-307`), where `Checkpoint` wraps a + `GitStoreCheckpoint` (`project::git_store::GitStoreCheckpoint`, + `crates/project/src/git_store.rs:321`, itself ultimately a + `GitRepositoryCheckpoint { commit_sha: Oid }`, + `crates/git/src/repository.rs:1278`). This `acp_thread::UserMessage` type + is the **ephemeral, in-memory client-side rendering layer** -- we found no + corresponding `checkpoint` field on the durable `agent::thread::UserMessage` + / `Message::User` type that gets serialized into `DbThread`. Checkpoints + are created/compared/restored via `GitStore::checkpoint`/`restore_checkpoint`/ + `compare_checkpoints` (`crates/project/src/git_store.rs:1880,1901,1928`, with local vs. + remote-RPC dispatch, and lower-level git-backend equivalents at + `crates/project/src/git_store.rs:9049,9072,9197`), all operating on live git repository state + (a commit sha of a WIP stash-like commit), not on any row in either + SQLite database. So: rewinding file state to a checkpoint works within a + live session, but the checkpoint pointer itself is not persisted -- reopen + the thread after a restart and the file-state rewind capability for past + turns is gone even though the message text remains. +- **There is no first-class fork.** The closest equivalent is a manual + clipboard copy/paste: `copy_thread_to_clipboard` + (`crates/agent_ui/src/agent_panel.rs:3717`) serializes the current + `DbThread` into a `SharedThread` (`crates/agent/src/db.rs:131-141`, `VERSION = "1.0.0"`, + zstd+base64-encoded JSON via `to_bytes`/`from_bytes`), and + `load_thread_from_clipboard` (`agent_panel.rs:3777`) reverses this via + `SharedThread::to_db_thread` (`db.rs`, the `to_db_thread` impl). Critically, + `to_db_thread` explicitly resets `subagent_context: None` and every other + identity-adjacent field to its default, and only prefixes the title with a + link emoji (`format!("🔗 {}", self.title)`) -- **no parent/lineage pointer, + shared-prefix reference, or origin session id is recorded anywhere**. The + pasted thread gets a brand-new `acp::SessionId` (minted wherever a new + thread is normally created) with zero durable connection to its origin. + This is explicitly not shared-prefix forking or copy-plus-lineage; it is + copy-and-forget. + +## Subagents and nested sessions + +- A subagent is a **first-class sibling row** in `ThreadsDatabase`, not + nested inside the parent's `messages` vector. `SubagentContext { + parent_thread_id: acp::SessionId, depth: u8 }` + (`thread.rs:143-149`) is attached via `DbThread.subagent_context: + Option` (`db.rs`, field list above), and + `Thread::new_subagent` (`thread.rs:1299`) is the constructor call site. +- Nesting is bounded to one level: `MAX_SUBAGENT_DEPTH: u8 = 1` + (`thread.rs:77`), checked at `thread.rs:2169` (`if self.depth() < + MAX_SUBAGENT_DEPTH`) before allowing a further spawn -- so a subagent cannot + itself spawn a grandchild subagent. +- Subagent threads are hidden from the normal sidebar listing: + `ThreadStore::entries()` explicitly filters out any row whose + `parent_session_id.is_some()` (`crates/agent/src/thread_store.rs:115,131`). + They are real rows with their own full transcript (isolated, not merged + into the parent's), just excluded from the top-level list view. +- Cascade on parent delete is genuine, not orphaning: + `ThreadsDatabase::delete_thread` (`crates/agent/src/db.rs:671-716`) does a stack-based + transitive walk over `parent_id` (`SELECT id FROM threads WHERE parent_id = + ?`, looped via a `frontier` vector popped from the back, so depth-first in + practice, collecting into `ids_to_delete`) and deletes every + transitively-found child in the same locked-connection block as the parent + itself. This is a clear point of contrast with stores that orphan child + sessions on parent deletion. Note: the whole walk-and-delete sequence runs + under one held `Mutex` guard but we did not find it wrapped in + an explicit SQL `BEGIN`/`COMMIT` -- see Open Questions for the crash-safety + implication. +- We found no code handling parent rewind/crash propagating to a still-live + subagent (e.g. cancellation) -- only the delete-cascade path was confirmed; + this is listed under Open Questions rather than asserted as absent. + +## Retention, deletion, and multi-host + +- **Retention/TTL**: no lifecycle policy or scheduled-cleanup mechanism was + found for ordinary threads -- deletion is user-driven (sidebar delete + action) or explicit archival, not time-based expiry. The one retention-like + mechanism present is tied to **git-worktree archival**, not thread age: + `ArchivedGitWorktree` (`thread_metadata_store.rs:457-470`) records + `worktree_path`, `main_repo_path`, and WIP commit hashes + (`staged_commit_hash`/`unstaged_commit_hash`/`original_commit_hash`, per + the struct's continuation) so that archiving a thread can also archive (and + later restore) the git worktree it was operating in -- a space-reclamation + feature bound to thread archival, not an independent TTL sweep. +- **Deletion cascade**: `ThreadMetadataStore::delete`/`delete_all` + (`thread_metadata_store.rs:1140,1175`) removes the metadata row (async, + through the same debounced op-channel as saves); `ThreadsDatabase::delete_thread` + (`crates/agent/src/db.rs:671`) removes the content row plus all cascaded subagent children + plus any `sandboxed_terminal_temp_dir` associated with a deleted row + (cleanup call at `db.rs` following the delete loop). These are two + separate delete calls against two separate databases -- we found no single + transactional operation spanning both `db.sqlite` and `threads.db`, so a + crash between the two delete calls could leave one store's row present + without its counterpart (flagged under Open Questions). +- **Multi-host**: thread data is **local to the machine running the Zed + client process** -- we found no remote-writeback path for agent thread + content or metadata. `RemoteConnectionIdentity` + (`remote/src/remote_identity.rs`) is the only multi-host-aware concept in + this subsystem, and it exists purely to *scope which locally-stored threads + a given remote connection's project should show* (matching normalized host + identity against `ThreadMetadata`'s remote-connection field), not to + replicate or fetch thread data from the remote host itself. In other + words: SSH/WSL/Docker remote projects still store their agent threads on + the **local** Zed client's SQLite files, keyed in part by which remote + identity the project was opened under. +- **Release-channel isolation**: each release channel (Stable/Preview/ + Nightly/Dev) gets a wholly separate `db.sqlite` at + `{db_dir}/0-{scope_name}/db.sqlite` (`db_path`, `crates/db/src/db.rs:164-167`, + `scope_name` trait methods at `:144-157`) -- so "multi-host" inside a single + machine also includes "multi release-channel," each with an independent + copy of both databases. +- **Stateless mode**: if `ZED_STATELESS` is set, `open_db` + (`crates/db/src/db.rs:174-180`) skips the on-disk path entirely and falls back to an + in-memory-only connection -- thread data becomes fully ephemeral, + process-lifetime-only, for that run. +- **Crash/concurrency handling**: `PRAGMA journal_mode=WAL; PRAGMA + busy_timeout=500;` (`crates/db/src/db.rs:130-131`) plus a fallback path, + `open_fallback_db` (`crates/db/src/db.rs:215-225`), reached on either of two + triggers from `open_db` (`crates/db/src/db.rs:174-203`): `ZED_STATELESS` is + set, or `open_main_db` returns `None` because the parent directory could not + be created or the file could not be opened, which also sets the global + `ALL_FILE_DB_FAILED` flag. The fallback opens an in-memory connection named + `FALLBACK_MEMORY_DB` behind a `log::warn!`, and `.expect`s on failure, so a + broken migration or initialization query panics rather than degrading + further. The practical consequence for session storage is that an + unopenable database does not stop the editor: threads are written to a + memory database and silently lost at process exit. + +## Interop with foreign session stores + +- Zed's agent thread stores do not read any *other* product's session store + format (no Claude Code, Codex, Cursor, etc. discovery/import code found in + `agent`, `agent_ui`, or `acp_thread`). The dedicated ACP-corpus research + already establishes Zed's role as an ACP *client* that spawns and talks to + external agent processes over the protocol + ([../../../acp/products/zed.md](../../../acp/products/zed.md)); that is a + live-protocol integration, not a session-store import, and it is out of + scope for the storage question this dossier answers. +- The one real interop feature is **self-interop across release channels**: + `channels_with_threads` (`crates/agent_ui/src/thread_import.rs:66`) opens a + raw `sqlez::connection::Connection` directly against another release + channel's `db.sqlite` file to detect whether it has thread rows, and + `import_threads_from_other_channels`/`_in` + (`thread_import.rs:891,896`) plus + `thread_metadata_store::list_thread_metadata_from_connection` + (referenced at `thread_import.rs:955`) read that foreign channel's metadata + read-only for an import UI flow (`AcpThreadImportOnboarding`/ + `CrossChannelImportOnboarding`, per file structure). This is Zed importing + its **own** prior data across Stable/Preview/Nightly/Dev boundaries, not a + foreign-product import. + +## What this implies for our Session Store (our inference) + +- Zed's split -- a strictly-migrated metadata table for listing versus an + ad-hoc-migrated content table for the actual document -- is a real-world + example of two different reliability bars applied to two parts of the same + logical "session": the part that must never silently drift (schema + identity/structure) got a text-compared ratchet with a hard-failure default; + the part that changes shape often (message content, feature flags on the + thread) got additive `#[serde(default)]` fields and swallowed-error + `ALTER TABLE`. For our event-sourced Session Store, this argues for the + same asymmetry rather than one migration story for everything: the event + schema (envelope, ordering, aggregate/stream identity) deserves a strict + ratchet; the payload/data-carrying fields on individual event types can + reasonably tolerate additive evolution. +- Zed is a mutable-document store dressed in ACP's identity vocabulary, not + an event-sourced system: full-document overwrite on every reactive change, + no expected-version precondition on writes, no append-only backing log for + content. Its compaction design is the interesting counter-example, though -- + compaction is implemented as an **in-place marker plus a scan-from-marker + replay rule** even inside a document-store world, which is functionally + identical to how our decider stack would fold from a snapshot/marker event + forward. That validates marker-based compaction as a pattern independent of + whether the underlying store is log-based or document-based. +- Truncate/rewind being genuinely destructive (`Vec::drain` followed by full + overwrite) is a caution, not a pattern to copy: our event-sourced design + should keep rewind as an appended fact (a recorded "rewound to X" event) + precisely so that undo-of-undo and audit remain possible -- Zed's design + shows what is lost when rewind is implemented as in-place mutation instead. +- Zed's subagent model (bounded to one level, first-class sibling row, + genuine cascade-delete on parent removal, hidden from the top-level list by + a `parent_session_id` filter rather than by physical separation) is a clean + reference point for ADR 0031/0035's child-session direction: it argues for + explicit parent/child linkage as a first-class fact plus list-time + filtering, and for treating cascade-delete as a deliberate policy decision + (Zed chose "delete descendants," not "orphan them" -- our design should make + this choice explicitly rather than by omission). +- The complete absence of any content search index (title-only in-memory + fuzzy match) is a useful negative data point: a metadata/read-model table + by itself does not give you real search, and any product wanting + cross-thread content search needs a deliberately-built and + deliberately-kept-consistent index (FTS or otherwise) -- Zed simply hasn't + built one for this feature yet. + +## Open questions + +- What happens when an **old** Zed binary opens a database that a **newer** + Zed binary has already migrated forward? The ratchet mechanism + (`sqlez::migrations.rs`) only checks the migrations the old binary knows + about against what's recorded, and skips or fails based on text match for + *those* steps -- we did not find explicit handling (e.g., a schema-version + ceiling check) for the case where the stored ledger contains *later* steps + the old binary's `MIGRATIONS` array doesn't include at all. +- Does any code path reconcile a worktree rename/move that happened while + Zed was **not running** (i.e. at next-launch load time, rather than via + the live `WorktreePathsChanged` event)? We found the live-event + reconciliation path (`sidebar.rs::move_entry_paths`, + `agent_panel.rs::update_thread_work_dirs`) but no evidence of an + offline/startup reconciliation check. +- Is the metadata-delete and content-delete pair + (`ThreadMetadataStore::delete` / `ThreadsDatabase::delete_thread`) ever + made atomic across the two separate SQLite files, or is a crash between + the two calls an accepted (if rare) inconsistency window? We found no + cross-database transaction. +- Is `ThreadsDatabase::delete_thread`'s subagent cascade walk-and-delete + sequence protected by an explicit SQL transaction, or only by holding the + process-local `Mutex` for the duration? We saw the mutex guard + but no `BEGIN`/`COMMIT` in the read excerpt. +- We treated "no cross-thread content search index" as a well-supported + finding based on keyword greps across `sidebar.rs`, `thread_metadata_store.rs`, + and `thread_search_bar.rs`, but did not read every line of + `crates/agent_ui/src/conversation_view/thread_search_bar.rs` end-to-end -- + it's possible that file implements something beyond in-conversation + find-in-page that our search missed. +- We did not read `crates/agent/src/legacy_thread.rs` in full, so the exact + field-level shape of the pre-`0.3.0` `SerializedThread` schema (and + therefore precisely what `upgrade_from_agent_1` translates) is inferred + from the call site, not confirmed against the legacy struct definition + itself. +- We did not read `crates/agent_ui/src/threads_archive_view.rs` or + `crates/agent_ui/src/thread_worktree_archive.rs` in depth, so the exact + restore-flow mechanics for an `ArchivedGitWorktree` (how a restored + worktree is reattached, what happens if the main repo path no longer + exists beyond the doc-comment's stated failure mode) are not verified + beyond the `ArchivedGitWorktree` struct's own field-level doc comments. +- Whether any deployment actually relies on the `open_fallback_db` in-memory + path in practice, and therefore how often thread data is silently discarded + at process exit, is not observable from source. The trigger conditions are + confirmed; their real-world frequency is not. +- Whether the two independent `Uuid::new_v4()` mint sites for `acp::SessionId` + (`crates/agent/src/thread.rs:1380`, `crates/agent_ui/src/agent_panel.rs:3819`) + are meant to be the only two, or whether the second is a duplication of the + first, is not stated anywhere we could find. Both produce UUIDv4, so the + scheme is unambiguous even if the ownership of it is not. diff --git a/docs/research/session-store/products/zed/vs-session-events.md b/docs/research/session-store/products/zed/vs-session-events.md new file mode 100644 index 000000000..d4a86bd36 --- /dev/null +++ b/docs/research/session-store/products/zed/vs-session-events.md @@ -0,0 +1,696 @@ +# Zed compared to our session event catalog + +Part of Session Store Research. +Produced by running [RESEARCH_PROMPT_COMPARISON](../../RESEARCH_PROMPT_COMPARISON.md). +Stage-one dossier: [Zed](./index.md). +Compared against `proto/trogonai/session/sessions/v1alpha1/` and [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) on 2026-08-04. + +**Store maturity: 11/12** -- evolution scars 3/3 (`sqlez::Connection::migrate` +stores each migration's formatted SQL text and hard-fails on drift, +`crates/sqlez/src/migrations.rs:37-104`, with its own regression test +`changed_migration_fails`, `crates/sqlez/src/migrations.rs:311-346`; separately, +`DbThread::VERSION = "0.3.0"` is checked against the JSON `"version"` field and +falls back to `upgrade_from_agent_1` against `crate::legacy_thread::SerializedThread` +for any older value, `crates/agent/src/db.rs:189`, the dossier's [Entry/message structure and versioning](./index.md#entrymessage-structure-and-versioning) -- +two independent, format-version-carrying evolution mechanisms is strong +evidence by the axis's own bar, even though one of them has no ledger; see +"Resolving the maturity tension" below), operational age 2/3 (crash-safety +code exists -- `PRAGMA journal_mode=WAL; PRAGMA busy_timeout=500;`, +`crates/db/src/db.rs:130-131`; `cx.on_app_quit(Self::flush_threads_on_quit)`, +`crates/agent/src/agent.rs:575,1795`; an `open_fallback_db` crash-recovery path, +`crates/db/src/db.rs:215` -- but the dossier could not find a first-commit date for the store +or a filed corruption/growth/lock-contention issue to cite, so this is scored +on inferred failure-mode-driven design, not documented incidents), exposure +3/3 (vendor-shipped desktop editor with four isolated release channels -- +Stable/Preview/Nightly/Dev, each its own `db.sqlite`, `crates/db/src/db.rs:164-167` +-- plus first-class SSH/WSL/Docker remote-project handling via +`RemoteConnectionIdentity`, `crates/remote/src/remote_identity.rs:10-27`), +design independence 3/3 (the agent thread store -- `crates/agent`, `crates/agent_ui`, +`crates/sqlez` -- is original Zed engineering, not inherited from an upstream +fork; the dossier found no fork-parent code to diverge from). + +This score sits one point below OpenCode/T3 Code-class evidence (not +established in this document; see [synthesis.md](../../synthesis.md)) but is +the highest-scoring **document store** in the corpus by this rubric -- most of +its strength comes from evolution scars and exposure, not from being +event-sourced, which it explicitly is not (see below). Where Zed disagrees +with a higher-scoring event-sourced precedent (T3 Code, OpenCode), those +precedents are the default per the scoring rule; where Zed is the *only* +product in the corpus that actually cascade-deletes subagents (see +[the two gaps the industry has not closed](#the-two-gaps-the-industry-has-not-closed)), its 11/12 score is why that evidence +is weighted heavily here despite Zed not being a log-shaped store. + +### Resolving the maturity tension + +The task framing is right to flag a tension: Zed is the oldest codebase +studied, yet `ThreadsDatabase` -- the table holding actual message content -- +has no migration ledger at all, only best-effort `ALTER TABLE ... ADD COLUMN` +with **errors swallowed if the column already exists** +(`crates/agent/src/db.rs:456-471`). Averaging that against `ThreadMetadataDb`'s +strict ratchet into a single "evolution scars" number would erase the most +important fact in the dossier. The axis asks whether the store format changed +under load and carried its data forward -- it did, twice, by two deliberately +different mechanisms for two deliberately different reliability +requirements: metadata identity/structure (which must never silently drift) +got the hard-fail ratchet; message content and per-thread feature flags +(which change shape often) got additive `#[serde(default)]` fields and an +error-swallowing `ALTER TABLE`. That asymmetry *is* the evidence, not a +disqualifying inconsistency -- it is the strongest real-world precedent in +this corpus for treating a session's envelope/ordering schema and its +payload schema as two different reliability problems, which is exactly what +[ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 3 already does (`LEGACY_REQUIRED` presence plus `reserved` +retired field numbers on the envelope and event registration, versus purely +additive optional payload fields with no per-event version branch). See +[recommendation 2](#2-add-an-automated-drift-ratchet-for-the-sessionevent-envelope-and-oneof-registration) +for where our side of that asymmetry is currently a convention, not an +enforced contract, unlike Zed's ratchet. + +## The one structural difference everything else follows from + +Zed's agent thread store is a **reactive, whole-document, last-write-wins +overwrite**, not a log. `crates/agent/src/agent.rs:820` registers +`cx.observe(&thread_handle, ...)` on every `Thread` entity, and that observer +calls `save_thread` (`crates/agent/src/agent.rs:1736`) on **essentially every GPUI change +notification** -- a new message, a tool-call update, a title change, all +trigger the same path. `ThreadsDatabase::save_thread_sync` +(`crates/agent/src/db.rs:489`) does a full JSON+zstd blob upsert of the +*entire* `messages: Vec>` on every one of those saves; there is +no per-turn append record, no per-fact append record, and no expected-version +precondition anywhere in the write path (the dossier's [Write and append path](./index.md#write-and-append-path-ordering-durability-concurrency-delivery)). Our +catalog commits at **fact granularity**: `UserMessageRecorded`, +`ToolCallRequested`, `ToolCallCompleted`, and 38 further arms are separate +append-only events, each durable the instant it lands, each classified under +one of `NoStream`/`At`/`Any` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 2). + +This single fact is the root of every other divergence in this document, not +a coincidence alongside them: + +- **Rewind is destructive because the unit of write is the whole document.** + `Thread::truncate` (`thread.rs:2359-2383`) does + `for message in self.messages.drain(position..)` -- an in-memory `Vec::drain` + -- and the very next reactive save overwrites `threads.db` with that + shortened vector. There is nothing to append a marker *to* that would + survive the overwrite; the only way to "keep" a rewound tail would be to + never overwrite the document, which is not how this store works. Our + `SessionRewound.keep_through` (`proto/trogonai/session/sessions/v1alpha1/session_rewound.proto:18`) + is a fact appended *alongside* the untouched history, because history is + never overwritten in the first place ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 2/6). +- **Compaction survives only because its marker happens to live inside the + same document that gets overwritten, not because it is durable by design.** + `Message::Compaction(CompactionInfo::Summary(...))` is inserted into + `self.messages` (`thread.rs:3216-3234`) and is retained on every subsequent + save simply because nothing removes it from the vector before the next + overwrite. It is durable by omission, not by a contract that says "this + fact, once appended, is never edited." Our `Compacted` event + (`proto/trogonai/session/sessions/v1alpha1/compacted.proto:19`) is durable by + the same append-only guarantee every other event gets -- the two behave + identically in Zed today only because nothing has yet exercised the case + where compaction *should* be reverted, which truncate's existence shows is a + real user operation. +- **There is no expected-version precondition because there is nothing + partial to guard.** A whole-document overwrite has only one meaningful + concurrency question -- "did I have the latest version when I overwrote?" -- + and Zed answers it with SQLite's WAL mode plus a 500ms busy-timeout + (`crates/db/src/db.rs:130-131`), not a compare-and-swap. Our per-command + `WRITE_PRECONDITION` classification (`NoStream`/`At`/`Any`, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 2) + exists precisely because our unit of write is a single fact that can + conflict with another single fact, a problem a whole-document store does + not have in the same shape. +- **Resume is all-or-nothing because there is no cursor into a document.** + `ThreadsDatabase::load_thread` (`crates/agent/src/db.rs:607`) is a single-row `SELECT`, + zstd-decompress, then a full deserialize of the entire message vector in one + shot (the dossier's [Read and resume path](./index.md#read-and-resume-path)). Our aggregate resumes from the newest + snapshot and replays only the tail after it ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 8) because the + underlying representation is a sequence of facts with a position, not one + blob. + +Zed is, in other words, a genuinely mature **mutable-document store** wearing +ACP's identity vocabulary (see [Semantic mismatches](#semantic-mismatches)), +not an approximation of event-sourcing the way the JSONL-transcript products +in the wider corpus are. That makes it a cleaner contrast than fx's +turn-granular commit: fx at least closes a write once per turn; Zed closes +one once per *any* observable mutation of the whole thread. + +## Mapping + +| Zed | Ours | Verdict | +| --- | --- | --- | +| `ThreadMetadataDb`/`sidebar_threads` (title, timestamps, project paths, remote scoping, archival flag) | Fold of `SessionStarted`/`SessionRenamed`/`SessionArchived`/`SessionUnarchived`/`SessionHidden` into `SessionProjection` (ADR facet 8) | Ours (a rebuildable projection, not a separately-maintained row that can drift from the log) | +| `ThreadsDatabase`/`threads` (one full JSON+zstd blob per thread) | The `SessionEvent` stream itself, fact-per-event | Ours, decisively (see structural difference above) | +| `ThreadId` (newtype `uuid::Uuid`) + `ThreadMetadata.session_id: Option` + in-memory reverse index `threads_by_session` | `SessionId`, opaque, minted atomically at `CreateSession`'s `NoStream` batch; no pre-session identity exists | Semantic mismatch -- see below | +| `acp::SessionId` as storage key for `ThreadsDatabase` | `SessionId`, mapped to subject `session.sessions.events.` by a `StreamSubjectResolver` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 1) | Semantic mismatch -- see below | +| `DbThread.messages: Vec>` with `Message::{User,Agent,Resume,Compaction}` | `UserMessageRecorded`, `AssistantMessage{Started,Completed,Failed}`, `ToolCall{Requested,Started,Completed,Failed}` as separate append-only facts | Ours (fact granularity) | +| `Message::Compaction(CompactionInfo::Summary)`, retained in-vector, request view derived by `latest_compaction_message_ix_before` scan | `Compacted{summary_content, covers_from, covers_through: SessionOrdinal, trigger, usage}` | Equivalent design; validates [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 4 | +| `Thread::truncate` → `self.messages.drain(position..)`, destructive | `SessionRewound{keep_through: SessionOrdinal}` (inclusive), non-destructive | Ours, decisively | +| `SubagentContext{parent_thread_id, depth: u8}`, `MAX_SUBAGENT_DEPTH = 1`, sibling row, hidden by `parent_session_id.is_some()` filter | `ParentLinked{parent_session_id, parent_dispatched_at: SessionOrdinal, cascade_policy, operation_id}` + `DelegationDispatched` on the parent, list-time projection filter ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 6) | Ours, decisively for crash-safety and audit; trade-off on depth bound -- see [recommendation 3](#3-do-not-add-a-max_subagent_depth-analog-to-the-proto-schema) | +| `ThreadsDatabase::delete_thread`, stack-based transitive walk over `parent_id`, one mutex, no explicit `BEGIN`/`COMMIT` found | `[ParentTerminated, SessionCancelled]` atomic per-child batch via reconciler, transitive because `SessionCancelled` is itself a terminal marker ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 6) | Ours, decisively -- see [the two gaps the industry has not closed](#the-two-gaps-the-industry-has-not-closed) | +| Copy-to-clipboard "fork" (`copy_thread_to_clipboard`/`load_thread_from_clipboard`), `to_db_thread` resets `subagent_context: None`, no lineage field anywhere | `SessionForked{source_session_id, context_prefix_boundary: SessionOrdinal, reason}`, atomic `[SessionStarted, SessionForked]` batch | Ours, decisively | +| `acp_thread::UserMessage.checkpoint: Option` wrapping a `GitStoreCheckpoint` (git commit sha), client-side rendering type only, not persisted to either database | `Checkpoint{reference, checkpoint_type, digest, implementation_version, checkpoint_id, producing_execution_attempt_id, covers_through: SessionOrdinal, session_execution_plan_digest, capture_attestation_ref, capture_attestation_digest, effective_history_digest}`, embedded in `CheckpointProduced`/`ExecutionAttemptStarted.restored_checkpoint`, digest-verified and durable | Ours, decisively -- different "checkpoint" concepts, see [Semantic mismatches](#semantic-mismatches) | +| `sqlez::Domain` migration ratchet on `ThreadMetadataDb` (stored SQL text, hard-fail on drift) | `LEGACY_REQUIRED` field presence + `reserved` retired field numbers on the envelope/event registration (e.g. `execution_attempt_started.proto:29-30`, `reserved 7; reserved resume_cursor;`) | Trade-off, currently a convention on our side, not an enforced ratchet -- see [recommendation 2](#2-add-an-automated-drift-ratchet-for-the-sessionevent-envelope-and-oneof-registration) | +| `ThreadsDatabase`'s swallowed-error `ALTER TABLE ADD COLUMN`, `#[serde(default)]` fields, `DbThread::VERSION` sniffing plus `upgrade_from_agent_1` legacy bridge | Additive optional fields, no per-event version branch ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 3) | Ours is stricter at the boundary (validated, typed, rejected on malformed shape) but has not yet proven it tolerates an *old reader* against a *newer* log the way this axis implicitly tests -- see [recommendation 4](#4-add-forward-compatibility-regression-tests-for-additive-only-replay) | +| `PathList`, set-based path identity (`path_list.rs:27-39`), live-only relocation reconciliation (`sidebar.rs:992-1012`, `agent_panel.rs:4113`), admitted no offline-reconciliation path found | `WorkspaceRef{workspace_id, uri, revision}` on `SessionStarted.workspace`; `workspace_id` is "assigned by the platform and independent of location" (`workspace.proto:14`), binding immutable for session life | Ours, decisively -- identity is decoupled from location structurally, not reconciled after the fact; see [Open questions](#open-questions-for-the-adr) for the residual question this still leaves | +| `RemoteConnectionIdentity`, normalized SSH/WSL/Docker host/user/port matching, scopes which local threads a remote project shows | No equivalent; multi-host/multi-writer correctness is a substrate property (any JetStream replica may append) rather than a client-side scoping filter | Trade-off -- different problems, not comparable; see [Trade-offs](#trade-offs-not-gaps) | +| `ArchivedGitWorktree{worktree_path, main_repo_path, staged_commit_hash, unstaged_commit_hash, original_commit_hash}` | No equivalent; `SessionArchived`/`SessionUnarchived` are pure reversible listing-visibility facts | Gap, deliberate -- git-worktree space reclamation is a workspace-lifecycle concern, not a session-log concern; see [Open questions](#open-questions-for-the-adr) | +| Release-channel isolation (`{db_dir}/0-{scope_name}/db.sqlite`), `ZED_STATELESS` in-memory fallback | No equivalent concept | N/A -- a per-deployment/local-install concern, not a per-session one | +| `channels_with_threads`/`import_threads_from_other_channels`, cross-release-channel self-import | No equivalent | N/A -- Zed's answer to having four separate on-disk copies of its own store, a problem our single-deployment topology does not have | +| `fuzzy_match_positions` title-only in-memory search over the resident `ThreadMetadata` cache; no FTS/vector index found | "Any full-text or vector search subsystem is a separate, independently bootstrapped projection off the same log, out of scope here" ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 8) | Convergent gap -- neither side has built this yet | +| `DbThread.cumulative_token_usage`, `request_token_usage: HashMap` | `TokenUsage` on `CanonicalMessage.usage` and `Compacted.usage`, folded per read model | Ours -- no denormalized cumulative counter stored in the row to drift from the log | +| `DbThread.model`, `profile`, `speed`, `thinking_enabled`, `thinking_effort` as mutable per-thread fields, no change event found | `ModelSettings{max_output_tokens, temperature, top_p, thinking_budget_tokens, stop_sequences, raw_settings}` on `AssistantMessageStarted.settings`, per-completion | Ours -- a mid-session settings change is two adjacent facts, not a field overwrite the next document save silently carries | +| `DbThread.draft_prompt: Option>` (unsent draft text) | No equivalent | Gap, deliberate -- an unsent draft is not a "happened" fact; recording client-local unsent input in the durable log would leak view state into the aggregate | +| `DbThread.ui_scroll_position`, `sandboxed_terminal_temp_dir`, `sandbox_grants` | No equivalent | Gap, deliberate -- UI/view state and sandbox runtime plumbing, not domain facts of the run | + +## Semantic mismatches + +**"Session id" is a wire identity in Zed and a pure storage key in ours -- and +Zed pays for the conflation with a second keyspace.** `ThreadMetadata.session_id: +Option` (`crates/agent_ui/src/thread_metadata_store.rs:311`) +and `ThreadsDatabase::load_thread`/`delete_thread` +(`crates/agent/src/db.rs:607,671`) are keyed by `acp::SessionId` -- the literal +Agent Client Protocol wire type, not a storage-internal id Zed minted for its +own purposes. Content, by contrast, is Zed's own internal `Message`/ +`UserMessageContent`/`AgentMessageContent` types, converted to and from the ACP +wire schema only at two named boundary functions +(`UserMessageContent::from_content_block`, `thread.rs:6717`; `impl From +for acp::ContentBlock`, `thread.rs:6773`; the dossier's [Entry/message structure and versioning](./index.md#entrymessage-structure-and-versioning)). So identity +borrows a wire protocol's type directly as a database primary key, while +content gets a translation boundary; the asymmetry is exactly backwards from +where instability actually lives; a client-facing wire protocol's identity +type is exactly the kind of thing more likely to gain a v2 (the sibling ACP +corpus already documents `agent-client-protocol-schema` mid-migration to a +`v2.0.0-alpha.2` JSON Schema line, [docs/research/acp/products/zed.md:9](../../../acp/products/zed.md)), +while message content shape is comparatively more stable. + +Our `SessionId` is opaque and minted by us, independent of any wire protocol we +also happen to speak ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 1/2: "a `StreamSubjectResolver` maps the +opaque `SessionId` to the subject... `SessionId` is opaque and time-sortable by +construction... but its sort order is never load-bearing"). What opaque +identity buys that Zed's ACP-as-primary-key choice gives up: an ACP schema +version bump can never force a storage-key migration on us, because our +storage key was never the wire type in the first place. What Zed pays for the +conflation concretely: a second identity (`ThreadId`, a newtype `uuid::Uuid`, +`thread_metadata_store.rs:34`) has to exist *only* because a metadata row can +predate a session (a draft), plus an in-memory reverse index +`threads_by_session: HashMap` +(`thread_metadata_store.rs:505`) that must stay consistent with two +independently-keyed, independently-migrated on-disk tables -- a second +consistency surface our design does not have, because nothing in our catalog +exists before `SessionStarted` (there is no "pre-session" phase in the store; +[recommendation 1](#1-do-not-introduce-a-pre-session-draft-identity-or-second-keyspace) +makes this explicit as a rejection of a plausible future feature request). + +**"Checkpoint" names three unrelated things, two of them inside Zed itself.** +Zed's `Checkpoint` (wrapping `GitStoreCheckpoint`/`GitRepositoryCheckpoint{commit_sha: +Oid}`, `crates/project/src/git_store.rs:321`, `crates/git/src/repository.rs:1278`) +is a git commit sha of a WIP stash-like commit, used to rewind *file state* to +a point in a live session; it lives only on the ephemeral, in-memory +`acp_thread::UserMessage` client-rendering type and is never written to either +SQLite database (the dossier's [Rewind, checkpoints, and fork](./index.md#rewind-checkpoints-and-fork) -- "we found no corresponding +`checkpoint` field on the durable `agent::thread::UserMessage` / `Message::User` +type"). Our `Checkpoint` +(`proto/trogonai/session/sessions/v1alpha1/checkpoint.proto:17`) is a harness +recovery checkpoint: a digest-verified, out-of-line artifact reference with its +own `checkpoint_id`, `producing_execution_attempt_id`, and +`session_execution_plan_digest`, embedded durably in `CheckpointProduced` and +`ExecutionAttemptStarted.restored_checkpoint` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 3). These are not +approximately the same thing wearing different names -- Zed's checkpoint +restores workspace files for a UI undo action and vanishes on restart; ours +restores harness process state and is the one artifact [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md)'s "Four +records with separate authority" section goes out of its way to distinguish +from the event log, the aggregate snapshot, and a read-side checkpoint. A +reader who assumes "Zed has checkpoints too" implies durability would be +wrong twice over: Zed's is not durable, and even if it were, it answers a +different question than ours does. + +## What we should consider changing + +Ordered by how consequential it is to get wrong, not by implementation cost. + +### 1. Do not introduce a pre-session "draft" identity or second keyspace + +**The change (rejected):** do not add a metadata row, a "pending session," or +any identity that can exist before `SessionStarted` is appended. Keep +`SessionId` as the sole key from creation, per [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 2 ("opaque +addressing key") and the `CreateSession` command's `NoStream` precondition +(command matrix, [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) §"Command-by-command matrix": `CreateSession` reads +no state and requires the stream not exist). + +**Evidence anchor:** Zed (store maturity 11/12). +`ThreadMetadata.session_id: Option` +(`crates/agent_ui/src/thread_metadata_store.rs:311`), comment "Drafts may not +have a session_id yet; only index by session" (the dossier's [Keying and identity](./index.md#keying-and-identity)), and +the in-memory reverse index `threads_by_session: HashMap` (`:505`) it takes to keep the two keyspaces reconciled. + +**Blast radius:** breaking the decision, not the schema -- this is a +rejection of a plausible future ask (a product wants "compose a message +before a session exists" UX, exactly Zed's draft feature), not a proposal to +change today's proto. + +**Why it is not a good idea:** Zed's own dossier shows the cost directly -- +`ThreadId` exists *only* to key drafts, `ThreadMetadataStore` maintains a +`threads_by_session` map purely to translate between the two, and the store +has two independently-migrated tables (`sqlez` ratchet vs. ad hoc `ALTER +TABLE`) whose consistency the reverse index has to bridge at runtime. None of +this is a defect in Zed's design for its purpose (an editor needs to let a +user type before committing to an agent run); it is the cost of letting +"session" mean two different lifecycle stages. Our aggregate has exactly one +lifecycle stage: a stream that does not exist, and a stream that does. A +client wanting draft-composition UX can hold unsent text locally and call +`CreateSession` only once a first message is ready -- the store never needs to +model the in-between state. + +**What it costs to reject this:** none directly; the cost is opportunity -- +if a product team later wants Zed-style draft persistence (survive an app +restart with unsent text), that has to be solved above the session store +(client-local storage), not inside it. Recording that trade-off here so it is +not re-proposed without this evidence. + +### 2. Add an automated drift ratchet for the `SessionEvent` envelope and oneof registration + +**The change:** add a golden-file or generated-descriptor comparison test that +fails the build if an already-shipped `SessionEvent` oneof field number, +event-file `reserved` range, or envelope field (`Event.id`, timestamps, +correlation/causation headers) changes incompatibly -- mirroring `sqlez`'s +approach of storing each migration's exact text and diffing it on every boot, +but applied to our proto registration rather than SQL. This is additive +tooling around [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 3's existing rule ("Schema evolution is +additive... never a per-event version branch") and the `reserved` numbers +already present, e.g. `proto/trogonai/session/sessions/v1alpha1/execution_attempt_started.proto:29-30` +(`reserved 7; reserved resume_cursor;`). + +**Evidence anchor:** Zed (store maturity 11/12, and this is the axis where the +tension noted above cuts both ways). `sqlez::Connection::migrate` +(`crates/sqlez/src/migrations.rs:37-104`) stores every applied migration's +formatted SQL text in a `migrations` table and panics on any subsequent +mismatch unless the domain opts in via `should_allow_migration_change` +(`domain.rs:7-9`), confirmed by its own regression test +`changed_migration_fails` (`crates/sqlez/src/migrations.rs:311-346`). Contrast: +`ThreadsDatabase`'s `ALTER TABLE ... ADD COLUMN` runs with errors swallowed if +the column already exists (`crates/agent/src/db.rs:456-471`) and keeps no +ledger at all -- the exact failure mode a drift ratchet exists to catch never +gets caught there, it just silently no-ops or silently succeeds. + +**Blast radius:** additive -- new CI tooling and a golden descriptor snapshot, +no schema change. + +**Why it is a good idea:** decision 3's additive-only rule is currently +enforced by review discipline and `reserved` numbers a human has to remember +to add, exactly the situation `ThreadsDatabase` is in today (a convention with +no ledger). `ThreadMetadataDb`'s ratchet is the corpus's best evidence that +the alternative -- a machine-checked, hard-failing comparison against the +prior shipped shape -- is buildable and has already caught real drift in a +shipped product. We should be on that side of the asymmetry the maturity +tension surfaces, not the swallowed-`ALTER-TABLE` side. + +**What it costs beyond migration:** a golden descriptor file to maintain and +regenerate deliberately on every accepted additive change (a small, repeated +review step); a new CI failure mode to diagnose when it fires on an +unintentional break; no runtime cost, since this is a build-time check, not a +storage-boundary validator. + +### 3. Do not add a `MAX_SUBAGENT_DEPTH` analog to the proto schema + +**The change (rejected):** do not add a depth counter or a hard nesting limit +to `DelegationDispatched`, `ParentLinked`, or `CascadePolicy`. [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) +decision 6 already gives acyclicity by construction (a fresh +`child_session_id` every dispatch, `NoStream` rejects re-parenting an +existing stream) without needing to bound depth to get it. + +**Evidence anchor:** Zed (store maturity 11/12). +`MAX_SUBAGENT_DEPTH: u8 = 1` (`crates/agent/src/thread.rs:77`), checked at +`thread.rs:2169` (`if self.depth() < MAX_SUBAGENT_DEPTH`) before allowing a +further spawn. + +**Blast radius:** additive, if ever pursued -- but the recommendation is to +pursue this only as a command-authorization policy check (draft [ADR#0026](../../../../adr/0026-command-authorization-principal.md)'s +`CommandPrincipal`/`CommandAuthorizer`), never as a proto field, so there is no +schema blast radius at all for the rejected option. + +**Why it is not a good idea as a schema change:** acyclicity already prevents +the failure mode a depth bound is usually defending against (infinite +self-reference); an *unbounded but acyclic* tree is a resource/product policy +question (how much fan-out or nesting is acceptable for a given deployment or +tenant), not a store-correctness question, and different deployments may +reasonably want different bounds. Baking `max_depth = 1` into the event +schema the way Zed does would foreclose that per-deployment flexibility and +conflate a policy decision with a durable fact -- the exact anti-pattern +`CascadePolicy` (D6) already avoids by making cascade behavior data instead of +code. + +**What it costs to reject this:** an unbounded delegation chain is possible +today until a policy-layer bound exists; this is recorded as an +[open question for the ADR](#open-questions-for-the-adr) rather than silently +assumed away. + +### 4. Add forward-compatibility regression tests for additive-only replay + +**The change:** add tests asserting that an older reader of the codec/decoder +(one built against an earlier accepted set of optional fields) correctly +ignores unknown/newer optional fields on replay rather than failing closed, +for every event type under `validate_session_event` and the Session-owned +replay boundary ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 3). + +**Evidence anchor:** Zed (store maturity 11/12, this specific finding drawn +from its own unresolved Open Question, so it is thin evidence for the +*problem* rather than a solution -- flagged accordingly). The dossier +could not find explicit handling for "what happens when an **old** Zed binary +opens a database that a **newer** Zed binary has already migrated forward" -- +the ratchet only checks migrations the old binary's compiled `MIGRATIONS` +array knows about, with no ceiling check for later steps it does not know +about at all (dossier Open Questions, [Open questions](./index.md#open-questions)). + +**Blast radius:** additive -- test-only, no schema or validator change assuming +the current additive-only discipline already holds; the test's purpose is to +prove that assumption rather than change behavior. + +**Why it is a good idea:** decision 3's promise ("Schema evolution is +additive... never a per-event version branch") is currently unverified by an +automated test in the direction that actually matters operationally: an +older running service instance processing a stream a newer instance already +wrote to. Protobuf's wire format tolerates unknown fields for free, so this +may already work -- but "may already work" is exactly the gap Zed's own +unresolved question shows a mature, shipped store can still leave open for +years. Proving it with a fixture-based test (encode with a newer field set, +decode with an older generated-code snapshot, assert no rejection) turns an +assumption into a checked invariant. + +**What it costs beyond migration:** a small fixture corpus of "prior accepted +shape" golden messages per event type, regenerated deliberately whenever a +new optional field is accepted (a similar discipline to recommendation 2's +golden descriptor, and plausibly the same CI job). + +## Trade-offs, not gaps + +**Synchronous mutex-held cascade delete versus eventually-consistent saga +cascade.** Zed's `ThreadsDatabase::delete_thread` holds one +`Mutex` for the entire stack-based walk-and-delete of a parent and +all its transitive children (`crates/agent/src/db.rs:671-716`) -- cascade is immediate and, +modulo the missing explicit `BEGIN`/`COMMIT` (see +[the two gaps the industry has not closed](#the-two-gaps-the-industry-has-not-closed)), effectively atomic from the caller's +perspective. Our reconciler cascades via a `[ParentTerminated, SessionCancelled]` +atomic batch **per child**, discovered through a parent-to-children lineage +projection, taking "D sequential reconciler round-trips" for a chain of depth +D ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 6 Consequences). Zed buys immediacy and a single lock scope +at the cost of a single-process, single-database assumption that cannot +survive a cross-stream write (which does not exist on JetStream, and which +our own Alternatives Considered rejects for exactly that reason). We buy +crash-safety, multi-writer correctness, and an audit trail that survives +cascade (nothing is deleted, only marked `SessionCancelled`) at the cost of +latency and eventual -- not immediate -- consistency. Neither is wrong; they +are solving different constraint sets (single embedded SQLite file vs. a +distributed append-only log with no cross-subject transaction). + +**Hard depth cap versus acyclic-but-unbounded tree.** Covered in +[recommendation 3](#3-do-not-add-a-max_subagent_depth-analog-to-the-proto-schema) +as a rejected schema change, but it is also a genuine trade-off independent of +that recommendation: Zed's `MAX_SUBAGENT_DEPTH = 1` buys a predictable, +reviewable resource bound at the cost of rejecting legitimate deeper +collaboration patterns outright; our design buys flexibility (any depth, so +long as it is acyclic) at the cost of no built-in guard against runaway +fan-out, which is why it is called out as an open question rather than +silently accepted. + +**Ratchet-with-hard-fail versus reserved-numbers-by-convention.** `sqlez`'s +migration ratchet (`crates/sqlez/src/migrations.rs:37-104`) buys Zed a +hard-fail on any drift to `ThreadMetadataDb`'s schema, at the cost of a real +operational failure mode: an edited migration string is a startup crash for +every user, not a warning. Our envelope/oneof schema currently buys a softer +failure mode (a reviewer has to notice a `reserved` number should have been +added) at the cost of no automated enforcement yet -- which is not a settled +trade-off so much as an open gap, addressed by +[recommendation 2](#2-add-an-automated-drift-ratchet-for-the-sessionevent-envelope-and-oneof-registration). + +## What our design already does better + +**Rewind is an appended fact, not an in-memory `Vec::drain`.** +`SessionRewound.keep_through` (`proto/trogonai/session/sessions/v1alpha1/session_rewound.proto:18`, +a `SessionOrdinal`, inclusive) leaves every event on the stream; only the +model-visible fold changes. Zed's `Thread::truncate` +(`crates/agent/src/thread.rs:2359-2383`) does `self.messages.drain(position..)` +and the next reactive save permanently erases the tail from `threads.db` -- "no +tombstone, no soft-delete, and no server-side ability to un-rewind once a save +has landed" (the dossier's [Rewind, checkpoints, and fork](./index.md#rewind-checkpoints-and-fork)). This is the clearest, most concrete +win in the comparison: the same operation is recoverable-by-construction on +our side and permanently destructive on theirs. + +**Fork is atomic and carries real lineage; Zed's is copy-and-forget.** +`SessionForked{source_session_id, context_prefix_boundary, reason}` +(`proto/trogonai/session/sessions/v1alpha1/session_forked.proto:17`) is the +second event in an atomic `[SessionStarted, SessionForked]` batch under +`NoStream` ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 5). Zed's only fork-like feature is +`copy_thread_to_clipboard`/`load_thread_from_clipboard` +(`crates/agent_ui/src/agent_panel.rs:3717,3777`), whose `to_db_thread` +conversion explicitly resets `subagent_context: None` and every other +identity-adjacent field to default, mints a brand-new `acp::SessionId`, and +records "no parent/lineage pointer, shared-prefix reference, or origin session +id... anywhere" (the dossier's [Rewind, checkpoints, and fork](./index.md#rewind-checkpoints-and-fork)). A forked session in our catalog +can always answer "where did you come from"; a pasted thread in Zed cannot. + +**Harness recovery checkpoints are durable and digest-verified; Zed's file-state +checkpoint does not survive a restart.** Our `Checkpoint` +(`proto/trogonai/session/sessions/v1alpha1/checkpoint.proto:17`) is embedded in +`CheckpointProduced`/`ExecutionAttemptStarted.restored_checkpoint`, carries its +own `checkpoint_id`, `covers_through`, and `session_execution_plan_digest`, and +is admitted only after full digest/plan/attempt verification ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet +3). Zed's `acp_thread::UserMessage.checkpoint: Option` +(`crates/acp_thread/src/acp_thread.rs:294-307`) wraps a git commit sha and lives +only on an ephemeral client-rendering type never persisted to either database +-- "reopen the thread after a restart and the file-state rewind capability for +past turns is gone even though the message text remains" (dossier +[Rewind, checkpoints, and fork](./index.md#rewind-checkpoints-and-fork)). + +**Cascade is a crash-safe, two-fact saga; Zed's two-database delete has an +admitted inconsistency window.** `DelegationDetached`/`ParentDetached` +(`proto/trogonai/session/sessions/v1alpha1/delegation_detached.proto`, +`parent_detached.proto`) are joined by one durable `detach_operation_id`, each +its own invariant-bearing local fact, with idempotent reconciler repair on +either side ([ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 6). Zed's `ThreadMetadataStore::delete` and +`ThreadsDatabase::delete_thread` are "two separate delete calls against two +separate databases -- we found no single transactional operation spanning both +`db.sqlite` and `threads.db`, so a crash between the two delete calls could +leave one store's row present without its counterpart" (dossier +[Retention, deletion, and multi-host](./index.md#retention-deletion-and-multi-host), also listed under its own Open Questions). + +**Cascade policy is a recorded choice, not a hardcoded behavior.** +`CascadePolicy` (`proto/trogonai/session/sessions/v1alpha1/cascade_policy.proto:8`) +is a two-arm enum -- `CASCADE_ON_PARENT_TERMINAL` or `INDEPENDENT` -- chosen at +dispatch time and copied verbatim onto the child, so the answer to "what +happens to this child when its parent dies" is data on the log, auditable per +child. Zed's cascade is unconditional: every subagent is deleted when its +parent is (`crates/agent/src/db.rs:671-716`), a single hardcoded behavior with no equivalent to +`INDEPENDENT`. + +**Workspace identity is decoupled from location by construction, not +reconciled after the fact.** `WorkspaceRef.workspace_id` +(`proto/trogonai/session/sessions/v1alpha1/workspace.proto:14-15`) is "assigned +by the platform and independent of location," carried inline on +`SessionStarted` so "every session for this workspace" never requires decoding +plan bytes. Zed's `PathList` compares only the literal set of absolute paths +(`crates/util/src/path_list.rs:27-39`); a rename or move mints a new identity +key, and reconciliation is a live event subscription bolted on at the +workspace layer (`sidebar.rs:992-1012`) that the dossier could not confirm +fires for a rename that happened while Zed was not running ([Keying and identity](./index.md#keying-and-identity)). + +**Fact-granularity commit means no reactive whole-document rewrite +bottleneck and no lost in-flight turn.** Covered fully under +[the one structural difference](#the-one-structural-difference-everything-else-follows-from); +restated here because it is squarely a place our design is ahead, not merely +different -- Zed's own dossier independently concludes the same thing ("Zed is +a mutable-document store dressed in ACP's identity vocabulary... not an +event-sourced system," [What this implies for our Session Store](./index.md#what-this-implies-for-our-session-store-our-inference)). + +## What not to copy + +Zed is licensed **per-crate**. Per the dossier's front matter, every crate +cited in this document is `GPL-3.0-or-later` **except** `gpui`, `util` +(which is where `path_list.rs` lives), and `collections`, which are +`Apache-2.0`. Concretely: `crates/agent`, `crates/agent_ui`, `crates/db`, +`crates/sqlez`, `crates/remote`, `crates/project`, `crates/git`, and +`crates/acp_thread` are all `GPL-3.0-or-later`. Every pattern below that is +worth learning *from* as an architectural idea is not safe to copy as literal +code into our codebase, because the code text itself carries that license; +only `PathList`'s own implementation (`crates/util/src/path_list.rs`, +Apache-2.0) would be license-safe to port verbatim, and this document does not +recommend porting it (see the identity-decoupling point above -- our +`workspace_id` design is already ahead of `PathList`'s approach). + +- **Reactive whole-document overwrite on every observable mutation.** + `cx.observe(&thread_handle, ...)` → `save_thread` on essentially every GPUI + change notification (`crates/agent/src/agent.rs:820,1736`) is the direct + cause of destructive rewind and the absence of any OCC precondition. Do not + adopt "just re-save the whole aggregate on every change" as a pattern + anywhere in the platform, even for a small or simple aggregate -- it + silently forecloses partial resume and non-destructive undo the moment + anyone builds a feature (like rewind) that needs them. +- **Swallowed-error `ALTER TABLE ... ADD COLUMN` with no migration ledger.** + `crates/agent/src/db.rs:456-471` treats a failed column-add as + indistinguishable from "already applied." Silent schema drift is + undetectable by definition; this is the opposite of + [recommendation 2](#2-add-an-automated-drift-ratchet-for-the-sessionevent-envelope-and-oneof-registration)'s + direction and should never be the model for how our own additive evolution + is enforced. +- **Cross-store delete without a shared transaction or a joining saga id.** + `ThreadMetadataStore::delete`/`ThreadsDatabase::delete_thread` are two + independent calls against two independent SQLite files with no + cross-database transaction (the dossier's [Retention, deletion, and multi-host](./index.md#retention-deletion-and-multi-host)). If we ever have a + legitimate reason to split a session's data across two independently-owned + stores, the answer is [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) facet 6's two-fact saga pattern + (`DelegationDetached`/`ParentDetached` joined by `detach_operation_id`), not + an unguarded double-delete with an admitted inconsistency window. +- **Clipboard-copy "fork" with identity reset and zero lineage.** + `to_db_thread` resetting `subagent_context: None` and minting a disconnected + `acp::SessionId` with no origin reference (the dossier's [Rewind, checkpoints, and fork](./index.md#rewind-checkpoints-and-fork)) is not + a lightweight-fork pattern worth any version of adopting, even for a + deliberately minimal "duplicate this session" feature -- the missing + `source_session_id` is the entire value a fork provides over a plain copy. + +## The two gaps the industry has not closed + +### Subagent cascade + +Our position is already decided: [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 6 -- parent-first dispatch +with crash-safe repair, acyclicity by construction, rewind invalidation kept +distinct from terminal cascade, transitive cascade via a reconciler process +manager, and a two-fact detach saga joined by `detach_operation_id`. Zed's +evidence bears on that decision directly rather than leaving it unaddressed. + +**Zed validates transitive cascade-on-terminal as the right default.** +`ThreadsDatabase::delete_thread`'s stack-based walk over `parent_id` +(`crates/agent/src/db.rs:671-716`) genuinely finds and deletes every +transitive descendant, not just direct children -- "a clear point of contrast +with stores that orphan child sessions on parent deletion" (dossier +[Subagents and nested sessions](./index.md#subagents-and-nested-sessions)). This matters because Zed is the exception in the wider +corpus, not the rule: [synthesis.md](../../synthesis.md) convergence #7 records +that Codex CLI, Goose, OpenCode, and T3 Code all *orphan* subagents on parent +delete rather than cascading, and the one other product studied with an +apparent cascade guarantee, Cline, has it "only... one level deep, and only +from a root" -- its child-delete query "sits inside `if (!row.isSubagent)`," so +"deleting a session that is itself a subagent never looks for its own +children," making the guarantee "a property of how deep the graph happens to +get, not of the delete algorithm" +([Cline, Subagents and nested sessions](../cline/index.md#subagents-and-nested-sessions)). +Zed's walk has no such limitation -- it is genuinely recursive over `parent_id` +regardless of depth. Decision 6's choice to make cascade "transitive, because +`SessionCancelled` is itself a terminal marker the same reconciler reacts to" +is therefore not a hypothetical improvement over an unproven industry +practice; it is validated by the one product in the corpus that actually +built transitive cascade and chose the same behavior Zed did, independently. + +**Where decision 6 is already ahead of Zed's implementation of the same +choice.** Zed's cascade walk runs "under one held `Mutex` guard +but we did not find it wrapped in an explicit SQL `BEGIN`/`COMMIT`" (dossier +[Subagents and nested sessions](./index.md#subagents-and-nested-sessions), also an Open Question) -- a crash mid-walk could leave a +partially-deleted subtree with no recorded state to resume from, and because +delete is physical, there is nothing to retry against once some rows are +gone. Our reconciler's `[ParentTerminated, SessionCancelled]` batch per child +is individually atomic and idempotently retryable (the command matrix's +`ReconcileParentTerminal` row: "no-op if child already terminal"), so a crash +mid-cascade at any depth resumes cleanly rather than leaving a structurally +ambiguous partial state. Zed's cascade also permanently deletes; ours marks +`SessionCancelled` on a keep-forever log (decision 7), so the entire +collaboration tree remains auditable *after* cascade runs, which Zed's design +cannot offer once a delete has executed. + +**Where Zed has nothing corresponding to decision 6's rewind/termination +split.** The dossier explicitly "found no code handling parent rewind/crash +propagating to a still-live subagent (e.g. cancellation) -- only the +delete-cascade path was confirmed" ([Subagents and nested sessions](./index.md#subagents-and-nested-sessions), Open Questions). Zed -- +the one product in the corpus with a real cascade -- still has no analog to +`ParentHistoryInvalidated`/`SessionRewound.keep_through` invalidating a +child whose dispatch point no longer exists in the parent's history, while +the parent itself keeps running (not terminal). Decision 6's insistence that +"a rewound parent is not terminal and may keep running, [so] invalidating +such a child is not the same event as a terminal cascade" is therefore a +genuine capability gap Zed's cascade does not close, reinforcing that this +part of decision 6 is original design work, not industry-standard practice +restated. + +**Depth bound remains a refinement question, not a gap in decision 6.** +Decision 6 gives acyclicity, never a depth bound; Zed's hard +`MAX_SUBAGENT_DEPTH = 1` (`thread.rs:77`) is evidence that a real shipped +product found value in bounding depth, but as +[recommendation 3](#3-do-not-add-a-max_subagent_depth-analog-to-the-proto-schema) +argues, that bound belongs at a policy layer, not the event schema -- recorded +as an [open question](#open-questions-for-the-adr), not treated as a defect in +decision 6. + +### Retention on an unbounded log + +Our position is already decided: [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md) decision 7 -- keep-forever, +`SessionHidden` replacing `SessionDeleted` as an honest visibility tombstone, +`RedactionApplied` for read-time masking of specific event ids, `ArtifactErased` +for out-of-band artifact-byte destruction independent of event-log retention, +and aggregate snapshots that bound replay cost, not storage. + +Zed contributes little new evidence here, and the reason is itself +informative: a mutable-document store never actually faces the failure mode +decision 7 exists to solve. The dossier found "no lifecycle policy or +scheduled-cleanup mechanism... for ordinary threads -- deletion is user-driven +(sidebar delete action) or explicit archival, not time-based expiry" ([Retention, deletion, and multi-host](./index.md#retention-deletion-and-multi-host)). +The one retention-adjacent feature, `ArchivedGitWorktree` +(`thread_metadata_store.rs:457-470`), reclaims **git-worktree disk space** tied +to thread archival -- a workspace-lifecycle feature, not a session-log +retention story at all. Zed has no analog whatsoever to `SessionHidden`, +`RedactionApplied`, or `ArtifactErased`: its "archive" (`archive`/`unarchive`, +`thread_metadata_store.rs:858,873`) is fully reversible and carries none of +`SessionHidden`'s terminal-marker/cascade semantics (compare +`session_hidden.proto:13` -- a typed, terminal `SessionHiddenReason` that +still cascades per decision 6 -- against Zed's archive, which is closer in +shape to our own reversible `SessionArchived`/`SessionUnarchived`). Zed never +built partial masking of an otherwise-immutable record because its record is +never immutable in the first place -- a whole document is simply replaced or +deleted, which is a strictly easier problem than masking *part of* a +keep-forever fact stream while leaving the rest intact. + +This is not a criticism of Zed; it is the structural consequence of the "one +difference" identified above. It does mean this specific product cannot serve +as evidence either for or against decision 7's specific mechanisms +(`SessionHidden`/`RedactionApplied`/`ArtifactErased`) the way an actual +log-shaped store could -- Zed simply never had to design for this problem. +The one genuinely new datum it contributes is `ArchivedGitWorktree`'s +space-reclamation angle, which points at a real but different problem +(reclaiming space bound to a *workspace*, not a session log) that decision 7 +does not claim to cover and, per its scope, should not -- recorded as an +[open question](#open-questions-for-the-adr) rather than folded silently into +decision 7's boundary. + +## Open questions for the ADR + +- Should draft [ADR#0026](../../../../adr/0026-command-authorization-principal.md)'s `CommandAuthorizer` carry a configurable + maximum-delegation-depth policy check, layered above decision 6's + acyclicity-by-construction, given Zed's `MAX_SUBAGENT_DEPTH = 1` is real + shipped evidence that *some* products want a hard bound even though decision + 6 never claimed to need one for correctness? +- Is an automated drift ratchet for the `SessionEvent` envelope/oneof + registration (recommendation 2) worth building before `v1alpha1` promotes to + `v1`, given `sqlez`'s hard-fail ratchet is the corpus's best evidence that + such a mechanism is buildable and has caught real drift in a shipped + product, while our own additive-only rule is currently convention-enforced + only? +- Is workspace-identity resolution (mapping a stable `WorkspaceRef.workspace_id` + back to its possibly-relocated `uri`) ever a session-store concern, or is it + entirely external, given `WorkspaceRef` is immutable for a session's life + and a location change requires a new session or fork? Zed's live-only path + reconciliation and admitted no-offline-reconciliation gap (dossier Open + Questions, [Open questions](./index.md#open-questions)) is a caution about what happens when this kind + of concern is left informally bolted on elsewhere rather than answered once, + explicitly. +- Does decision 7's `ArtifactErased` or the optional cold-tiering job ever need + to reach into workspace-adjacent storage reclamation -- the problem Zed's + `ArchivedGitWorktree` solves at the workspace layer -- or is that + unambiguously a different aggregate's concern, out of [ADR#0035](../../../../adr/0035-session-store-decider-aggregate.md)'s scope + entirely? + +## Things this document could not verify + +- Zed's own dossier flags several claims as unread-in-full or inferred rather + than confirmed (the exact pre-`0.3.0` legacy schema shape, the + `open_fallback_db` trigger condition, whether an offline worktree-rename + reconciliation path exists, the exact `acp::SessionId` minting call site). + Every citation in this document to those specific claims carries the same + uncertainty the stage-one dossier already recorded; none of it is restated + here as more certain than the dossier states it. +- This document did not independently re-read the cited Zed source lines + against a fresh checkout; it relies on the stage-one dossier's pinned + `path:line` anchors at commit `4aad57fd1f002f9feeea2b7fb6229ccbcd576cb1`, per + this prompt's precondition that a verified dossier is trustworthy input. diff --git a/docs/research/session-store/synthesis.md b/docs/research/session-store/synthesis.md index 742ae0dea..ae76927be 100644 --- a/docs/research/session-store/synthesis.md +++ b/docs/research/session-store/synthesis.md @@ -11,20 +11,20 @@ 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 +sit [T3 Code](./products/t3code/index.md) and [OpenCode](./products/opencode/index.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 +SQL projections. At the other sits [Goose](./products/goose/index.md) and +[Hermes](./products/hermes-agent/index.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 +on. The CLIs in between, [Claude Agent SDK](./products/claude-agent-sdk/index.md), +[Codex CLI](./products/codex-cli/index.md), [Gemini CLI](./products/gemini-cli/index.md), +and [Grok Build](./products/grok-build/index.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) +lack. [LangGraph](./products/langgraph/index.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. @@ -33,23 +33,23 @@ cleanest event-sourcing analog in the corpus after T3 Code. **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 +derived from it.** [Claude Agent SDK](./products/claude-agent-sdk/index.md): "a session whose store holds 503 raw entries may return 18 messages from -`getSessionMessages`." [Codex CLI](./products/codex-cli.md): "the live and +`getSessionMessages`." [Codex CLI](./products/codex-cli/index.md): "the live and persisted histories remain identical" even as `replacement_history` -replaces what the model re-reads. [Grok Build](./products/grok-build.md): +replaces what the model re-reads. [Grok Build](./products/grok-build/index.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 +durable source of truth." [T3 Code](./products/t3code/index.md) and +[OpenCode](./products/opencode/index.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), +Even [Goose](./products/goose/index.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) +SDK](./products/claude-agent-sdk/index.md), [Codex CLI](./products/codex-cli/index.md), +[Gemini CLI](./products/gemini-cli/index.md), and [Grok Build](./products/grok-build/index.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 @@ -75,72 +75,72 @@ 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): +store merely records, never triggers or understands.** [LangGraph](./products/langgraph/index.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): +next value of a channel. [Claude Agent SDK](./products/claude-agent-sdk/index.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 +[Codex CLI](./products/codex-cli/index.md) appends a `Compacted` item with +`replacement_history`. [OpenCode](./products/opencode/index.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 +[Goose](./products/goose/index.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 +session id.** [Claude Agent SDK](./products/claude-agent-sdk/index.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) +the old session ID, so the SDK does not use one." [Codex CLI](./products/codex-cli/index.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 +plus `history_base`. [T3 Code](./products/t3code/index.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) +[Grok Build](./products/grok-build/index.md): "Fork is copy-plus-lineage, not a +shared-prefix reference." [Goose](./products/goose/index.md) mints a fresh +`YYYYMMDD_N` id via `copy_session`. Only [LangGraph](./products/langgraph/index.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 +[Codex CLI](./products/codex-cli/index.md): `SessionMeta.parent_thread_id`, "a +first-class sibling thread." [T3 Code](./products/t3code/index.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): +steerable, and resumable." [OpenCode](./products/opencode/index.md): "a +first-class sibling session," linked by `parent_id`. [Grok Build](./products/grok-build/index.md): +its own directory plus a `SubagentMeta` pointer file. [Goose](./products/goose/index.md): +its own row, linked by `parent_session_id`. [Hermes](./products/hermes-agent/index.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 +[Claude Agent SDK](./products/claude-agent-sdk/index.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 +and nobody has a clean answer.** [Codex CLI](./products/codex-cli/index.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): +cascade." [Goose](./products/goose/index.md): "no cascade to children... leaving +a subagent row with a dangling `parent_session_id`." [OpenCode](./products/opencode/index.md): "deleting a parent session row does not delete children, they would -orphan." [T3 Code](./products/t3code.md): "No cascade to child threads was +orphan." [T3 Code](./products/t3code/index.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 +[Grok Build](./products/grok-build/index.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): +purest event-sourced designs.** [Claude Agent SDK](./products/claude-agent-sdk/index.md): +"there is no expected-position precondition on `append`." [Goose](./products/goose/index.md): +"no expected-version precondition anywhere; there is no CAS." [Hermes](./products/hermes-agent/index.md): "there is no optimistic-concurrency / expected-position precondition -anywhere." [Gemini CLI](./products/gemini-cli.md) and [Codex CLI](./products/codex-cli.md) +anywhere." [Gemini CLI](./products/gemini-cli/index.md) and [Codex CLI](./products/codex-cli/index.md) rely on a single-writer-per-session assumption with no lock. Only -[T3 Code](./products/t3code.md) (a unique `(aggregate_kind, stream_id, +[T3 Code](./products/t3code/index.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 +[OpenCode](./products/opencode/index.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 +conflict-detection teeth; [LangGraph](./products/langgraph/index.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. @@ -148,20 +148,20 @@ own dossier flags "no expected-version/OCC in the OSS savers" and notes ## 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) +[T3 Code](./products/t3code/index.md) ("unambiguously session-as-log +(event-sourced)"), [OpenCode](./products/opencode/index.md) v2 ("This is +unambiguously session-as-log"), [LangGraph](./products/langgraph/index.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), +[Claude Agent SDK](./products/claude-agent-sdk/index.md), [Codex CLI](./products/codex-cli/index.md), +[Gemini CLI](./products/gemini-cli/index.md), [Grok Build](./products/grok-build/index.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 +Mutable row: [Goose](./products/goose/index.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- +and [Hermes](./products/hermes-agent/index.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 @@ -172,22 +172,22 @@ 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) +Random, v4-shaped (observed on disk, not documented): [Claude Agent SDK](./products/claude-agent-sdk/index.md) +session id. Time-ordered UUIDv7/ULID-like, client-minted: [Codex CLI](./products/codex-cli/index.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) +[OpenCode](./products/opencode/index.md) (`ses_` ids pack timestamp + counter, +bit-inverted for descending sort), [LangGraph](./products/langgraph/index.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 +[Grok Build](./products/grok-build/index.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 +[Goose](./products/goose/index.md) (`YYYYMMDD_N`, a per-day counter, "not a +UUID... but no location"), [Hermes](./products/hermes-agent/index.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 +[T3 Code](./products/t3code/index.md) (`stream_version` per aggregate plus a +global `sequence`) and [OpenCode](./products/opencode/index.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 @@ -197,15 +197,15 @@ 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) +SDK](./products/claude-agent-sdk/index.md) (`projectKey` flattens the cwd into +the path), [Codex CLI](./products/codex-cli/index.md) (time-sharded but global +within `$CODEX_HOME`, filtered by `cwd_filters`), [Gemini CLI](./products/gemini-cli/index.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 +column: [Goose](./products/goose/index.md) ("no cwd/project path is encoded into +the key... project_id is just a nullable column"), [Hermes](./products/hermes-agent/index.md) +(one `state.db` per profile, `cwd` a plain column), [T3 Code](./products/t3code/index.md) +and [OpenCode](./products/opencode/index.md) (one DB, `project_id`/`workspace_id` +columns), [Grok Build](./products/grok-build/index.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: @@ -218,17 +218,17 @@ on a plain column; OpenCode is the middle case, still appending a 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) +file vs pure append marker.** Rewrite in place: [Goose](./products/goose/index.md) (`DELETE all rows, re-INSERT` via `replace_conversation`) and -[Hermes](./products/hermes-agent.md) (`UPDATE active=0, compacted=1` then +[Hermes](./products/hermes-agent/index.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` +log marker: [Grok Build](./products/grok-build/index.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) +[Claude Agent SDK](./products/claude-agent-sdk/index.md) (`isCompactSummary` +entry in the same log), [Codex CLI](./products/codex-cli/index.md) (`Compacted` +item with `replacement_history` inline), [T3 Code](./products/t3code/index.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 @@ -239,44 +239,44 @@ 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 +mechanism only: [Claude Agent SDK](./products/claude-agent-sdk/index.md) ("The SDK never deletes from your store on its own... TTLs, S3 lifecycle -policies... are the adapter's responsibility"), [LangGraph](./products/langgraph.md) +policies... are the adapter's responsibility"), [LangGraph](./products/langgraph/index.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) +concrete default: [Claude Agent SDK](./products/claude-agent-sdk/index.md)'s own +CLI (`cleanupPeriodDays`, default 30), [Gemini CLI](./products/gemini-cli/index.md) +(delete-on-exit-if-not-resumable), [Hermes](./products/hermes-agent/index.md) (`prune_sessions(older_than_days=90)`, invoked, not scheduled). No -retention story at all, log grows forever: [T3 Code](./products/t3code.md) +retention story at all, log grows forever: [T3 Code](./products/t3code/index.md) ("no retention or log-truncation/snapshotting, the log grows unbounded") -and [OpenCode](./products/opencode.md) ("none found... the log is retained +and [OpenCode](./products/opencode/index.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) +coordination: [Codex CLI](./products/codex-cli/index.md), [Gemini CLI](./products/gemini-cli/index.md), +[Goose](./products/goose/index.md) (SQLite write-lock only), [T3 Code](./products/t3code/index.md) ("the database is never shared across hosts"). Single-host with -network-filesystem awareness: [Hermes](./products/hermes-agent.md) (WAL on +network-filesystem awareness: [Hermes](./products/hermes-agent/index.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 +interface: [Claude Agent SDK](./products/claude-agent-sdk/index.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 +rather than solved: [Grok Build](./products/grok-build/index.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` + +[OpenCode](./products/opencode/index.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) +Multi-host via a shared database instance: [LangGraph](./products/langgraph/index.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 @@ -284,13 +284,13 @@ 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) +opaque entries, store is a pure byte-transport: [Claude Agent SDK](./products/claude-agent-sdk/index.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) +[OpenCode](./products/opencode/index.md) (event `data` "decoded and validated +through Effect Schema on both append and read"), [T3 Code](./products/t3code/index.md) (same, via Effect Schema, plus derived `actor_kind`). Partially parsed, -targeted introspection: [Goose](./products/goose.md) ("neither fully +targeted introspection: [Goose](./products/goose/index.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 @@ -310,8 +310,8 @@ application. | --- | --- | | 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-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`) | @@ -324,15 +324,15 @@ the log is the file, and the file's path is the addressing scheme). | 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" | +| [Claude Agent SDK](./products/claude-agent-sdk/index.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/index.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/index.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/index.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/index.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/index.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/index.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/index.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/index.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 @@ -348,10 +348,10 @@ each with the industry's answer where one exists: 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 + 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 + 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 @@ -421,7 +421,7 @@ each with the industry's answer where one exists: 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 + 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. @@ -432,10 +432,86 @@ each with the industry's answer where one exists: 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) — +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 +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 +subagent cascade semantics and retention on an unbounded log. + +## Stage-two results, not yet absorbed above + +Everything above is frozen as decision-time input from nine dossiers. Sixteen +stage-two comparisons have landed since, and this section records what they add +without rewriting the frozen text around it. Where the two disagree, the +comparisons are the newer reading and the ADR is authoritative over both. + +**The design mostly survives contact with the evidence.** Across the +comparisons' 55 numbered recommendations there are 45 blast-radius statements. +Eight mention breaking in any form: four are "do not do X later" guardrails +against regressions we have not committed to (Cline on deterministic child ids, +Letta on relaxing optimistic concurrency for an `At` transition, OpenHands on a +second non-replayable authoritative store, Zed on a pre-session draft keyspace), +three are conditional on which answer we pick (Crush on a parent-cost rollup, +Pi on `SessionForked` crossing a `WorkspaceRef`, Cline on a claim-check +threshold), and exactly one asks for a change that breaks something today: +adopt an explicit schema-version marker and a written back-compat policy at the +`v1alpha1` to `v1` promotion (Google ADK, 10/12, with AWS Strands at 6/12 +arriving at the same question from the opposite direction). The remaining +statements are additive, and most of those are documentation, Non-Goals, tests, +or CI. The two thinnest stores, SWE-agent at 3/12 and Aider at 4/12, yield zero +recommendations by explicit argument, which is the maturity rubric discarding +evidence rather than letting a weak store anchor a change. + +**A tenth convergence, and the strongest single finding of the second stage: a +pluggable store interface systematically hides the guarantees callers assume it +provides.** Four products span the full maturity range and fail the same way. +Google ADK (10/12) has real expected-version optimistic concurrency in +`DatabaseSessionService`, materially weaker checking in `SqliteSessionService`, +and none at all in the in-memory and Vertex backends, so "the same interface" +conceals a behavioral cliff on concurrent append. Mastra (11/12) has four +adapters reaching four different atomicity conclusions from one abstract +interface. The OpenAI Agents SDK (5/12) has nine backends each re-deriving +identity, ordering, and concurrency independently (an autoincrement column, a +Mongo `seq`, a Dapr ETag), several imperfectly. Pi (7/12) has three +implementations of one interface that have already silently diverged on a single +field, with the checked-in documentation then describing harness-only behavior as +if it were the CLI's. This converts Pi's recommendation 2 from a nice-to-have +into the best-supported precondition in the corpus: any second `trogon-decider` +implementation must pass a shared conformance suite over every +`WRITE_PRECONDITION` class before it ships. + +**Convergence 8 above survives eighteen more products and gets sharper.** Even +the two stores that do have optimistic concurrency put it in the wrong place. +Letta version-checks exactly one ORM model, `Block`, which holds +memory-configuration data, while the actual per-turn hot pointer +`Agent.message_ids` has no guard at all. Substrate-level `At(current_position)` +by default remains the corpus outlier, in our favor. + +**Cascade-on-terminal is validated, and the rewind split is not merely +unvalidated but unattempted.** Zed is the only studied product with genuinely +transitive cascade, and it chose the same behavior +[ADR#0035](../../adr/0035-session-store-decider-aggregate.md) decision 6 does, +independently. Cline's stops one level deep and only from a root. Codex CLI, +Goose, OpenCode, and T3 Code orphan. Roo Code, queued as a presumed restatement +of Cline, recurses the full child-task tree and is the second real cascade in the +corpus. Nobody anywhere has an analog to invalidating a child whose dispatch +point a still-running parent has rewound away, so that half of decision 6 is +original design work rather than industry practice restated. + +**Decision 7 gets no evidence either way, structurally.** A mutable-document +store never faces the problem, because replacing or deleting a whole document is +strictly easier than masking part of a keep-forever fact stream. This is why no +product validates `SessionHidden`, `RedactionApplied`, or `ArtifactErased`, and +it is a property of the sample rather than a weakness in the decision. + +**The message payload is documented per product and not at all per provider.** +Twenty-four of twenty-five dossiers carry an entry-structure section, and twelve +comparisons map the product's message type row by row against `CanonicalMessage` +and its seven-arm `ContentBlock` oneof. What no artifact covers is the provider +side: `ProviderBlock` exists to absorb blocks the typed arms cannot model, and +nothing in the corpus enumerates what would go through it, so whether seven arms +are the right seven is still open. The queued stage three in the +[backlog](./backlog.md) takes a provider rather than a product as its unit of +study for that reason. \ No newline at end of file diff --git a/proto/trogonai/session/sessions/v1alpha1/checkpoint.proto b/proto/trogonai/session/sessions/v1alpha1/checkpoint.proto index d502c8577..5f598ae70 100644 --- a/proto/trogonai/session/sessions/v1alpha1/checkpoint.proto +++ b/proto/trogonai/session/sessions/v1alpha1/checkpoint.proto @@ -11,8 +11,12 @@ import "trogonai/session/sessions/v1alpha1/session_ordinal.proto"; // restored via ExecutionAttemptStarted.restored_checkpoint, where it is // deliberately embedded rather than referenced: it is attempt evidence of // exactly what was restored, digest-verified, and now joined unambiguously to -// its producing event via checkpoint_id. The validator requires -// session_execution_plan_digest to match the session's own plan digest. +// its producing event via checkpoint_id. Per-event validation requires a +// restored checkpoint's plan digest to match its ExecutionAttemptStarted plan +// digest; the aggregate binds that digest to the session's stored plan. +// Admission additionally verifies the supervisor capture attestation and the +// effective-history digest (ADR#0031 §3) before CheckpointProduced is +// recorded, and restoration re-verifies the same proof before trusting bytes. message Checkpoint { // Locator for the checkpoint artifact stored out of line. string reference = 1 [features.field_presence = LEGACY_REQUIRED]; @@ -34,4 +38,16 @@ message Checkpoint { // Digest of the session's StoredSessionExecutionPlan at checkpoint time; // validated against the session's plan digest before restore. Digest session_execution_plan_digest = 8 [features.field_presence = LEGACY_REQUIRED]; + // Locator for the capture attestation stored out of line: the producing + // attempt's platform-controlled supervisor binds the artifact, attempt, + // plan digest, covers_through, and effective_history_digest under that + // attempt's confirmation key (ADR#0031 §3). + string capture_attestation_ref = 9 [features.field_presence = LEGACY_REQUIRED]; + // Digest over the capture attestation bytes, verified at admission and + // re-verified before restore. + Digest capture_attestation_digest = 10 [features.field_presence = LEGACY_REQUIRED]; + // Digest over the harness-relevant effective session facts, in fold order, + // through covers_through; admission recomputes it from authoritative + // history and requires equality with the attested value (ADR#0031 §3). + Digest effective_history_digest = 11 [features.field_presence = LEGACY_REQUIRED]; } diff --git a/proto/trogonai/session/sessions/v1alpha1/events.proto b/proto/trogonai/session/sessions/v1alpha1/events.proto index 5c3a00e04..eed74d189 100644 --- a/proto/trogonai/session/sessions/v1alpha1/events.proto +++ b/proto/trogonai/session/sessions/v1alpha1/events.proto @@ -51,6 +51,17 @@ import "trogonai/session/sessions/v1alpha1/user_message_recorded.proto"; // blocked on ADR#0027's resolver contract, and v1alpha1 is the room in which // that lands additively rather than as a speculative tenant_id today (D0). // +// Within v1alpha1 a field may still be added as LEGACY_REQUIRED. That window is +// open only while both conditions hold -- no deployed producer has written these +// events, and this package has not promoted -- and it closes at whichever comes +// first. The break a new required field causes is a current validator rejecting +// already-stored bytes, so a producer shipping on v1alpha1 closes the window +// early by creating those bytes, and promotion closes it regardless of producers +// because promotion is the act of accepting the compatibility obligation. Once it +// closes, a new required field needs a new package version. buf breaking under +// WIRE_JSON does not catch this, because it compares fields present on both sides +// and a field new to one side is not among them. +// // SessionEvent is the session aggregate's event catalog: one oneof arm per // concrete event type. It is a convenience union for matching and codegen, not // the persisted form -- the store persists each concrete event's own bytes under diff --git a/proto/trogonai/session/sessions/v1alpha1/execution_attempt_started.proto b/proto/trogonai/session/sessions/v1alpha1/execution_attempt_started.proto index c0b7e007d..474f0c9e9 100644 --- a/proto/trogonai/session/sessions/v1alpha1/execution_attempt_started.proto +++ b/proto/trogonai/session/sessions/v1alpha1/execution_attempt_started.proto @@ -26,7 +26,8 @@ message ExecutionAttemptStarted { // invariant checked against folded state (ADR#0035 command matrix). string previous_attempt_id = 5; Checkpoint restored_checkpoint = 6; - string resume_cursor = 7; + reserved 7; + reserved resume_cursor; string host_artifact_ref = 8 [features.field_presence = LEGACY_REQUIRED]; Digest host_artifact_digest = 9 [features.field_presence = LEGACY_REQUIRED]; string authenticated_remote_subject = 10; diff --git a/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.session.sessions.v1alpha1.checkpoint.__view.rs b/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.session.sessions.v1alpha1.checkpoint.__view.rs index 5aec1dea3..354779984 100644 --- a/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.session.sessions.v1alpha1.checkpoint.__view.rs +++ b/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.session.sessions.v1alpha1.checkpoint.__view.rs @@ -7,8 +7,12 @@ /// restored via ExecutionAttemptStarted.restored_checkpoint, where it is /// deliberately embedded rather than referenced: it is attempt evidence of /// exactly what was restored, digest-verified, and now joined unambiguously to -/// its producing event via checkpoint_id. The validator requires -/// session_execution_plan_digest to match the session's own plan digest. +/// its producing event via checkpoint_id. Per-event validation requires a +/// restored checkpoint's plan digest to match its ExecutionAttemptStarted plan +/// digest; the aggregate binds that digest to the session's stored plan. +/// Admission additionally verifies the supervisor capture attestation and the +/// effective-history digest (ADR#0031 §3) before CheckpointProduced is +/// recorded, and restoration re-verifies the same proof before trusting bytes. #[derive(Clone, Debug, Default)] pub struct CheckpointView<'a> { /// Locator for the checkpoint artifact stored out of line. @@ -51,6 +55,28 @@ pub struct CheckpointView<'a> { pub session_execution_plan_digest: ::buffa::MessageFieldView< super::super::__buffa::view::DigestView<'a>, >, + /// Locator for the capture attestation stored out of line: the producing + /// attempt's platform-controlled supervisor binds the artifact, attempt, + /// plan digest, covers_through, and effective_history_digest under that + /// attempt's confirmation key (ADR#0031 §3). + /// + /// Field 9: `capture_attestation_ref` + pub capture_attestation_ref: &'a str, + /// Digest over the capture attestation bytes, verified at admission and + /// re-verified before restore. + /// + /// Field 10: `capture_attestation_digest` + pub capture_attestation_digest: ::buffa::MessageFieldView< + super::super::__buffa::view::DigestView<'a>, + >, + /// Digest over the harness-relevant effective session facts, in fold order, + /// through covers_through; admission recomputes it from authoritative + /// history and requires equality with the attested value (ADR#0031 §3). + /// + /// Field 11: `effective_history_digest` + pub effective_history_digest: ::buffa::MessageFieldView< + super::super::__buffa::view::DigestView<'a>, + >, #[doc(hidden)] pub __buffa_required_seen_0: u64, } @@ -119,6 +145,30 @@ Mirrors `is_set()` on the field: `true` after decoding a message where the field pub const fn has_session_execution_plan_digest(&self) -> bool { self.session_execution_plan_digest.is_set() } + /**Whether required field `capture_attestation_ref` was present on the wire. + +Distinguishes a field that was absent from one explicitly encoded with its default value (required scalar fields are stored as bare, non-`Option` types, so the value alone cannot tell the two apart). Presence is recorded only by the wire decoder: a default or hand-built view reports `false`. Encoding is unaffected — required fields are always written.*/ + #[must_use] + #[inline] + pub const fn has_capture_attestation_ref(&self) -> bool { + self.__buffa_required_seen_0 & 32u64 != 0 + } + /**Whether required field `capture_attestation_digest` is set. + +Mirrors `is_set()` on the field: `true` after decoding a message where the field was present on the wire, and `true` on a hand-built view whose field is populated. Encoding is unaffected — required fields are always written.*/ + #[must_use] + #[inline] + pub const fn has_capture_attestation_digest(&self) -> bool { + self.capture_attestation_digest.is_set() + } + /**Whether required field `effective_history_digest` is set. + +Mirrors `is_set()` on the field: `true` after decoding a message where the field was present on the wire, and `true` on a hand-built view whose field is populated. Encoding is unaffected — required fields are always written.*/ + #[must_use] + #[inline] + pub const fn has_effective_history_digest(&self) -> bool { + self.effective_history_digest.is_set() + } } impl<'a> ::buffa::MessageView<'a> for CheckpointView<'a> { type Owned = super::super::Checkpoint; @@ -253,6 +303,56 @@ impl<'a> ::buffa::MessageView<'a> for CheckpointView<'a> { } } } + 9u32 => { + ::buffa::encoding::check_wire_type( + tag, + ::buffa::encoding::WireType::LengthDelimited, + )?; + view.capture_attestation_ref = ::buffa::types::borrow_str(&mut cur)?; + view.__buffa_required_seen_0 |= 32u64; + } + 10u32 => { + ::buffa::encoding::check_wire_type( + tag, + ::buffa::encoding::WireType::LengthDelimited, + )?; + let __sub_ctx = ctx.descend()?; + let sub = ::buffa::types::borrow_bytes(&mut cur)?; + match view.capture_attestation_digest.as_mut() { + Some(existing) => { + ::buffa::MessageView::merge_into_view(existing, sub, __sub_ctx)? + } + None => { + view.capture_attestation_digest = ::buffa::MessageFieldView::set( + ::decode_view_ctx( + sub, + __sub_ctx, + )?, + ); + } + } + } + 11u32 => { + ::buffa::encoding::check_wire_type( + tag, + ::buffa::encoding::WireType::LengthDelimited, + )?; + let __sub_ctx = ctx.descend()?; + let sub = ::buffa::types::borrow_bytes(&mut cur)?; + match view.effective_history_digest.as_mut() { + Some(existing) => { + ::buffa::MessageView::merge_into_view(existing, sub, __sub_ctx)? + } + None => { + view.effective_history_digest = ::buffa::MessageFieldView::set( + ::decode_view_ctx( + sub, + __sub_ctx, + )?, + ); + } + } + } _ => { ::buffa::encoding::skip_field_depth(tag, &mut cur, ctx.depth())?; } @@ -310,6 +410,26 @@ impl<'a> ::buffa::MessageView<'a> for CheckpointView<'a> { } None => ::buffa::MessageField::none(), }, + capture_attestation_ref: self.capture_attestation_ref.to_string(), + capture_attestation_digest: match self.capture_attestation_digest.as_option() + { + Some(v) => { + ::buffa::MessageField::< + super::super::Digest, + ::buffa::Inline, + >::some(v.to_owned_from_source(__buffa_src)?) + } + None => ::buffa::MessageField::none(), + }, + effective_history_digest: match self.effective_history_digest.as_option() { + Some(v) => { + ::buffa::MessageField::< + super::super::Digest, + ::buffa::Inline, + >::some(v.to_owned_from_source(__buffa_src)?) + } + None => ::buffa::MessageField::none(), + }, ..::core::default::Default::default() }) } @@ -356,6 +476,26 @@ impl<'a> ::buffa::ViewEncode<'a> for CheckpointView<'a> { += 1u64 + ::buffa::encoding::varint_len(inner_size as u64) as u64 + inner_size as u64; } + size + += 1u64 + + ::buffa::types::string_encoded_len(&self.capture_attestation_ref) + as u64; + if self.capture_attestation_digest.is_set() { + let __slot = __cache.reserve(); + let inner_size = self.capture_attestation_digest.compute_size(__cache); + __cache.set(__slot, inner_size); + size + += 1u64 + ::buffa::encoding::varint_len(inner_size as u64) as u64 + + inner_size as u64; + } + if self.effective_history_digest.is_set() { + let __slot = __cache.reserve(); + let inner_size = self.effective_history_digest.compute_size(__cache); + __cache.set(__slot, inner_size); + size + += 1u64 + ::buffa::encoding::varint_len(inner_size as u64) as u64 + + inner_size as u64; + } ::buffa::saturate_size(size) } #[allow(clippy::needless_borrow)] @@ -399,6 +539,23 @@ impl<'a> ::buffa::ViewEncode<'a> for CheckpointView<'a> { ); self.session_execution_plan_digest.write_to(__cache, buf); } + ::buffa::types::put_string_field(9u32, &self.capture_attestation_ref, buf); + if self.capture_attestation_digest.is_set() { + ::buffa::types::put_len_delimited_header( + 10u32, + u64::from(__cache.consume_next()), + buf, + ); + self.capture_attestation_digest.write_to(__cache, buf); + } + if self.effective_history_digest.is_set() { + ::buffa::types::put_len_delimited_header( + 11u32, + u64::from(__cache.consume_next()), + buf, + ); + self.effective_history_digest.write_to(__cache, buf); + } } } /// Serializes this view as protobuf JSON. @@ -456,6 +613,26 @@ impl<'__a> ::serde::Serialize for CheckpointView<'__a> { __map.serialize_entry("sessionExecutionPlanDigest", __v)?; } } + { + __map + .serialize_entry("captureAttestationRef", self.capture_attestation_ref)?; + } + { + if let ::core::option::Option::Some(__v) = self + .capture_attestation_digest + .as_option() + { + __map.serialize_entry("captureAttestationDigest", __v)?; + } + } + { + if let ::core::option::Option::Some(__v) = self + .effective_history_digest + .as_option() + { + __map.serialize_entry("effectiveHistoryDigest", __v)?; + } + } __map.end() } } @@ -615,6 +792,37 @@ impl CheckpointOwnedView { ) -> &::buffa::MessageFieldView> { &self.0.reborrow().session_execution_plan_digest } + /// Locator for the capture attestation stored out of line: the producing + /// attempt's platform-controlled supervisor binds the artifact, attempt, + /// plan digest, covers_through, and effective_history_digest under that + /// attempt's confirmation key (ADR#0031 §3). + /// + /// Field 9: `capture_attestation_ref` + #[must_use] + pub fn capture_attestation_ref(&self) -> &'_ str { + self.0.reborrow().capture_attestation_ref + } + /// Digest over the capture attestation bytes, verified at admission and + /// re-verified before restore. + /// + /// Field 10: `capture_attestation_digest` + #[must_use] + pub fn capture_attestation_digest( + &self, + ) -> &::buffa::MessageFieldView> { + &self.0.reborrow().capture_attestation_digest + } + /// Digest over the harness-relevant effective session facts, in fold order, + /// through covers_through; admission recomputes it from authoritative + /// history and requires equality with the attested value (ADR#0031 §3). + /// + /// Field 11: `effective_history_digest` + #[must_use] + pub fn effective_history_digest( + &self, + ) -> &::buffa::MessageFieldView> { + &self.0.reborrow().effective_history_digest + } } impl ::core::convert::From<::buffa::OwnedView>> for CheckpointOwnedView { diff --git a/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.session.sessions.v1alpha1.checkpoint.rs b/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.session.sessions.v1alpha1.checkpoint.rs index ea2fd78bb..b1f411c57 100644 --- a/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.session.sessions.v1alpha1.checkpoint.rs +++ b/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.session.sessions.v1alpha1.checkpoint.rs @@ -7,8 +7,12 @@ /// restored via ExecutionAttemptStarted.restored_checkpoint, where it is /// deliberately embedded rather than referenced: it is attempt evidence of /// exactly what was restored, digest-verified, and now joined unambiguously to -/// its producing event via checkpoint_id. The validator requires -/// session_execution_plan_digest to match the session's own plan digest. +/// its producing event via checkpoint_id. Per-event validation requires a +/// restored checkpoint's plan digest to match its ExecutionAttemptStarted plan +/// digest; the aggregate binds that digest to the session's stored plan. +/// Admission additionally verifies the supervisor capture attestation and the +/// effective-history digest (ADR#0031 §3) before CheckpointProduced is +/// recorded, and restoration re-verifies the same proof before trusting bytes. #[derive(Clone, PartialEq, Default)] #[derive(::serde::Serialize, ::serde::Deserialize)] #[serde(default)] @@ -82,6 +86,34 @@ pub struct Checkpoint { Digest, ::buffa::Inline, >, + /// Locator for the capture attestation stored out of line: the producing + /// attempt's platform-controlled supervisor binds the artifact, attempt, + /// plan digest, covers_through, and effective_history_digest under that + /// attempt's confirmation key (ADR#0031 §3). + /// + /// Field 9: `capture_attestation_ref` + #[serde( + rename = "captureAttestationRef", + alias = "capture_attestation_ref", + with = "::buffa::json_helpers::proto_string" + )] + pub capture_attestation_ref: ::buffa::alloc::string::String, + /// Digest over the capture attestation bytes, verified at admission and + /// re-verified before restore. + /// + /// Field 10: `capture_attestation_digest` + #[serde(rename = "captureAttestationDigest", alias = "capture_attestation_digest")] + pub capture_attestation_digest: ::buffa::MessageField< + Digest, + ::buffa::Inline, + >, + /// Digest over the harness-relevant effective session facts, in fold order, + /// through covers_through; admission recomputes it from authoritative + /// history and requires equality with the attested value (ADR#0031 §3). + /// + /// Field 11: `effective_history_digest` + #[serde(rename = "effectiveHistoryDigest", alias = "effective_history_digest")] + pub effective_history_digest: ::buffa::MessageField>, } impl ::core::fmt::Debug for Checkpoint { fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { @@ -97,6 +129,9 @@ impl ::core::fmt::Debug for Checkpoint { ) .field("covers_through", &self.covers_through) .field("session_execution_plan_digest", &self.session_execution_plan_digest) + .field("capture_attestation_ref", &self.capture_attestation_ref) + .field("capture_attestation_digest", &self.capture_attestation_digest) + .field("effective_history_digest", &self.effective_history_digest) .finish() } } @@ -163,6 +198,26 @@ impl ::buffa::Message for Checkpoint { += 1u64 + ::buffa::encoding::varint_len(inner_size as u64) as u64 + inner_size as u64; } + size + += 1u64 + + ::buffa::types::string_encoded_len(&self.capture_attestation_ref) + as u64; + if self.capture_attestation_digest.is_set() { + let __slot = __cache.reserve(); + let inner_size = self.capture_attestation_digest.compute_size(__cache); + __cache.set(__slot, inner_size); + size + += 1u64 + ::buffa::encoding::varint_len(inner_size as u64) as u64 + + inner_size as u64; + } + if self.effective_history_digest.is_set() { + let __slot = __cache.reserve(); + let inner_size = self.effective_history_digest.compute_size(__cache); + __cache.set(__slot, inner_size); + size + += 1u64 + ::buffa::encoding::varint_len(inner_size as u64) as u64 + + inner_size as u64; + } ::buffa::saturate_size(size) } fn write_to( @@ -205,6 +260,23 @@ impl ::buffa::Message for Checkpoint { ); self.session_execution_plan_digest.write_to(__cache, buf); } + ::buffa::types::put_string_field(9u32, &self.capture_attestation_ref, buf); + if self.capture_attestation_digest.is_set() { + ::buffa::types::put_len_delimited_header( + 10u32, + u64::from(__cache.consume_next()), + buf, + ); + self.capture_attestation_digest.write_to(__cache, buf); + } + if self.effective_history_digest.is_set() { + ::buffa::types::put_len_delimited_header( + 11u32, + u64::from(__cache.consume_next()), + buf, + ); + self.effective_history_digest.write_to(__cache, buf); + } } fn merge_field( &mut self, @@ -288,6 +360,35 @@ impl ::buffa::Message for Checkpoint { ctx, )?; } + 9u32 => { + ::buffa::encoding::check_wire_type( + tag, + ::buffa::encoding::WireType::LengthDelimited, + )?; + ::buffa::types::merge_string(&mut self.capture_attestation_ref, buf)?; + } + 10u32 => { + ::buffa::encoding::check_wire_type( + tag, + ::buffa::encoding::WireType::LengthDelimited, + )?; + ::buffa::Message::merge_length_delimited( + self.capture_attestation_digest.get_or_insert_default(), + buf, + ctx, + )?; + } + 11u32 => { + ::buffa::encoding::check_wire_type( + tag, + ::buffa::encoding::WireType::LengthDelimited, + )?; + ::buffa::Message::merge_length_delimited( + self.effective_history_digest.get_or_insert_default(), + buf, + ctx, + )?; + } _ => { ::buffa::encoding::skip_field_depth(tag, buf, ctx.depth())?; } @@ -303,6 +404,9 @@ impl ::buffa::Message for Checkpoint { self.producing_execution_attempt_id.clear(); self.covers_through = ::buffa::MessageField::none(); self.session_execution_plan_digest = ::buffa::MessageField::none(); + self.capture_attestation_ref.clear(); + self.capture_attestation_digest = ::buffa::MessageField::none(); + self.effective_history_digest = ::buffa::MessageField::none(); } } impl ::buffa::json_helpers::ProtoElemJson for Checkpoint { diff --git a/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.session.sessions.v1alpha1.events.__view.rs b/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.session.sessions.v1alpha1.events.__view.rs index e329cfe34..9882f91d2 100644 --- a/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.session.sessions.v1alpha1.events.__view.rs +++ b/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.session.sessions.v1alpha1.events.__view.rs @@ -8,6 +8,17 @@ /// blocked on ADR#0027's resolver contract, and v1alpha1 is the room in which /// that lands additively rather than as a speculative tenant_id today (D0). /// +/// Within v1alpha1 a field may still be added as LEGACY_REQUIRED. That window is +/// open only while both conditions hold -- no deployed producer has written these +/// events, and this package has not promoted -- and it closes at whichever comes +/// first. The break a new required field causes is a current validator rejecting +/// already-stored bytes, so a producer shipping on v1alpha1 closes the window +/// early by creating those bytes, and promotion closes it regardless of producers +/// because promotion is the act of accepting the compatibility obligation. Once it +/// closes, a new required field needs a new package version. buf breaking under +/// WIRE_JSON does not catch this, because it compares fields present on both sides +/// and a field new to one side is not among them. +/// /// SessionEvent is the session aggregate's event catalog: one oneof arm per /// concrete event type. It is a convenience union for matching and codegen, not /// the persisted form -- the store persists each concrete event's own bytes under diff --git a/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.session.sessions.v1alpha1.events.rs b/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.session.sessions.v1alpha1.events.rs index 93c6ffd17..914e5a81c 100644 --- a/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.session.sessions.v1alpha1.events.rs +++ b/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.session.sessions.v1alpha1.events.rs @@ -8,6 +8,17 @@ /// blocked on ADR#0027's resolver contract, and v1alpha1 is the room in which /// that lands additively rather than as a speculative tenant_id today (D0). /// +/// Within v1alpha1 a field may still be added as LEGACY_REQUIRED. That window is +/// open only while both conditions hold -- no deployed producer has written these +/// events, and this package has not promoted -- and it closes at whichever comes +/// first. The break a new required field causes is a current validator rejecting +/// already-stored bytes, so a producer shipping on v1alpha1 closes the window +/// early by creating those bytes, and promotion closes it regardless of producers +/// because promotion is the act of accepting the compatibility obligation. Once it +/// closes, a new required field needs a new package version. buf breaking under +/// WIRE_JSON does not catch this, because it compares fields present on both sides +/// and a field new to one side is not among them. +/// /// SessionEvent is the session aggregate's event catalog: one oneof arm per /// concrete event type. It is a convenience union for matching and codegen, not /// the persisted form -- the store persists each concrete event's own bytes under diff --git a/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.session.sessions.v1alpha1.execution_attempt_started.__view.rs b/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.session.sessions.v1alpha1.execution_attempt_started.__view.rs index 28a765e28..eb8ae33f0 100644 --- a/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.session.sessions.v1alpha1.execution_attempt_started.__view.rs +++ b/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.session.sessions.v1alpha1.execution_attempt_started.__view.rs @@ -33,8 +33,6 @@ pub struct ExecutionAttemptStartedView<'a> { pub restored_checkpoint: ::buffa::MessageFieldView< super::super::__buffa::view::CheckpointView<'a>, >, - /// Field 7: `resume_cursor` - pub resume_cursor: ::core::option::Option<&'a str>, /// Field 8: `host_artifact_ref` pub host_artifact_ref: &'a str, /// Field 9: `host_artifact_digest` @@ -214,13 +212,6 @@ impl<'a> ::buffa::MessageView<'a> for ExecutionAttemptStartedView<'a> { } } } - 7u32 => { - ::buffa::encoding::check_wire_type( - tag, - ::buffa::encoding::WireType::LengthDelimited, - )?; - view.resume_cursor = Some(::buffa::types::borrow_str(&mut cur)?); - } 8u32 => { ::buffa::encoding::check_wire_type( tag, @@ -338,7 +329,6 @@ impl<'a> ::buffa::MessageView<'a> for ExecutionAttemptStartedView<'a> { } None => ::buffa::MessageField::none(), }, - resume_cursor: self.resume_cursor.map(|s| s.to_string()), host_artifact_ref: self.host_artifact_ref.to_string(), host_artifact_digest: match self.host_artifact_digest.as_option() { Some(v) => { @@ -396,9 +386,6 @@ impl<'a> ::buffa::ViewEncode<'a> for ExecutionAttemptStartedView<'a> { += 1u64 + ::buffa::encoding::varint_len(inner_size as u64) as u64 + inner_size as u64; } - if let Some(ref v) = self.resume_cursor { - size += 1u64 + ::buffa::types::string_encoded_len(v) as u64; - } size += 1u64 + ::buffa::types::string_encoded_len(&self.host_artifact_ref) as u64; if self.host_artifact_digest.is_set() { @@ -455,9 +442,6 @@ impl<'a> ::buffa::ViewEncode<'a> for ExecutionAttemptStartedView<'a> { ); self.restored_checkpoint.write_to(__cache, buf); } - if let Some(ref v) = self.resume_cursor { - ::buffa::types::put_string_field(7u32, v, buf); - } ::buffa::types::put_string_field(8u32, &self.host_artifact_ref, buf); if self.host_artifact_digest.is_set() { ::buffa::types::put_len_delimited_header( @@ -533,9 +517,6 @@ impl<'__a> ::serde::Serialize for ExecutionAttemptStartedView<'__a> { __map.serialize_entry("restoredCheckpoint", __v)?; } } - if let ::core::option::Option::Some(__v) = self.resume_cursor { - __map.serialize_entry("resumeCursor", __v)?; - } { __map.serialize_entry("hostArtifactRef", self.host_artifact_ref)?; } @@ -692,11 +673,6 @@ impl ExecutionAttemptStartedOwnedView { ) -> &::buffa::MessageFieldView> { &self.0.reborrow().restored_checkpoint } - /// Field 7: `resume_cursor` - #[must_use] - pub fn resume_cursor(&self) -> ::core::option::Option<&'_ str> { - self.0.reborrow().resume_cursor - } /// Field 8: `host_artifact_ref` #[must_use] pub fn host_artifact_ref(&self) -> &'_ str { diff --git a/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.session.sessions.v1alpha1.execution_attempt_started.rs b/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.session.sessions.v1alpha1.execution_attempt_started.rs index 621633304..a2c40b631 100644 --- a/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.session.sessions.v1alpha1.execution_attempt_started.rs +++ b/rsworkspace/crates/platform/trogonai-proto/src/gen/trogonai.session.sessions.v1alpha1.execution_attempt_started.rs @@ -66,13 +66,6 @@ pub struct ExecutionAttemptStarted { Checkpoint, ::buffa::Inline, >, - /// Field 7: `resume_cursor` - #[serde( - rename = "resumeCursor", - alias = "resume_cursor", - skip_serializing_if = "::core::option::Option::is_none" - )] - pub resume_cursor: ::core::option::Option<::buffa::alloc::string::String>, /// Field 8: `host_artifact_ref` #[serde( rename = "hostArtifactRef", @@ -118,7 +111,6 @@ impl ::core::fmt::Debug for ExecutionAttemptStarted { .field("attempt_number", &self.attempt_number) .field("previous_attempt_id", &self.previous_attempt_id) .field("restored_checkpoint", &self.restored_checkpoint) - .field("resume_cursor", &self.resume_cursor) .field("host_artifact_ref", &self.host_artifact_ref) .field("host_artifact_digest", &self.host_artifact_digest) .field("authenticated_remote_subject", &self.authenticated_remote_subject) @@ -147,16 +139,6 @@ impl ExecutionAttemptStarted { } #[must_use = "with_* setters return `self` by value; assign or chain the result"] #[inline] - ///Sets [`Self::resume_cursor`] to `Some(value)`, consuming and returning `self`. - pub fn with_resume_cursor( - mut self, - value: impl Into<::buffa::alloc::string::String>, - ) -> Self { - self.resume_cursor = Some(value.into()); - self - } - #[must_use = "with_* setters return `self` by value; assign or chain the result"] - #[inline] ///Sets [`Self::authenticated_remote_subject`] to `Some(value)`, consuming and returning `self`. pub fn with_authenticated_remote_subject( mut self, @@ -220,9 +202,6 @@ impl ::buffa::Message for ExecutionAttemptStarted { += 1u64 + ::buffa::encoding::varint_len(inner_size as u64) as u64 + inner_size as u64; } - if let Some(ref v) = self.resume_cursor { - size += 1u64 + ::buffa::types::string_encoded_len(v) as u64; - } size += 1u64 + ::buffa::types::string_encoded_len(&self.host_artifact_ref) as u64; if self.host_artifact_digest.is_set() { @@ -278,9 +257,6 @@ impl ::buffa::Message for ExecutionAttemptStarted { ); self.restored_checkpoint.write_to(__cache, buf); } - if let Some(ref v) = self.resume_cursor { - ::buffa::types::put_string_field(7u32, v, buf); - } ::buffa::types::put_string_field(8u32, &self.host_artifact_ref, buf); if self.host_artifact_digest.is_set() { ::buffa::types::put_len_delimited_header( @@ -371,18 +347,6 @@ impl ::buffa::Message for ExecutionAttemptStarted { ctx, )?; } - 7u32 => { - ::buffa::encoding::check_wire_type( - tag, - ::buffa::encoding::WireType::LengthDelimited, - )?; - ::buffa::types::merge_string( - self - .resume_cursor - .get_or_insert_with(::buffa::alloc::string::String::new), - buf, - )?; - } 8u32 => { ::buffa::encoding::check_wire_type( tag, @@ -449,7 +413,6 @@ impl ::buffa::Message for ExecutionAttemptStarted { self.attempt_number = 0u64; self.previous_attempt_id = ::core::option::Option::None; self.restored_checkpoint = ::buffa::MessageField::none(); - self.resume_cursor = ::core::option::Option::None; self.host_artifact_ref.clear(); self.host_artifact_digest = ::buffa::MessageField::none(); self.authenticated_remote_subject = ::core::option::Option::None; diff --git a/rsworkspace/crates/platform/trogonai-proto/src/session/sessions/codec/tests.rs b/rsworkspace/crates/platform/trogonai-proto/src/session/sessions/codec/tests.rs index b4f2cbde7..c1fdbde8c 100644 --- a/rsworkspace/crates/platform/trogonai-proto/src/session/sessions/codec/tests.rs +++ b/rsworkspace/crates/platform/trogonai-proto/src/session/sessions/codec/tests.rs @@ -61,6 +61,9 @@ fn checkpoint() -> v1alpha1::Checkpoint { producing_execution_attempt_id: "attempt-1".to_string(), covers_through: MessageField::some(session_ordinal(1)), session_execution_plan_digest: MessageField::some(digest()), + capture_attestation_ref: "attestation-ref".to_string(), + capture_attestation_digest: MessageField::some(digest()), + effective_history_digest: MessageField::some(digest()), } } @@ -294,7 +297,6 @@ fn execution_attempt_started() -> v1alpha1::ExecutionAttemptStarted { attempt_number: 1, previous_attempt_id: None, restored_checkpoint: MessageField::none(), - resume_cursor: None, host_artifact_ref: "host-ref".to_string(), host_artifact_digest: MessageField::some(digest()), authenticated_remote_subject: None, diff --git a/rsworkspace/crates/platform/trogonai-proto/src/session/sessions/validate.rs b/rsworkspace/crates/platform/trogonai-proto/src/session/sessions/validate.rs index 1a4582133..80dc60892 100644 --- a/rsworkspace/crates/platform/trogonai-proto/src/session/sessions/validate.rs +++ b/rsworkspace/crates/platform/trogonai-proto/src/session/sessions/validate.rs @@ -132,6 +132,9 @@ pub enum SessionEventValidationError { #[error("{field}.value must be exactly 32 bytes for algorithm sha256, got {actual}")] Sha256DigestWrongLength { field: &'static str, actual: usize }, + #[error("restored_checkpoint.session_execution_plan_digest must match session_execution_plan_digest")] + RestoredCheckpointPlanDigestMismatch, + #[error("{field} must be well-formed JSON")] InvalidJson { field: &'static str }, @@ -428,6 +431,18 @@ fn validate_checkpoint(checkpoint: &v1alpha1::Checkpoint) -> Result<(), SessionE &checkpoint.session_execution_plan_digest, "checkpoint.session_execution_plan_digest", )?; + require_non_empty( + &checkpoint.capture_attestation_ref, + "checkpoint.capture_attestation_ref", + )?; + require_digest( + &checkpoint.capture_attestation_digest, + "checkpoint.capture_attestation_digest", + )?; + require_digest( + &checkpoint.effective_history_digest, + "checkpoint.effective_history_digest", + )?; Ok(()) } @@ -746,6 +761,9 @@ fn validate_execution_attempt_started( } if let Some(checkpoint) = event.restored_checkpoint.as_option() { validate_checkpoint(checkpoint)?; + if checkpoint.session_execution_plan_digest.as_option() != event.session_execution_plan_digest.as_option() { + return Err(SessionEventValidationError::RestoredCheckpointPlanDigestMismatch); + } } require_set_timestamp(&event.started_at, "started_at") } diff --git a/rsworkspace/crates/platform/trogonai-proto/src/session/sessions/validate/tests.rs b/rsworkspace/crates/platform/trogonai-proto/src/session/sessions/validate/tests.rs index 6838fdee2..7e3a1addd 100644 --- a/rsworkspace/crates/platform/trogonai-proto/src/session/sessions/validate/tests.rs +++ b/rsworkspace/crates/platform/trogonai-proto/src/session/sessions/validate/tests.rs @@ -129,6 +129,9 @@ fn checkpoint() -> v1alpha1::Checkpoint { producing_execution_attempt_id: "attempt-1".to_string(), covers_through: MessageField::some(session_ordinal(1)), session_execution_plan_digest: MessageField::some(digest()), + capture_attestation_ref: "attestation-ref".to_string(), + capture_attestation_digest: MessageField::some(digest()), + effective_history_digest: MessageField::some(digest()), } } @@ -668,7 +671,6 @@ fn validate_execution_attempt_started_rejects_zero_attempt_number() { attempt_number: 0, previous_attempt_id: None, restored_checkpoint: MessageField::none(), - resume_cursor: None, host_artifact_ref: "host-ref".to_string(), host_artifact_digest: MessageField::some(digest()), authenticated_remote_subject: None, @@ -696,7 +698,6 @@ fn validate_execution_attempt_started_accepts_positive_attempt_number() { attempt_number: 1, previous_attempt_id: None, restored_checkpoint: MessageField::none(), - resume_cursor: None, host_artifact_ref: "host-ref".to_string(), host_artifact_digest: MessageField::some(digest()), authenticated_remote_subject: None, @@ -2102,7 +2103,6 @@ fn validate_execution_attempt_started_accepts_valid_restored_checkpoint() { attempt_number: 1, previous_attempt_id: None, restored_checkpoint: MessageField::some(checkpoint()), - resume_cursor: None, host_artifact_ref: "host-ref".to_string(), host_artifact_digest: MessageField::some(digest()), authenticated_remote_subject: None, @@ -2127,7 +2127,6 @@ fn validate_execution_attempt_started_rejects_first_attempt_with_previous_attemp attempt_number: 1, previous_attempt_id: Some("attempt-0".to_string()), restored_checkpoint: MessageField::none(), - resume_cursor: None, host_artifact_ref: "host-ref".to_string(), host_artifact_digest: MessageField::some(digest()), authenticated_remote_subject: None, @@ -2155,7 +2154,6 @@ fn validate_execution_attempt_started_rejects_restart_without_previous_attempt_i attempt_number: 2, previous_attempt_id: None, restored_checkpoint: MessageField::none(), - resume_cursor: None, host_artifact_ref: "host-ref".to_string(), host_artifact_digest: MessageField::some(digest()), authenticated_remote_subject: None, @@ -2183,7 +2181,6 @@ fn validate_execution_attempt_started_accepts_restart_with_previous_attempt_id() attempt_number: 2, previous_attempt_id: Some("attempt-1".to_string()), restored_checkpoint: MessageField::none(), - resume_cursor: None, host_artifact_ref: "host-ref".to_string(), host_artifact_digest: MessageField::some(digest()), authenticated_remote_subject: None, @@ -2211,7 +2208,6 @@ fn validate_execution_attempt_started_rejects_invalid_restored_checkpoint() { attempt_number: 1, previous_attempt_id: None, restored_checkpoint: MessageField::some(broken_checkpoint), - resume_cursor: None, host_artifact_ref: "host-ref".to_string(), host_artifact_digest: MessageField::some(digest()), authenticated_remote_subject: None, @@ -2286,7 +2282,6 @@ fn validate_execution_attempt_started_rejects_checkpoint_with_empty_producing_ex attempt_number: 1, previous_attempt_id: None, restored_checkpoint: MessageField::some(broken_checkpoint), - resume_cursor: None, host_artifact_ref: "host-ref".to_string(), host_artifact_digest: MessageField::some(digest()), authenticated_remote_subject: None, @@ -2322,7 +2317,6 @@ fn validate_execution_attempt_started_rejects_checkpoint_with_invalid_session_ex attempt_number: 1, previous_attempt_id: None, restored_checkpoint: MessageField::some(broken_checkpoint), - resume_cursor: None, host_artifact_ref: "host-ref".to_string(), host_artifact_digest: MessageField::some(digest()), authenticated_remote_subject: None, @@ -2341,6 +2335,39 @@ fn validate_execution_attempt_started_rejects_checkpoint_with_invalid_session_ex ); } +#[test] +fn validate_execution_attempt_started_rejects_checkpoint_for_a_different_session_execution_plan() { + let mut restored_checkpoint = checkpoint(); + restored_checkpoint.session_execution_plan_digest = MessageField::some(v1alpha1::Digest { + algorithm: "sha256".to_string(), + value: vec![1u8; 32], + }); + + let event = v1alpha1::SessionEvent { + event: Some( + v1alpha1::ExecutionAttemptStarted { + session_id: "session-1".to_string(), + execution_attempt_id: "attempt-2".to_string(), + session_execution_plan_digest: MessageField::some(digest()), + attempt_number: 2, + previous_attempt_id: Some("attempt-1".to_string()), + restored_checkpoint: MessageField::some(restored_checkpoint), + host_artifact_ref: "host-ref".to_string(), + host_artifact_digest: MessageField::some(digest()), + authenticated_remote_subject: None, + isolation_placement: None, + started_at: MessageField::some(valid_timestamp()), + } + .into(), + ), + }; + + assert_eq!( + validate_session_event(&event), + Err(SessionEventValidationError::RestoredCheckpointPlanDigestMismatch) + ); +} + #[test] fn validate_user_message_recorded_accepts_artifact_ref_content_block() { let event = v1alpha1::SessionEvent { @@ -3331,7 +3358,6 @@ fn validate_execution_attempt_started_accepts_valid_started_at() { attempt_number: 1, previous_attempt_id: None, restored_checkpoint: MessageField::none(), - resume_cursor: None, host_artifact_ref: "host-ref".to_string(), host_artifact_digest: MessageField::some(digest()), authenticated_remote_subject: None, @@ -3356,7 +3382,6 @@ fn validate_execution_attempt_started_rejects_invalid_started_at() { attempt_number: 1, previous_attempt_id: None, restored_checkpoint: MessageField::none(), - resume_cursor: None, host_artifact_ref: "host-ref".to_string(), host_artifact_digest: MessageField::some(digest()), authenticated_remote_subject: None, @@ -3384,7 +3409,6 @@ fn validate_execution_attempt_started_rejects_missing_started_at() { attempt_number: 1, previous_attempt_id: None, restored_checkpoint: MessageField::none(), - resume_cursor: None, host_artifact_ref: "host-ref".to_string(), host_artifact_digest: MessageField::some(digest()), authenticated_remote_subject: None, @@ -3992,6 +4016,81 @@ fn validate_checkpoint_produced_rejects_empty_implementation_version() { ); } +#[test] +fn validate_checkpoint_produced_rejects_empty_capture_attestation_ref() { + let mut broken_checkpoint = checkpoint(); + broken_checkpoint.capture_attestation_ref = String::new(); + + let event = v1alpha1::SessionEvent { + event: Some( + v1alpha1::CheckpointProduced { + session_id: "session-1".to_string(), + checkpoint: MessageField::some(broken_checkpoint), + } + .into(), + ), + }; + + assert_eq!( + validate_session_event(&event), + Err(SessionEventValidationError::EmptyIdentifier { + field: "checkpoint.capture_attestation_ref" + }) + ); +} + +#[test] +fn validate_checkpoint_produced_rejects_invalid_capture_attestation_digest() { + let mut broken_checkpoint = checkpoint(); + broken_checkpoint.capture_attestation_digest = MessageField::some(v1alpha1::Digest { + algorithm: String::new(), + value: vec![0u8; 32], + }); + + let event = v1alpha1::SessionEvent { + event: Some( + v1alpha1::CheckpointProduced { + session_id: "session-1".to_string(), + checkpoint: MessageField::some(broken_checkpoint), + } + .into(), + ), + }; + + assert_eq!( + validate_session_event(&event), + Err(SessionEventValidationError::EmptyDigestAlgorithm { + field: "checkpoint.capture_attestation_digest" + }) + ); +} + +#[test] +fn validate_checkpoint_produced_rejects_invalid_effective_history_digest() { + let mut broken_checkpoint = checkpoint(); + broken_checkpoint.effective_history_digest = MessageField::some(v1alpha1::Digest { + algorithm: String::new(), + value: vec![0u8; 32], + }); + + let event = v1alpha1::SessionEvent { + event: Some( + v1alpha1::CheckpointProduced { + session_id: "session-1".to_string(), + checkpoint: MessageField::some(broken_checkpoint), + } + .into(), + ), + }; + + assert_eq!( + validate_session_event(&event), + Err(SessionEventValidationError::EmptyDigestAlgorithm { + field: "checkpoint.effective_history_digest" + }) + ); +} + #[test] fn validate_session_started_rejects_empty_workspace_id() { let mut event = session_started();