9.4 KiB
永続化データ構造の設計・実装プラン
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 の中身であり、前回の終了理由ではない。
RunOutcome の Finished/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 enum(JSONL 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"を追加
新規コード: データ型
LogEntry(session_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 に対応)
AssistantItem { ts: u64, item: Item },
// ツール実行結果(worker.rs:897-900, 1072-1076 に対応)
ToolResult { ts: u64, item: Item },
// typed system injection
SystemItem { ts: u64, item: SystemItem },
// ターン境界
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 ロジック: 全エントリ種別を走査し、AssistantItem / ToolResult / SystemItem / UserInput → history に append、
TurnEnd → turn_count 更新、CacheLocked → locked_prefix_len 設定。
TraceEntry(event_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 trait(store.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 不要。
FsStore(fs_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 を appendSession::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。
セッションログとは完全に分離。
実装順序
RequestConfigに Serialize/Deserialize 追加(llm-worker 側)- Worker に
set_turn_count/set_last_run_interrupted追加(llm-worker 側) crates/llm-worker-persistence/クレート作成(Cargo.toml + ワークスペース登録)session_log.rs作成(LogEntry + Outcome + replay_entries)event_trace.rs作成(TraceEntry)store.rs作成(Store trait + StoreError)fs_store.rs作成(JSONL ファイルシステム実装)session.rs作成(Session ラッパー + restore_session)lib.rs作成(re-exports・SessionId 型エイリアス・new_session_id)- テスト作成(replay round-trip, FsStore 読み書き, Session::run ログ記録)
docs/persistence.md設計ドキュメント作成
検証方法
- ユニットテスト:
replay_entriesに手動構築した LogEntry 列を渡し、復元状態を検証 - 統合テスト: MockLlmClient + FsStore で Session::run → restore_session → history 一致を確認
- Fork テスト: fork → 新セッションの history が fork 時点と一致することを確認
- cargo test: 既存テストが壊れていないことを確認
- cargo clippy / cargo check: 警告なし