tui-session-restore完了

This commit is contained in:
Keisuke Hirata 2026-04-29 19:52:24 +09:00
parent b9575f1534
commit 44d660c894
3 changed files with 0 additions and 213 deletions

View File

@ -10,7 +10,6 @@
- [ ] フルスクリーン化によるオーバーホール → [tickets/tui-fullscreen-overhaul.md](tickets/tui-fullscreen-overhaul.md)
- [ ] Run 中の入力キューイング → [tickets/tui-input-queue.md](tickets/tui-input-queue.md)
- [ ] ユーザーマニフェストのモデル設定 wizard → [tickets/tui-user-model-setup.md](tickets/tui-user-model-setup.md)
- [ ] 既存セッションからの Pod 復帰 → [tickets/tui-session-restore.md](tickets/tui-session-restore.md)
- [ ] サブミット入力
- [ ] TUI 補完 + 型付き atom 化 → [tickets/submit-tui-completion.md](tickets/submit-tui-completion.md)
- [ ] セッションログの Segment 保持 → [tickets/session-log-segments.md](tickets/session-log-segments.md)

View File

@ -1,107 +0,0 @@
# TUI: 既存セッションからの Pod 復帰
## 背景
`session-store` は JSONL ログから Worker 状態を復元でき、Pod 側にも低レベルの `Pod::restore(session_id, ...)` が存在する。一方で、現在の実行経路は新規 Pod 起動 (`Pod::from_manifest`) と生存中 Pod への attach / `Paused` 状態の `Resume` に限られており、停止済み Pod を既存 `SessionId` から起動するユーザー向け導線がない。さらに既存の `Pod::restore` は manifest cascade を踏まず、`scope_lock` への登録もしない低レベル API のため、CLI / TUI からそのまま使えない。
TUI には既に新規 Pod 起動用の spawn UI があるため、同じような選択 UI で既存 session を一覧し、選択した session を復元した Pod を起動して attach できるようにする。
## 要件
### 起動導線
- TUI の既存挙動は維持する:
- `tui`(引数なし): 新規 Pod spawn。現在と同じ name 入力ダイアログから始める
- `tui <pod-name>`: 生存中 Pod への attach
- 既存 session 復帰用に `tui -r` / `tui --resume` を追加する
- `--resume` はユーザー向けの「過去 session から復帰」入口であり、protocol の `Method::Resume`Paused turn の続行)とは別概念として扱う
- `--resume` 指定時のみ、name 入力ダイアログの前段に session 選択プロンプトを表示する
- session id を直接指定するショートカットとして `tui --session <UUID>` を追加する
- `--session` は session picker をスキップし、指定 session を復元対象にした name 入力ダイアログから始める
- `--resume``--session` は併用不可
- Pod CLI に `pod --session <UUID>` を追加し、復元 Pod を起動できるようにする
- 名前は他のフラグと同様 manifest cascade / overlay で決めるCLI 単体では `--overlay 'pod.name = "..."'` を使う想定)
- TUI の `--resume` / `--session` 復帰フローは、name 入力ダイアログ確定後に `pod --session <UUID> --overlay 'pod.name = "<入力名>"'` を子プロセスとして起動して attach する
### セッション一覧
- `manifest::paths::sessions_dir()` または既存の `--store` 相当設定で解決される session store を読み、新しい順に **直近 10 件** を表示する
- 一覧には少なくとも以下を表示する:
- `SessionId`(短縮表記)
- 並び順は `Store::list_sessions` が返す UUIDv7 順(= 作成時刻順、新しい順)でよい
- 履歴の簡易プレビュー(最後の user / assistant メッセージ等、取得できる範囲でよい)
- その session が今 live かどうか(`scope.lock` を引いて判定)
- session log が壊れている、復元不能、または現在のバージョンで読めない場合は、その行を復帰不可として表示するか、エラー表示してスキップする
- session が 1 件もない場合は、エラー表示して終了する(`tui` で新規 spawn する案内を出す)
### 復元 Pod の構築
復元 Pod はソース session の **同じ session_id** を引き継ぎ、以降の turn を同じ jsonl に追記する。fork は行わない。
- 同一 session への同時書き込みは `scope.lock``session_id` で構造的に防止する次節ので、resume 時にあえて新しい session を起こす必要はない。fork すると `SessionStart` だけが入った空に近い session が picker に積み上がるため不適切。
- manifest / scope / tool 登録 / prompt loader は、通常の新規 Pod 起動と同じ現在の cascade 解決結果を使う。
- Worker の会話履歴・system prompt・request config・turn count・usage history 等は `session_store::restore` で得た `RestoredState` を使う。`system_prompt` は session に保存された値をそのまま使い、`SystemPromptTemplate` の再レンダリングはしない。
- `head_hash``RestoredState.head_hash` をそのまま採用し、次回の append が正しい hash chain で繋がる。
- 復元起動時、runtime の `history.json` / `status.json` / `Event::History` で TUI が初期履歴を正しく再構築できる。
- 復元された session が interrupted / paused 相当の状態を持つ場合、起動直後に `Resume` 可能な状態として扱う。通常終了済みなら `Idle` として新しい入力を受け付ける。
### Pod / 高レベル API の整理
- `Pod::restore_from_manifest(source_session_id, manifest, store, loader) -> Pod` を新設する。中身は `from_manifest` と共通のセットアップpwd 解決 / scope 構築 / `scope_lock` 登録 / `provider::build_client` / `PromptCatalog::load`)を踏みつつ、上記 fork 処理と `RestoredState` 流し込みを行う。`from_manifest` / `from_manifest_spawned` / `restore_from_manifest` の共通部分は private setup 関数に括る。
- 既存の低レベル `Pod::restore(session_id, manifest, client, store, pwd, scope)` は呼出元なしのため削除する。
### `scope.lock` への session_id 追加と live 検出
`scope.lock``Allocation``session_id: SessionId` フィールドを追加し、マシン全体での「session X が今 live か」を `scope.lock` 経由で判定できるようにする。
- `register_pod` / `delegate_scope` / `adopt_allocation` / `install_top_level` のシグネチャに `session_id` を追加する。
- `LockFile::find_by_session(id)` を提供する。
- `scope_lock::lookup_session(id) -> Option<SessionLockInfo { pod_name, socket, pid }>` を提供し、`Pod::restore_from_manifest` 内で fork 前のチェックに使う。
- 検出時は `Pod::restore_from_manifest` がエラーを返し、CLI / TUI 側で適切なメッセージを出す。
- `scope.lock` のスキーマ変更については、既存ファイルが残っていたら手で消す前提dev 期間中の互換性は不要)。
### 二重起動の扱い
- TUI の resume / `--session` 経路で `Pod::restore_from_manifest` がエラーを返した場合、エラー表示して TUI を終了するpicker 復帰や自動 attach 切替はしない)。
- ユーザーは別途 `tui <pod-name>` で attach できる。エラーメッセージには live Pod の `pod_name` / socket を含める。
### UI / 操作
- `tui -r` / `tui --resume` では、まず session picker を表示する
- session picker は上下キーで session を選択し、Enter で決定、Esc / Ctrl-C でキャンセルできる
- 直近 10 件のみ表示するためスクロール UI は不要
- session 決定後、name 入力ダイアログを表示する
- picker と name 入力は別の inline viewport として描画してよい(高さの都合で viewport を作り直す)
- 入力する name は、復元された session を載せる新しい Pod 実行インスタンス名runtime dir / socket 名)
- default name は現行と同じ cwd 由来でよい
- 表示上は `Resume Pod` / `session: <short-id>` のように、新規 spawn ではなく復帰であることを明示する
- `tui --session <UUID>` では session picker を省略し、指定 session を対象にした name 入力ダイアログから始める
- 将来的な検索フィルタ追加を妨げない state 構造にするが、本チケットでは必須にしない
- 復帰に失敗した場合pod プロセスが ready line を返さない、`SessionInUse` など)はエラー表示してそのまま終了する
## 完了条件
- `pod --session <UUID>` で既存 session から Pod を起動でき、以降の turn は同じ jsonl に追記されるfork 由来の空 session は生成されない)
- `tui -r` / `tui --resume` で直近 10 件の既存 session 一覧を表示し、選択した session を復元対象にできる
- `tui --session <UUID>` で session picker を経由せず、指定 session の復帰 name 入力へ進める
- 復帰フローでは session 選択後または `--session` 指定後に name 入力ダイアログが表示され、その name の Pod として起動・attach できる
- 復元直後の TUI に過去履歴が表示される
- 復元後に新しい入力を送ると、既存履歴に続く turn として動作し、ソース session の jsonl にそのまま追記される
- interrupted / paused 状態の session では、復元直後に Resume 導線が動作する
- 同一 source session に対する live Pod が存在する場合、復元起動はエラーで終了し、既存 Pod の `pod_name` / socket がメッセージに表示される
- `scope.lock` には各 Pod の `session_id` が記録される
## 範囲外
- session log の全文検索 UI
- compact 前後の session chain を 1 つの論理スレッドとして束ねる UI
- 過去 session の編集・削除・名前付け
- spawn された子 Pod / scope delegation ツリー全体の復元
- 別マシンから転送された session store の import UI
- `tui` での picker 復帰や自動 attach 切替live セッション選択時はエラー終了)
- 任意位置からの fork 起動(`fork_at` を resume 経路に組み込まない。将来別フローとして扱う)
## Review
- 状態: Approve with follow-up
- レビュー詳細: [./tui-session-restore.review.md](./tui-session-restore.review.md)
- 日付: 2026-04-28

View File

@ -1,105 +0,0 @@
# 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 / 操作
- 上下キー / `j` `k` / 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` 記録、の二点のみ。次の経路で構造保証が崩れる:
1. **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)` を成立させてしまう。
2. **`ensure_head_or_fork` 後**`crates/session-store/src/session.rs:109-138`: `self.session_id` が auto-fork で書き換わるが scope.lock は同じく置き去り。
3. **TOCTOU**: `lookup_session``LockFileGuard` を閉じてから後段の `install_top_level` で再オープンするまでの間に、別プロセスが同じ流れで通り抜ける可能性。`register_pod` 内では `session_id` の重複チェックを行っていない(`pod_name` と scope のみ)ので、別 `pod_name` を指定する 2 つの resume が両方成功し、両方が同じ session jsonl に append しに行ける。
- 実害は `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 の分離)。
### 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 改善で対処を推奨。
- **`pod` CLI および 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 restores `id`」 → 同上。
- 動作には影響しないが、レビュー観点(コードベースを歪めていないか)で読者を誤導するため早めに直したい。
- **`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>` for `PickerError`)と 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 で潰してほしい。