Skip to content

Shared-prefix session lineage: prototype and copy-cost baseline #5439

Open

Description

@Astro-Han

Problem

Every revision and branch in Maka materializes the sliced prefix into a new Session: transcript messages, ledger events, artifacts, model_call rows, and linked references are physically copied on every fork (see session-revision-coordinator.ts). Fork cost therefore grows linearly with history length, and every Edit-and-resend, Branch, and future retry pays it. There is no measured baseline for what this costs at representative history sizes, so we cannot currently say whether it is a non-issue or the dominant write cost of conversational branching.

This came up in #5132 while deciding the retry model. Regenerate removal proceeds independently; this issue is about the storage model underneath all revision operations.

Desired outcome

A decision, backed by evidence, between two storage models:

A. Materialized prefix (status quo). Each Session is a self-contained unit of ownership: delete means delete its rows, export/import bundles stand alone (load-bearing since #5308), usage attribution is a group-by on sessionId, and ledger repair never crosses session boundaries.

B. Shared-prefix lineage. A revision or branch stores topology metadata (parentSessionId, throughSequence, local suffix) and shares an immutable history prefix instead of duplicating it, giving O(1) forks. The child references a fixed snapshot boundary: parent continuation, repair, or compaction must not change the prefix a child observes. Under this model, deletion becomes reference-counted (history is reclaimable only when no session references it), inherited history creates no new usage accounting facts, export materializes the dependency closure at the boundary, and physical repair preserves logical history identity while logical changes require new versions. Checkpoint materialization bounds ancestor-chain read depth; content-addressed immutable artifacts fit naturally.

Note that exclusive storage is not strict today: the context-offload copyReferences path already creates target-Session references while reusing the same blobId. Mapping that existing sharing boundary is part of the work.

The concrete deliverables, in order:

  1. Copy-cost baseline. Measure write volume, latency, and storage amplification of a revision copy at representative history sizes. This currently has no owner and no tracking issue.
  2. Sharing-boundary map. Document which payloads are already shared across Sessions (blobId reuse and friends) and which are duplicated.
  3. Prototype. A small storage-layer prototype of shared prefix + local suffix that verifies inherited-history and model-input stability across parent continuation, parent deletion, and repair, plus standalone export/import of the child. Compare write volume, read latency, and recovery cost against the materialized baseline.
  4. Decision record. The baseline informs urgency and migration timing, not the identity/accounting semantics, which should be settled explicitly.

Alternatives or workarounds

Keep materialized copies and optimize the copy path itself (selective sharing of artifact payloads, deferred copy) if the baseline shows cost concentrated in a few payload types. A hidden-sibling variant inside one Session was considered in #5132 for retry and rejected there; it does not address Edit/Branch copy cost either.

cc @likun666661 @M4n5ter — you both converged on shared lineage in #5132. @M4n5ter would you drive the prototype? @likun666661 would you co-review the lifecycle semantics? I can take the baseline measurement.

Activity

  1. likun666661 commented on Sep 17, 2026

    @likun666661
    Member

    I think the most useful mature precedent is not a branch-capable database by itself, but a combination of ideas from Git/Nix, Iceberg, and copy-on-write snapshot systems, implemented above the existing SQLite stores.

    The key refinement is that lineage should not be represented only as:

    parentSessionId + throughSequence
    

    A copy currently spans several independently stored domains: messages, Runtime Events, task ledger facts, model-call facts, artifacts, and context references. One sequence number does not necessarily define an atomic boundary across all of them. I would instead make the durable dependency an immutable conversation snapshot:

    SessionRef {
      sessionId
      headSnapshotId
      provenanceParentSessionId   // provenance/UI only
      forkSnapshotId
    }
    
    ConversationSnapshot {
      snapshotId
      parentSnapshotId
      messageRoot
      runtimeEventRoot
      taskLedgerRoot
      artifactManifestRoot
      usageFactRoot
      sourceTurnId
    }
    

    A settled Turn atomically publishes a snapshot manifest. Edit/Branch then creates a new SessionRef pointing at that exact snapshot and appends a local suffix. The child does not need the parent Session to remain alive, and later parent appends, repair, or compaction cannot change what the child observes.

    Storage shape

    I would keep SQLite and introduce an application-level immutable object store rather than replace the database:

    history_objects(
      object_id BLOB PRIMARY KEY, -- content hash
      kind TEXT NOT NULL,
      payload BLOB,
      external_locator TEXT,
      size_bytes INTEGER NOT NULL
    );
    
    history_edges(
      parent_object_id BLOB NOT NULL,
      child_object_id BLOB NOT NULL,
      PRIMARY KEY(parent_object_id, child_object_id)
    );
    
    conversation_snapshots(
      snapshot_id BLOB PRIMARY KEY,
      parent_snapshot_id BLOB,
      manifest_object_id BLOB NOT NULL,
      source_turn_id TEXT,
      created_at INTEGER NOT NULL
    );
    
    session_heads(
      session_id TEXT PRIMARY KEY,
      head_snapshot_id BLOB NOT NULL,
      provenance_parent_session_id TEXT,
      fork_snapshot_id BLOB
    );

    Messages and Runtime Events should probably be sealed into bounded immutable segments rather than stored as one object per row. The active Turn can continue using a mutable session-local tail; once it settles, the tail is sealed and a new snapshot is published. This matches the current rule that an active Session is not copyable.

    The existing context-offload path is already a useful local precedent: copyReferences creates a target-Session reference while reusing the same content-addressed blobId. The first migration step can extend that ownership/reference split to the large payloads currently copied by copyConversationArtifacts and cloneConversationRuntimeLedger, before changing every read path.

    Lifecycle semantics

    I would borrow the following rules:

    • Git: immutable logical objects, mutable refs, and physical repacking that does not change object identity.
    • Nix: dependency-closure export and GC roots.
    • Iceberg: atomic snapshot refs and explicit snapshot-retention semantics.
    • ZFS/Neon: a writable child rooted at a fixed immutable snapshot.

    Deletion should remove the Session ref, not immediately delete shared history. Reference counts may be maintained as an optimization, but I would not make them the source of truth: crashes and partial migrations can make them drift. The authoritative collector should periodically mark from all live Session heads plus temporary roots held by active operations/exports, traverse object edges, and sweep unreachable objects after a grace period.

    Usage accounting should preserve stable physical model-call identities. A Session may display inherited usage, but global spend must deduplicate by the original model-call fact rather than charge once per reachable Session.

    Export is the correct place to materialize. It should walk the selected snapshot's dependency closure and emit a self-contained bundle. The fact that export must be standalone does not require every internal fork to eagerly duplicate that closure.

    Repair and compaction must distinguish logical identity from physical representation. Rebuilding indexes, checkpoints, or packed segments may preserve a snapshot identity; changing logical history must publish a new snapshot rather than mutating content already referenced by children.

    Bounded reads

    I would avoid making every read recursively traverse an unbounded Session-parent chain. Snapshot manifests should reference immutable history segments directly, and the implementation can add checkpoint manifests or flatten equivalent physical representations when depth or segment count crosses a threshold. This keeps ordinary forks O(1) while bounding reads without changing logical snapshot identity.

    Suggested prototype order

    1. Map the current ownership/sharing boundary, especially context blobs, artifacts, Runtime Events, model-call rows, and task ledger rows.
    2. Introduce an immutable snapshot manifest covering exact cross-store boundaries for one settled Turn.
    3. Make a prototype child Session reference that snapshot and append a local suffix without copying the prefix.
    4. Verify stability across parent continuation, parent deletion, repair/compaction, restart, and child export/import.
    5. Add reachability GC and checkpoint/materialization, then compare write amplification, read latency, and recovery cost with the current materialized-copy baseline.

    This is effectively a small Git/Nix-like object database implemented on top of SQLite. It keeps SQLite's operational advantages while moving Session independence from "owns an exclusive physical copy" to "owns a stable immutable root and can be used, deleted, and exported independently."

    简体中文

    我认为最值得借鉴的不是直接采用某个“支持分支的数据库”,而是把 Git/Nix、Iceberg 和写时复制快照系统的成熟机制组合起来,继续构建在现有 SQLite 存储之上。

    关键点是:lineage 不应只表示为:

    parentSessionId + throughSequence
    

    一次会话复制横跨 messages、Runtime Events、task ledger、model-call facts、artifacts 和 context references 等多个独立存储域。单个 sequence 不一定能定义它们之间的原子边界。更稳妥的方式是把真正的数据依赖表示为不可变 conversation snapshot:

    SessionRef {
      sessionId
      headSnapshotId
      provenanceParentSessionId   // 只用于来源/UI
      forkSnapshotId
    }
    
    ConversationSnapshot {
      snapshotId
      parentSnapshotId
      messageRoot
      runtimeEventRoot
      taskLedgerRoot
      artifactManifestRoot
      usageFactRoot
      sourceTurnId
    }
    

    一个 Turn settled 后原子发布 snapshot manifest。Edit/Branch 只需创建指向该 snapshot 的新 SessionRef,随后追加自己的 local suffix。子 Session 不依赖父 Session 继续存在,父会话后续追加、repair 或 compaction 也不能改变子会话看到的历史。

    实现上建议保留 SQLite,增加应用层不可变对象存储。消息和 Runtime Events 可以封装成有界的 immutable segments;活跃 Turn 继续写 session-local mutable tail,settled 后 seal 并发布新 snapshot。这也符合当前 active Session 不可复制的约束。

    当前 context-offload 的 copyReferences 已经是很好的本地先例:目标 Session 创建新 reference,但复用同一个 content-addressed blobId。第一阶段可以先把这种“所有权引用与 payload 分离”的模型扩展到 copyConversationArtifacts 和 cloneConversationRuntimeLedger 当前复制的大 payload,再逐步改读取路径。

    生命周期语义建议如下:

    • 借鉴 Git:不可变逻辑对象、可变 refs,物理 repack 不改变对象身份。
    • 借鉴 Nix:dependency closure 导出和 GC roots。
    • 借鉴 Iceberg:原子 snapshot refs 和明确的保留策略。
    • 借鉴 ZFS/Neon:子分支固定在不可变 snapshot 上,只写自己的差异。

    删除 Session 时只删除 Session ref,不立即删除共享历史。refcount 可以作为优化,但不应作为最终正确性来源;权威 GC 应从所有 live Session heads、活跃操作和导出使用的临时 roots 开始 mark,遍历对象依赖,在 grace period 后清理不可达对象。

    Usage 应保留稳定的物理 model-call identity。单个 Session 可以展示继承用量,但全局真实成本必须按原始 model-call fact 去重,不能因为多个 Session 可达而重复计费。

    导出是合理的物化边界:遍历所选 snapshot 的完整依赖闭包,生成可独立导入的 bundle。导出需要独立,并不意味着每次内部 fork 都要提前复制完整历史。

    Repair/compaction 必须区分逻辑身份与物理表示。重建索引、checkpoint 或 packed segments 可以保持同一个 snapshot 的逻辑身份;修改逻辑历史则必须发布新 snapshot,不能原地修改已被子会话引用的内容。

    读取路径也不应递归遍历无限深的 Session 父链。Snapshot manifest 应直接引用不可变 history segments,并在深度或 segment 数超过阈值时创建 checkpoint manifest 或 flatten 等价的物理表示。这样 fork 保持 O(1),读取成本也有界。

    建议原型顺序:

    1. 梳理 context blobs、artifacts、Runtime Events、model-call rows 和 task ledger rows 的现有共享/所有权边界。
    2. 为一个 settled Turn 引入覆盖所有存储域精确边界的 immutable snapshot manifest。
    3. 让子 Session 引用该 snapshot 并追加 local suffix,不复制历史前缀。
    4. 验证父会话继续、父会话删除、repair/compaction、重启,以及子会话 export/import 后历史仍稳定。
    5. 增加 reachability GC 和 checkpoint/materialization,并与当前物理复制方案比较写放大、读取延迟和恢复成本。

    本质上,这是一个构建在 SQLite 上的小型 Git/Nix 式对象数据库。它保留 SQLite 的运维优势,同时把 Session 独立性的定义从“拥有一份物理独占副本”改为“拥有一个稳定不可变 root,并可独立使用、删除和导出”。

  2. M4n5ter commented on Sep 17, 2026

    @M4n5ter
    Member

    @likun666661 I approve of your proposal

  3. Astro-Han commented on Sep 17, 2026

    @Astro-Han
    ContributorAuthor

    The snapshot-manifest direction looks right. Making the durable dependency an immutable cross-store manifest instead of parentSessionId + throughSequence is the important correction, since a single sequence cannot define an atomic boundary across messages, events, ledger, model_call, and artifacts. Mark-sweep over live heads plus temp roots with a grace period, refcount as optimization only, and extending the copyReferences ownership/payload split before touching readers are all the right calls.

    Four things to settle before the prototype:

    Read-path blast radius. The object store is four tables, but the expensive part is every history reader moving from WHERE session_id = ? to manifest → segments → mutable tail assembly. The real consumers are transcript load, the model-history projection, the revision-consistent usage reads from #5387, ledger repair, export/import verification, eval readers, and context offload. A prototype that only proves "child appends a local suffix" measures a fake number. Port one real consumer, ideally transcript, to price the assembly cost honestly.

    Migration. Agreed this is a full migration, no permanent dual path. One second-order decision to record: seal everything at upgrade (blocking startup on large histories) versus background sealing, where unsealed sessions still read the old way during a converging transition window. The latter is not a dual-path design, but the design should admit the window exists. A conditional benefit worth checking: sibling revisions that were materialized copies of the same prefix would dedup automatically during sealing if segments hash canonical logical content rather than raw row bytes, since copied rows may carry remapped ids. Which identity a segment hashes is itself a design decision.

    GC roots must be durable and leased. Temporary roots held by active operations cannot live in process memory. The hazard: an export resolves snapshot S and walks its closure while the user deletes the session; the head ref disappears, GC sweeps S's segments, and the export reads freed objects mid-flight. Same class for a fork that resolved forkSnapshotId but has not committed its session_heads row when the source session is deleted. The fix is a gc_roots table (snapshotId plus expiry) that survives crashes and whose leases expire so a crashed operation cannot leak roots. Mark starts from live heads union unexpired roots. This is the same hazard class as the copy-attempt window from #5132: the gap between "the operation decided it needs these objects" and "the objects hang off a permanent root" needs a crash-surviving placeholder.

    Settle-path overhead. A settled turn atomically publishing a snapshot means every turn completion, the hottest path in the system, seals the mutable tail into segments, hashes payloads (re-reading the bytes), writes object/edge/manifest/snapshot rows, and updates session_heads. Constant-ish work, but on the latency-critical path. The prototype should measure settle cost separately from fork savings.

    中文版

    snapshot manifest 方向是对的。把持久依赖从 parentSessionId + throughSequence 改成不可变的跨存储 manifest 是关键修正:单个 sequence 无法给 messages、events、ledger、model_call、artifacts 定义原子边界。mark-sweep + 宽限期、refcount 只作优化、先把 copyReferences 的引用/payload 分离扩展到大 payload 再动读路径,这几个判断都对。

    动工前有四件事要定下来:

    读路径爆炸半径。 对象存储只有四张表,但真正贵的是每个读历史的地方从 WHERE session_id = ? 换成 manifest → segments → mutable tail 的拼装。真实消费者:transcript 加载、model-history 投影、#5387 的 revision-consistent usage 读、ledger 修复、导出/导入校验、eval 读取、context offload。原型只验证"子 Session 能追加 local suffix"量出来的是假数字,应该移植一个真实消费者(建议 transcript)才能定出真实成本。

    迁移。 同意全量迁移,不留永久双路径。有一个二级决策要记录:升级时全量 seal(大历史库会阻塞启动)还是后台 seal(迁移窗口内未 seal 会话仍走旧读法)。后者不算双路径设计,但设计里要承认这个窗口存在。一个待验证的收益:如果 segment 按规范化逻辑内容哈希而非原始行字节,物化复制的兄弟修订在 seal 时会自动去重;复制行可能带重映射的 id,所以"segment 哈希什么身份"本身就是一个设计决策。

    GC 临时 roots 必须 durable 且带租约。 活跃操作持有的临时 root 不能只活在进程内存里。危险场景:导出解析出 snapshot S 开始遍历闭包,此时用户删除会话,head ref 消失,GC 扫掉 S 的 segment,导出读到一半对象没了。同类场景:fork 解析了 forkSnapshotId 但还没提交 session_heads 行时源会话被删。解法是一张 gc_roots 表(snapshotId + 过期时间),崩溃后仍在,租约过期保证崩溃操作不泄漏 root。mark 从 live heads 并上未过期 roots 出发。这和 #5132 的 copy-attempt 窗口是同族危险:"操作决定需要这批对象"和"对象挂上永久 root"之间的缝隙需要一个能活过崩溃的占位。

    settle 路径开销。 "settled turn 原子发布 snapshot"意味着每次 turn 完成(全系统最热的路径)都要把 mutable tail 封成 segment、对 payload 算哈希(重读字节)、写 object/edge/manifest/snapshot 行、更新 session_heads。常数级开销但打在延迟敏感点上,原型要单独测 settle 成本,不能只量 fork 省了多少。

  4. likun666661 commented on Sep 17, 2026

    @likun666661
    Member

    After inspecting the current Session execution and read paths more closely, I think the previous proposal over-models the problem by starting from a generic cross-store snapshot database.

    Maka already has a much stronger primitive: the Session's semantic history is append-only.

    • runtime_events is the canonical factual log.
    • Each invocation has an immutable event_seq.
    • runtime_session_event_ordinals gives the Session a durable append order across invocations.
    • transcript messages, Turn status, model history, terminal state, recovery, and compaction are projections over that log.
    • runtime_continuation_claims already binds execution to an immutable prefix using a high-water mark and prefix digest.

    That suggests a simpler Session branch model:

    ChildSession = Checkout(ParentSession, PrefixCheckpoint) + ChildLocalEvents
    

    or, as a logical history view:

    History(child)
      = Resolve(parent)[0..checkpoint]
      ++ LocalEvents(child)
    

    The durable relationship could be approximately:

    interface SessionLineage {
      parentSessionId: string
      checkpoint: {
        throughOrdinal: number
        throughTurnId: string
        prefixDigest: string
      }
    }

    The checkpoint must refer to a settled, immutable Session prefix. It is the child's persistent checkout of the parent's execution scene. The parent may continue appending after the fork, but the child never observes anything beyond the recorded checkpoint.

    This also gives the permission model a precise meaning: the child does not receive general read access to the parent's current state. It receives a durable capability to read exactly the inherited immutable prefix. It cannot append to the parent, follow the parent's moving head, or read later events.

    The child then writes only its own suffix. Inherited RuntimeEvents keep their original event, Run, invocation, and model-call identities. They appear in the child's logical history without being rewritten into new physical execution facts.

    This is where the current conversation-copy implementation creates most of its own complexity. cloneConversationRuntimeLedger physically clones the source Runs and RuntimeEvents, regenerates IDs, and then rewrites tool-operation IDs, provider trace IDs, artifact addresses, transition references, and other dependent identities. Once we stop pretending inherited facts are newly executed facts, most of that rewrite graph disappears.

    Context scheduling should operate on the resolved logical history rather than on rows physically owned by one Session:

    Inherited parent prefix
            +
    Child-local event suffix
            |
            v
    Session history resolver
            |
            +--> transcript projection
            +--> model-context projection
            +--> compaction checkpoint
            +--> tool-result pruning/offload
            +--> export/materialization
    

    Parent and child can therefore produce different context projections over their respective histories while sharing the same immutable facts before the checkpoint. Compaction remains a projection; it must not rewrite or duplicate the inherited log.

    The word "snapshot" is still useful here, but it should mean a logical checkout checkpoint, not an eager physical copy of every store. Non-log state that is genuinely part of the execution scene can be attached to the checkpoint by immutable version reference:

    • workspace state: a Git tree / workspace snapshot ID;
    • immutable artifacts and context blobs: shared content references with branch-local authorization/ownership metadata;
    • another canonical append-only ledger: its own high-water and digest;
    • pending queues or unfinished interactions: normally excluded rather than inherited.

    So the Session topology supplies lineage, the prefix checkpoint supplies the stable checkout boundary, and the history resolver maps that topology into the real context to project and schedule.

    A more direct prototype would therefore be:

    1. Add a parent Session + immutable prefix-checkpoint relation.
    2. Teach one real reader, preferably transcript plus model-history assembly, to resolve inherited prefix + local suffix.
    3. Append a new Turn only to the child suffix.
    4. Verify that parent continuation does not affect the child, inherited event identities remain stable, and both Sessions can compact independently.
    5. Bind workspace/artifact versions at the same checkpoint boundary without cloning their payloads.
    6. Adjust deletion so removing the parent catalog entry cannot invalidate a prefix still referenced by a child.

    This is closer to persistent branch/checkout semantics over an event log than to implementing general MVCC over all existing SQLite tables. Append-only history makes the switch natural: changing Session means selecting a different logical head and projecting context from that head.

    简体中文

    进一步检查当前 Session 的执行和读取路径后,我认为之前的方案从“通用跨 store snapshot 数据库”出发,把问题设计得过重了。

    Maka 已经有一个更强的基础:Session 的语义历史本身就是 append-only 的。

    • runtime_events 是权威事实日志;
    • 每个 invocation 内有不可变的 event_seq;
    • runtime_session_event_ordinals 给出了跨 invocation 的 Session 级追加顺序;
    • transcript、Turn 状态、模型历史、terminal state、恢复和 compaction 都是这份日志的投影;
    • runtime_continuation_claims 已经通过 high-water 和 prefix digest 绑定不可变历史前缀。

    因此 Session branch 可以更简单地表达为:

    ChildSession = Checkout(ParentSession, PrefixCheckpoint) + ChildLocalEvents
    

    或者:

    History(child)
      = Resolve(parent)[0..checkpoint]
      ++ LocalEvents(child)
    

    持久化关系大致可以是:

    interface SessionLineage {
      parentSessionId: string
      checkpoint: {
        throughOrdinal: number
        throughTurnId: string
        prefixDigest: string
      }
    }

    checkpoint 必须落在 settled、不可变的 Session prefix 上。它就是子 Session 对父 Session 现场的持久 checkout。父 Session 后续可以继续追加,但子 Session 永远看不到 checkpoint 之后的事件。

    这里的权限语义也很明确:子 Session 不是获得读取父 Session 当前状态的通用权限,而是获得读取指定不可变 prefix 的持久 capability。它不能向父 Session 写入、不能跟随父 Session 的移动 head,也不能读取之后追加的事件。

    子 Session 后续只写自己的 suffix。继承的 RuntimeEvents 保持原始 event、Run、invocation 和 model-call identity,只出现在子 Session 的逻辑历史中,不被重写成一套新的物理执行事实。

    当前 conversation-copy 的大部分复杂度正是由物理复制制造出来的。cloneConversationRuntimeLedger 会复制 Runs 和 RuntimeEvents、重新生成 ID,然后重写 tool-operation ID、provider trace ID、artifact 地址和各种内部引用。一旦不再把继承事实伪装成重新执行的事实,这张 identity rewrite 图大部分都可以消失。

    上下文调度应该基于解析后的逻辑历史,而不是只读取某个 Session 物理拥有的行:

    父 Session 的继承 prefix
            +
    子 Session 的本地 suffix
            |
            v
    Session history resolver
            |
            +--> transcript projection
            +--> model-context projection
            +--> compaction checkpoint
            +--> tool-result pruning/offload
            +--> export/materialization
    

    父子 Session 可以基于各自的逻辑历史产生不同的上下文投影,同时共享 checkpoint 之前完全相同的不可变事实。Compaction 仍然只是 projection,不能重写或复制继承日志。

    这里仍然可以使用 “snapshot” 这个词,但它应该表示逻辑 checkout checkpoint,而不是立即物理复制所有 store。真正属于执行现场的非日志状态,可以通过不可变版本引用绑定到 checkpoint:

    • workspace 状态:Git tree 或 workspace snapshot ID;
    • immutable artifacts 和 context blobs:共享内容引用,加 branch-local 权限/所有权 metadata;
    • 其他 canonical append-only ledger:记录各自的 high-water 和 digest;
    • pending queue 或未完成 interaction:通常不继承。

    因此,Session topology 定义 lineage,prefix checkpoint 定义稳定 checkout 边界,history resolver 再从这个逻辑关系映射出真正需要投影和调度的上下文。

    更直接的原型顺序应该是:

    1. 增加 parent Session + immutable prefix checkpoint 关系;
    2. 先让一个真实 reader(建议 transcript 和 model-history assembly)支持 inherited prefix + local suffix;
    3. 新 Turn 只追加到 child suffix;
    4. 验证 parent 后续继续不会影响 child、继承 event identity 保持稳定、父子可独立 compaction;
    5. 在相同 checkpoint 边界绑定 workspace/artifact version,但不复制 payload;
    6. 调整删除语义,使删除父 Session 的 catalog entry 不会破坏仍被子 Session 引用的 prefix。

    这更像是在 append-only event log 上建立持久 branch/checkout,而不是给现有所有 SQLite 表实现通用 MVCC。Append-only history 让 Session 切换非常自然:切换 Session 本质上就是选择另一个逻辑 head,然后从该 head 投影上下文。

  5. likun666661 commented on Sep 17, 2026

    @likun666661
    Member

    Putting the two halves together, I think the complete Session-fork model can be substantially simpler than either a generic snapshot database or a new virtual filesystem.

    A fork is defined by two immutable coordinates captured at the same execution boundary:

    interface SessionCheckout {
      parentSessionId: string
    
      contextCheckpoint: {
        throughOrdinal: number
        throughTurnId: string
        prefixDigest: string
      }
    
      workspaceSnapshotId: string
    }

    The context side is a checkout over the append-only Runtime Event log:

    History(child)
      = Resolve(parent)[0..contextCheckpoint]
      ++ LocalEvents(child)
    

    The workspace side uses the corresponding filesystem snapshot as the base of an isolated writable checkout:

    Workspace(child)
      = WritableCheckout(workspaceSnapshotId)
    

    This means a child Session does not own a copied conversation and does not keep following the parent's workspace. It owns a stable pair:

    (runtime-event prefix, workspace snapshot)
    

    Everything after that boundary is branch-local: new Runtime Events are appended to the child suffix, and file changes are written only to the child's writable checkout.

    Fork protocol

    The operation can be made recoverable with a small ordering rule:

    1. Enter a Session/workspace barrier: the Runtime Event prefix must be settled, and managed workspace writers must be quiesced.
    2. Seal the current workspace into a durable immutable snapshot S.
    3. In one metadata transaction, create the child Session lineage and bind its context checkpoint to S.
    4. Materialize the child's writable checkout from S. This can be lazy because it is reconstructible from the durable snapshot.
    5. Release the barrier. Parent and child now continue independently.

    If the process crashes after step 2 but before step 3, S is an unreferenced snapshot that can be reclaimed. If it crashes after step 3 but before step 4, the child checkout can simply be materialized again from S. The durable state never points at something that cannot be reconstructed.

    The workspace barrier is the only essential consistency requirement for the V0 backend. A recursive clone while compilers, language servers, or commands are still writing could otherwise capture files from different moments.

    V0 workspace backend

    The first implementation does not need OverlayFS, a Merkle object store, or content-addressed manifests. It only needs this interface:

    interface WorkspaceBackend {
      seal(workspacePath: string): Promise<WorkspaceSnapshotId>
      checkout(
        snapshotId: WorkspaceSnapshotId,
        sessionId: SessionId,
      ): Promise<string>
    }

    A practical V0 implementation is a private immutable snapshot directory plus a writable copy-on-write clone:

    .maka/workspaces/
      snapshots/
        <snapshotId>/root/       # backend-owned, never writable by an agent
      checkouts/
        <sessionId>/root/        # writable Session workspace
    

    seal recursively reflink-clones the current workspace into snapshots/<id>/root. checkout reflink-clones that snapshot into checkouts/<sessionId>/root and exposes only the checkout to the agent.

    • On macOS this can use clonefile/copyfile cloning.
    • On Linux it can use FICLONE or cp --reflink where supported.
    • On unsupported filesystems it falls back to a normal recursive copy.

    The semantics stay identical; only the cost changes. On reflink-capable filesystems, unchanged file data blocks remain shared and writes copy only the modified blocks. The operation is still O(number of directory entries), which is acceptable for a first prototype and gives us an honest baseline.

    workspaceSnapshotId can initially be an opaque UUID mapped to the immutable snapshot directory. It does not need to be a content hash. Filesystem-level COW already provides the important storage sharing; content addressing can be introduced later only if cross-snapshot deduplication, remote transport, or verification justifies it.

    Git should not define the workspace snapshot contract. A real execution scene may contain dirty tracked files, untracked/ignored files, generated outputs, symlinks, and may not be a Git repository at all. A Git tree/worktree backend can still be an optimization for source-only workspaces, but the logical contract should remain a complete filesystem checkout.

    If recursive reflink metadata traversal later becomes the bottleneck, the same interface can be backed by OverlayFS, Btrfs/ZFS/APFS snapshots, or another native snapshot mechanism without changing Session semantics.

    Resulting architecture

    Session topology
      + immutable Runtime Event prefix checkpoint
      + immutable workspace snapshot ID
                        |
                        v
                  Session checkout
                        |
              +---------+----------+
              |                    |
      history resolver      workspace materializer
              |                    |
      parent prefix +       snapshot + local writes
      child suffix
    

    Transcript projection, model-context assembly, compaction, and export operate on the resolved logical history. Tools and commands operate inside the materialized writable workspace. Both are fixed by the same fork boundary.

    Deletion also becomes a reference-lifetime question rather than a copy question: removing the parent Session catalog entry must not invalidate either the inherited event prefix or the workspace snapshot while a child checkout still references them. The exact retention mechanism can be designed independently of the fork/read path.

    So the prototype does not need to implement general MVCC, a branch-capable database, or a filesystem. It needs two small resolvers over immutable state:

    context checkout   = event-log prefix + local suffix
    workspace checkout = immutable directory snapshot + local COW writes
    

    That appears to be the natural end-to-end consequence of Maka already being append-only.

    简体中文

    把上下文和文件现场两部分合起来后,我认为完整的 Session fork 模型可以比通用 snapshot 数据库或新虚拟文件系统简单得多。

    一次 fork 只需要在同一个执行边界固定两个不可变坐标:

    interface SessionCheckout {
      parentSessionId: string
    
      contextCheckpoint: {
        throughOrdinal: number
        throughTurnId: string
        prefixDigest: string
      }
    
      workspaceSnapshotId: string
    }

    上下文侧是在 append-only Runtime Event log 上做 checkout:

    History(child)
      = Resolve(parent)[0..contextCheckpoint]
      ++ LocalEvents(child)
    

    文件侧则以对应的文件系统 snapshot 为基础,建立隔离的可写 checkout:

    Workspace(child)
      = WritableCheckout(workspaceSnapshotId)
    

    也就是说,子 Session 不拥有一份复制出来的 conversation,也不会继续跟随父 Session 的 workspace。它拥有的是一个稳定的二元组:

    (runtime-event prefix, workspace snapshot)
    

    边界之后的状态全部归属于各自分支:新的 Runtime Events 只追加到 child suffix,文件修改只写入 child 的 writable checkout。

    Fork 协议

    通过一个简单的顺序约束,就可以让操作具备崩溃恢复能力:

    1. 进入 Session/workspace barrier:Runtime Event prefix 必须已经 settled,受 Maka 管理的 workspace writer 必须暂停。
    2. 把当前 workspace seal 成持久不可变 snapshot S。
    3. 在一个 metadata transaction 中创建 child Session lineage,并把 context checkpoint 和 S 绑定。
    4. 从 S 物化 child 的 writable checkout。因为它可以从持久 snapshot 重建,所以允许延迟执行。
    5. 释放 barrier,父子 Session 从此独立继续。

    如果进程在步骤 2 之后、步骤 3 之前崩溃,S 只是一个没有引用的 snapshot,可以后续回收。如果在步骤 3 之后、步骤 4 之前崩溃,则直接从 S 重新物化 child checkout。持久状态不会指向无法重建的对象。

    Workspace barrier 是 V0 后端唯一关键的一致性要求。否则在递归 clone 过程中,编译器、语言服务器或命令仍在写文件时,snapshot 可能混入不同时刻的文件状态。

    V0 workspace backend

    第一版不需要 OverlayFS、Merkle object store 或 content-addressed manifest,只需要下面的接口:

    interface WorkspaceBackend {
      seal(workspacePath: string): Promise<WorkspaceSnapshotId>
      checkout(
        snapshotId: WorkspaceSnapshotId,
        sessionId: SessionId,
      ): Promise<string>
    }

    实际实现就是私有的不可变 snapshot 目录,加一个可写的 COW clone:

    .maka/workspaces/
      snapshots/
        <snapshotId>/root/       # 只归 backend 管理,绝不交给 agent 写
      checkouts/
        <sessionId>/root/        # Session 的可写 workspace
    

    seal 把当前 workspace 递归 reflink clone 到 snapshots/<id>/root。checkout 再把 snapshot reflink clone 到 checkouts/<sessionId>/root,只把后者暴露给 agent。

    • macOS 可以使用 clonefile/copyfile cloning;
    • Linux 可以在支持时使用 FICLONE 或 cp --reflink;
    • 不支持 reflink 的文件系统退化为普通递归复制。

    三种实现的语义完全相同,只是成本不同。在支持 reflink 的文件系统上,未修改文件的数据块保持共享,只有被写入的数据块发生复制。整个操作仍然是 O(目录项数量),但作为第一版原型是合理的,也能给出真实基线。

    workspaceSnapshotId 第一版可以只是映射到不可变目录的 opaque UUID,不需要是内容哈希。文件系统 COW 已经解决了最重要的存储共享;只有在跨 snapshot 去重、远程传输或完整性校验确实需要时,再引入 content addressing。

    Git 不应该定义 workspace snapshot 的语义。真实执行现场可能包含 dirty tracked files、untracked/ignored files、生成物和符号链接,甚至根本不是 Git 仓库。Git tree/worktree backend 仍然可以作为 source-only workspace 的优化,但上层契约应该始终表示完整的 filesystem checkout。

    如果未来递归 reflink 的目录元数据遍历成为瓶颈,同一接口可以替换为 OverlayFS、Btrfs/ZFS/APFS snapshot 或其他原生快照机制,而不改变 Session 语义。

    最终结构

    Session topology
      + immutable Runtime Event prefix checkpoint
      + immutable workspace snapshot ID
                        |
                        v
                  Session checkout
                        |
              +---------+----------+
              |                    |
      history resolver      workspace materializer
              |                    |
      parent prefix +       snapshot + local writes
      child suffix
    

    Transcript projection、model-context assembly、compaction 和 export 都基于解析后的逻辑历史。工具和命令则运行在物化出的 writable workspace 中。两者由同一个 fork boundary 固定。

    删除问题也从复制问题变成引用生命周期问题:只要 child checkout 仍在引用,删除父 Session 的 catalog entry 就不能破坏继承的 event prefix 或 workspace snapshot。具体 retention 机制可以独立于 fork/read path 设计。

    因此,原型不需要实现通用 MVCC、支持 branch 的数据库或一个新文件系统。它只需要两个基于不可变状态的小型 resolver:

    context checkout   = event-log prefix + local suffix
    workspace checkout = immutable directory snapshot + local COW writes
    

    这应该就是 Maka 已经采用 append-only 模型后最自然的端到端结果。

  6. likun666661 commented on Sep 17, 2026

    @likun666661
    Member

    I think there is an even simpler product-level solution, and this is the approach I would now prototype first.

    Instead of preserving a durable parent-prefix dependency, create a completely new Session and physically seed it with a copy of the source Session's resolved context. Then append one bootstrap message asking the new Agent to reconstruct the workspace itself.

    clone Session
      = create new Session
      + copy resolved context
      + append workspace-recovery instruction
      + run the new Agent
    

    The important distinction is that we copy the context projection the model actually consumes, not the entire graph of Runtime Events, Runs, invocations, model-call facts, task-ledger rows, and rewritten execution identities.

    Clone flow

    1. Wait until the source Session's current Turn is settled.
    2. Resolve its complete model-visible context after compaction/offload.
    3. Create a new independent Session.
    4. Materialize the resolved context as that Session's initial context.
    5. Optionally record originSessionId for provenance/UI only.
    6. Append an internal bootstrap message instructing the Agent to inspect and restore the workspace.
    7. Run the new Agent until the workspace is ready or user input is required.
    

    Conceptually:

    async function cloneSession(sourceSessionId: string) {
      const context = await resolveModelContext(sourceSessionId)
    
      const child = await createSession({
        originSessionId: sourceSessionId, // provenance only
        initialContext: cloneContext(context),
      })
    
      await appendInternalMessage(child.id, {
        type: "restore_workspace",
        content: `
          This Session was cloned from ${sourceSessionId}.
          Reconstruct the expected workspace from the inherited context.
          Inspect the current repository and filesystem state first.
          Avoid destructive checkout over unrelated dirty changes.
          Prefer an isolated Git worktree when appropriate.
          Report when the workspace is ready, or ask for help if the
          expected state cannot be reconstructed safely.
        `,
      })
    
      await runAgent(child.id)
      return child
    }

    The new Agent already has the inherited conversation, commands, tool results, commits, patches, and reasoning that are still present in the resolved context. It can inspect the actual environment and choose the appropriate recovery operation:

    • continue when the workspace already matches;
    • check out the expected commit or branch;
    • create an isolated Git worktree;
    • replay a patch or reconstruct a change;
    • stop and ask the user when dirty or conflicting state makes automatic recovery unsafe.

    A successful recovery can append an ordinary durable fact such as WorkspaceCheckoutCompleted. Failure is not a storage-layer consistency problem; it becomes a normal Agent interaction that explains what is missing or conflicting.

    Resulting semantics

    Every cloned Session is self-contained:

    source Session ──copy resolved context──> new Session
    

    originSessionId is only tree/provenance metadata. It is not consulted by transcript reads, model-history assembly, deletion, compaction, or export. The source Session may continue, compact, or be deleted without changing the child.

    The workspace is deliberately not part of the Session clone transaction:

    Maka checks out the Agent's memory.
    The Agent checks out its working world.
    

    This removes the need for:

    • parent-prefix resolution on every read;
    • immutable prefix capabilities and parent-lifetime rules;
    • lineage-aware compaction;
    • cross-Session read authorization;
    • workspace snapshot IDs and filesystem snapshot backends;
    • atomic context/filesystem checkpoints;
    • workspace snapshot retention and GC.

    The tradeoff is intentional: cloning becomes O(resolved context size) and duplicates context storage. In return, each Session is operationally independent and the long-term topology/read-path complexity disappears. We should measure copy latency and bytes, but this is likely the right place to spend resources if Session contexts are reasonably bounded by compaction.

    This makes the tree clone a composition of existing Agent capabilities rather than a new storage system:

    copy memory -> append recovery task -> Agent restores workspace -> ready
    
    简体中文

    我认为还有一个更简单的产品层方案,而且现在应该优先原型验证这个方案。

    不再让新 Session 持久依赖父 Session 的 prefix,而是直接创建一个完全独立的新 Session,把源 Session 已解析完成的上下文物化复制过去,然后在尾部追加一条 bootstrap message,让新 Agent 自己恢复工作现场。

    clone Session
      = 创建新 Session
      + 复制 resolved context
      + 追加 workspace recovery 指令
      + 运行新 Agent
    

    关键区别是:只复制模型真正消费的 context projection,而不是复制整张 Runtime Events、Runs、invocations、model-call facts、task ledger 和执行 identity rewrite 图。

    Clone 流程

    1. 等源 Session 当前 Turn settled。
    2. Resolve 出经过 compaction/offload 后的完整 model-visible context。
    3. 创建一个全新且独立的 Session。
    4. 把 resolved context 物化为新 Session 的 initial context。
    5. 可选记录 originSessionId,但只用于 provenance/UI。
    6. 追加一条内部 bootstrap message,要求 Agent 检查并恢复 workspace。
    7. 运行新 Agent,直到 workspace ready 或需要用户介入。
    

    概念代码:

    async function cloneSession(sourceSessionId: string) {
      const context = await resolveModelContext(sourceSessionId)
    
      const child = await createSession({
        originSessionId: sourceSessionId, // 只用于 provenance
        initialContext: cloneContext(context),
      })
    
      await appendInternalMessage(child.id, {
        type: "restore_workspace",
        content: `
          This Session was cloned from ${sourceSessionId}.
          Reconstruct the expected workspace from the inherited context.
          Inspect the current repository and filesystem state first.
          Avoid destructive checkout over unrelated dirty changes.
          Prefer an isolated Git worktree when appropriate.
          Report when the workspace is ready, or ask for help if the
          expected state cannot be reconstructed safely.
        `,
      })
    
      await runAgent(child.id)
      return child
    }

    新 Agent 已经拥有 resolved context 中保留下来的对话、命令、tool result、commit、patch 和推理。它可以检查真实环境,并自行选择合适的恢复方式:

    • workspace 已经匹配时直接继续;
    • checkout 预期 commit 或 branch;
    • 创建隔离的 Git worktree;
    • 重放 patch 或重新构造修改;
    • dirty/conflicting state 无法安全自动处理时停止并询问用户。

    恢复成功后,可以追加一个普通的持久事实,例如 WorkspaceCheckoutCompleted。恢复失败也不再是存储层一致性问题,而是普通 Agent interaction:说明缺少或冲突的状态,并请求用户处理。

    最终语义

    每个 clone 出来的 Session 都是自包含的:

    source Session ──复制 resolved context──> new Session
    

    originSessionId 只是树形展示和来源 metadata。Transcript 读取、model-history assembly、删除、compaction 和 export 都不依赖它。源 Session 后续继续、压缩或删除,都不会改变 child。

    Workspace 被有意排除在 Session clone transaction 之外:

    Maka checkout Agent 的记忆。
    Agent checkout 自己的工作世界。
    

    因此不再需要:

    • 每次读取时解析 parent prefix;
    • immutable prefix capability 和 parent lifetime 规则;
    • lineage-aware compaction;
    • 跨 Session 读取权限;
    • workspace snapshot ID 和文件系统 snapshot backend;
    • context/filesystem 原子 checkpoint;
    • workspace snapshot retention 和 GC。

    这是一个有意接受的取舍:clone 成本变为 O(resolved context size),并重复占用 context 存储。作为交换,每个 Session 在运行和生命周期上完全独立,长期的拓扑与读取路径复杂度消失。如果 Session context 已经被 compaction 控制在合理范围内,那么复制 latency 和 bytes 很可能是值得付出的成本。

    最终,树形 clone 只是现有 Agent 能力的组合,而不是一套新的存储系统:

    复制记忆 -> 追加恢复任务 -> Agent 恢复 workspace -> ready
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions