resumeの実装
This commit is contained in:
parent
023ed09adc
commit
2b89bb6d2e
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -3575,6 +3575,7 @@ dependencies = [
|
||||||
"protocol",
|
"protocol",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"session-store",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
"unicode-width",
|
"unicode-width",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
|
|
@ -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
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 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 セッション選択時はエラー終了)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user