From e98a5962350fe2c45fb35adbc934262a33dacfd8 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 28 Apr 2026 20:19:50 +0900 Subject: [PATCH] =?UTF-8?q?=E4=B8=8D=E8=A6=81=E3=81=AAfork=E3=81=AE?= =?UTF-8?q?=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/pod/src/pod.rs | 53 +++++++++++----------------------- crates/tui/src/picker.rs | 28 ++++++++++++++++-- tickets/tui-session-restore.md | 14 ++++----- 3 files changed, 50 insertions(+), 45 deletions(-) diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index 041255df..0678ace7 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -1581,45 +1581,40 @@ impl Pod, St> { /// Restore a Pod from an existing session log. /// /// Resolves the manifest cascade exactly like [`Self::from_manifest`] - /// (pwd / scope / scope-lock / client / prompt catalog), then forks - /// the source session at its current head and seeds a fresh Worker - /// from the resulting `RestoredState`. The Pod writes to the new - /// fork session's jsonl; the source session's log is left intact. + /// (pwd / scope / scope-lock / client / prompt catalog), seeds a + /// fresh Worker from the source session's `RestoredState`, and + /// reuses the same `session_id` so subsequent turns append to the + /// source jsonl as a continuation of the same conversation. /// - /// Refuses to resume if another live Pod is currently writing to - /// `source_session_id` (detected via `scope.lock`). + /// Concurrent writers are prevented by the `scope.lock` registry: + /// the registration carries `session_id`, and this constructor + /// refuses to start when `scope_lock::lookup_session` already finds + /// a live Pod writing to `session_id`. So there is no need to fork — + /// resume is "the same session, a different process owning it". /// /// `system_prompt` is replayed verbatim from the session log — /// templates are not re-rendered on restore so a long-running /// session keeps a stable cache prefix even when the manifest's /// instruction template would render differently today. pub async fn restore_from_manifest( - source_session_id: SessionId, + session_id: SessionId, manifest: PodManifest, store: St, loader: PromptLoader, ) -> Result { // Refuse to resume into a session that's already being written. - if let Some(info) = scope_lock::lookup_session(source_session_id)? { + if let Some(info) = scope_lock::lookup_session(session_id)? { return Err(PodError::SessionInUse { - session_id: source_session_id, + session_id, pod_name: info.pod_name, socket: info.socket, }); } - // Read the source state, then fork it into a fresh session id. - // The fork's SessionStart captures the full history with - // `forked_from` provenance pointing back to the source, so the - // source jsonl stays untouched and double-write races are - // impossible by construction. - let state = session_store::restore(&store, source_session_id).await?; - let Some(source_head) = state.head_hash.clone() else { - return Err(PodError::SessionEmpty { - session_id: source_session_id, - }); - }; - let session_id = session_store::fork_at(&store, source_session_id, &source_head).await?; + let state = session_store::restore(&store, session_id).await?; + if state.head_hash.is_none() { + return Err(PodError::SessionEmpty { session_id }); + } let common = prepare_pod_common(&manifest, &loader, /* parse_template */ false)?; @@ -1663,26 +1658,12 @@ impl Pod, St> { let extract_pointer = memory::extract::fold_pointer(&state.extensions); - // The fork's SessionStart hash is the new head. We could - // recompute it by reading the new session log, but - // `session_store::fork_at` already returns the new session_id - // and we know the chain starts fresh. The next `save_delta` - // call will read head from store before appending, so leaving - // `head_hash = None` here is safe but less efficient — we - // refresh from the store to avoid a chain refresh on first - // append. - let head_hash = store - .read_head_hash(session_id) - .await - .ok() - .flatten(); - let mut pod = Self { manifest, worker: Some(worker), store, session_id, - head_hash, + head_hash: state.head_hash, pwd: common.pwd, scope: common.scope, hook_builder: HookRegistryBuilder::new(), diff --git a/crates/tui/src/picker.rs b/crates/tui/src/picker.rs index 6040789a..3ba7a694 100644 --- a/crates/tui/src/picker.rs +++ b/crates/tui/src/picker.rs @@ -102,17 +102,41 @@ pub async fn run() -> Result { } } Some(Action::Submit) => { - drop(terminal); + close_viewport(&mut terminal)?; return Ok(PickerOutcome::Picked(rows[selected].id)); } Some(Action::Cancel) => { - drop(terminal); + close_viewport(&mut terminal)?; return Ok(PickerOutcome::Cancelled); } } } } +/// Park the cursor at the very bottom of the picker's inline viewport +/// and emit one newline before dropping the terminal. Without this the +/// inline area is left with the cursor still inside it, so the next +/// `Terminal::with_options(Inline(_))` call (the resume name dialog) +/// computes its own area starting from inside the picker — drawing the +/// new dialog on top of the lower picker rows. +/// +/// Setting the cursor to `area.bottom() - 1` and writing `\r\n` +/// scrolls the terminal up exactly one row, so the next inline +/// viewport opens immediately below the picker rather than on top of +/// it. +fn close_viewport( + terminal: &mut Terminal>, +) -> io::Result<()> { + let area = terminal.get_frame().area(); + let last_row = area.bottom().saturating_sub(1); + terminal.set_cursor_position((0, last_row))?; + use std::io::Write; + let mut out = io::stdout(); + out.write_all(b"\r\n")?; + out.flush()?; + Ok(()) +} + async fn open_default_store() -> Result { let dir = manifest::paths::sessions_dir().ok_or_else(|| { PickerError::Io(io::Error::new( diff --git a/tickets/tui-session-restore.md b/tickets/tui-session-restore.md index 55e95cb1..54d99c80 100644 --- a/tickets/tui-session-restore.md +++ b/tickets/tui-session-restore.md @@ -36,13 +36,12 @@ TUI には既に新規 Pod 起動用の spawn UI があるため、同じよう ### 復元 Pod の構築 -復元時はソース session を直接書き継がず、**fork** して新しい session を起こす。 +復元 Pod はソース session の **同じ session_id** を引き継ぎ、以降の turn を同じ jsonl に追記する。fork は行わない。 -- `session_store::fork_at(source_session_id, source_head_hash)` で新しい session を作成する。新 session の `SessionStart` には全履歴と `forked_from = SessionOrigin { source_session_id, source_head_hash }` を載せる。 -- Pod は新 session_id 上で動作し、ソース session の jsonl は不変のまま残る。 -- これにより同一 session への同時書き込みは構造的に発生しない。 +- 同一 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` として新しい入力を受け付ける。 @@ -82,12 +81,12 @@ TUI には既に新規 Pod 起動用の spawn UI があるため、同じよう ## 完了条件 -- `pod --session ` で既存 session から Pod を起動でき、ソース session jsonl は不変のまま新しい fork session が作られる +- `pod --session ` で既存 session から Pod を起動でき、以降の turn は同じ jsonl に追記される(fork 由来の空 session は生成されない) - `tui -r` / `tui --resume` で直近 10 件の既存 session 一覧を表示し、選択した session を復元対象にできる - `tui --session ` で session picker を経由せず、指定 session の復帰 name 入力へ進める - 復帰フローでは session 選択後または `--session` 指定後に name 入力ダイアログが表示され、その name の Pod として起動・attach できる - 復元直後の TUI に過去履歴が表示される -- 復元後に新しい入力を送ると、既存履歴に続く turn として動作し、新しい fork session の jsonl に追記される +- 復元後に新しい入力を送ると、既存履歴に続く turn として動作し、ソース session の jsonl にそのまま追記される - interrupted / paused 状態の session では、復元直後に Resume 導線が動作する - 同一 source session に対する live Pod が存在する場合、復元起動はエラーで終了し、既存 Pod の `pod_name` / socket がメッセージに表示される - `scope.lock` には各 Pod の `session_id` が記録される @@ -95,8 +94,9 @@ TUI には既に新規 Pod 起動用の spawn UI があるため、同じよう ## 範囲外 - session log の全文検索 UI -- compact 前後の session chain / fork チェーンを 1 つの論理スレッドとして束ねる UI +- compact 前後の session chain を 1 つの論理スレッドとして束ねる UI - 過去 session の編集・削除・名前付け - spawn された子 Pod / scope delegation ツリー全体の復元 - 別マシンから転送された session store の import UI - `tui` での picker 復帰や自動 attach 切替(live セッション選択時はエラー終了) +- 任意位置からの fork 起動(`fork_at` を resume 経路に組み込まない。将来別フローとして扱う)