tuiからセッションを復帰する経路の実装

This commit is contained in:
2026-04-29 19:03:03 +09:00
parent 87768c2e2d
commit fd96a517bb
15 changed files with 512 additions and 34 deletions
+1
View File
@@ -12,6 +12,7 @@ session-store = { version = "0.1.0", path = "../session-store" }
manifest = { version = "0.1.0", path = "../manifest" }
protocol = { version = "0.1.0", path = "../protocol" }
provider = { version = "0.1.0", path = "../provider" }
scope-lock = { version = "0.1.0", path = "../scope-lock" }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
thiserror = "2.0"
+3 -3
View File
@@ -44,9 +44,9 @@ struct Cli {
#[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.
/// Restore a Pod from an existing session. The Pod re-uses the
/// given session id and appends new turns to the same jsonl;
/// concurrent writers are prevented by the `scope.lock` registry.
/// Mutually exclusive with `--adopt` (spawned children always start
/// fresh).
#[arg(long, value_name = "UUID", conflicts_with = "adopt")]
+27 -23
View File
@@ -100,10 +100,11 @@ pub struct Pod<C: LlmClient, St: Store> {
/// PodInterceptor installed in `ensure_interceptor_installed`.
pending_notifies: NotifyBuffer,
/// Scope allocation in the machine-wide lock file. `Some` for
/// Pods built via `from_manifest` (production path); `None` for
/// lower-level constructors (`Pod::new`, `Pod::restore`) that
/// bypass the registry. Kept purely for its `Drop` impl, which
/// releases the allocation when the Pod is dropped.
/// Pods built via `from_manifest` / `from_manifest_spawned` /
/// `restore_from_manifest` (production paths); `None` for the
/// low-level `Pod::new` constructor used in tests, which bypasses
/// the registry. Kept purely for its `Drop` impl, which releases
/// the allocation when the Pod is dropped.
#[allow(dead_code)]
scope_allocation: Option<ScopeAllocationGuard>,
/// Socket path of the spawning Pod. `Some` only for Pods built via
@@ -717,6 +718,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
self.head_hash = Some(hash);
return Ok(());
}
let prev_session_id = self.session_id;
session_store::ensure_head_or_fork(
&self.store,
&mut self.session_id,
@@ -724,6 +726,13 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
state,
)
.await?;
// ensure_head_or_fork mints a fresh session_id when it auto-
// forks. Sync that to scope.lock so a concurrent
// restore_from_manifest can't see "no live writer" for the new
// session and grab it.
if self.session_id != prev_session_id && self.scope_allocation.is_some() {
scope_lock::update_session(&self.manifest.pod.name, self.session_id)?;
}
Ok(())
}
@@ -1155,6 +1164,15 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
// until its first LLM call.
self.session_id = new_session_id;
self.head_hash = Some(new_head_hash);
// Keep scope.lock pointing at the live session_id. Without this
// a concurrent `restore_from_manifest(new_session_id)` would
// see no live writer and grab the session this Pod just moved
// into, causing two writers to race on the same jsonl. Skipped
// when no allocation is installed (e.g. compact under
// `Pod::new` in tests).
if self.scope_allocation.is_some() {
scope_lock::update_session(&self.manifest.pod.name, new_session_id)?;
}
let worker = self.worker.as_mut().unwrap();
worker.set_history(new_history);
// Anchor the prompt cache at the summary item so that Anthropic
@@ -1602,15 +1620,6 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
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(session_id)? {
return Err(PodError::SessionInUse {
session_id,
pod_name: info.pod_name,
socket: info.socket,
});
}
let state = session_store::restore(&store, session_id).await?;
if state.head_hash.is_none() {
return Err(PodError::SessionEmpty { session_id });
@@ -1618,6 +1627,11 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
let common = prepare_pod_common(&manifest, &loader, /* parse_template */ false)?;
// Atomic: register_pod inside install_top_level rejects when
// another live allocation already holds `session_id`. Wrapping
// the lookup + install inside a single `LockFileGuard` is what
// makes "no two live Pods write to the same session log"
// actually structural rather than a hopeful pre-check.
let socket_path = dir::default_base()
.map_err(ScopeLockError::from)?
.join(&manifest.pod.name)
@@ -1883,16 +1897,6 @@ 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 },
}
+1 -1
View File
@@ -1,2 +1,2 @@
pub mod dir;
pub mod scope_lock;
pub use ::scope_lock;
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -441,7 +441,8 @@ fn scope_lock_err_to_tool(e: ScopeLockError) -> ToolError {
ScopeLockError::NotSubset { .. }
| ScopeLockError::WriteConflict { .. }
| ScopeLockError::DuplicatePodName(_)
| ScopeLockError::UnknownPod(_) => ToolError::InvalidArgument(e.to_string()),
| ScopeLockError::UnknownPod(_)
| ScopeLockError::SessionConflict { .. } => ToolError::InvalidArgument(e.to_string()),
ScopeLockError::Io(_) => ToolError::ExecutionFailed(e.to_string()),
}
}
+86
View File
@@ -0,0 +1,86 @@
//! Integration tests for `Pod::restore_from_manifest`'s pre-build
//! validation paths.
//!
//! These cases all return before `prepare_pod_common` runs, so they
//! do not need a real LLM client or scope-lock environment — only the
//! session store needs to be present.
use std::sync::{LazyLock, Mutex};
use pod::{Pod, PodError};
use session_store::{FsStore, SessionId, StoreError};
const MINIMAL_MANIFEST_TOML: &str = r#"
[pod]
name = "restore-test"
pwd = "./"
[model]
scheme = "anthropic"
model_id = "test-model"
[worker]
max_tokens = 100
[[scope.allow]]
target = "./"
permission = "write"
"#;
/// Serialises tests that mutate runtime-dir env vars, mirroring the
/// pattern used by other integration tests in this crate.
static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
#[tokio::test]
async fn restore_from_manifest_rejects_unknown_session() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let store_tmp = tempfile::tempdir().unwrap();
let store = FsStore::new(store_tmp.path()).await.unwrap();
let manifest = pod::PodManifest::from_toml(MINIMAL_MANIFEST_TOML).unwrap();
// A freshly-minted id with no jsonl file at all → store returns
// NotFound, which `Pod::restore_from_manifest` surfaces verbatim
// as `PodError::Store`.
let unknown = session_store::new_session_id();
let result = Pod::restore_from_manifest(
unknown,
manifest,
store,
pod::PromptLoader::builtins_only(),
)
.await;
match result {
Err(PodError::Store(StoreError::NotFound(id))) => assert_eq!(id, unknown),
Err(other) => panic!("expected Store(NotFound), got {other:?}"),
Ok(_) => panic!("expected unknown session to fail"),
}
}
#[tokio::test]
async fn restore_from_manifest_rejects_empty_session_log() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let store_tmp = tempfile::tempdir().unwrap();
let store = FsStore::new(store_tmp.path()).await.unwrap();
let manifest = pod::PodManifest::from_toml(MINIMAL_MANIFEST_TOML).unwrap();
// Pre-create an empty `<id>.jsonl` so `read_all` succeeds with no
// entries. `collect_state` returns `head_hash = None`, which
// `restore_from_manifest` rejects with `SessionEmpty` *before* it
// gets as far as building the LLM client — so the test does not
// need credentials or a runtime sandbox.
let id: SessionId = session_store::new_session_id();
let path = store_tmp.path().join(format!("{id}.jsonl"));
std::fs::write(&path, b"").unwrap();
let result =
Pod::restore_from_manifest(id, manifest, store, pod::PromptLoader::builtins_only()).await;
match result {
Err(PodError::SessionEmpty { session_id }) => assert_eq!(session_id, id),
Err(other) => panic!("expected SessionEmpty, got {other:?}"),
Ok(_) => panic!("expected empty session log to fail"),
}
}