resumeの実装

This commit is contained in:
Keisuke Hirata 2026-04-28 18:52:58 +09:00
parent 023ed09adc
commit 2b89bb6d2e
13 changed files with 802 additions and 173 deletions

1
Cargo.lock generated
View File

@ -3575,6 +3575,7 @@ dependencies = [
"protocol",
"ratatui",
"serde_json",
"session-store",
"tokio",
"toml",
"unicode-width",

View File

@ -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<PathBuf>,
/// 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<SessionId>,
}
async fn build_factory(cli: &Cli) -> Result<PodFactory, String> {
@ -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,

View File

@ -210,75 +210,11 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
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<PromptCatalog> {
&self.prompts
}
pub async fn restore(
session_id: SessionId,
manifest: PodManifest,
client: C,
store: St,
pwd: PathBuf,
scope: Scope,
) -> Result<Self, PodError> {
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<St: Store> Pod<Box<dyn LlmClient>, St> {
store: St,
loader: PromptLoader,
) -> Result<Self, PodError> {
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<St: Store> Pod<Box<dyn LlmClient>, 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<St: Store> Pod<Box<dyn LlmClient>, 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<Self, PodError> {
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<St: Store> Pod<Box<dyn LlmClient>, 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<Self, PodError> {
// 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<dyn LlmClient>,
prompts: Arc<PromptCatalog>,
system_prompt_template: Option<SystemPromptTemplate>,
}
/// 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<PodCommon, PodError> {
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

View File

@ -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<String>,
/// 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<SessionId>,
}
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: `<runtime_dir>/scope.lock` resolved via
@ -294,6 +309,7 @@ pub fn register_pod(
pid: u32,
socket: PathBuf,
scope_allow: Vec<ScopeRule>,
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<ScopeRule>,
session_id: SessionId,
) -> Result<ScopeAllocationGuard, ScopeLockError> {
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<ScopeAllocationGuard, ScopeLockError> {
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<Option<SessionLockInfo>, 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<Mutex<()>> = 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 {

View File

@ -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(

View File

@ -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);

View File

@ -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

View File

@ -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,

View File

@ -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" }

View File

@ -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>) -> PathBuf {
@ -47,27 +50,101 @@ enum Mode {
pod_name: String,
socket_override: Option<PathBuf>,
},
/// `tui -r` / `tui --resume`: open the session picker first, then
/// run the same name dialog as Spawn but in resume mode.
Resume,
/// `tui --session <UUID>`: 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<Mode, ParseError> {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.is_empty() {
return Mode::Spawn;
let mut resume = false;
let mut session: Option<SessionId> = None;
let mut socket_override: Option<PathBuf> = None;
let mut positional: Option<String> = 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::<SessionId>()
.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<dyn std::error::Error>> {
let ready = match spawn::run().await? {
async fn run_resume() -> Result<(), Box<dyn std::error::Error>> {
// 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<SessionId>) -> Result<(), Box<dyn std::error::Error>> {
let ready = match spawn::run(resume_from).await? {
SpawnOutcome::Ready(r) => r,
SpawnOutcome::Cancelled => return Ok(()),
};

282
crates/tui/src/picker.rs Normal file
View File

@ -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<io::Error> for PickerError {
fn from(e: io::Error) -> Self {
Self::Io(e)
}
}
impl From<session_store::StoreError> 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<PickerOutcome, PickerError> {
let store = open_default_store().await?;
let ids = store.list_sessions().await?;
if ids.is_empty() {
return Err(PickerError::NoSessions);
}
let mut rows: Vec<Row> = 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<FsStore, PickerError> {
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<String> {
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<String> {
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<Terminal<CrosstermBackend<io::Stdout>>> {
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<Option<Action>> {
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<Constraint> =
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()
}

View File

@ -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<io::Error> for SpawnError {
type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>;
pub async fn run() -> Result<SpawnOutcome, SpawnError> {
/// Source session for a resume run. `None` = fresh spawn (current
/// behaviour); `Some(id)` swaps the dialog into "Resume Pod" mode and
/// passes `--session <id>` to the spawned `pod` child.
pub async fn run(resume_from: Option<SessionId>) -> Result<SpawnOutcome, SpawnError> {
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<SpawnOutcome, SpawnError> {
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 <id>` so it forks and
/// restores `id`.
resume_from: Option<SessionId>,
}
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,
}
}

View File

@ -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-name>`: 生存中 Pod への attach
- 既存 session 復帰用に `tui -r` / `tui --resume` を追加する
- `--resume` はユーザー向けの「過去 session から復帰」入口であり、protocol の `Method::Resume`Paused turn の続行)とは別概念として扱う
- `--resume` 指定時のみ、現在の name 入力ダイアログの前段に session 選択プロンプトを表示する
- `--resume` 指定時のみ、name 入力ダイアログの前段に session 選択プロンプトを表示する
- session id を直接指定するショートカットとして `tui --session <UUID>` を追加する
- `--session` は session picker をスキップし、指定 session を復元対象にした name 入力ダイアログから始める
- `--resume``--session` は併用不可
- 直接起動用に、Pod CLI に session id を指定して復元起動するフラグを追加する(`pod --session <UUID>`
- TUI の `--resume` / `--session` 復帰フローは最終的にこの Pod CLI 復元起動経路を使う
- 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 を読み、既存 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<SessionLockInfo { pod_name, socket, pid }>` を提供し、`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 <pod-name>` で 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: <short-id>` のように、新規 spawn ではなく復帰であることを明示する
- `tui --session <UUID>` では session picker を省略し、指定 session を対象にした name 入力ダイアログから始める
- 将来的な検索フィルタ追加を妨げない state 構造にするが、本チケットでは必須にしない
- 復帰に失敗した場合、inline / alt-screen 内にエラーを表示し、一覧へ戻るか終了できる
- 復帰に失敗した場合pod プロセスが ready line を返さない、`SessionInUse` など)はエラー表示してそのまま終了す
## 完了条件
- `pod --session <UUID>` で既存 session から Pod を起動できる
- `tui -r` / `tui --resume` で既存 session 一覧を表示し、選択した session を復元対象にできる
- `pod --session <UUID>` で既存 session から Pod を起動でき、ソース session 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 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 セッション選択時はエラー終了)