feat: resume時のscope claimを過去の有効scopeに揃える
This commit is contained in:
@@ -134,6 +134,8 @@ impl PodController {
|
||||
// `PodFsView` to the shared state once the latter exists.
|
||||
let fs_for_view: tools::ScopedFs;
|
||||
|
||||
let scope_change_sink = pod.scope_change_sink();
|
||||
|
||||
// Register event bridge callbacks on the worker
|
||||
{
|
||||
let worker = pod.worker_mut();
|
||||
@@ -257,7 +259,8 @@ impl PodController {
|
||||
// worker) reads from it, and any future scope mutation
|
||||
// (SpawnPod-style revoke, future GrantScope) propagates
|
||||
// through it.
|
||||
let fs = tools::ScopedFs::with_shared_scope(scope_handle.clone(), pwd_for_tools.clone());
|
||||
let fs =
|
||||
tools::ScopedFs::with_shared_scope(scope_handle.clone(), pwd_for_tools.clone());
|
||||
let tracker = tools::Tracker::new();
|
||||
// The same ScopedFs also powers the IPC `ListCompletions`
|
||||
// query — keep a clone for the FS view we attach below,
|
||||
@@ -293,6 +296,7 @@ impl PodController {
|
||||
self_parent_socket.clone(),
|
||||
spawner_model.clone(),
|
||||
scope_handle.clone(),
|
||||
scope_change_sink.clone(),
|
||||
));
|
||||
worker.register_tool(send_to_pod_tool(spawned_registry.clone()));
|
||||
worker.register_tool(read_pod_output_tool(spawned_registry.clone()));
|
||||
|
||||
+105
-5
@@ -7,12 +7,12 @@ use llm_worker::llm_client::RequestConfig;
|
||||
use llm_worker::llm_client::client::LlmClient;
|
||||
use llm_worker::state::Mutable;
|
||||
use llm_worker::{ToolOutputLimits, UsageRecord, Worker, WorkerError, WorkerResult};
|
||||
use session_store::{EntryHash, SessionId, SessionStartState, Store, StoreError};
|
||||
use session_store::{EntryHash, PodScopeSnapshot, SessionId, SessionStartState, Store, StoreError};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use manifest::{
|
||||
PodManifest, PodManifestConfig, ResolveError, Scope, ScopeError, ScopeRule, SharedScope,
|
||||
WorkerManifest,
|
||||
PodManifest, PodManifestConfig, ResolveError, Scope, ScopeConfig, ScopeError, ScopeRule,
|
||||
SharedScope, WorkerManifest,
|
||||
};
|
||||
|
||||
use crate::compact::state::CompactState;
|
||||
@@ -143,6 +143,10 @@ pub struct Pod<C: LlmClient, St: Store> {
|
||||
/// Phase 2 (consolidation) workers set this to false so the
|
||||
/// agentic worker pulls knowledge through the search tools instead.
|
||||
inject_resident_knowledge: bool,
|
||||
/// Latest runtime scope snapshot queued by dynamic scope changes.
|
||||
/// Drained into the session log before the next turn result is
|
||||
/// persisted, so resume never silently reclaims delegated writes.
|
||||
pending_scope_snapshot: Arc<Mutex<Option<PodScopeSnapshot>>>,
|
||||
/// Phase 1 (memory.extract) reentry guard. `true` while an extract
|
||||
/// worker is running; subsequent triggers are skipped per spec
|
||||
/// (`docs/plan/memory.md` §Phase 1 並走防止). `Arc<AtomicBool>` so
|
||||
@@ -216,6 +220,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
workflow_registry: memory::WorkflowRegistry::empty(),
|
||||
memory_layout: None,
|
||||
inject_resident_knowledge: true,
|
||||
pending_scope_snapshot: Arc::new(Mutex::new(None)),
|
||||
extract_in_flight: Arc::new(AtomicBool::new(false)),
|
||||
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
|
||||
extract_pointer: Mutex::new(None),
|
||||
@@ -307,6 +312,55 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
.update(|cur| cur.with_added_deny_rules(revoke.clone()))
|
||||
}
|
||||
|
||||
/// Snapshot the current runtime scope in the session log. The entry
|
||||
/// is intentionally appended as soon as a session head exists: if the
|
||||
/// process later exits while children keep their allocations, resume
|
||||
/// can restore the narrowed scope instead of reclaiming delegated
|
||||
/// writes.
|
||||
pub async fn persist_scope_snapshot(&mut self) -> Result<(), StoreError> {
|
||||
if self.head_hash.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let snapshot = {
|
||||
let scope = self.scope.snapshot();
|
||||
PodScopeSnapshot {
|
||||
allow: scope.allow_rules(),
|
||||
deny: scope.deny_rules(),
|
||||
}
|
||||
};
|
||||
session_store::save_pod_scope(&self.store, self.session_id, &mut self.head_hash, &snapshot)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Cloneable callback handed to dynamic-scope tools. It cannot append
|
||||
/// directly to the async store from a sync tool callback, so it records
|
||||
/// the latest snapshot and the controller flushes it after the tool
|
||||
/// turn completes.
|
||||
pub fn scope_change_sink(&self) -> Arc<dyn Fn(PodScopeSnapshot) + Send + Sync> {
|
||||
let pending = self.pending_scope_snapshot.clone();
|
||||
Arc::new(move |snapshot| {
|
||||
*pending.lock().expect("pending_scope_snapshot poisoned") = Some(snapshot);
|
||||
})
|
||||
}
|
||||
|
||||
async fn flush_pending_scope_snapshot(&mut self) -> Result<(), StoreError> {
|
||||
let snapshot = self
|
||||
.pending_scope_snapshot
|
||||
.lock()
|
||||
.expect("pending_scope_snapshot poisoned")
|
||||
.take();
|
||||
if let Some(snapshot) = snapshot {
|
||||
session_store::save_pod_scope(
|
||||
&self.store,
|
||||
self.session_id,
|
||||
&mut self.head_hash,
|
||||
&snapshot,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Direct access to the underlying Worker.
|
||||
pub fn worker(&self) -> &Worker<C, Mutable> {
|
||||
self.worker.as_ref().expect("worker taken during run")
|
||||
@@ -903,6 +957,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
let hash =
|
||||
session_store::create_session_with_id(&self.store, self.session_id, state).await?;
|
||||
self.head_hash = Some(hash);
|
||||
self.persist_scope_snapshot().await?;
|
||||
return Ok(());
|
||||
}
|
||||
let prev_session_id = self.session_id;
|
||||
@@ -1059,6 +1114,8 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
session_store::save_delta(&self.store, self.session_id, &mut self.head_hash, new_items)
|
||||
.await?;
|
||||
|
||||
self.flush_pending_scope_snapshot().await?;
|
||||
|
||||
let turn_count = self.worker.as_ref().unwrap().turn_count();
|
||||
session_store::save_turn_end(
|
||||
&self.store,
|
||||
@@ -1365,6 +1422,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
.lock()
|
||||
.expect("usage_history poisoned")
|
||||
.clear();
|
||||
self.persist_scope_snapshot().await?;
|
||||
// Reset Phase 1 pointer alongside usage_history: the compacted
|
||||
// session has a fresh log with no `LogEntry::Extension` entries
|
||||
// yet, so a cold restore here would set extract_pointer to None
|
||||
@@ -1939,6 +1997,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||
workflow_registry: common.workflow_registry,
|
||||
memory_layout: common.memory_layout,
|
||||
inject_resident_knowledge: true,
|
||||
pending_scope_snapshot: Arc::new(Mutex::new(None)),
|
||||
extract_in_flight: Arc::new(AtomicBool::new(false)),
|
||||
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
|
||||
extract_pointer: Mutex::new(None),
|
||||
@@ -2000,6 +2059,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||
workflow_registry: common.workflow_registry,
|
||||
memory_layout: common.memory_layout,
|
||||
inject_resident_knowledge: true,
|
||||
pending_scope_snapshot: Arc::new(Mutex::new(None)),
|
||||
extract_in_flight: Arc::new(AtomicBool::new(false)),
|
||||
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
|
||||
extract_pointer: Mutex::new(None),
|
||||
@@ -2037,8 +2097,20 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||
if state.head_hash.is_none() {
|
||||
return Err(PodError::SessionEmpty { session_id });
|
||||
}
|
||||
let scope_snapshot = state
|
||||
.pod_scope
|
||||
.clone()
|
||||
.ok_or(PodError::SessionScopeMissing { session_id })?;
|
||||
|
||||
let common = prepare_pod_common(&manifest, &loader, /* parse_template */ false)?;
|
||||
let common = prepare_pod_common_with_scope(
|
||||
&manifest,
|
||||
&loader,
|
||||
/* parse_template */ false,
|
||||
ScopeConfig {
|
||||
allow: scope_snapshot.allow,
|
||||
deny: scope_snapshot.deny,
|
||||
},
|
||||
)?;
|
||||
|
||||
// Atomic: register_pod inside install_top_level rejects when
|
||||
// another live allocation already holds `session_id`. Wrapping
|
||||
@@ -2049,11 +2121,12 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||
.map_err(ScopeLockError::from)?
|
||||
.join(&manifest.pod.name)
|
||||
.join("sock");
|
||||
let scope_allocation = pod_registry::install_top_level(
|
||||
let scope_allocation = pod_registry::install_top_level_with_deny(
|
||||
manifest.pod.name.clone(),
|
||||
std::process::id(),
|
||||
socket_path,
|
||||
common.scope.allow_rules(),
|
||||
common.scope.deny_rules(),
|
||||
session_id,
|
||||
)?;
|
||||
|
||||
@@ -2113,6 +2186,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||
workflow_registry: common.workflow_registry,
|
||||
memory_layout: common.memory_layout,
|
||||
inject_resident_knowledge: true,
|
||||
pending_scope_snapshot: Arc::new(Mutex::new(None)),
|
||||
extract_in_flight: Arc::new(AtomicBool::new(false)),
|
||||
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
|
||||
extract_pointer: Mutex::new(extract_pointer),
|
||||
@@ -2327,6 +2401,11 @@ pub enum PodError {
|
||||
|
||||
#[error("session {session_id} has no entries to restore")]
|
||||
SessionEmpty { session_id: SessionId },
|
||||
|
||||
#[error(
|
||||
"session {session_id} has no persisted scope snapshot; refusing resume without explicit scope"
|
||||
)]
|
||||
SessionScopeMissing { session_id: SessionId },
|
||||
}
|
||||
|
||||
/// Bundle of resources that every high-level Pod constructor needs:
|
||||
@@ -2360,6 +2439,27 @@ fn prepare_pod_common(
|
||||
) -> Result<PodCommon, PodError> {
|
||||
let pwd = current_pwd()?;
|
||||
let scope = build_scope_with_memory(manifest, &pwd)?;
|
||||
prepare_pod_common_from_scope(manifest, loader, parse_template, pwd, scope)
|
||||
}
|
||||
|
||||
fn prepare_pod_common_with_scope(
|
||||
manifest: &PodManifest,
|
||||
loader: &PromptLoader,
|
||||
parse_template: bool,
|
||||
scope_config: ScopeConfig,
|
||||
) -> Result<PodCommon, PodError> {
|
||||
let pwd = current_pwd()?;
|
||||
let scope = Scope::from_config(&scope_config).map_err(PodError::Scope)?;
|
||||
prepare_pod_common_from_scope(manifest, loader, parse_template, pwd, scope)
|
||||
}
|
||||
|
||||
fn prepare_pod_common_from_scope(
|
||||
manifest: &PodManifest,
|
||||
loader: &PromptLoader,
|
||||
parse_template: bool,
|
||||
pwd: PathBuf,
|
||||
scope: Scope,
|
||||
) -> Result<PodCommon, PodError> {
|
||||
if !scope.is_readable(&pwd) {
|
||||
return Err(PodError::PwdOutsideScope { pwd });
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ use manifest::{
|
||||
use protocol::Method;
|
||||
use protocol::stream::JsonLineWriter;
|
||||
use serde::Deserialize;
|
||||
use session_store::PodScopeSnapshot;
|
||||
use tokio::net::UnixStream;
|
||||
use tokio::process::Command;
|
||||
use tokio::time::sleep;
|
||||
@@ -127,6 +128,9 @@ pub struct SpawnPodTool {
|
||||
/// `effective_write` semantics: Write is the only permission
|
||||
/// tracked across Pods, so revocation only touches Write.
|
||||
spawner_scope: SharedScope,
|
||||
/// Called after the spawner scope has been updated so the new
|
||||
/// effective scope can be persisted to the session log.
|
||||
scope_changed: Arc<dyn Fn(PodScopeSnapshot) + Send + Sync>,
|
||||
}
|
||||
|
||||
impl SpawnPodTool {
|
||||
@@ -139,6 +143,7 @@ impl SpawnPodTool {
|
||||
parent_socket: Option<PathBuf>,
|
||||
spawner_model: ModelManifest,
|
||||
spawner_scope: SharedScope,
|
||||
scope_changed: Arc<dyn Fn(PodScopeSnapshot) + Send + Sync>,
|
||||
) -> Self {
|
||||
Self {
|
||||
spawner_name,
|
||||
@@ -149,6 +154,7 @@ impl SpawnPodTool {
|
||||
parent_socket,
|
||||
spawner_model,
|
||||
spawner_scope,
|
||||
scope_changed,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,9 +249,12 @@ impl Tool for SpawnPodTool {
|
||||
if !revoke_write.is_empty() {
|
||||
self.spawner_scope
|
||||
.update(|cur| cur.with_added_deny_rules(revoke_write.clone()))
|
||||
.map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!("revoke spawner scope: {e}"))
|
||||
})?;
|
||||
.map_err(|e| ToolError::ExecutionFailed(format!("revoke spawner scope: {e}")))?;
|
||||
let current = self.spawner_scope.snapshot();
|
||||
(self.scope_changed)(PodScopeSnapshot {
|
||||
allow: current.allow_rules(),
|
||||
deny: current.deny_rules(),
|
||||
});
|
||||
}
|
||||
|
||||
send_run(&predicted_socket, &input.task).await?;
|
||||
@@ -488,6 +497,7 @@ pub fn spawn_pod_tool(
|
||||
parent_socket: Option<PathBuf>,
|
||||
spawner_model: ModelManifest,
|
||||
spawner_scope: SharedScope,
|
||||
scope_changed: Arc<dyn Fn(PodScopeSnapshot) + Send + Sync>,
|
||||
) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(SpawnPodInput);
|
||||
@@ -504,6 +514,7 @@ pub fn spawn_pod_tool(
|
||||
parent_socket.clone(),
|
||||
spawner_model.clone(),
|
||||
spawner_scope.clone(),
|
||||
scope_changed.clone(),
|
||||
));
|
||||
(meta, tool)
|
||||
})
|
||||
|
||||
@@ -80,3 +80,31 @@ async fn restore_from_manifest_rejects_empty_session_log() {
|
||||
Ok(_) => panic!("expected empty session log to fail"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_from_manifest_rejects_session_without_scope_snapshot() {
|
||||
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();
|
||||
|
||||
let id = session_store::new_session_id();
|
||||
let state = session_store::SessionStartState {
|
||||
system_prompt: None,
|
||||
config: &Default::default(),
|
||||
history: &[],
|
||||
};
|
||||
session_store::create_session_with_id(&store, id, state)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result =
|
||||
Pod::restore_from_manifest(id, manifest, store, pod::PromptLoader::builtins_only()).await;
|
||||
|
||||
match result {
|
||||
Err(PodError::SessionScopeMissing { session_id }) => assert_eq!(session_id, id),
|
||||
Err(other) => panic!("expected SessionScopeMissing, got {other:?}"),
|
||||
Ok(_) => panic!("expected missing scope snapshot to fail"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +187,7 @@ async fn spawn_pod_delegates_scope_and_sends_run() {
|
||||
None,
|
||||
dummy_model(),
|
||||
spawner_scope.clone(),
|
||||
std::sync::Arc::new(|_| {}),
|
||||
);
|
||||
let (_meta, tool) = def();
|
||||
|
||||
@@ -275,6 +276,7 @@ async fn spawn_pod_rejects_scope_outside_spawner() {
|
||||
None,
|
||||
dummy_model(),
|
||||
spawner_scope.clone(),
|
||||
std::sync::Arc::new(|_| {}),
|
||||
);
|
||||
let (_meta, tool) = def();
|
||||
|
||||
@@ -346,6 +348,7 @@ async fn spawn_pod_rolls_back_reservation_when_socket_never_appears() {
|
||||
None,
|
||||
dummy_model(),
|
||||
spawner_scope.clone(),
|
||||
std::sync::Arc::new(|_| {}),
|
||||
);
|
||||
let (_meta, tool) = def();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user