workdir: separate identity from worker session

This commit is contained in:
2026-08-03 18:12:47 +09:00
parent 0ffaa6c741
commit 05e8b00bf0
22 changed files with 541 additions and 314 deletions
+11 -11
View File
@@ -27,8 +27,8 @@ use llm_engine::interceptor::{Interceptor, PreRequestAction, PreToolAction, Tool
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput, ToolResult};
use serde::Deserialize;
#[cfg(test)]
use workdir::LocalWorkdir;
use workdir::{ReadRequest, WorkdirHandle, WorkdirPath};
use workdir::LocalWorkdirSession;
use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle};
use crate::compact::usage_tracker::UsageTracker;
use crate::fs_view::ReadRequirement;
@@ -327,7 +327,7 @@ fn truncate_to_token_budget(text: &mut String, max_tokens: u64) -> bool {
}
struct MarkReadRequiredTool {
workdir: WorkdirHandle,
session: WorkdirSessionHandle,
ctx: Arc<Mutex<CompactWorkerContext>>,
}
@@ -342,12 +342,12 @@ impl Tool for MarkReadRequiredTool {
ToolError::InvalidArgument(format!("invalid mark_read_required input: {e}"))
})?;
// Read through the shared Workdir so scope and I/O errors surface the
// Read through the shared WorkdirSession so scope and I/O errors surface the
// same way the regular `read_file` tool does.
let path = WorkdirPath::new(params.file_path.to_string_lossy())
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
let result = self
.workdir
.session
.read(ReadRequest {
path,
offset: params.offset.unwrap_or(0),
@@ -454,7 +454,7 @@ impl Tool for WriteSummaryTool {
}
pub(crate) fn mark_read_required_tool(
workdir: WorkdirHandle,
session: WorkdirSessionHandle,
ctx: Arc<Mutex<CompactWorkerContext>>,
) -> ToolDefinition {
Arc::new(move || {
@@ -464,7 +464,7 @@ pub(crate) fn mark_read_required_tool(
.description(MARK_DESCRIPTION)
.input_schema(schema_value);
let tool: Arc<dyn Tool> = Arc::new(MarkReadRequiredTool {
workdir: workdir.clone(),
session: session.clone(),
ctx: ctx.clone(),
});
(meta, tool)
@@ -635,9 +635,9 @@ mod tests {
use super::*;
use manifest::Scope;
fn make_fs(tmp: &std::path::Path) -> WorkdirHandle {
fn make_fs(tmp: &std::path::Path) -> WorkdirSessionHandle {
let scope = Scope::writable(tmp.to_path_buf()).unwrap();
Arc::new(LocalWorkdir::new(scope, tmp.to_path_buf()))
Arc::new(LocalWorkdirSession::new(scope, tmp.to_path_buf()))
}
fn make_usage(input: u64) -> llm_engine::timeline::event::UsageEvent {
@@ -732,7 +732,7 @@ mod tests {
let ctx = Arc::new(Mutex::new(CompactWorkerContext::with_budget(1_000)));
let tool: Arc<dyn Tool> = Arc::new(MarkReadRequiredTool {
workdir: make_fs(tmp.path()),
session: make_fs(tmp.path()),
ctx: ctx.clone(),
});
let input = serde_json::json!({ "file_path": path.file_name().unwrap().to_str().unwrap() })
@@ -754,7 +754,7 @@ mod tests {
let ctx = Arc::new(Mutex::new(CompactWorkerContext::with_budget(100)));
let tool: Arc<dyn Tool> = Arc::new(MarkReadRequiredTool {
workdir: make_fs(tmp.path()),
session: make_fs(tmp.path()),
ctx: ctx.clone(),
});
let input = serde_json::json!({ "file_path": path.file_name().unwrap().to_str().unwrap() })
+31 -11
View File
@@ -222,6 +222,26 @@ impl WorkerController {
}
async fn spawn_inner<C, St>(
worker: Worker<C, St>,
runtime_base: &Path,
runtime_managed: bool,
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
where
C: LlmClient + Clone + 'static,
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
{
let session = worker.workdir_session().cloned();
let result = Self::spawn_initialized(worker, runtime_base, runtime_managed).await;
if result.is_err()
&& let Some(session) = session
&& let Err(error) = session.close().await
{
tracing::warn!(%error, "Workdir session close after controller startup failure failed");
}
result
}
async fn spawn_initialized<C, St>(
mut worker: Worker<C, St>,
runtime_base: &Path,
runtime_managed: bool,
@@ -562,7 +582,7 @@ fn wire_event_bridges_on_engine<C, St>(
/// Register the builtin file-manipulation tools, optional memory tools,
/// and the Worker-orchestration tools (SpawnWorker + comm) on the Worker's
/// Engine. Returns the Workdir handle used to attach a `WorkerFsView` to
/// Engine. Returns the WorkdirSession handle used to attach a `WorkerFsView` to
/// the shared state.
async fn register_worker_tools<C, St>(
worker: &mut Worker<C, St>,
@@ -570,7 +590,7 @@ async fn register_worker_tools<C, St>(
spawner_socket: PathBuf,
runtime_base: PathBuf,
spawned_registry: Arc<SpawnedWorkerRegistry>,
) -> std::io::Result<Option<workdir::WorkdirHandle>>
) -> std::io::Result<Option<workdir::WorkdirSessionHandle>>
where
C: LlmClient + Clone + 'static,
St: Store + WorkerMetadataStore + Clone + 'static,
@@ -578,7 +598,7 @@ where
// Worker-immutable snapshots taken before the mutable worker borrow
// below so the worker borrow doesn't conflict with reads on `worker`.
let scope_handle = worker.scope().clone();
let worker_workdir = worker.workdir().cloned();
let worker_workdir = worker.workdir_session().cloned();
let local_filesystem = worker.local_working_directory().cloned();
let local_workspace_root = local_filesystem.as_ref().map(|local| local.root.clone());
let task_feature = worker.task_feature();
@@ -1174,17 +1194,17 @@ async fn controller_loop<C, St>(
}
}
if let Some(workdir) = worker.workdir()
&& let Err(error) = workdir.shutdown().await
{
tracing::warn!(%error, "Workdir provider shutdown failed");
}
// Background memory jobs own extract/consolidate workers after a
// turn completes. Join them before the controller task exits so
// staging writes and consolidation cleanups are not abandoned.
// turn completes. Join them before closing the Workdir session so no
// Worker-owned task can outlive its operation attachment.
worker.wait_for_memory_jobs().await;
if let Some(session) = worker.workdir_session()
&& let Err(error) = session.close().await
{
tracing::warn!(%error, "Workdir session close failed");
}
// Report upward that this Worker is stopping before the controller
// task exits. Awaited (not fire-and-forget): after `shutdown_tx.send`
// the process may exit quickly, and a spawned task would be killed
+25 -21
View File
@@ -1,6 +1,6 @@
//! Worker 視点のファイルシステム操作。
//!
//! `Workdir` の上に「Worker が読み取りたい / 列挙したい」操作を集約する軽い wrapper。
//! `WorkdirSession` の上に「Worker が読み取りたい / 列挙したい」操作を集約する軽い wrapper。
//!
//! - `ReadRequirement` と `render_auto_read` — compact worker が `mark_read_required`
//! で nominate したファイルを再読し、`[Auto-read file: ...]` system message に
@@ -16,8 +16,10 @@ use llm_engine::Item;
use tools::ToolsError;
use tracing::warn;
#[cfg(test)]
use workdir::LocalWorkdir;
use workdir::{EntryKind, ListRequest, ReadRequest, StatRequest, WorkdirHandle, WorkdirPath};
use workdir::LocalWorkdirSession;
use workdir::{
EntryKind, ListRequest, ReadRequest, StatRequest, WorkdirPath, WorkdirSessionHandle,
};
/// 補完候補1件の最大数。`list_file_completions` がこの値を超えたら打ち切り。
const COMPLETION_LIMIT: usize = 100;
@@ -38,10 +40,10 @@ pub struct ReadRequirement {
pub limit: Option<usize>,
}
/// Worker から見えるファイルシステム操作の入口。Clone は cheap`Workdir` 内 `Arc`)。
/// Worker から見えるファイルシステム操作の入口。Clone は cheap`WorkdirSession` 内 `Arc`)。
#[derive(Debug, Clone)]
pub struct WorkerFsView {
workdir: WorkdirHandle,
session: WorkdirSessionHandle,
}
/// `list_file_completions` が返す候補1件。
@@ -54,10 +56,10 @@ pub struct FileCandidate {
}
/// `resolve_file_ref` の失敗理由。Worker 側で Alert に振り分けるために
/// Workdir / 内部判定の両方を区別できるよう保持する。
/// WorkdirSession / 内部判定の両方を区別できるよう保持する。
#[derive(Debug)]
pub enum ResolveError {
/// Path resolution / scope check failed via `Workdir`.
/// Path resolution / scope check failed via `WorkdirSession`.
Fs(ToolsError),
/// File contents are not valid UTF-8 (binary / non-text).
Binary { path: PathBuf },
@@ -77,11 +79,11 @@ impl std::fmt::Display for ResolveError {
impl std::error::Error for ResolveError {}
impl WorkerFsView {
pub fn new(workdir: WorkdirHandle) -> Self {
Self { workdir }
pub fn new(session: WorkdirSessionHandle) -> Self {
Self { session }
}
pub fn workdir(&self) -> &WorkdirHandle {
&self.workdir
pub fn session(&self) -> &WorkdirSessionHandle {
&self.session
}
pub async fn render_auto_read(&self, requirements: &[ReadRequirement]) -> Vec<Item> {
@@ -95,7 +97,7 @@ impl WorkerFsView {
}
};
match self
.workdir
.session
.read(ReadRequest {
path: path.clone(),
offset: req.offset.unwrap_or(0),
@@ -128,7 +130,7 @@ impl WorkerFsView {
.map_err(ToolsError::from)
.map_err(ResolveError::Fs)?;
let stat = self
.workdir
.session
.stat(StatRequest {
path: logical.clone(),
})
@@ -137,7 +139,7 @@ impl WorkerFsView {
.map_err(ResolveError::Fs)?;
if stat.kind == EntryKind::Directory {
let result = self
.workdir
.session
.list(ListRequest {
path: logical.clone(),
limit: DIR_FILE_REF_ENTRY_LIMIT,
@@ -175,7 +177,7 @@ impl WorkerFsView {
return Ok(Item::system_message(text));
}
let result = self
.workdir
.session
.read(ReadRequest {
path: logical.clone(),
offset: 0,
@@ -216,7 +218,7 @@ impl WorkerFsView {
return Vec::new();
};
let Ok(result) = self
.workdir
.session
.list(ListRequest {
path: parent,
limit: COMPLETION_LIMIT,
@@ -285,8 +287,8 @@ mod tests {
use std::sync::Arc;
use tempfile::TempDir;
fn fs_for(dir: &TempDir) -> WorkdirHandle {
Arc::new(LocalWorkdir::new(
fn fs_for(dir: &TempDir) -> WorkdirSessionHandle {
Arc::new(LocalWorkdirSession::new(
Scope::writable(dir.path()).unwrap(),
dir.path().to_path_buf(),
))
@@ -417,7 +419,8 @@ mod tests {
}],
};
let scope = Scope::from_config(&cfg).unwrap();
let fs: WorkdirHandle = Arc::new(LocalWorkdir::new(scope, dir.path().to_path_buf()));
let fs: WorkdirSessionHandle =
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
let view = WorkerFsView::new(fs);
let item = view.resolve_file_ref("docs", 4096).await.unwrap();
@@ -493,7 +496,7 @@ mod tests {
std::fs::create_dir(&inner).unwrap();
std::fs::write(outer.path().join("secret.txt"), "nope").unwrap();
let scope = Scope::writable(&inner).unwrap();
let fs: WorkdirHandle = Arc::new(LocalWorkdir::new(scope, inner.clone()));
let fs: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(scope, inner.clone()));
let view = WorkerFsView::new(fs);
// Absolute path outside of scope.
@@ -579,7 +582,8 @@ mod tests {
}],
};
let scope = Scope::from_config(&cfg).unwrap();
let fs: WorkdirHandle = Arc::new(LocalWorkdir::new(scope, dir.path().to_path_buf()));
let fs: WorkdirSessionHandle =
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
let view = WorkerFsView::new(fs);
let cands = view.list_file_completions("").await;
+1 -1
View File
@@ -23,7 +23,7 @@ pub struct WorkerSharedState {
pub greeting: protocol::Greeting,
pub status: RwLock<WorkerStatus>,
/// Worker-from-the-inside view of the filesystem. Set once in
/// `WorkerController::start` after the local Workdir provider is
/// `WorkerController::start` after the local WorkdirSession provider is
/// materialised, and read from the IPC server layer to answer
/// `ListCompletions` queries without going through the controller. It is
/// unset only in unit tests that construct `WorkerSharedState` directly.
+25 -25
View File
@@ -76,7 +76,7 @@ use protocol::{
use tokio::net::UnixStream;
use tokio::sync::broadcast;
use tokio::task::JoinHandle;
use workdir::{LocalWorkdir, WorkdirCapabilities, WorkdirHandle};
use workdir::{LocalWorkdirSession, WorkdirSessionCapabilities, WorkdirSessionHandle};
const RESTORE_RECONCILIATION_REACHABILITY_TIMEOUT: Duration = Duration::from_millis(500);
@@ -643,14 +643,14 @@ pub struct Worker<C: LlmClient, St: Store> {
/// Explicit local filesystem authority, or `None` for Workers with no
/// local cwd and no filesystem/Bash tool surface.
filesystem_authority: WorkerFilesystemAuthority,
/// Live Workdir provider derived once from the WorkerWorkdir binding.
/// Live WorkdirSession provider derived once from the WorkerWorkdir binding.
/// Local tools, file views, and compaction workers clone this handle.
workdir: Option<WorkdirHandle>,
workdir_session: Option<WorkdirSessionHandle>,
/// Path-free workspace identity/client context injected by Runtime/host.
/// This never grants local filesystem authority.
workspace_context: WorkerWorkspaceContext,
/// Shared, atomically-swappable view of the Worker's resolved scope.
/// Cloned into local Workdir providers used by builtin tools, fs_view,
/// Cloned into local WorkdirSession providers used by builtin tools, fs_view,
/// and compaction so updates propagate at the next permission check.
scope: SharedScope,
/// Filesystem authority this Worker may pass to spawned children. Direct tools
@@ -827,7 +827,7 @@ impl<C: LlmClient + Clone + 'static, St: Store + Clone + 'static> Worker<C, St>
worker_metadata_writer: None,
segment_state: self.segment_state.clone(),
filesystem_authority: self.filesystem_authority.clone(),
workdir: self.workdir.clone(),
workdir_session: self.workdir_session.clone(),
workspace_context: self.workspace_context.clone(),
scope: self.scope.clone(),
delegation_scope: self.delegation_scope.clone(),
@@ -1016,7 +1016,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
let delegation_scope =
DelegationScope::from_config(&manifest.delegation_scope).map_err(WorkerError::Scope)?;
let scope = SharedScope::new(scope);
let workdir = workdir_from_authority(&filesystem_authority, &scope);
let workdir_session = workdir_session_from_authority(&filesystem_authority, &scope);
let mut worker = Self {
manifest,
engine: Some(worker),
@@ -1024,7 +1024,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
worker_metadata_writer: None,
segment_state: SegmentState::new(session_id, segment_id, 0),
filesystem_authority,
workdir,
workdir_session,
workspace_context,
scope,
delegation_scope,
@@ -1139,15 +1139,15 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
self.filesystem_authority.as_local()
}
pub fn workdir(&self) -> Option<&WorkdirHandle> {
self.workdir.as_ref()
pub fn workdir_session(&self) -> Option<&WorkdirSessionHandle> {
self.workdir_session.as_ref()
}
/// Replace the constructor fallback with the provider binding resolved by
/// the owning Runtime. Runtime calls this before the Worker controller is
/// spawned, so tools only ever observe the Runtime-bound handle.
pub fn bind_workdir(&mut self, workdir: Option<WorkdirHandle>) {
self.workdir = workdir;
pub fn bind_workdir_session(&mut self, workdir_session: Option<WorkdirSessionHandle>) {
self.workdir_session = workdir_session;
}
/// Path-free workspace identity, if Runtime/host associated this Worker
@@ -2014,7 +2014,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
/// unresolved placeholder stays in the flattened user message so the LLM
/// still sees the intent.
async fn resolve_file_refs(&self, segments: &[Segment]) -> Vec<SystemItem> {
let Some(workdir) = self.workdir.clone() else {
let Some(workdir) = self.workdir_session.clone() else {
for seg in segments {
if let Segment::FileRef { path } = seg {
self.alert(
@@ -2792,9 +2792,9 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
)));
// Build an independent compact worker. It clones the main Worker's
// provider handle, so compact-time reads use the same Workdir instance.
// provider handle, so compact-time reads use the same WorkdirSession instance.
// No-workdir Workers deliberately omit compact-time filesystem tools.
let workdir = self.workdir.clone();
let workdir = self.workdir_session.clone();
let summary_tracker = tools::Tracker::new();
let summary_client: Box<dyn LlmClient> = self.build_compactor_client()?;
let summary_system_prompt = self
@@ -3858,7 +3858,7 @@ where
worker.set_cache_key(Some(segment_id.to_string()));
let worker_metadata_writer = Some(worker_metadata_writer_for_store(&store));
let scope = SharedScope::new(common.scope);
let workdir = workdir_from_authority(&common.filesystem_authority, &scope);
let workdir_session = workdir_session_from_authority(&common.filesystem_authority, &scope);
let mut worker = Self {
manifest,
@@ -3867,7 +3867,7 @@ where
worker_metadata_writer,
segment_state: SegmentState::new(session_id, segment_id, 0),
filesystem_authority: common.filesystem_authority,
workdir,
workdir_session,
workspace_context: common.workspace_context,
scope,
delegation_scope: common.delegation_scope,
@@ -3967,7 +3967,7 @@ where
worker.set_cache_key(Some(segment_id.to_string()));
let worker_metadata_writer = Some(worker_metadata_writer_for_store(&store));
let scope = SharedScope::new(common.scope);
let workdir = workdir_from_authority(&common.filesystem_authority, &scope);
let workdir_session = workdir_session_from_authority(&common.filesystem_authority, &scope);
let mut worker = Self {
manifest,
@@ -3976,7 +3976,7 @@ where
worker_metadata_writer,
segment_state: SegmentState::new(session_id, segment_id, 0),
filesystem_authority: common.filesystem_authority,
workdir,
workdir_session,
workspace_context: common.workspace_context,
scope,
delegation_scope: common.delegation_scope,
@@ -4259,7 +4259,7 @@ where
let task_feature = TaskFeature::from_history(&state.history);
let worker_metadata_writer = Some(worker_metadata_writer_for_store(&store));
let scope = SharedScope::new(common.scope);
let workdir = workdir_from_authority(&common.filesystem_authority, &scope);
let workdir_session = workdir_session_from_authority(&common.filesystem_authority, &scope);
let mut worker = Self {
manifest,
@@ -4268,7 +4268,7 @@ where
worker_metadata_writer,
segment_state: SegmentState::new(session_id, segment_id, state.entries_count),
filesystem_authority: common.filesystem_authority,
workdir,
workdir_session,
workspace_context: common.workspace_context,
scope,
delegation_scope: common.delegation_scope,
@@ -4934,17 +4934,17 @@ pub enum WorkerError {
},
}
fn workdir_from_authority(
fn workdir_session_from_authority(
authority: &WorkerFilesystemAuthority,
scope: &SharedScope,
) -> Option<WorkdirHandle> {
) -> Option<WorkdirSessionHandle> {
authority.as_local().map(|local| {
Arc::new(LocalWorkdir::materialized(
Arc::new(LocalWorkdirSession::materialized(
local.root.clone(),
local.cwd.clone(),
scope.clone(),
WorkdirCapabilities::ALL,
)) as WorkdirHandle
WorkdirSessionCapabilities::ALL,
)) as WorkdirSessionHandle
})
}
+44 -9
View File
@@ -11,7 +11,10 @@ use llm_engine::llm_client::{ClientError, LlmClient, Request};
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use session_store::{CombinedStore, FsWorkerStore};
use session_store::{FsStore, LogEntry};
use workdir::{CommandRequest, LocalWorkdir, WorkdirCapabilities, WorkdirError, WorkdirHandle};
use workdir::{
CommandRequest, LocalWorkdirSession, Workdir, WorkdirError, WorkdirSessionCapabilities,
WorkdirSessionHandle,
};
use worker::{
Event, Method, Worker, WorkerController, WorkerFilesystemAuthority, WorkerHandle,
@@ -215,16 +218,16 @@ async fn spawn_controller(worker: Worker<MockClient, TestStore>) -> WorkerHandle
}
#[tokio::test]
async fn shutdown_closes_bound_workdir_commands() {
async fn shutdown_closes_bound_workdir_session() {
let (mut worker, pwd) = make_worker_with_pwd(MockClient::new(simple_text_events())).await;
let workdir: WorkdirHandle = Arc::new(LocalWorkdir::materialized_bound(
Some("controller-test-workdir".to_owned()),
let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::materialized_bound(
Workdir::new("controller-test-workdir"),
pwd.clone(),
pwd,
worker.scope().clone(),
WorkdirCapabilities::ALL,
WorkdirSessionCapabilities::ALL,
));
let command = workdir
let command = session
.start_command(CommandRequest {
command: "sleep 30".to_owned(),
timeout_secs: 60,
@@ -232,7 +235,7 @@ async fn shutdown_closes_bound_workdir_commands() {
})
.await
.unwrap();
worker.bind_workdir(Some(Arc::clone(&workdir)));
worker.bind_workdir_session(Some(Arc::clone(&session)));
let runtime_base = tempfile::tempdir().unwrap();
let (handle, shutdown_rx) = WorkerController::spawn(worker, runtime_base.path())
@@ -245,8 +248,40 @@ async fn shutdown_closes_bound_workdir_commands() {
.expect("controller shutdown signal should remain open");
assert!(matches!(
workdir.command_status(command).await,
Err(WorkdirError::UnknownCommand(_))
session.command_status(command).await,
Err(WorkdirError::Unavailable(_))
));
}
#[tokio::test]
async fn controller_startup_failure_closes_bound_workdir_session() {
let (mut worker, pwd) = make_worker_with_pwd(MockClient::new(simple_text_events())).await;
let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::materialized_bound(
Workdir::new("controller-startup-failure-workdir"),
pwd.clone(),
pwd,
worker.scope().clone(),
WorkdirSessionCapabilities::ALL,
));
worker.bind_workdir_session(Some(Arc::clone(&session)));
let runtime_base = tempfile::tempdir().unwrap();
let invalid_runtime_base = runtime_base.path().join("not-a-directory");
std::fs::write(&invalid_runtime_base, "file").unwrap();
assert!(
WorkerController::spawn(worker, &invalid_runtime_base)
.await
.is_err()
);
assert!(matches!(
session
.start_command(CommandRequest {
command: "printf unreachable".to_owned(),
timeout_secs: 5,
output_limit: 1024,
})
.await,
Err(WorkdirError::Unavailable(_))
));
}