14 KiB
Review: TUI: 既存セッションからの Pod 復帰
レビュー対象は 3c90729 以降のワーキングツリー (e98a596 までの 2 commit) と、それに伴う tickets/tui-session-restore.md の更新版。
前提・要件の確認
起動導線
tui(引数なし)/tui <pod-name>の既存挙動はMode::Spawn/Mode::Attachとして温存(crates/tui/src/main.rs:130-136)。OK。tui -r/tui --resume(Mode::Resume)は picker →run_spawn(Some(id))の二段構成で実装(crates/tui/src/main.rs:202-211)。OK。tui --session <UUID>は picker をスキップしてrun_spawn(Some(id))直行(crates/tui/src/main.rs:166)。OK。--resumeと--sessionの排他はparse_argsで明示チェック(crates/tui/src/main.rs:120-122)。OK。- pod CLI に
--sessionを追加し--adoptと排他(crates/pod/src/main.rs:52)。OK。
セッション一覧
manifest::paths::sessions_dir()経由で store を開き直近 10 件を表示(crates/tui/src/picker.rs:76-86)。OK。SessionId短縮表記、最後の user/assistant メッセージプレビュー(同:259-300,last_message_preview)。OK。- 並び順は
Store::list_sessionsの UUIDv7 順(=作成時刻順、新しい順)。OK。 - session が 0 件のときは
PickerError::NoSessionsを返してエラー終了(:79-81)。OK。 - ログ破損時は
[corrupt]プレビューで行を残す(build_previewのErr分岐,:154)。スキップではないが「エラー表示して」の文言を満たす。OK。 - 未実装: 「その session が今 live かどうか」の表示 —
scope_lock連携が picker に入っていない(crates/tui/src/picker.rs内にscope_lock参照なし)。要件に明記された情報項目なので blocking 寄り。後述。
復元 Pod の構築
- 同じ
session_idを引き継ぎ、追記する設計に変更されている(crates/pod/src/pod.rs:1599-1690)。fork は行わない。OK。 system_promptは session 保存値そのまま、SystemPromptTemplate再レンダなし(prepare_pod_common(.., parse_template = false)、system_prompt_template = None)。OK。head_hashはRestoredState.head_hashをそのまま保持(:1666)。OK。- 履歴・request_config・turn_count・usage_history・extract_pointer・interrupted フラグ復元は揃っている(
:1651-1686)。OK。 - 圧縮アンカー: 先頭が
Role::Systemのときcache_anchor=Some(0)にする処理(:1644-1657)。compact 後の resume を想定した妥当な追加。OK。
Pod / 高レベル API の整理
Pod::restore_from_manifest(session_id, manifest, store, loader)新設(:1599)。OK。- 旧
Pod::restoreは削除済み(grep で呼出元なし、Pod::newのドキュメントコメントだけ「Pod::restore」表記が残存=軽微)。OK。 prepare_pod_commonでfrom_manifest/from_manifest_spawned/restore_from_manifestの共通部分を集約(:1922)。粒度妥当。OK。
scope.lock への session_id 追加と live 検出
Allocation::session_id: Option<SessionId>を追加(crates/pod/src/runtime/scope_lock.rs:58-60)。#[serde(default)]で旧フォーマット読込時はNone扱いになる(dev 期間互換不要だが副作用としては安全)。OK。register_pod/install_top_level/adopt_allocationのシグネチャにsession_idを追加(:306, :501, :533)。OK。delegate_scopeのシグネチャは変更せずsession_id = Noneのまま事前予約し、子側adopt_allocationで確定する設計(:344-387, :533-551)。チケット文面(57 行)はdelegate_scopeも含めて挙げているが、子の session_id が spawner 時点で未確定であるためOption化が技術的に妥当で、ユーザの設計判断 (3) として明確に記録されている。ticket 本文と乖離があるが、コメント (:55-60, :381-384) で意図が明記されているため許容。LockFile::find_by_session提供(:73-77)。OK。scope_lock::lookup_session(id) -> Option<SessionLockInfo>提供(:567-578)、SessionLockInfo { pod_name, socket, pid }も要件通り(:556-560)。OK。- 既存
scope.lockの互換性は#[serde(default)]で旧形式も壊れずに読める。OK。
二重起動の扱い
- TUI 経路では
pod子プロセスがPodError::SessionInUseで非ゼロ終了 → spawn がPodExitedEarlyでスタブ末尾を viewport に表示してそのまま終了(crates/tui/src/spawn.rs:329-336)。エラーメッセージ本体(session_id/pod_name/socket)もPodError::SessionInUseの#[error]に含まれて stderr 経由で通る(crates/pod/src/pod.rs:1886-1894)。要件通り picker 復帰や自動 attach 切替なし。OK。
UI / 操作
- 上下キー /
jk/ Enter / Esc / Ctrl-C で picker 操作(crates/tui/src/picker.rs:223-232)。OK。 - 直近 10 件のみのためスクロール UI なし。OK。
- 決定後 name 入力ダイアログを別 inline viewport として描画。
close_viewportで改行を 1 行押し出して name 入力との重なりを防止(:127-138)。OK。 - title
resume pod session: <short-id>で復帰モード明示(crates/tui/src/spawn.rs:516-519)。OK。 - name のデフォルトは cwd 由来でこれまでと同じ。OK。
- 検索フィルタは将来拡張余地のある state で、必須にしない。OK。
完了条件
- 「
pod --session <UUID>で fork 由来の空 session が生成されない」: 実装上Pod::restore_from_manifestは新 session を作らず元のsession_idを再利用するので OK。 - 「復帰直後に過去履歴が表示される」:
RestoredState.historyをworker.set_history経由で流し込んでおり、Controller の history.json/Event::History 経路は変更不要。OK(手動動作確認は範囲外)。 - 「同一 source session に対する live Pod が存在する場合、復元起動はエラーで終了し、
pod_name/ socket がメッセージに表示」: 静的状態に対しては成立。ただし live 写し書き入れ替わり時の整合性に注意点あり(後述 Blocking)。
アーキテクチャ・スコープ
- 新規クレートなし。
tui側にsession-storeを依存追加したのみ(crates/tui/Cargo.toml:17)。session-storeが picker のためにContentPart/Item/Roleをpub useで再公開(crates/session-store/src/lib.rs:43)。llm-workerのレイヤを跨いで TUI が直接型を持ち込まずに済むので妥当。 prepare_pod_commonの抽出は 3 系統の共通部分(pwd / scope / client / catalog / template parse 切替)の自然な集約で、過剰抽象化していない。PodCommon構造体も local。OK。PodErrorへのSessionInUse/SessionEmpty追加はrestore固有の正当な失敗モード。thiserrorの表現に揃っている。OK。- picker の preview 実装は素朴(
read_allで全件読む)だが、大セッション時の重さは本チケット範囲外と user note (5) で明記されており妥当。OK。 - spawn.rs と picker.rs の境界が「picker は選択のみ、scope-lock チェックや fork は後段」で揃っており、低レベル基盤と UI が混ざっていない。OK。
指摘事項
Blocking
- scope.lock の session_id による「構造的」二重書き込み防止には穴がある
- 要件 / 設計判断 (1) は「同一 session への同時書き込みは
scope.lockのsession_idで構造的に防止する」が前提。実装は (a)restore_from_manifest入口のlookup_sessionチェックと (b)adopt_allocation/install_top_level時のsession_id記録、の二点のみ。次の経路で構造保証が崩れる:- compaction 後(
crates/pod/src/pod.rs:1133-1156):self.session_id = new_session_idに差し替わるが、scope.lock 上のAllocation.session_idは旧 session_id のまま更新されない。結果としてlookup_session(new_session_id)はNoneを返し、live Pod が新セッションへ書いている最中でも別プロセスがrestore_from_manifest(new_session_id)を成立させてしまう。 ensure_head_or_fork後(crates/session-store/src/session.rs:109-138):self.session_idが auto-fork で書き換わるが scope.lock は同じく置き去り。- TOCTOU:
lookup_sessionのLockFileGuardを閉じてから後段のinstall_top_levelで再オープンするまでの間に、別プロセスが同じ流れで通り抜ける可能性。register_pod内ではsession_idの重複チェックを行っていない(pod_nameと scope のみ)ので、別pod_nameを指定する 2 つの resume が両方成功し、両方が同じ session jsonl に append しに行ける。
- compaction 後(
- 実害は
ensure_head_or_forkの auto-fork が後段で救うとはいえ、それ自体「fork しない」設計の前提を破る挙動になる。少なくとも以下のいずれかが必要:register_podに session_id 重複チェックを追加し、lookup_sessionと install を同一LockFileGuard配下で行う API を提供する。- compaction 完了時 /
ensure_head_or_fork後に scope.lock の自分のエントリのsession_idを書き換える明示的な hook を入れる。
- 該当箇所:
crates/pod/src/pod.rs:1156、crates/pod/src/runtime/scope_lock.rs:306-339(register_podに session_id 衝突チェックなし)、crates/pod/src/pod.rs:1599-1631(lookup と install の分離)。
- 要件 / 設計判断 (1) は「同一 session への同時書き込みは
Non-blocking / Follow-up
-
picker に live セッションの可視化が無い(要件「その session が今 live かどうか(
scope.lockを引いて判定)」)。crates/tui/src/picker.rs全体にscope_lock参照なし。Rowにlive: boolを持たせ、scope_lock::lookup_session(id)で判定して表示色を変える程度で完結する。要件の文言上は blocking 級だが、live セッションを picker から選んだ場合でもPod::restore_from_manifest側でSessionInUseで弾かれて pod が落ち、TUI もそのまま終了するという完了条件は満たせるため follow-up とする。次回の TUI 改善で対処を推奨。 -
podCLI および TUI のForm.resume_fromドキュメントコメントが旧 fork 設計のまま。crates/pod/src/main.rs:47-51: 「The source session log is forked at its head into a new session id, so the original jsonl is left untouched and double-write races are impossible.」 → 実装は fork しないし、二重書き込み防止は scope.lock 経由の前提に変わっている。crates/tui/src/spawn.rs:448-452: 「the child pod is launched with--session <id>so it forks and restoresid」 → 同上。- 動作には影響しないが、レビュー観点(コードベースを歪めていないか)で読者を誤導するため早めに直したい。
-
Pod::newのドキュメントコメントに削除済みのPod::restore表記が残存(crates/pod/src/pod.rs:104)。「lower-level constructors (Pod::new,Pod::restore)」のPod::restoreを削るか、低レベル API の用途記述をPod::new単独に書き換えれば足りる。 -
restore_from_manifest/lookup_session/find_by_session/SessionInUse/SessionEmptyのテストが未追加。scope_lock.rs既存テストは register/delegate/adopt 系のみ。find_by_sessionの挙動(session_id == Some(_)のみマッチしNoneの placeholder をスキップする)と、empty session に対するrestore_from_manifestのSessionEmpty経路は意図そのものが今回新規なので、ユニットテスト 1〜2 本でも欲しい。 -
picker の preview が重い場合(user note (5) 明記)の挙動は範囲外で良いが、
read_allを 10 セッション分逐次(for id in ids ... build_preview(...).await)で行っているため大規模 store ではブロッキング感が出る。将来tokio::join_all並走化で済む程度なので、follow-up 記録のみ。
Nits
PickerErrorのDisplayがunable系の標準形式を踏襲していて良いが、StoreErrorをBox<dyn Error>経由で渡す経路でFromの方向(From<StoreError>forPickerError)と main.rs 側のBox<dyn Error>の整合は保たれている。問題なし。crates/tui/src/picker.rs:23のpub use llm_worker::llm_client::types::{ContentPart, Item, Role}は本来 picker のためだけにsession-store経由で入っている。将来 picker 以外も使うようならsession-storeが「保存対象の構造を再公開する」責務として正規化する余地あり。
判断
Approve with follow-up — チケットのコア要件(tui --resume / --session 起点の復帰経路、同 session_id 再利用、scope.lock への session_id 拡張、二重起動時のエラー終了、共通部分の抽出と低レベル Pod::restore 削除)はすべて達成されており、build / cargo test -p pod も緑。
ただし設計判断 (1) で「scope.lock で構造的に二重書き込み防止」と打ち出している割に、(a) compaction 後 (b) ensure_head_or_fork 後 (c) lookup→install の TOCTOU の 3 経路で構造保証が崩れる点は要承知。ticket の「fork しない」前提を厳密に守るには上記 Blocking のいずれかの補強が必要だが、現実には ensure_head_or_fork の auto-fork で実害が起きにくいため、本チケット完了は Non-blocking として現行 PR を許容しつつ、scope.lock 二重書き防止の補強を別チケットで切り出す運用が妥当。
加えて、要件文に明記されている picker の live 表示と、stale ドキュメント(pod CLI / spawn.rs Form / Pod::new)の刷新は早めに follow-up で潰してほしい。