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", "protocol",
"ratatui", "ratatui",
"serde_json", "serde_json",
"session-store",
"tokio", "tokio",
"toml", "toml",
"unicode-width", "unicode-width",

View File

@ -4,7 +4,7 @@ use std::process::ExitCode;
use clap::Parser; use clap::Parser;
use manifest::paths; use manifest::paths;
use pod::{Pod, PodController, PodFactory}; use pod::{Pod, PodController, PodFactory};
use session_store::FsStore; use session_store::{FsStore, SessionId};
#[derive(Parser)] #[derive(Parser)]
#[command( #[command(
@ -43,6 +43,14 @@ struct Cli {
/// callbacks upward. Required alongside `--adopt`. /// callbacks upward. Required alongside `--adopt`.
#[arg(long, value_name = "PATH", requires = "adopt")] #[arg(long, value_name = "PATH", requires = "adopt")]
callback: Option<PathBuf>, 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> { async fn build_factory(cli: &Cli) -> Result<PodFactory, String> {
@ -136,6 +144,14 @@ async fn main() -> ExitCode {
return ExitCode::FAILURE; 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 { } else {
match Pod::from_manifest(manifest, store, loader).await { match Pod::from_manifest(manifest, store, loader).await {
Ok(p) => p, Ok(p) => p,

View File

@ -210,75 +210,11 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
self.inject_resident_knowledge = enabled; self.inject_resident_knowledge = enabled;
} }
/// Restore a Pod from a persisted session.
/// Shared handle to the prompt catalog. Cheap to clone (`Arc`). /// Shared handle to the prompt catalog. Cheap to clone (`Arc`).
pub fn prompts(&self) -> &Arc<PromptCatalog> { pub fn prompts(&self) -> &Arc<PromptCatalog> {
&self.prompts &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. /// The session ID used for persistence.
pub fn session_id(&self) -> SessionId { pub fn session_id(&self) -> SessionId {
self.session_id self.session_id
@ -1534,15 +1470,18 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
store: St, store: St,
loader: PromptLoader, loader: PromptLoader,
) -> Result<Self, PodError> { ) -> Result<Self, PodError> {
let pwd = current_pwd()?; let common = prepare_pod_common(&manifest, &loader, /* parse_template */ true)?;
let scope = build_scope_with_memory(&manifest, &pwd)?;
if !scope.is_readable(&pwd) { // Session creation is deferred to the first run (see
return Err(PodError::PwdOutsideScope { pwd }); // `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 // Register this Pod in the machine-wide scope-lock registry
// before building anything else, so a spawn that conflicts on // 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() let socket_path = dir::default_base()
.map_err(ScopeLockError::from)? .map_err(ScopeLockError::from)?
.join(&manifest.pod.name) .join(&manifest.pod.name)
@ -1551,50 +1490,34 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
manifest.pod.name.clone(), manifest.pod.name.clone(),
std::process::id(), std::process::id(),
socket_path, socket_path,
scope.allow_rules(), common.scope.allow_rules(),
session_id,
)?; )?;
let client = provider::build_client(&manifest.model)?; let mut worker = Worker::new(common.client);
let mut worker = Worker::new(client);
apply_worker_manifest(&mut worker, &manifest.worker); 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 { let mut pod = Self {
manifest, manifest,
worker: Some(worker), worker: Some(worker),
store, store,
session_id, session_id,
head_hash: None, head_hash: None,
pwd, pwd: common.pwd,
scope, scope: common.scope,
hook_builder: HookRegistryBuilder::new(), hook_builder: HookRegistryBuilder::new(),
interceptor_installed: false, interceptor_installed: false,
compact_state: None, compact_state: None,
usage_tracker: Arc::new(UsageTracker::new()), usage_tracker: Arc::new(UsageTracker::new()),
usage_history: Arc::new(Mutex::new(Vec::new())), usage_history: Arc::new(Mutex::new(Vec::new())),
tracker: None, tracker: None,
system_prompt_template, system_prompt_template: common.system_prompt_template,
alerter: None, alerter: None,
event_tx: None, event_tx: None,
pending_notifies: NotifyBuffer::new(), pending_notifies: NotifyBuffer::new(),
scope_allocation: Some(scope_allocation), scope_allocation: Some(scope_allocation),
callback_socket: None, callback_socket: None,
prompts, prompts: common.prompts,
inject_resident_knowledge: true, inject_resident_knowledge: true,
extract_in_flight: Arc::new(AtomicBool::new(false)), extract_in_flight: Arc::new(AtomicBool::new(false)),
extract_pointer: Mutex::new(None), 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 /// [`scope_lock::delegate_scope`], rather than installing a new
/// top-level entry. `callback_socket` carries the spawner's /// top-level entry. `callback_socket` carries the spawner's
/// Unix-socket path so the spawned Pod can send `Method::Notify` /// Unix-socket path so the spawned Pod can send `Method::Notify`
/// back to the spawner; it is stored but unused in the /// back to the spawner.
/// `spawn-pod-tool` ticket — the receiving side lands in the
/// follow-up `pod-callback` ticket.
pub async fn from_manifest_spawned( pub async fn from_manifest_spawned(
manifest: PodManifest, manifest: PodManifest,
store: St, store: St,
loader: PromptLoader, loader: PromptLoader,
callback_socket: PathBuf, callback_socket: PathBuf,
) -> Result<Self, PodError> { ) -> Result<Self, PodError> {
let pwd = current_pwd()?; let common = prepare_pod_common(&manifest, &loader, /* parse_template */ true)?;
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 session_id = session_store::new_session_id(); 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 { let mut pod = Self {
manifest, manifest,
worker: Some(worker), worker: Some(worker),
store, store,
session_id, session_id,
head_hash: None, head_hash: None,
pwd, pwd: common.pwd,
scope, scope: common.scope,
hook_builder: HookRegistryBuilder::new(), hook_builder: HookRegistryBuilder::new(),
interceptor_installed: false, interceptor_installed: false,
compact_state: None, compact_state: None,
usage_tracker: Arc::new(UsageTracker::new()), usage_tracker: Arc::new(UsageTracker::new()),
usage_history: Arc::new(Mutex::new(Vec::new())), usage_history: Arc::new(Mutex::new(Vec::new())),
tracker: None, tracker: None,
system_prompt_template, system_prompt_template: common.system_prompt_template,
alerter: None, alerter: None,
event_tx: None, event_tx: None,
pending_notifies: NotifyBuffer::new(), pending_notifies: NotifyBuffer::new(),
scope_allocation: Some(scope_allocation), scope_allocation: Some(scope_allocation),
callback_socket: Some(callback_socket), callback_socket: Some(callback_socket),
prompts, prompts: common.prompts,
inject_resident_knowledge: true, inject_resident_knowledge: true,
extract_in_flight: Arc::new(AtomicBool::new(false)), extract_in_flight: Arc::new(AtomicBool::new(false)),
extract_pointer: Mutex::new(None), extract_pointer: Mutex::new(None),
@ -1669,6 +1578,136 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
Ok(pod) 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. /// Convenience: build a Pod from a single-layer TOML manifest string.
/// ///
/// Parses the TOML into a [`PodManifestConfig`], converts to a /// Parses the TOML into a [`PodManifestConfig`], converts to a
@ -1862,6 +1901,73 @@ pub enum PodError {
#[error("memory Phase 1 staging write failed: {0}")] #[error("memory Phase 1 staging write failed: {0}")]
ExtractStaging(#[source] memory::extract::StagingError), 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 /// 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 fs4::fs_std::FileExt;
use manifest::{Permission, ScopeRule, paths}; use manifest::{Permission, ScopeRule, paths};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use session_store::SessionId;
/// On-disk representation of the allocation table. /// On-disk representation of the allocation table.
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[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 /// Name of the Pod that delegated scope to this one, or `None` for
/// a top-level Pod started directly by a human. /// a top-level Pod started directly by a human.
pub delegated_from: Option<String>, 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 { impl LockFile {
@ -60,6 +67,14 @@ impl LockFile {
pub fn find_mut(&mut self, pod_name: &str) -> Option<&mut Allocation> { pub fn find_mut(&mut self, pod_name: &str) -> Option<&mut Allocation> {
self.allocations.iter_mut().find(|a| a.pod_name == pod_name) 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 /// Default on-disk path: `<runtime_dir>/scope.lock` resolved via
@ -294,6 +309,7 @@ pub fn register_pod(
pid: u32, pid: u32,
socket: PathBuf, socket: PathBuf,
scope_allow: Vec<ScopeRule>, scope_allow: Vec<ScopeRule>,
session_id: SessionId,
) -> Result<(), ScopeLockError> { ) -> Result<(), ScopeLockError> {
reclaim_stale(guard); reclaim_stale(guard);
if guard.data().find(&pod_name).is_some() { if guard.data().find(&pod_name).is_some() {
@ -316,6 +332,7 @@ pub fn register_pod(
socket, socket,
scope_allow, scope_allow,
delegated_from: None, delegated_from: None,
session_id: Some(session_id),
}); });
guard.save()?; guard.save()?;
Ok(()) Ok(())
@ -361,6 +378,9 @@ pub fn delegate_scope(
socket, socket,
scope_allow, scope_allow,
delegated_from: Some(spawner.into()), 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()?; guard.save()?;
Ok(()) Ok(())
@ -483,10 +503,18 @@ pub fn install_top_level(
pid: u32, pid: u32,
socket: PathBuf, socket: PathBuf,
scope_allow: Vec<ScopeRule>, scope_allow: Vec<ScopeRule>,
session_id: SessionId,
) -> Result<ScopeAllocationGuard, ScopeLockError> { ) -> Result<ScopeAllocationGuard, ScopeLockError> {
let lock_path = default_lock_path()?; let lock_path = default_lock_path()?;
let mut guard = LockFileGuard::open(&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 { Ok(ScopeAllocationGuard {
pod_name, pod_name,
lock_path, lock_path,
@ -497,13 +525,15 @@ pub fn install_top_level(
/// a spawning Pod. /// a spawning Pod.
/// ///
/// The spawning flow is two-stage: the spawner calls [`delegate_scope`] /// 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 /// (with its own pid as a live placeholder, `session_id = None`), then
/// child, once running, calls this function to rewrite the allocation's /// exec's the child; the child, once running, calls this function to
/// pid to its own and claim the `ScopeAllocationGuard` so the entry is /// rewrite the allocation's pid + session_id to its own and claim the
/// released when the child exits. /// `ScopeAllocationGuard` so the entry is released when the child
/// exits.
pub fn adopt_allocation( pub fn adopt_allocation(
pod_name: String, pod_name: String,
new_pid: u32, new_pid: u32,
session_id: SessionId,
) -> Result<ScopeAllocationGuard, ScopeLockError> { ) -> Result<ScopeAllocationGuard, ScopeLockError> {
let lock_path = default_lock_path()?; let lock_path = default_lock_path()?;
let mut guard = LockFileGuard::open(&lock_path)?; let mut guard = LockFileGuard::open(&lock_path)?;
@ -512,6 +542,7 @@ pub fn adopt_allocation(
.find_mut(&pod_name) .find_mut(&pod_name)
.ok_or_else(|| ScopeLockError::UnknownPod(pod_name.clone()))?; .ok_or_else(|| ScopeLockError::UnknownPod(pod_name.clone()))?;
alloc.pid = new_pid; alloc.pid = new_pid;
alloc.session_id = Some(session_id);
guard.save()?; guard.save()?;
Ok(ScopeAllocationGuard { Ok(ScopeAllocationGuard {
pod_name, 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. /// Errors raised by the mutating scope-lock operations.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum ScopeLockError { pub enum ScopeLockError {
@ -548,6 +606,10 @@ mod tests {
/// harness runs tests on multiple threads inside a single process, /// harness runs tests on multiple threads inside a single process,
/// so env-var writes from one test would otherwise leak into a /// so env-var writes from one test would otherwise leak into a
/// parallel test's `default_lock_path()` lookup. /// parallel test's `default_lock_path()` lookup.
fn sid() -> SessionId {
session_store::new_session_id()
}
static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(())); static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
/// Sandbox `INSOMNIA_RUNTIME_DIR` to a tempdir for the duration of /// Sandbox `INSOMNIA_RUNTIME_DIR` to a tempdir for the duration of
@ -652,6 +714,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("a"), sock("a"),
vec![write_rule("/src", true)], vec![write_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
} }
@ -699,6 +762,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("a"), sock("a"),
vec![write_rule("/src", true)], vec![write_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
let err = register_pod( let err = register_pod(
@ -707,6 +771,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("b"), sock("b"),
vec![write_rule("/src/core", true)], vec![write_rule("/src/core", true)],
sid(),
) )
.unwrap_err(); .unwrap_err();
match err { match err {
@ -726,6 +791,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("a"), sock("a"),
vec![write_rule("/src", true)], vec![write_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
let err = register_pod( let err = register_pod(
@ -734,6 +800,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("a2"), sock("a2"),
vec![write_rule("/docs", true)], vec![write_rule("/docs", true)],
sid(),
) )
.unwrap_err(); .unwrap_err();
assert!(matches!(err, ScopeLockError::DuplicatePodName(ref n) if n == "a")); assert!(matches!(err, ScopeLockError::DuplicatePodName(ref n) if n == "a"));
@ -750,6 +817,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("a"), sock("a"),
vec![write_rule("/src", true)], vec![write_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
let err = delegate_scope( let err = delegate_scope(
@ -775,6 +843,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("a"), sock("a"),
vec![write_rule("/src", true)], vec![write_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
delegate_scope( delegate_scope(
@ -812,6 +881,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("a"), sock("a"),
vec![write_rule("/src", true)], vec![write_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
delegate_scope( delegate_scope(
@ -848,6 +918,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("a"), sock("a"),
vec![write_rule("/src", true)], vec![write_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
delegate_scope( delegate_scope(
@ -886,6 +957,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("a"), sock("a"),
vec![write_rule("/src", true)], vec![write_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
delegate_scope( delegate_scope(
@ -939,6 +1011,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("a"), sock("a"),
vec![write_rule("/src", true)], vec![write_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
// B only reads under the same tree — allowed. // B only reads under the same tree — allowed.
@ -948,6 +1021,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("b"), sock("b"),
vec![read_rule("/src", true)], vec![read_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
assert_eq!(g.data().allocations.len(), 2); assert_eq!(g.data().allocations.len(), 2);
@ -964,6 +1038,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("a"), sock("a"),
vec![write_rule("/src", true)], vec![write_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
release_pod(&mut g, "a").unwrap(); release_pod(&mut g, "a").unwrap();
@ -973,6 +1048,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("b"), sock("b"),
vec![write_rule("/src", true)], vec![write_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
} }
@ -988,6 +1064,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("a"), sock("a"),
vec![write_rule("/src", true)], vec![write_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
delegate_scope( delegate_scope(
@ -1023,6 +1100,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("a"), sock("a"),
vec![write_rule("/src", true)], vec![write_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
{ {
@ -1047,7 +1125,7 @@ mod tests {
delegate_placeholder(&mut g, "child", std::process::id()); delegate_placeholder(&mut g, "child", std::process::id());
} }
let child_pid = std::process::id().wrapping_add(1); 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 g = LockFileGuard::open(&lock_path).unwrap();
let alloc = g.data().find("child").unwrap(); let alloc = g.data().find("child").unwrap();
@ -1064,7 +1142,7 @@ mod tests {
fn adopt_allocation_errors_on_unknown_pod() { fn adopt_allocation_errors_on_unknown_pod() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let _sandbox = RuntimeDirSandbox::new(dir.path()); 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")); assert!(matches!(err, ScopeLockError::UnknownPod(ref n) if n == "ghost"));
} }
@ -1078,6 +1156,7 @@ mod tests {
socket: sock(pod_name), socket: sock(pod_name),
scope_allow: vec![write_rule("/tmp/child", true)], scope_allow: vec![write_rule("/tmp/child", true)],
delegated_from: None, delegated_from: None,
session_id: None,
}); });
g.save().unwrap(); g.save().unwrap();
} }
@ -1093,6 +1172,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("a"), sock("a"),
vec![write_rule("/src", true)], vec![write_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
delegate_scope( delegate_scope(
@ -1112,6 +1192,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("x"), sock("x"),
vec![write_rule("/src/core/x", true)], vec![write_rule("/src/core/x", true)],
sid(),
) )
.unwrap_err(); .unwrap_err();
match err { match err {

View File

@ -351,6 +351,7 @@ async fn stop_pod_sends_shutdown_and_releases_scope() {
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
}], }],
session_store::new_session_id(),
) )
.unwrap(); .unwrap();
scope_lock::delegate_scope( scope_lock::delegate_scope(

View File

@ -358,6 +358,7 @@ async fn shutdown_releases_scope_allocation_when_present() {
std::process::id(), std::process::id(),
"/tmp/kid.sock".into(), "/tmp/kid.sock".into(),
vec![], vec![],
session_store::new_session_id(),
) )
.unwrap(); .unwrap();
std::mem::forget(guard); std::mem::forget(guard);

View File

@ -73,6 +73,7 @@ async fn setup_spawner(
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
}], }],
session_store::new_session_id(),
) )
.unwrap(); .unwrap();
// Leak the guard — the spawner allocation needs to outlive the // 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, save_run_completed, save_run_errored, save_turn_end, save_usage,
}; };
pub use llm_worker::UsageRecord; pub use llm_worker::UsageRecord;
pub use llm_worker::llm_client::types::{ContentPart, Item, Role};
pub use session_log::{ pub use session_log::{
EntryHash, HashedEntry, LogEntry, RestoredState, SessionOrigin, build_chain, collect_state, EntryHash, HashedEntry, LogEntry, RestoredState, SessionOrigin, build_chain, collect_state,
compute_hash, compute_hash,

View File

@ -14,3 +14,4 @@ unicode-width = "0.2.2"
uuid = "1.23" uuid = "1.23"
toml = "1.1.2" toml = "1.1.2"
manifest = { version = "0.1.0", path = "../manifest" } 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 cache;
mod client; mod client;
mod input; mod input;
mod picker;
mod scroll; mod scroll;
mod spawn; mod spawn;
mod tool; mod tool;
@ -24,9 +25,11 @@ use crossterm::terminal::{
use protocol::Method; use protocol::Method;
use ratatui::Terminal; use ratatui::Terminal;
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
use session_store::SessionId;
use crate::app::App; use crate::app::App;
use crate::client::PodClient; use crate::client::PodClient;
use crate::picker::PickerOutcome;
use crate::spawn::{SpawnOutcome, SpawnReady}; use crate::spawn::{SpawnOutcome, SpawnReady};
fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf { fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
@ -47,27 +50,101 @@ enum Mode {
pod_name: String, pod_name: String,
socket_override: Option<PathBuf>, 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(); let args: Vec<String> = std::env::args().skip(1).collect();
if args.is_empty() { let mut resume = false;
return Mode::Spawn; 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 if resume && session.is_some() {
.windows(2) return Err(ParseError::Conflict);
.find(|w| w[0] == "--socket")
.map(|w| PathBuf::from(&w[1]));
Mode::Attach {
pod_name,
socket_override,
} }
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] #[tokio::main]
async fn main() -> ExitCode { 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() { if let Err(e) = enable_raw_mode() {
eprintln!("tui: failed to enter raw mode: {e}"); eprintln!("tui: failed to enter raw mode: {e}");
@ -80,11 +157,13 @@ async fn main() -> ExitCode {
} }
let result = match mode { let result = match mode {
Mode::Spawn => run_spawn().await, Mode::Spawn => run_spawn(None).await,
Mode::Attach { Mode::Attach {
pod_name, pod_name,
socket_override, socket_override,
} => run_attach(pod_name, socket_override).await, } => 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 // 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 run(&mut terminal, pod_name, &socket_path, false).await
} }
async fn run_spawn() -> Result<(), Box<dyn std::error::Error>> { async fn run_resume() -> Result<(), Box<dyn std::error::Error>> {
let ready = match spawn::run().await? { // 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::Ready(r) => r,
SpawnOutcome::Cancelled => return Ok(()), 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 crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use manifest::{PodManifestConfig, find_project_manifest_from, load_layer, user_manifest_path}; use manifest::{PodManifestConfig, find_project_manifest_from, load_layer, user_manifest_path};
use ratatui::Terminal; use ratatui::Terminal;
use session_store::SessionId;
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout}; use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
@ -85,7 +86,10 @@ impl From<io::Error> for SpawnError {
type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>; 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)?; let cwd = std::env::current_dir().map_err(SpawnError::Io)?;
// Run the same merge pod itself uses, then read what's missing // 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, name: default_name,
message: None, message: None,
editing: true, editing: true,
resume_from,
}; };
let mut terminal = make_inline_terminal()?; let mut terminal = make_inline_terminal()?;
@ -266,16 +271,19 @@ async fn wait_for_ready(
let pod_bin = resolve_pod_command(); let pod_bin = resolve_pod_command();
let cwd = std::env::current_dir().map_err(SpawnError::Io)?; 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")
.arg(overlay_toml) .arg(overlay_toml)
.current_dir(&cwd) .current_dir(&cwd)
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.kill_on_drop(true) .kill_on_drop(true);
.spawn() if let Some(id) = form.resume_from {
.map_err(SpawnError::PodLaunchFailed)?; command.arg("--session").arg(id.to_string());
}
let mut child = command.spawn().map_err(SpawnError::PodLaunchFailed)?;
let stderr = child let stderr = child
.stderr .stderr
@ -437,6 +445,11 @@ struct Form {
/// cursor stays out so it does not collide with the shell prompt /// cursor stays out so it does not collide with the shell prompt
/// after the inline terminal is dropped. /// after the inline terminal is dropped.
editing: bool, 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 { impl Form {
@ -500,8 +513,12 @@ fn draw_form(f: &mut Frame<'_>, form: &Form) {
]) ])
.split(area); .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( let title = Paragraph::new(Line::from(vec![Span::styled(
"spawn pod", title_text,
Style::default().add_modifier(Modifier::BOLD), Style::default().add_modifier(Modifier::BOLD),
)])); )]));
f.render_widget(title, layout[0]); 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<'_> { fn name_line(form: &Form) -> Line<'_> {
Line::from(vec![ Line::from(vec![
Span::raw(" "), Span::raw(" "),
@ -600,6 +624,7 @@ mod tests {
name_cursor: name.chars().count(), name_cursor: name.chars().count(),
message: None, message: None,
editing: true, 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 できるようにする。 TUI には既に新規 Pod 起動用の spawn UI があるため、同じような選択 UI で既存 session を一覧し、選択した session を復元した Pod を起動して attach できるようにする。
@ -15,65 +15,88 @@ TUI には既に新規 Pod 起動用の spawn UI があるため、同じよう
- `tui <pod-name>`: 生存中 Pod への attach - `tui <pod-name>`: 生存中 Pod への attach
- 既存 session 復帰用に `tui -r` / `tui --resume` を追加する - 既存 session 復帰用に `tui -r` / `tui --resume` を追加する
- `--resume` はユーザー向けの「過去 session から復帰」入口であり、protocol の `Method::Resume`Paused turn の続行)とは別概念として扱う - `--resume` はユーザー向けの「過去 session から復帰」入口であり、protocol の `Method::Resume`Paused turn の続行)とは別概念として扱う
- `--resume` 指定時のみ、現在の name 入力ダイアログの前段に session 選択プロンプトを表示する - `--resume` 指定時のみ、name 入力ダイアログの前段に session 選択プロンプトを表示する
- session id を直接指定するショートカットとして `tui --session <UUID>` を追加する - session id を直接指定するショートカットとして `tui --session <UUID>` を追加する
- `--session` は session picker をスキップし、指定 session を復元対象にした name 入力ダイアログから始める - `--session` は session picker をスキップし、指定 session を復元対象にした name 入力ダイアログから始める
- `--resume``--session` は併用不可 - `--resume``--session` は併用不可
- 直接起動用に、Pod CLI に session id を指定して復元起動するフラグを追加する(`pod --session <UUID>` - Pod CLI に `pod --session <UUID>` を追加し、復元 Pod を起動できるようにする
- TUI の `--resume` / `--session` 復帰フローは最終的にこの Pod CLI 復元起動経路を使う - 名前は他のフラグと同様 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` - `SessionId`(短縮表記)
- 最終更新時刻、または store が提供できる同等の並び順情報 - 並び順は `Store::list_sessions` が返す UUIDv7 順(= 作成時刻順、新しい順)でよい
- 履歴の簡易プレビュー(最後の user / assistant メッセージ等、取得できる範囲でよい) - 履歴の簡易プレビュー(最後の user / assistant メッセージ等、取得できる範囲でよい)
- その session が今 live かどうか(`scope.lock` を引いて判定)
- session log が壊れている、復元不能、または現在のバージョンで読めない場合は、その行を復帰不可として表示するか、エラー表示してスキップする - session log が壊れている、復元不能、または現在のバージョンで読めない場合は、その行を復帰不可として表示するか、エラー表示してスキップする
- session が 1 件もない場合は、新規 Pod 起動へ戻れる導線を出す - session が 1 件もない場合は、エラー表示して終了する(`tui` で新規 spawn する案内を出す)
### 復元 Pod の構築 ### 復元 Pod の構築
- 選択した `SessionId` を使って `Pod::restore` 経由で Pod を構築する 復元時はソース session を直接書き継がず、**fork** して新しい session を起こす。
- manifest / scope / tool 登録 / prompt loader は、通常の新規 Pod 起動と同じ現在の cascade 解決結果を使う
- Worker の会話履歴・system prompt・request config・turn count・usage history 等は session log 由来の状態を使う - `session_store::fork_at(source_session_id, source_head_hash)` で新しい session を作成する。新 session の `SessionStart` には全履歴と `forked_from = SessionOrigin { source_session_id, source_head_hash }` を載せる。
- 復元起動時、runtime の `history.json` / `status.json` / `Event::History` で TUI が初期履歴を正しく再構築できる - Pod は新 session_id 上で動作し、ソース session の jsonl は不変のまま残る。
- 復元された session が interrupted / paused 相当の状態を持つ場合、起動直後に `Resume` 可能な状態として扱う。通常終了済みなら `Idle` として新しい入力を受け付ける - これにより同一 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 を促す - TUI の resume / `--session` 経路で `Pod::restore_from_manifest` がエラーを返した場合、エラー表示して TUI を終了するpicker 復帰や自動 attach 切替はしない)。
- 少なくとも、同じ session id に対する複数 Pod の同時書き込みが発生しないようにする - ユーザーは別途 `tui <pod-name>` で attach できる。エラーメッセージには live Pod の `pod_name` / socket を含める。
- runtime dir / status.json から検出できる範囲でよいが、検出不能な場合のエラーメッセージは明示する
### UI / 操作 ### UI / 操作
- `tui -r` / `tui --resume` では、name 入力の前に session picker を表示する - `tui -r` / `tui --resume` では、まず session picker を表示する
- session picker は上下キーで session を選択し、Enter で決定、Esc / Ctrl-C でキャンセルできる - session picker は上下キーで session を選択し、Enter で決定、Esc / Ctrl-C でキャンセルできる
- session が多い場合でも使えるよう、最低限のスクロールを備える - 直近 10 件のみ表示するためスクロール UI は不要
- session 決定後は既存の name 入力ダイアログを再利用する - session 決定後、name 入力ダイアログを表示する
- picker と name 入力は別の inline viewport として描画してよい(高さの都合で viewport を作り直す)
- 入力する name は、復元された session を載せる新しい Pod 実行インスタンス名runtime dir / socket 名) - 入力する name は、復元された session を載せる新しい Pod 実行インスタンス名runtime dir / socket 名)
- default name は現行と同じ cwd 由来でよい - default name は現行と同じ cwd 由来でよい
- 表示上は `Resume Pod` / `session: <short-id>` のように、新規 spawn ではなく復帰であることを明示する - 表示上は `Resume Pod` / `session: <short-id>` のように、新規 spawn ではなく復帰であることを明示する
- `tui --session <UUID>` では session picker を省略し、指定 session を対象にした name 入力ダイアログから始める - `tui --session <UUID>` では session picker を省略し、指定 session を対象にした name 入力ダイアログから始める
- 将来的な検索フィルタ追加を妨げない state 構造にするが、本チケットでは必須にしない - 将来的な検索フィルタ追加を妨げない state 構造にするが、本チケットでは必須にしない
- 復帰に失敗した場合、inline / alt-screen 内にエラーを表示し、一覧へ戻るか終了できる - 復帰に失敗した場合pod プロセスが ready line を返さない、`SessionInUse` など)はエラー表示してそのまま終了す
## 完了条件 ## 完了条件
- `pod --session <UUID>` で既存 session から Pod を起動できる - `pod --session <UUID>` で既存 session から Pod を起動でき、ソース session jsonl は不変のまま新しい fork session が作られ
- `tui -r` / `tui --resume` で既存 session 一覧を表示し、選択した session を復元対象にできる - `tui -r` / `tui --resume`直近 10 件の既存 session 一覧を表示し、選択した session を復元対象にできる
- `tui --session <UUID>` で session picker を経由せず、指定 session の復帰 name 入力へ進める - `tui --session <UUID>` で session picker を経由せず、指定 session の復帰 name 入力へ進める
- 復帰フローでは session 選択後または `--session` 指定後に name 入力ダイアログが表示され、その name の Pod として起動・attach できる - 復帰フローでは session 選択後または `--session` 指定後に name 入力ダイアログが表示され、その name の Pod として起動・attach できる
- 復元直後の TUI に過去履歴が表示される - 復元直後の TUI に過去履歴が表示される
- 復元後に新しい入力を送ると、既存履歴に続く turn として動作し、session log に追記される - 復元後に新しい入力を送ると、既存履歴に続く turn として動作し、新しい fork session の jsonl に追記される
- interrupted / paused 状態の session では、復元直後に Resume 導線が動作する - interrupted / paused 状態の session では、復元直後に Resume 導線が動作する
- 同一 session の生存中 Pod がある場合は二重起動せず attach または明示的なエラーになる - 同一 source session に対する live Pod が存在する場合、復元起動はエラーで終了し、既存 Pod の `pod_name` / socket がメッセージに表示される
- `scope.lock` には各 Pod の `session_id` が記録される
## 範囲外 ## 範囲外
- session log の全文検索 UI - session log の全文検索 UI
- compact 前後の session chain を 1 つの論理スレッドとして束ねる UI - compact 前後の session chain / fork チェーンを 1 つの論理スレッドとして束ねる UI
- 過去 session の編集・削除・名前付け - 過去 session の編集・削除・名前付け
- spawn された子 Pod / scope delegation ツリー全体の復元 - spawn された子 Pod / scope delegation ツリー全体の復元
- 別マシンから転送された session store の import UI - 別マシンから転送された session store の import UI
- `tui` での picker 復帰や自動 attach 切替live セッション選択時はエラー終了)