resumeの実装
This commit is contained in:
parent
023ed09adc
commit
2b89bb6d2e
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -3575,6 +3575,7 @@ dependencies = [
|
|||
"protocol",
|
||||
"ratatui",
|
||||
"serde_json",
|
||||
"session-store",
|
||||
"tokio",
|
||||
"toml",
|
||||
"unicode-width",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.is_empty() {
|
||||
return Mode::Spawn;
|
||||
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"),
|
||||
}
|
||||
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 {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<Mode, ParseError> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
282
crates/tui/src/picker.rs
Normal 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()
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 セッション選択時はエラー終了)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user