diff --git a/Cargo.lock b/Cargo.lock index cc0c11fc..bb141e52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3575,6 +3575,7 @@ dependencies = [ "protocol", "ratatui", "serde_json", + "session-store", "tokio", "toml", "unicode-width", diff --git a/crates/pod/src/main.rs b/crates/pod/src/main.rs index 40747cdc..ba07d6d9 100644 --- a/crates/pod/src/main.rs +++ b/crates/pod/src/main.rs @@ -4,7 +4,7 @@ use std::process::ExitCode; use clap::Parser; use manifest::paths; use pod::{Pod, PodController, PodFactory}; -use session_store::FsStore; +use session_store::{FsStore, SessionId}; #[derive(Parser)] #[command( @@ -43,6 +43,14 @@ struct Cli { /// callbacks upward. Required alongside `--adopt`. #[arg(long, value_name = "PATH", requires = "adopt")] callback: Option, + + /// Restore a Pod from an existing session. 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. + /// Mutually exclusive with `--adopt` (spawned children always start + /// fresh). + #[arg(long, value_name = "UUID", conflicts_with = "adopt")] + session: Option, } async fn build_factory(cli: &Cli) -> Result { @@ -136,6 +144,14 @@ async fn main() -> ExitCode { return ExitCode::FAILURE; } } + } else if let Some(source_session_id) = cli.session { + match Pod::restore_from_manifest(source_session_id, manifest, store, loader).await { + Ok(p) => p, + Err(e) => { + eprintln!("error: failed to restore pod: {e}"); + return ExitCode::FAILURE; + } + } } else { match Pod::from_manifest(manifest, store, loader).await { Ok(p) => p, diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index e53fc1eb..041255df 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -210,75 +210,11 @@ impl Pod { self.inject_resident_knowledge = enabled; } - /// Restore a Pod from a persisted session. /// Shared handle to the prompt catalog. Cheap to clone (`Arc`). pub fn prompts(&self) -> &Arc { &self.prompts } - pub async fn restore( - session_id: SessionId, - manifest: PodManifest, - client: C, - store: St, - pwd: PathBuf, - scope: Scope, - ) -> Result { - let state = session_store::restore(&store, session_id).await?; - let mut worker = Worker::new(client); - if let Some(ref prompt) = state.system_prompt { - worker.set_system_prompt(prompt); - } - // A leading `Role::System` item can only come from `compact` - // (the Pod's one and only write path that prepends a summary at - // history[0]). Restoring the anchor lets Anthropic re-use a - // stable cache prefix for long-lived restored sessions. - let anchored_on_summary = matches!( - state.history.first(), - Some(Item::Message { - role: llm_worker::Role::System, - .. - }) - ); - worker.set_history(state.history); - worker.set_request_config(state.config); - worker.set_turn_count(state.turn_count); - worker.set_last_run_interrupted(state.last_run_interrupted); - if anchored_on_summary { - worker.set_cache_anchor(Some(0)); - } - - let prompts = PromptCatalog::builtins_only()?; - let extract_pointer = memory::extract::fold_pointer(&state.extensions); - let mut pod = Self { - manifest, - worker: Some(worker), - store, - session_id, - head_hash: state.head_hash, - pwd, - scope, - hook_builder: HookRegistryBuilder::new(), - interceptor_installed: false, - compact_state: None, - usage_tracker: Arc::new(UsageTracker::new()), - usage_history: Arc::new(Mutex::new(state.usage_history)), - tracker: None, - system_prompt_template: None, - alerter: None, - event_tx: None, - pending_notifies: NotifyBuffer::new(), - scope_allocation: None, - callback_socket: None, - prompts, - inject_resident_knowledge: true, - extract_in_flight: Arc::new(AtomicBool::new(false)), - extract_pointer: Mutex::new(extract_pointer), - }; - pod.apply_prune_from_manifest(); - Ok(pod) - } - /// The session ID used for persistence. pub fn session_id(&self) -> SessionId { self.session_id @@ -1534,15 +1470,18 @@ impl Pod, St> { store: St, loader: PromptLoader, ) -> Result { - let pwd = current_pwd()?; - let scope = build_scope_with_memory(&manifest, &pwd)?; - if !scope.is_readable(&pwd) { - return Err(PodError::PwdOutsideScope { pwd }); - } + let common = prepare_pod_common(&manifest, &loader, /* parse_template */ true)?; + + // Session creation is deferred to the first run (see + // `ensure_session_head`) so the SessionStart entry can capture + // the rendered system prompt, not the raw template source. The + // session_id is allocated here so the scope-lock registration + // can record it from the start. + let session_id = session_store::new_session_id(); // Register this Pod in the machine-wide scope-lock registry // before building anything else, so a spawn that conflicts on - // scope fails fast (and without having paid for client setup). + // scope fails fast. let socket_path = dir::default_base() .map_err(ScopeLockError::from)? .join(&manifest.pod.name) @@ -1551,50 +1490,34 @@ impl Pod, St> { manifest.pod.name.clone(), std::process::id(), socket_path, - scope.allow_rules(), + common.scope.allow_rules(), + session_id, )?; - let client = provider::build_client(&manifest.model)?; - let mut worker = Worker::new(client); + let mut worker = Worker::new(common.client); apply_worker_manifest(&mut worker, &manifest.worker); - // Resolve the instruction reference and parse the resulting - // template eagerly (syntax check only). Rendering is deferred - // to `ensure_system_prompt_materialized` at first turn so - // runtime values (date, tools, scope summary, ...) can be - // injected. - let system_prompt_template = Some( - SystemPromptTemplate::parse(&manifest.worker.instruction, loader.clone()) - .map_err(|source| PodError::InvalidSystemPromptTemplate { source })?, - ); - - let prompts = PromptCatalog::load(&loader, manifest.pod.prompt_pack.as_deref())?; - - // Session creation is deferred to the first run (see - // `ensure_session_head`) so the SessionStart entry can capture - // the rendered system prompt, not the raw template source. - let session_id = session_store::new_session_id(); let mut pod = Self { manifest, worker: Some(worker), store, session_id, head_hash: None, - pwd, - scope, + pwd: common.pwd, + scope: common.scope, hook_builder: HookRegistryBuilder::new(), interceptor_installed: false, compact_state: None, usage_tracker: Arc::new(UsageTracker::new()), usage_history: Arc::new(Mutex::new(Vec::new())), tracker: None, - system_prompt_template, + system_prompt_template: common.system_prompt_template, alerter: None, event_tx: None, pending_notifies: NotifyBuffer::new(), scope_allocation: Some(scope_allocation), callback_socket: None, - prompts, + prompts: common.prompts, inject_resident_knowledge: true, extract_in_flight: Arc::new(AtomicBool::new(false)), extract_pointer: Mutex::new(None), @@ -1610,57 +1533,43 @@ impl Pod, St> { /// [`scope_lock::delegate_scope`], rather than installing a new /// top-level entry. `callback_socket` carries the spawner's /// Unix-socket path so the spawned Pod can send `Method::Notify` - /// back to the spawner; it is stored but unused in the - /// `spawn-pod-tool` ticket — the receiving side lands in the - /// follow-up `pod-callback` ticket. + /// back to the spawner. pub async fn from_manifest_spawned( manifest: PodManifest, store: St, loader: PromptLoader, callback_socket: PathBuf, ) -> Result { - let pwd = current_pwd()?; - let scope = build_scope_with_memory(&manifest, &pwd)?; - if !scope.is_readable(&pwd) { - return Err(PodError::PwdOutsideScope { pwd }); - } - - let scope_allocation = - scope_lock::adopt_allocation(manifest.pod.name.clone(), std::process::id())?; - - let client = provider::build_client(&manifest.model)?; - let mut worker = Worker::new(client); - apply_worker_manifest(&mut worker, &manifest.worker); - - let system_prompt_template = Some( - SystemPromptTemplate::parse(&manifest.worker.instruction, loader.clone()) - .map_err(|source| PodError::InvalidSystemPromptTemplate { source })?, - ); - - let prompts = PromptCatalog::load(&loader, manifest.pod.prompt_pack.as_deref())?; + let common = prepare_pod_common(&manifest, &loader, /* parse_template */ true)?; let session_id = session_store::new_session_id(); + let scope_allocation = + scope_lock::adopt_allocation(manifest.pod.name.clone(), std::process::id(), session_id)?; + + let mut worker = Worker::new(common.client); + apply_worker_manifest(&mut worker, &manifest.worker); + let mut pod = Self { manifest, worker: Some(worker), store, session_id, head_hash: None, - pwd, - scope, + pwd: common.pwd, + scope: common.scope, hook_builder: HookRegistryBuilder::new(), interceptor_installed: false, compact_state: None, usage_tracker: Arc::new(UsageTracker::new()), usage_history: Arc::new(Mutex::new(Vec::new())), tracker: None, - system_prompt_template, + system_prompt_template: common.system_prompt_template, alerter: None, event_tx: None, pending_notifies: NotifyBuffer::new(), scope_allocation: Some(scope_allocation), callback_socket: Some(callback_socket), - prompts, + prompts: common.prompts, inject_resident_knowledge: true, extract_in_flight: Arc::new(AtomicBool::new(false)), extract_pointer: Mutex::new(None), @@ -1669,6 +1578,136 @@ impl Pod, St> { Ok(pod) } + /// 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. + /// + /// Refuses to resume if another live Pod is currently writing to + /// `source_session_id` (detected via `scope.lock`). + /// + /// `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, + 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)? { + return Err(PodError::SessionInUse { + session_id: source_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 common = prepare_pod_common(&manifest, &loader, /* parse_template */ false)?; + + let socket_path = dir::default_base() + .map_err(ScopeLockError::from)? + .join(&manifest.pod.name) + .join("sock"); + let scope_allocation = scope_lock::install_top_level( + manifest.pod.name.clone(), + std::process::id(), + socket_path, + common.scope.allow_rules(), + session_id, + )?; + + // Build the worker and apply the manifest defaults first, then + // overwrite the pieces the session log is authoritative for. + let mut worker = Worker::new(common.client); + apply_worker_manifest(&mut worker, &manifest.worker); + if let Some(ref prompt) = state.system_prompt { + worker.set_system_prompt(prompt); + } + // A leading `Role::System` item can only come from `compact` + // (the Pod's one and only write path that prepends a summary at + // history[0]). Restoring the anchor lets Anthropic re-use a + // stable cache prefix for long-lived restored sessions. + let anchored_on_summary = matches!( + state.history.first(), + Some(Item::Message { + role: llm_worker::Role::System, + .. + }) + ); + worker.set_history(state.history.clone()); + worker.set_request_config(state.config.clone()); + worker.set_turn_count(state.turn_count); + worker.set_last_run_interrupted(state.last_run_interrupted); + if anchored_on_summary { + worker.set_cache_anchor(Some(0)); + } + + 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, + pwd: common.pwd, + scope: common.scope, + hook_builder: HookRegistryBuilder::new(), + interceptor_installed: false, + compact_state: None, + usage_tracker: Arc::new(UsageTracker::new()), + usage_history: Arc::new(Mutex::new(state.usage_history)), + tracker: None, + // Restore replays the saved system_prompt verbatim — no + // template re-render on resume. + system_prompt_template: None, + alerter: None, + event_tx: None, + pending_notifies: NotifyBuffer::new(), + scope_allocation: Some(scope_allocation), + callback_socket: None, + prompts: common.prompts, + inject_resident_knowledge: true, + extract_in_flight: Arc::new(AtomicBool::new(false)), + extract_pointer: Mutex::new(extract_pointer), + }; + pod.apply_prune_from_manifest(); + Ok(pod) + } + /// Convenience: build a Pod from a single-layer TOML manifest string. /// /// Parses the TOML into a [`PodManifestConfig`], converts to a @@ -1862,6 +1901,73 @@ pub enum PodError { #[error("memory Phase 1 staging write failed: {0}")] ExtractStaging(#[source] memory::extract::StagingError), + + #[error( + "session {session_id} is currently in use by pod `{pod_name}` at {}", + .socket.display() + )] + SessionInUse { + session_id: SessionId, + pod_name: String, + socket: PathBuf, + }, + + #[error("session {session_id} has no entries to restore")] + SessionEmpty { session_id: SessionId }, +} + +/// Bundle of resources that every high-level Pod constructor needs: +/// pwd, scope, an LLM client, the prompt catalog, and (optionally) a +/// parsed system-prompt template. Built once by [`prepare_pod_common`] +/// from the manifest cascade and then split into Pod fields. +struct PodCommon { + pwd: PathBuf, + scope: Scope, + client: Box, + prompts: Arc, + system_prompt_template: Option, +} + +/// Resolve pwd / scope / LLM client / prompt catalog from a validated +/// manifest cascade. Used by `from_manifest`, `from_manifest_spawned`, +/// and `restore_from_manifest` so they share one definition of "what +/// pieces fall out of a manifest". +/// +/// `parse_template` controls whether the manifest's instruction is +/// parsed as a system-prompt template. New Pods always parse so the +/// template is rendered at first turn; restored Pods skip parsing +/// because the saved session log replays a previously-rendered +/// `system_prompt` verbatim. +fn prepare_pod_common( + manifest: &PodManifest, + loader: &PromptLoader, + parse_template: bool, +) -> Result { + let pwd = current_pwd()?; + let scope = build_scope_with_memory(manifest, &pwd)?; + if !scope.is_readable(&pwd) { + return Err(PodError::PwdOutsideScope { pwd }); + } + + let client = provider::build_client(&manifest.model)?; + let prompts = PromptCatalog::load(loader, manifest.pod.prompt_pack.as_deref())?; + + let system_prompt_template = if parse_template { + Some( + SystemPromptTemplate::parse(&manifest.worker.instruction, loader.clone()) + .map_err(|source| PodError::InvalidSystemPromptTemplate { source })?, + ) + } else { + None + }; + + Ok(PodCommon { + pwd, + scope, + client, + prompts, + system_prompt_template, + }) } /// Build the Pod's runtime [`Scope`] from the manifest, layering the diff --git a/crates/pod/src/runtime/scope_lock.rs b/crates/pod/src/runtime/scope_lock.rs index 0395ac8c..27416d6e 100644 --- a/crates/pod/src/runtime/scope_lock.rs +++ b/crates/pod/src/runtime/scope_lock.rs @@ -21,6 +21,7 @@ use std::path::{Path, PathBuf}; use fs4::fs_std::FileExt; use manifest::{Permission, ScopeRule, paths}; use serde::{Deserialize, Serialize}; +use session_store::SessionId; /// On-disk representation of the allocation table. #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -50,6 +51,12 @@ pub struct Allocation { /// Name of the Pod that delegated scope to this one, or `None` for /// a top-level Pod started directly by a human. pub delegated_from: Option, + /// Session ID this Pod is currently writing to. `None` means this + /// is a pre-reservation made by a spawner via [`delegate_scope`] + /// before the child has come up; the child fills it in at + /// [`adopt_allocation`] time. + #[serde(default)] + pub session_id: Option, } impl LockFile { @@ -60,6 +67,14 @@ impl LockFile { pub fn find_mut(&mut self, pod_name: &str) -> Option<&mut Allocation> { self.allocations.iter_mut().find(|a| a.pod_name == pod_name) } + + /// Find the allocation currently writing to `session_id`. Skips + /// pre-reservations whose `session_id` is still `None`. + pub fn find_by_session(&self, session_id: SessionId) -> Option<&Allocation> { + self.allocations + .iter() + .find(|a| a.session_id == Some(session_id)) + } } /// Default on-disk path: `/scope.lock` resolved via @@ -294,6 +309,7 @@ pub fn register_pod( pid: u32, socket: PathBuf, scope_allow: Vec, + session_id: SessionId, ) -> Result<(), ScopeLockError> { reclaim_stale(guard); if guard.data().find(&pod_name).is_some() { @@ -316,6 +332,7 @@ pub fn register_pod( socket, scope_allow, delegated_from: None, + session_id: Some(session_id), }); guard.save()?; Ok(()) @@ -361,6 +378,9 @@ pub fn delegate_scope( socket, scope_allow, delegated_from: Some(spawner.into()), + // Pre-reservation. The child fills in its own session_id when + // it calls `adopt_allocation` after the worker is built. + session_id: None, }); guard.save()?; Ok(()) @@ -483,10 +503,18 @@ pub fn install_top_level( pid: u32, socket: PathBuf, scope_allow: Vec, + session_id: SessionId, ) -> Result { let lock_path = default_lock_path()?; let mut guard = LockFileGuard::open(&lock_path)?; - register_pod(&mut guard, pod_name.clone(), pid, socket, scope_allow)?; + register_pod( + &mut guard, + pod_name.clone(), + pid, + socket, + scope_allow, + session_id, + )?; Ok(ScopeAllocationGuard { pod_name, lock_path, @@ -497,13 +525,15 @@ pub fn install_top_level( /// a spawning Pod. /// /// The spawning flow is two-stage: the spawner calls [`delegate_scope`] -/// (with its own pid as a live placeholder), then exec's the child; the -/// child, once running, calls this function to rewrite the allocation's -/// pid to its own and claim the `ScopeAllocationGuard` so the entry is -/// released when the child exits. +/// (with its own pid as a live placeholder, `session_id = None`), then +/// exec's the child; the child, once running, calls this function to +/// rewrite the allocation's pid + session_id to its own and claim the +/// `ScopeAllocationGuard` so the entry is released when the child +/// exits. pub fn adopt_allocation( pod_name: String, new_pid: u32, + session_id: SessionId, ) -> Result { let lock_path = default_lock_path()?; let mut guard = LockFileGuard::open(&lock_path)?; @@ -512,6 +542,7 @@ pub fn adopt_allocation( .find_mut(&pod_name) .ok_or_else(|| ScopeLockError::UnknownPod(pod_name.clone()))?; alloc.pid = new_pid; + alloc.session_id = Some(session_id); guard.save()?; Ok(ScopeAllocationGuard { pod_name, @@ -519,6 +550,33 @@ pub fn adopt_allocation( }) } +/// Information about a Pod that currently holds an allocation for a +/// given session. +#[derive(Debug, Clone)] +pub struct SessionLockInfo { + pub pod_name: String, + pub socket: PathBuf, + pub pid: u32, +} + +/// Open the default lock file, reclaim stale entries, and return the +/// allocation currently writing to `session_id`, if any. +/// +/// Used by `Pod::restore_from_manifest` to refuse a resume that would +/// race a live writer on the same source session. +pub fn lookup_session(session_id: SessionId) -> Result, ScopeLockError> { + let lock_path = default_lock_path()?; + let mut guard = LockFileGuard::open(&lock_path)?; + reclaim_stale(&mut guard); + Ok(guard.data().find_by_session(session_id).map(|a| { + SessionLockInfo { + pod_name: a.pod_name.clone(), + socket: a.socket.clone(), + pid: a.pid, + } + })) +} + /// Errors raised by the mutating scope-lock operations. #[derive(Debug, thiserror::Error)] pub enum ScopeLockError { @@ -548,6 +606,10 @@ mod tests { /// harness runs tests on multiple threads inside a single process, /// so env-var writes from one test would otherwise leak into a /// parallel test's `default_lock_path()` lookup. + fn sid() -> SessionId { + session_store::new_session_id() + } + static ENV_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); /// Sandbox `INSOMNIA_RUNTIME_DIR` to a tempdir for the duration of @@ -652,6 +714,7 @@ mod tests { std::process::id(), sock("a"), vec![write_rule("/src", true)], + sid(), ) .unwrap(); } @@ -699,6 +762,7 @@ mod tests { std::process::id(), sock("a"), vec![write_rule("/src", true)], + sid(), ) .unwrap(); let err = register_pod( @@ -707,6 +771,7 @@ mod tests { std::process::id(), sock("b"), vec![write_rule("/src/core", true)], + sid(), ) .unwrap_err(); match err { @@ -726,6 +791,7 @@ mod tests { std::process::id(), sock("a"), vec![write_rule("/src", true)], + sid(), ) .unwrap(); let err = register_pod( @@ -734,6 +800,7 @@ mod tests { std::process::id(), sock("a2"), vec![write_rule("/docs", true)], + sid(), ) .unwrap_err(); assert!(matches!(err, ScopeLockError::DuplicatePodName(ref n) if n == "a")); @@ -750,6 +817,7 @@ mod tests { std::process::id(), sock("a"), vec![write_rule("/src", true)], + sid(), ) .unwrap(); let err = delegate_scope( @@ -775,6 +843,7 @@ mod tests { std::process::id(), sock("a"), vec![write_rule("/src", true)], + sid(), ) .unwrap(); delegate_scope( @@ -812,6 +881,7 @@ mod tests { std::process::id(), sock("a"), vec![write_rule("/src", true)], + sid(), ) .unwrap(); delegate_scope( @@ -848,6 +918,7 @@ mod tests { std::process::id(), sock("a"), vec![write_rule("/src", true)], + sid(), ) .unwrap(); delegate_scope( @@ -886,6 +957,7 @@ mod tests { std::process::id(), sock("a"), vec![write_rule("/src", true)], + sid(), ) .unwrap(); delegate_scope( @@ -939,6 +1011,7 @@ mod tests { std::process::id(), sock("a"), vec![write_rule("/src", true)], + sid(), ) .unwrap(); // B only reads under the same tree — allowed. @@ -948,6 +1021,7 @@ mod tests { std::process::id(), sock("b"), vec![read_rule("/src", true)], + sid(), ) .unwrap(); assert_eq!(g.data().allocations.len(), 2); @@ -964,6 +1038,7 @@ mod tests { std::process::id(), sock("a"), vec![write_rule("/src", true)], + sid(), ) .unwrap(); release_pod(&mut g, "a").unwrap(); @@ -973,6 +1048,7 @@ mod tests { std::process::id(), sock("b"), vec![write_rule("/src", true)], + sid(), ) .unwrap(); } @@ -988,6 +1064,7 @@ mod tests { std::process::id(), sock("a"), vec![write_rule("/src", true)], + sid(), ) .unwrap(); delegate_scope( @@ -1023,6 +1100,7 @@ mod tests { std::process::id(), sock("a"), vec![write_rule("/src", true)], + sid(), ) .unwrap(); { @@ -1047,7 +1125,7 @@ mod tests { delegate_placeholder(&mut g, "child", std::process::id()); } let child_pid = std::process::id().wrapping_add(1); - let guard = adopt_allocation("child".into(), child_pid).unwrap(); + let guard = adopt_allocation("child".into(), child_pid, sid()).unwrap(); { let g = LockFileGuard::open(&lock_path).unwrap(); let alloc = g.data().find("child").unwrap(); @@ -1064,7 +1142,7 @@ mod tests { fn adopt_allocation_errors_on_unknown_pod() { let dir = TempDir::new().unwrap(); let _sandbox = RuntimeDirSandbox::new(dir.path()); - let err = adopt_allocation("ghost".into(), 42).unwrap_err(); + let err = adopt_allocation("ghost".into(), 42, sid()).unwrap_err(); assert!(matches!(err, ScopeLockError::UnknownPod(ref n) if n == "ghost")); } @@ -1078,6 +1156,7 @@ mod tests { socket: sock(pod_name), scope_allow: vec![write_rule("/tmp/child", true)], delegated_from: None, + session_id: None, }); g.save().unwrap(); } @@ -1093,6 +1172,7 @@ mod tests { std::process::id(), sock("a"), vec![write_rule("/src", true)], + sid(), ) .unwrap(); delegate_scope( @@ -1112,6 +1192,7 @@ mod tests { std::process::id(), sock("x"), vec![write_rule("/src/core/x", true)], + sid(), ) .unwrap_err(); match err { diff --git a/crates/pod/tests/pod_comm_tools_test.rs b/crates/pod/tests/pod_comm_tools_test.rs index 36a62143..dd194f43 100644 --- a/crates/pod/tests/pod_comm_tools_test.rs +++ b/crates/pod/tests/pod_comm_tools_test.rs @@ -351,6 +351,7 @@ async fn stop_pod_sends_shutdown_and_releases_scope() { permission: Permission::Write, recursive: true, }], + session_store::new_session_id(), ) .unwrap(); scope_lock::delegate_scope( diff --git a/crates/pod/tests/pod_events_test.rs b/crates/pod/tests/pod_events_test.rs index fd74282d..04abfb5c 100644 --- a/crates/pod/tests/pod_events_test.rs +++ b/crates/pod/tests/pod_events_test.rs @@ -358,6 +358,7 @@ async fn shutdown_releases_scope_allocation_when_present() { std::process::id(), "/tmp/kid.sock".into(), vec![], + session_store::new_session_id(), ) .unwrap(); std::mem::forget(guard); diff --git a/crates/pod/tests/spawn_pod_test.rs b/crates/pod/tests/spawn_pod_test.rs index bd31ae46..94929877 100644 --- a/crates/pod/tests/spawn_pod_test.rs +++ b/crates/pod/tests/spawn_pod_test.rs @@ -73,6 +73,7 @@ async fn setup_spawner( permission: Permission::Write, recursive: true, }], + session_store::new_session_id(), ) .unwrap(); // Leak the guard — the spawner allocation needs to outlive the diff --git a/crates/session-store/src/lib.rs b/crates/session-store/src/lib.rs index afa8d04e..5978fc0d 100644 --- a/crates/session-store/src/lib.rs +++ b/crates/session-store/src/lib.rs @@ -40,6 +40,7 @@ pub use session::{ save_run_completed, save_run_errored, save_turn_end, save_usage, }; pub use llm_worker::UsageRecord; +pub use llm_worker::llm_client::types::{ContentPart, Item, Role}; pub use session_log::{ EntryHash, HashedEntry, LogEntry, RestoredState, SessionOrigin, build_chain, collect_state, compute_hash, diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index fa59712e..06c24bba 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -14,3 +14,4 @@ unicode-width = "0.2.2" uuid = "1.23" toml = "1.1.2" manifest = { version = "0.1.0", path = "../manifest" } +session-store = { version = "0.1.0", path = "../session-store" } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index edcf91a7..49134766 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -3,6 +3,7 @@ mod block; mod cache; mod client; mod input; +mod picker; mod scroll; mod spawn; mod tool; @@ -24,9 +25,11 @@ use crossterm::terminal::{ use protocol::Method; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; +use session_store::SessionId; use crate::app::App; use crate::client::PodClient; +use crate::picker::PickerOutcome; use crate::spawn::{SpawnOutcome, SpawnReady}; fn resolve_socket(pod_name: &str, override_path: Option) -> PathBuf { @@ -47,27 +50,101 @@ enum Mode { pod_name: String, socket_override: Option, }, + /// `tui -r` / `tui --resume`: open the session picker first, then + /// run the same name dialog as Spawn but in resume mode. + Resume, + /// `tui --session `: skip the picker, go straight to the + /// resume name dialog with `id` baked in. + ResumeWithSession(SessionId), } -fn parse_args() -> Mode { +enum ParseError { + Conflict, + InvalidSession(String), + MissingValue(&'static str), +} + +impl std::fmt::Display for ParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Conflict => write!(f, "--resume and --session are mutually exclusive"), + Self::InvalidSession(s) => write!(f, "invalid --session UUID: {s}"), + Self::MissingValue(flag) => write!(f, "{flag} requires a value"), + } + } +} + +fn parse_args() -> Result { let args: Vec = std::env::args().skip(1).collect(); - if args.is_empty() { - return Mode::Spawn; + let mut resume = false; + let mut session: Option = None; + let mut socket_override: Option = None; + let mut positional: Option = None; + + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "-r" | "--resume" => { + resume = true; + i += 1; + } + "--session" => { + let raw = args + .get(i + 1) + .ok_or(ParseError::MissingValue("--session"))?; + session = Some( + raw.parse::() + .map_err(|_| ParseError::InvalidSession(raw.clone()))?, + ); + i += 2; + } + "--socket" => { + let raw = args + .get(i + 1) + .ok_or(ParseError::MissingValue("--socket"))?; + socket_override = Some(PathBuf::from(raw)); + i += 2; + } + other if positional.is_none() && !other.starts_with('-') => { + positional = Some(other.to_string()); + i += 1; + } + _ => { + // Unknown flag or extra positional — keep older + // behaviour of ignoring unknowns rather than aborting. + i += 1; + } + } } - let pod_name = args[0].clone(); - let socket_override = args - .windows(2) - .find(|w| w[0] == "--socket") - .map(|w| PathBuf::from(&w[1])); - Mode::Attach { - pod_name, - socket_override, + + if resume && session.is_some() { + return Err(ParseError::Conflict); } + + if let Some(id) = session { + return Ok(Mode::ResumeWithSession(id)); + } + if resume { + return Ok(Mode::Resume); + } + if let Some(pod_name) = positional { + return Ok(Mode::Attach { + pod_name, + socket_override, + }); + } + Ok(Mode::Spawn) } #[tokio::main] async fn main() -> ExitCode { - let mode = parse_args(); + let mode = match parse_args() { + Ok(m) => m, + Err(e) => { + eprintln!("tui: {e}"); + return ExitCode::FAILURE; + } + }; if let Err(e) = enable_raw_mode() { eprintln!("tui: failed to enter raw mode: {e}"); @@ -80,11 +157,13 @@ async fn main() -> ExitCode { } let result = match mode { - Mode::Spawn => run_spawn().await, + Mode::Spawn => run_spawn(None).await, Mode::Attach { pod_name, socket_override, } => run_attach(pod_name, socket_override).await, + Mode::Resume => run_resume().await, + Mode::ResumeWithSession(id) => run_spawn(Some(id)).await, }; // Always restore the terminal first so any pending eprintln below @@ -120,8 +199,19 @@ async fn run_attach( run(&mut terminal, pod_name, &socket_path, false).await } -async fn run_spawn() -> Result<(), Box> { - let ready = match spawn::run().await? { +async fn run_resume() -> Result<(), Box> { + // Phase 1: pick a session in its own inline viewport, dropping the + // viewport before the name dialog opens so each phase gets fresh + // vertical room. + let id = match picker::run().await? { + PickerOutcome::Picked(id) => id, + PickerOutcome::Cancelled => return Ok(()), + }; + run_spawn(Some(id)).await +} + +async fn run_spawn(resume_from: Option) -> Result<(), Box> { + let ready = match spawn::run(resume_from).await? { SpawnOutcome::Ready(r) => r, SpawnOutcome::Cancelled => return Ok(()), }; diff --git a/crates/tui/src/picker.rs b/crates/tui/src/picker.rs new file mode 100644 index 00000000..6040789a --- /dev/null +++ b/crates/tui/src/picker.rs @@ -0,0 +1,282 @@ +//! Inline-viewport "pick a session to restore" UX. +//! +//! Reads the most recent sessions from the configured store, lets the +//! user pick one with the arrow keys, and returns the chosen +//! `SessionId`. Closes its inline viewport before returning so the +//! caller can open a fresh viewport for the name dialog. +//! +//! The picker only handles selection. Forking, scope-lock checks, and +//! actual `pod` launch happen later in the resume flow. + +use std::io; +use std::time::Duration; + +use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Layout}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::{Frame, TerminalOptions, Viewport}; +use session_store::{ + ContentPart, FsStore, HashedEntry, Item, LogEntry, SessionId, Store, +}; + +const MAX_ROWS: usize = 10; +const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4; + +#[derive(Debug)] +pub enum PickerError { + Io(io::Error), + Store(session_store::StoreError), + NoSessions, +} + +impl std::fmt::Display for PickerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(e) => write!(f, "io error: {e}"), + Self::Store(e) => write!(f, "session store error: {e}"), + Self::NoSessions => write!( + f, + "no sessions found — start a fresh pod with `tui` and try again" + ), + } + } +} + +impl std::error::Error for PickerError {} + +impl From for PickerError { + fn from(e: io::Error) -> Self { + Self::Io(e) + } +} + +impl From for PickerError { + fn from(e: session_store::StoreError) -> Self { + Self::Store(e) + } +} + +pub enum PickerOutcome { + Picked(SessionId), + Cancelled, +} + +/// One row in the picker view. Rendered from the session log so the +/// user can recognise their session at a glance without parsing UUIDs. +struct Row { + id: SessionId, + /// Last user / assistant snippet, or a `[corrupt]` placeholder. + preview: String, +} + +pub async fn run() -> Result { + let store = open_default_store().await?; + let ids = store.list_sessions().await?; + if ids.is_empty() { + return Err(PickerError::NoSessions); + } + let mut rows: Vec = Vec::with_capacity(MAX_ROWS); + for id in ids.into_iter().take(MAX_ROWS) { + let preview = build_preview(&store, id).await; + rows.push(Row { id, preview }); + } + + let mut selected = 0usize; + let mut terminal = make_inline_terminal()?; + loop { + terminal.draw(|f| draw(f, &rows, selected))?; + match poll_event()? { + None => continue, + Some(Action::Up) => { + if selected > 0 { + selected -= 1; + } + } + Some(Action::Down) => { + if selected + 1 < rows.len() { + selected += 1; + } + } + Some(Action::Submit) => { + drop(terminal); + return Ok(PickerOutcome::Picked(rows[selected].id)); + } + Some(Action::Cancel) => { + drop(terminal); + return Ok(PickerOutcome::Cancelled); + } + } + } +} + +async fn open_default_store() -> Result { + let dir = manifest::paths::sessions_dir().ok_or_else(|| { + PickerError::Io(io::Error::new( + io::ErrorKind::NotFound, + "could not resolve sessions directory \ + (set INSOMNIA_HOME, INSOMNIA_DATA_DIR, or HOME)", + )) + })?; + Ok(FsStore::new(&dir).await?) +} + +async fn build_preview(store: &FsStore, id: SessionId) -> String { + match store.read_all(id).await { + Ok(entries) => last_message_preview(&entries).unwrap_or_else(|| "[empty]".to_string()), + Err(_) => "[corrupt]".to_string(), + } +} + +/// Walk the log from the tail looking for the most recent user-message +/// or assistant-message entry, then render its first text fragment in +/// a single line. +fn last_message_preview(entries: &[HashedEntry]) -> Option { + for hashed in entries.iter().rev() { + match &hashed.entry { + LogEntry::UserInput { item, .. } => { + if let Some(text) = first_text(item) { + return Some(format!("user: {}", trim_one_line(&text, 60))); + } + } + LogEntry::AssistantItems { items, .. } => { + if let Some(text) = items.iter().find_map(first_text) { + return Some(format!("assistant: {}", trim_one_line(&text, 60))); + } + } + _ => {} + } + } + None +} + +fn first_text(item: &Item) -> Option { + match item { + Item::Message { content, .. } => content.iter().find_map(|p| match p { + ContentPart::Text { text } => Some(text.clone()), + _ => None, + }), + _ => None, + } +} + +fn trim_one_line(s: &str, max_chars: usize) -> String { + let collapsed: String = s.chars().map(|c| if c == '\n' { ' ' } else { c }).collect(); + if collapsed.chars().count() <= max_chars { + collapsed + } else { + let truncated: String = collapsed.chars().take(max_chars - 1).collect(); + format!("{truncated}…") + } +} + +fn make_inline_terminal() -> io::Result>> { + let backend = CrosstermBackend::new(io::stdout()); + Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Inline(VIEWPORT_LINES), + }, + ) +} + +enum Action { + Up, + Down, + Submit, + Cancel, +} + +fn poll_event() -> io::Result> { + if !event::poll(Duration::from_millis(100))? { + return Ok(None); + } + match event::read()? { + TermEvent::Key(k) if k.kind != KeyEventKind::Release => { + let ctrl = k.modifiers.contains(KeyModifiers::CONTROL); + Ok(match k.code { + KeyCode::Up => Some(Action::Up), + KeyCode::Down => Some(Action::Down), + KeyCode::Char('k') if !ctrl => Some(Action::Up), + KeyCode::Char('j') if !ctrl => Some(Action::Down), + KeyCode::Enter => Some(Action::Submit), + KeyCode::Esc => Some(Action::Cancel), + KeyCode::Char('c') if ctrl => Some(Action::Cancel), + _ => None, + }) + } + _ => Ok(None), + } +} + +fn draw(f: &mut Frame<'_>, rows: &[Row], selected: usize) { + let area = f.area(); + let mut constraints: Vec = + Vec::with_capacity(rows.len() + 3); + constraints.push(Constraint::Length(1)); // title + for _ in rows { + constraints.push(Constraint::Length(1)); + } + constraints.push(Constraint::Length(1)); // hint + constraints.push(Constraint::Length(1)); // spacer + let layout = Layout::vertical(constraints).split(area); + + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + "resume pod pick a session", + Style::default().add_modifier(Modifier::BOLD), + )])), + layout[0], + ); + + for (i, row) in rows.iter().enumerate() { + f.render_widget( + Paragraph::new(row_line(row, i == selected)), + layout[i + 1], + ); + } + + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::raw(" "), + Span::styled("[↑/↓]", Style::default().fg(Color::DarkGray)), + Span::raw(" select "), + Span::styled("[enter]", Style::default().fg(Color::Green)), + Span::raw(" pick "), + Span::styled("[esc]", Style::default().fg(Color::Yellow)), + Span::raw(" cancel"), + ])), + layout[rows.len() + 1], + ); +} + +fn row_line(row: &Row, selected: bool) -> Line<'_> { + let marker = if selected { "▶ " } else { " " }; + let id_style = if selected { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Cyan) + }; + let preview_style = if selected { + Style::default().fg(Color::White) + } else { + Style::default().fg(Color::DarkGray) + }; + Line::from(vec![ + Span::raw(marker), + Span::styled(short_session(row.id), id_style), + Span::raw(" "), + Span::styled(row.preview.clone(), preview_style), + ]) +} + +fn short_session(id: SessionId) -> String { + let s = id.to_string(); + s.chars().take(8).collect() +} + diff --git a/crates/tui/src/spawn.rs b/crates/tui/src/spawn.rs index b7658eea..10de2e68 100644 --- a/crates/tui/src/spawn.rs +++ b/crates/tui/src/spawn.rs @@ -20,6 +20,7 @@ use std::time::Duration; use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; use manifest::{PodManifestConfig, find_project_manifest_from, load_layer, user_manifest_path}; use ratatui::Terminal; +use session_store::SessionId; use ratatui::backend::CrosstermBackend; use ratatui::layout::{Constraint, Layout}; use ratatui::style::{Color, Modifier, Style}; @@ -85,7 +86,10 @@ impl From for SpawnError { type InlineTerminal = Terminal>; -pub async fn run() -> Result { +/// Source session for a resume run. `None` = fresh spawn (current +/// behaviour); `Some(id)` swaps the dialog into "Resume Pod" mode and +/// passes `--session ` to the spawned `pod` child. +pub async fn run(resume_from: Option) -> Result { let cwd = std::env::current_dir().map_err(SpawnError::Io)?; // Run the same merge pod itself uses, then read what's missing @@ -135,6 +139,7 @@ pub async fn run() -> Result { name: default_name, message: None, editing: true, + resume_from, }; let mut terminal = make_inline_terminal()?; @@ -266,16 +271,19 @@ async fn wait_for_ready( let pod_bin = resolve_pod_command(); let cwd = std::env::current_dir().map_err(SpawnError::Io)?; - let mut child = Command::new(&pod_bin) + let mut command = Command::new(&pod_bin); + command .arg("--overlay") .arg(overlay_toml) .current_dir(&cwd) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()) - .kill_on_drop(true) - .spawn() - .map_err(SpawnError::PodLaunchFailed)?; + .kill_on_drop(true); + if let Some(id) = form.resume_from { + command.arg("--session").arg(id.to_string()); + } + let mut child = command.spawn().map_err(SpawnError::PodLaunchFailed)?; let stderr = child .stderr @@ -437,6 +445,11 @@ struct Form { /// cursor stays out so it does not collide with the shell prompt /// after the inline terminal is dropped. editing: bool, + /// `Some(id)` flips the dialog into "Resume Pod" mode: the title + /// switches, the source session is shown to the user, and the + /// child pod is launched with `--session ` so it forks and + /// restores `id`. + resume_from: Option, } impl Form { @@ -500,8 +513,12 @@ fn draw_form(f: &mut Frame<'_>, form: &Form) { ]) .split(area); + let title_text = match form.resume_from { + Some(id) => format!("resume pod session: {}", short_session(id)), + None => "spawn pod".to_string(), + }; let title = Paragraph::new(Line::from(vec![Span::styled( - "spawn pod", + title_text, Style::default().add_modifier(Modifier::BOLD), )])); f.render_widget(title, layout[0]); @@ -523,6 +540,13 @@ fn draw_form(f: &mut Frame<'_>, form: &Form) { } } +/// First 8 hex digits of a UUID — short enough to skim, long enough +/// to disambiguate inside a 10-row picker. +pub(crate) fn short_session(id: SessionId) -> String { + let s = id.to_string(); + s.chars().take(8).collect() +} + fn name_line(form: &Form) -> Line<'_> { Line::from(vec![ Span::raw(" "), @@ -600,6 +624,7 @@ mod tests { name_cursor: name.chars().count(), message: None, editing: true, + resume_from: None, } } diff --git a/tickets/tui-session-restore.md b/tickets/tui-session-restore.md index 50ac0c11..55e95cb1 100644 --- a/tickets/tui-session-restore.md +++ b/tickets/tui-session-restore.md @@ -2,7 +2,7 @@ ## 背景 -`session-store` は JSONL ログから Worker 状態を復元でき、Pod 側にも `Pod::restore(session_id, ...)` が存在する。一方で、現在の実行経路は新規 Pod 起動 (`Pod::from_manifest`) と生存中 Pod への attach / `Paused` 状態の `Resume` に限られており、停止済み Pod を既存 `SessionId` から起動するユーザー向け導線がない。 +`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 できるようにする。 @@ -15,65 +15,88 @@ TUI には既に新規 Pod 起動用の spawn UI があるため、同じよう - `tui `: 生存中 Pod への attach - 既存 session 復帰用に `tui -r` / `tui --resume` を追加する - `--resume` はユーザー向けの「過去 session から復帰」入口であり、protocol の `Method::Resume`(Paused turn の続行)とは別概念として扱う - - `--resume` 指定時のみ、現在の name 入力ダイアログの前段に session 選択プロンプトを表示する + - `--resume` 指定時のみ、name 入力ダイアログの前段に session 選択プロンプトを表示する - session id を直接指定するショートカットとして `tui --session ` を追加する - `--session` は session picker をスキップし、指定 session を復元対象にした name 入力ダイアログから始める - `--resume` と `--session` は併用不可 -- 直接起動用に、Pod CLI に session id を指定して復元起動するフラグを追加する(`pod --session `) -- TUI の `--resume` / `--session` 復帰フローは最終的にこの Pod CLI 復元起動経路を使う +- Pod CLI に `pod --session ` を追加し、復元 Pod を起動できるようにする + - 名前は他のフラグと同様 manifest cascade / overlay で決める(CLI 単体では `--overlay 'pod.name = "..."'` を使う想定) +- TUI の `--resume` / `--session` 復帰フローは、name 入力ダイアログ確定後に `pod --session --overlay 'pod.name = "<入力名>"'` を子プロセスとして起動して attach する ### セッション一覧 -- `manifest::paths::sessions_dir()` または既存の `--store` 相当設定で解決される session store を読み、既存 session を新しい順に一覧表示する +- `manifest::paths::sessions_dir()` または既存の `--store` 相当設定で解決される session store を読み、新しい順に **直近 10 件** を表示する - 一覧には少なくとも以下を表示する: - - `SessionId` - - 最終更新時刻、または store が提供できる同等の並び順情報 + - `SessionId`(短縮表記) + - 並び順は `Store::list_sessions` が返す UUIDv7 順(= 作成時刻順、新しい順)でよい - 履歴の簡易プレビュー(最後の user / assistant メッセージ等、取得できる範囲でよい) + - その session が今 live かどうか(`scope.lock` を引いて判定) - session log が壊れている、復元不能、または現在のバージョンで読めない場合は、その行を復帰不可として表示するか、エラー表示してスキップする -- session が 1 件もない場合は、新規 Pod 起動へ戻れる導線を出す +- session が 1 件もない場合は、エラー表示して終了する(`tui` で新規 spawn する案内を出す) ### 復元 Pod の構築 -- 選択した `SessionId` を使って `Pod::restore` 経由で Pod を構築する -- manifest / scope / tool 登録 / prompt loader は、通常の新規 Pod 起動と同じ現在の cascade 解決結果を使う -- Worker の会話履歴・system prompt・request config・turn count・usage history 等は session log 由来の状態を使う -- 復元起動時、runtime の `history.json` / `status.json` / `Event::History` で TUI が初期履歴を正しく再構築できる -- 復元された session が interrupted / paused 相当の状態を持つ場合、起動直後に `Resume` 可能な状態として扱う。通常終了済みなら `Idle` として新しい入力を受け付ける +復元時はソース session を直接書き継がず、**fork** して新しい session を起こす。 + +- `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 への同時書き込みは構造的に発生しない。 +- manifest / scope / tool 登録 / prompt loader は、通常の新規 Pod 起動と同じ現在の cascade 解決結果を使う。 +- Worker の会話履歴・system prompt・request config・turn count・usage history 等は `session_store::restore` で得た `RestoredState` を使う。`system_prompt` は session に保存された値をそのまま使い、`SystemPromptTemplate` の再レンダリングはしない。 +- 復元起動時、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` を提供し、`Pod::restore_from_manifest` 内で fork 前のチェックに使う。 +- 検出時は `Pod::restore_from_manifest` がエラーを返し、CLI / TUI 側で適切なメッセージを出す。 +- `scope.lock` のスキーマ変更については、既存ファイルが残っていたら手で消す前提(dev 期間中の互換性は不要)。 ### 二重起動の扱い -- 既に生きている Pod が同じ session を持っている場合は、新規復元起動せず既存 Pod への attach を促す -- 少なくとも、同じ session id に対する複数 Pod の同時書き込みが発生しないようにする -- runtime dir / status.json から検出できる範囲でよいが、検出不能な場合のエラーメッセージは明示する +- TUI の resume / `--session` 経路で `Pod::restore_from_manifest` がエラーを返した場合、エラー表示して TUI を終了する(picker 復帰や自動 attach 切替はしない)。 +- ユーザーは別途 `tui ` で attach できる。エラーメッセージには live Pod の `pod_name` / socket を含める。 ### UI / 操作 -- `tui -r` / `tui --resume` では、name 入力の前に session picker を表示する +- `tui -r` / `tui --resume` では、まず session picker を表示する - session picker は上下キーで session を選択し、Enter で決定、Esc / Ctrl-C でキャンセルできる -- session が多い場合でも使えるよう、最低限のスクロールを備える -- session 決定後は既存の name 入力ダイアログを再利用する +- 直近 10 件のみ表示するためスクロール UI は不要 +- session 決定後、name 入力ダイアログを表示する + - picker と name 入力は別の inline viewport として描画してよい(高さの都合で viewport を作り直す) - 入力する name は、復元された session を載せる新しい Pod 実行インスタンス名(runtime dir / socket 名) - default name は現行と同じ cwd 由来でよい - 表示上は `Resume Pod` / `session: ` のように、新規 spawn ではなく復帰であることを明示する - `tui --session ` では session picker を省略し、指定 session を対象にした name 入力ダイアログから始める - 将来的な検索フィルタ追加を妨げない state 構造にするが、本チケットでは必須にしない -- 復帰に失敗した場合、inline / alt-screen 内にエラーを表示し、一覧へ戻るか終了できる +- 復帰に失敗した場合(pod プロセスが ready line を返さない、`SessionInUse` など)はエラー表示してそのまま終了する ## 完了条件 -- `pod --session ` で既存 session から Pod を起動できる -- `tui -r` / `tui --resume` で既存 session 一覧を表示し、選択した session を復元対象にできる +- `pod --session ` で既存 session から Pod を起動でき、ソース session jsonl は不変のまま新しい fork session が作られる +- `tui -r` / `tui --resume` で直近 10 件の既存 session 一覧を表示し、選択した session を復元対象にできる - `tui --session ` で session picker を経由せず、指定 session の復帰 name 入力へ進める - 復帰フローでは session 選択後または `--session` 指定後に name 入力ダイアログが表示され、その name の Pod として起動・attach できる - 復元直後の TUI に過去履歴が表示される -- 復元後に新しい入力を送ると、既存履歴に続く turn として動作し、session log に追記される +- 復元後に新しい入力を送ると、既存履歴に続く turn として動作し、新しい fork session の jsonl に追記される - interrupted / paused 状態の session では、復元直後に Resume 導線が動作する -- 同一 session の生存中 Pod がある場合は二重起動せず attach または明示的なエラーになる +- 同一 source session に対する live Pod が存在する場合、復元起動はエラーで終了し、既存 Pod の `pod_name` / socket がメッセージに表示される +- `scope.lock` には各 Pod の `session_id` が記録される ## 範囲外 - session log の全文検索 UI -- compact 前後の session chain を 1 つの論理スレッドとして束ねる UI +- compact 前後の session chain / fork チェーンを 1 つの論理スレッドとして束ねる UI - 過去 session の編集・削除・名前付け - spawn された子 Pod / scope delegation ツリー全体の復元 - 別マシンから転送された session store の import UI +- `tui` での picker 復帰や自動 attach 切替(live セッション選択時はエラー終了)