yoi/tickets/persistence-semantics.md
2026-05-13 05:42:55 +09:00

11 KiB
Raw Blame History

永続化層のセマンティック整理

背景

現在の永続化は SessionId 単位の append-only JSONL log を中心に構成されている。これは実装上は扱いやすい一方で、今後 Pod 単位永続化、compaction、fork、DB backend 追加などを進めるにあたり、以下の概念が混ざり始めている。

  • ユーザー視点の「同じ会話 / 作業の継続単位」
  • Pod 視点の「現在 active な会話状態」
  • append-only log の物理的 / 復元上の単位
  • compaction によって生成される新しい履歴系列
  • fork の起点となる履歴中の境界
  • runtime dir に置かれる一時状態と、data dir / DB に置く永続正本

特に、現在は compaction によって新しい SessionId が発行される。これは append-only log の低レベル単位としては自然だが、ユーザー視点では「同じ会話が継続している」とも見えるため、Session という名称・粒度が今後の設計上あいまいになり得る。

このチケットでは、実装変更に入る前に、永続化層のドメイン概念・名称・責務境界を整理する。

目的

  • 永続化層で扱う概念を、ユーザー視点 / Pod 視点 / storage 視点に分けて定義する。
  • SessionId が今後も適切な中心概念か、あるいは別概念に分解すべきかを判断する。
  • compaction / fork / resume / Pod state / spawned child registry が、どの粒度のデータに属するかを決める。
  • 将来 DB backend を追加しても歪みにくいデータ構造を設計する。
  • 既存の session-store JSONL 実装から段階的に移行できる命名・API 境界を決める。

結論: Session / Segment / Entry の 3 階層

Session  ← ユーザー視点の会話の家系fork tree 全体。Segment 群の grouping
  └── Segment  ← Compaction / Fork で切れる物理 append-only 単位(現在の SessionId 相当)
        └── Entry  ← 1 永続化イベント

セマンティクスの対応:

  • resume = 同 Session 内の指定 leaf Segment に append
  • compaction = 同 Session, new Segmentlineage: compacted_from
  • fork (live / 過去いずれも) = 同 Session, new Segmentlineage: forked_from
  • branching は Session 内で完結する。Segment 間が DAG、Segment 内は完全 linear。
  • Session は分岐ツリーを持つ静的な構造。「今どの Segment に書いているか」(current active Segment) は Pod state 側が動的に保持する。

文脈合成 (merge / cherry-pick) は今後の要件として想定しないため、entry レベルの DAG任意の親 pointerは採用しない。Segment 内 linear + Segment 間 DAG の 2 階層に閉じることで、同 Segment 内は (segment_id, seq) の連番 PK で直線探索でき、Segment 間の lineage は粗粒度な DAG として軽量に管理できる。

fork で新しい Session を切らないのは、compaction で Segment を切るのと fork で Segment を切るのが対称な操作であり、ユーザー視点でも「同じ起源から派生した枝」として 1 つの単位で扱う方が自然なため。これにより Session 数が分岐で爆発せず、WHERE session_id = ? だけで fork tree 全体が取れる。

Restore / Resume の API

Session が分岐を含むため、resume の指定は (SessionId, leaf SegmentId) の組 で行う:

  • TUI 経路: ユーザーは Session を選択 → その Session 内の leaf を選択
  • 内部 API: restore は (SessionId, SegmentId) を取り、指定 Segment から replay する。leaf 以外を指定すれば read-only な過去状態の参照も可能。
  • 「最後に active だった leaf」は Pod state が保持するので、TUI が初期選択候補として使える。

既存コードとの対応

既存 新名称
SessionId SegmentId にリネーム
(なし) SessionId 新設Segment 群をまとめる、ユーザー視点 ID

session-store crate の型・関数は Segment 中心の命名に揃える。crate 名自体を変えるかは別論点(中身は引き続き Segment 単位の append-only log を扱う)。

llm-worker への影響

llm-worker は session 概念を持たない(Workerhistory / turn_count / RequestConfig を持つだけで永続化への hook も無い。Session / Segment 階層の導入は llm-worker 層に染み出さず、影響範囲は session-store / pod / pod-registry / pod-cli に閉じる。

Entry hash の廃止

現状、各 entry は SHA-256 hash chain (prev_hashhash) を持つが、実際に効いている用途は 2 つだけ:

  1. ensure_head_or_fork (pod.rs:1348) — store の末尾と Pod の保持する head_hash を比較し、不一致なら auto-fork。末尾識別子があれば良いhash chain そのものは要らない)。
  2. fork_at(source_id, at_hash) (session.rs:425) — 過去 entry pointer から fork。pod-session-fork.md の入口仕様は turn number で、entry hash は内部 pointer に過ぎない。turn boundary (TurnEnd entry の index) で代替可能。

改竄検知 (tamper-evident chain) は宣伝されているがコード上は walk して verify するルートが無いため、削除しても regression にならない。

廃止に伴う対応:

  • HashedEntry 廃止、JSONL は 1 行 1 LogEntry
  • SessionOrigin.at_hashat_turn_index (TurnEnd 由来) に置換。
  • ensure_head_or_fork の検知ロジックは、Segment 末尾の terminal marker entry または末尾 seq 比較に置換(形式は実装時に決める)。

Fork: 2 種類の書き込み方

Session 境界の話ではなく 元 Segment への marker 書き込みの有無で 2 種類を分ける。Session はどちらの場合も同じで、新 Segment が同 Session 内に生える。

  • live auto-forkconcurrent writer 検知)
    • 元 Segment の末尾に terminal marker (Forked { to: SegmentId } 等) を append → 以降の writer は marker を見て新 Segment へ自動移動。
    • CoW semantics: 元 Segment は immutable、生まれた Segment 同士は対等な兄弟。
  • 過去 forkUI で turn 選択)
    • 元 Segment は 無変更、replay して新 Segment を生やすだけ。
    • 起点は (source_segment_id, at_turn_index)
    • 元 Segment に書き込まないため、過去 fork を nested に重ねても解釈が単純。

両者を同じ marker で扱おうとすると、過去 fork から更に過去で fork した場合に元 Segment への marker 位置解釈が複雑化して破綻するため、過去 fork 側は元 Segment に触れない方針で固定する。

fork の物理操作(物理コピーは不要)

「fork = 同 Session 内に Segment を増やす」と言っても、過去 Segment を丸ごとコピーする必要は無い:

  • source segment の seq=N までを replay して得られた Vec<Item> を、新 Segment の seed entry (SessionStart 相当) に 1 回書き込む。
  • compaction の transitive な圧縮効果がここに乗るsource segment の SessionStart.history に過去 Segment の compaction 結果が既に埋まっている)ので、source segment 1 本だけ replay すれば fork seed が作れる。さらに過去の Segment は touch しない。
  • 新 Segment が持つ lineage 情報は (parent_segment_id, fork_at_turn) のメタデータのみ。

これは現状の fork_at (session.rs:430-456) の挙動と同じで、Session 階層が乗っても操作は変わらない。

RDB backend を想定した概念モデル

将来 DB backend を追加しても歪みにくい形として、以下の関係を仮定する:

sessions (
  id PK,
  ...                                  -- ユーザー視点 metadata
)

segments (
  id PK,
  session_id FK,
  parent_segment_id FK NULL,           -- compaction / fork の元
  fork_at_turn INT NULL,
  origin_kind ENUM (new | compact | fork),
  lineage_path ltree,                  -- 祖先・子孫の逆引き用 (materialized path)
  ...
)

entries (
  segment_id FK,
  seq INT,
  kind, payload, ts,
  PRIMARY KEY (segment_id, seq)        -- 同 Segment 内 linear scan
)
  • 同 Segment 内 entry は (segment_id, seq) PK で linear scan、surrogate identity なので hash 不要。
  • Segment 間 lineage は parent_segment_id chain。深さは compaction / fork 回数のみSession あたり数〜数十)。
  • Segment lineage の祖先・子孫逆引きは lineage_path<@ / @> で 1 index 引き。entry 単位の DAG ではないため materialized path のメンテコストも軽い。
  • 通常 append は lineage_path 更新を伴わないSegment 生成時に確定)。
  • 「Session の fork tree 全体」は WHERE session_id = ? で取れる。Session 単位の listing / GC が自然。

FsStore 実装はこの構造のサブセット相当として位置付ける1 Segment = 1 jsonl、session_id は Segment の metadata に持たせるか別ファイルに index する)。

残る検討事項

  • pod.scope extension entry を Pod state 側に寄せるか、Segment log に残すか(tickets/pod-persistent-state.md 側と合わせて決定)。
    • 撤廃の選択肢: (a) Segment log から削除し Pod state を唯一の正本にする / (b) snapshot 保持責務だけ Pod state に寄せ、scope 変更 event は Segment log に残す / (c) 現状維持で Pod state は Segment への参照のみ。
  • live auto-fork の marker 形式 (terminal entry vs Segment 末尾 seq 比較)。
  • pod-cli / TUI の --session 引数を Session 単位にするか Segment 単位にするか。debug 用 ID とユーザー向け ID の分離。
  • session-store crate 名のリネーム要否。

完了条件

  • 永続化層の主要概念と名称が文書化されている。
  • compaction / fork / resume / Pod state のデータ粒度が決まっている。
  • 現在の SessionId / session-store API をどう扱うか、維持・alias・rename・段階移行の方針が決まっている。
  • DB backend を追加する場合の概念モデルが、最低限テーブル / relation 相当で説明できる。
  • tickets/pod-persistent-state.md や fork 関連チケットに反映すべき前提が整理されている。

範囲外

  • このチケット単体での大規模 rename 実装。
  • DB backend の実装。
  • UI の履歴表示 / branch 表示の詳細 UX。
  • GC / retention policy の実装。
  • Session を跨ぐ merge / cherry-pick。

関連

  • tickets/pod-persistent-state.md
  • tickets/pod-session-fork.md
  • crates/session-store/
  • crates/pod/src/pod.rs