yoi/docs/plan/llm_presistence.md
2026-04-05 05:14:20 +09:00

9.4 KiB
Raw Blame History

永続化データ構造の設計・実装プラン

Context

INSOMNIA の llm-worker クレートは現在すべてのセッション状態をインメモリで保持しており、 プロセス終了時に会話履歴やターン状態が失われる。 Coding Agent として Pause/Resume・Fork をまたいだセッション継続を実現するため、 永続化レイヤーを追加する。

設計方針: Codex CLI / Claude Code と同様の JSONL append-only ログ 方式を採用。 セッションログを replay することで Worker 状態を完全に復元する。 Pause/正常終了で永続化データの構造に差異を設けない。 理由: Worker の状態は Pause 時も正常終了時も同じ形(history: Vec<Item> + turn_count + request_config)であり、 APIレスポンスのデータ構造上、両者に本質的な違いがない。 resume() は「ユーザー入力を追加せず run_turn_loop() に再入する」だけなので、 復元に必要なのは history の中身であり、前回の終了理由ではない。 RunOutcomeFinished/Paused 区分は監査・デバッグ用メタデータであり、replay ロジックの分岐には使わない。

命名規約:

  • SessionLog / LogEntry — 状態復元用の構造化された記録(永続化の本体)
  • EventTrace / TraceEntry — デバッグ用の生ストリームイベント全録(オプション)

クレート構成

永続化は llm-worker とは別クレートに分離する。 llm-worker は永続化を一切知らず、Session ラッパーが外から Worker を包む。

crates/
  llm-worker/                ← 既存LLM クライアント + Worker、変更なし以外は最小限
  llm-worker-macros/         ← 既存(変更なし)
  llm-worker-persistence/    ← 新規クレート
    Cargo.toml
    src/
      lib.rs              -- モジュールルート・re-exports・SessionId 型エイリアス
      session_log.rs      -- LogEntry enumJSONL 1行 = 1エントリ
      event_trace.rs      -- TraceEntryデバッグ用生イベント記録
      store.rs            -- Store traitバックエンド抽象
      fs_store.rs         -- JSONL ファイルシステム実装
      session.rs          -- Session<C, St> ラッパー + replay/restore
  insomnia/                  ← 将来のトップレベルアプリ

docs/persistence.md          -- 設計ドキュメント(このプランの清書版)

依存グラフ:

llm-worker-persistence → llm-worker → llm-worker-macros
insomnia (将来) → llm-worker-persistence, llm-worker

llm-worker-persistence/Cargo.toml 依存

[dependencies]
llm-worker = { path = "../llm-worker" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["fs", "io-util"] }
uuid = { version = "1", features = ["v7", "serde"] }
thiserror = "2"

既存コードへの変更llm-worker 側)

1. RequestConfig に Serialize/Deserialize 追加

  • ファイル: crates/llm-worker/src/llm_client/types.rs:504
  • #[derive(Debug, Clone, Default)]#[derive(Debug, Clone, Default, Serialize, Deserialize)]

2. Worker に復元用セッター追加

  • ファイル: crates/llm-worker/src/worker.rs
  • impl<C: LlmClient> Worker<C, Mutable> ブロックに追加:
    • pub fn set_turn_count(&mut self, count: usize)
    • pub fn set_last_run_interrupted(&mut self, interrupted: bool)

3. ワークスペース Cargo.toml にメンバー追加

  • ファイル: Cargo.toml(ワークスペースルート)
  • members"crates/llm-worker-persistence" を追加

新規コード: データ型

LogEntrysession_log.rs

状態復元に必要な構造化記録。JSONL 1行 = 1エントリ。

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum LogEntry {
    // セッション開始ログ先頭、fork 時は history にシード状態を含む)
    SessionStart {
        ts: u64,
        system_prompt: Option<String>,
        config: RequestConfig,
        history: Vec<Item>,
    },

    // ユーザー入力worker.rs:229 に対応)
    UserInput { ts: u64, item: Item },

    // アシスタント応答worker.rs:1040-1041 に対応)
    AssistantItems { ts: u64, items: Vec<Item> },

    // ツール実行結果worker.rs:897-900, 1072-1076 に対応)
    ToolResults { ts: u64, items: Vec<Item> },

    // Hook 注入 Itemsworker.rs:1055 ContinueWithMessages に対応)
    HookInjectedItems { ts: u64, items: Vec<Item> },

    // ターン境界
    TurnEnd { ts: u64, turn_count: usize },

    // KV キャッシュロック/アンロック
    CacheLocked { ts: u64, locked_prefix_len: usize },
    CacheUnlocked { ts: u64 },

    // run/resume の終了結果
    RunOutcome { ts: u64, outcome: Outcome, interrupted: bool },

    // RequestConfig 変更
    ConfigChanged { ts: u64, config: RequestConfig },
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Outcome { Finished, Paused, Error { message: String } }

Replay ロジック: 全エントリ種別を走査し、*Items / UserInput → history に append、 TurnEnd → turn_count 更新、CacheLocked → locked_prefix_len 設定。

TraceEntryevent_trace.rs

デバッグ用の生ストリームイベント全録。デフォルト OFF。 セッションログとは別ファイル {session_id}.trace.jsonl に記録。

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraceEntry {
    pub ts: u64,
    pub turn: usize,
    pub event: Event,
}

replay 対象外。状態復元には使わない。

SessionId

uuid クレートの UUID v7 をそのまま使用。型エイリアスのみ。

pub type SessionId = uuid::Uuid;

pub fn new_session_id() -> SessionId {
    uuid::Uuid::now_v7()
}

UUID v7 はタイムスタンプ埋め込みで辞書順 = 時系列順。独自フォーマット不要。

Store traitstore.rs

pub trait Store: Send + Sync {
    fn append(&self, id: SessionId, entry: &LogEntry) -> impl Future<Output = Result<(), StoreError>> + Send;
    fn read_all(&self, id: SessionId) -> impl Future<Output = Result<Vec<LogEntry>, StoreError>> + Send;
    fn list_sessions(&self) -> impl Future<Output = Result<Vec<SessionId>, StoreError>> + Send;
    fn create_session(&self, id: SessionId, entries: &[LogEntry]) -> impl Future<Output = Result<(), StoreError>> + Send;
    fn exists(&self, id: SessionId) -> impl Future<Output = Result<bool, StoreError>> + Send;

    // EventTrace 用(デバッグモード時のみ使用)
    fn append_trace(&self, id: SessionId, entry: &TraceEntry) -> impl Future<Output = Result<(), StoreError>> + Send;
}

RPITIT (Rust 1.75+) 使用。async_trait 不要。

FsStorefs_store.rs

ファイル配置:

  • セッションログ: {root}/{session_id}.jsonl
  • イベントトレース: {root}/{session_id}.trace.jsonl

append モードで書き込み。SQLite インデックスなし。

Session ラッパーsession.rs

Worker を直接変更せず、外部ラッパー として実装:

pub struct Session<C: LlmClient, St: Store> {
    pub worker: Worker<C, Mutable>,  // pub で直接アクセス可能
    store: St,
    session_id: SessionId,
    config: SessionConfig,
}
  • Session::new() → SessionStart を append
  • Session::run() → Worker::run() の前後で history.len() を比較、差分をログ記録
  • Session::resume() → 同上
  • Session::fork() → 現在の history をシードにした新 SessionStart を書き込み
  • Session::fork_at(store, source_id, entry_idx) → 任意地点から分岐

復元: restore_session(client, store, session_id) → read_all → replay_entries → Worker 再構築

EventTrace 記録(デバッグモード)

SessionConfig::record_event_trace: bool(デフォルト false)が true の場合、 Session が Worker に OnStreamChunk Hook を登録。 Hook 内で TraceEntry{session_id}.trace.jsonl に append。 セッションログとは完全に分離。

実装順序

  1. RequestConfig に Serialize/Deserialize 追加llm-worker 側)
  2. Worker に set_turn_count / set_last_run_interrupted 追加llm-worker 側)
  3. crates/llm-worker-persistence/ クレート作成Cargo.toml + ワークスペース登録)
  4. session_log.rs 作成LogEntry + Outcome + replay_entries
  5. event_trace.rs 作成TraceEntry
  6. store.rs 作成Store trait + StoreError
  7. fs_store.rs 作成JSONL ファイルシステム実装)
  8. session.rs 作成Session ラッパー + restore_session
  9. lib.rs 作成re-exports・SessionId 型エイリアス・new_session_id
  10. テスト作成replay round-trip, FsStore 読み書き, Session::run ログ記録)
  11. docs/persistence.md 設計ドキュメント作成

検証方法

  1. ユニットテスト: replay_entries に手動構築した LogEntry 列を渡し、復元状態を検証
  2. 統合テスト: MockLlmClient + FsStore で Session::run → restore_session → history 一致を確認
  3. Fork テスト: fork → 新セッションの history が fork 時点と一致することを確認
  4. cargo test: 既存テストが壊れていないことを確認
  5. cargo clippy / cargo check: 警告なし