From a38fcdf04dfed2ae859879eb17c95c89e9bf109a Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 11 Jul 2026 07:04:02 +0900 Subject: [PATCH 01/15] ticket: hold workspace backend work for authority base --- .../artifacts/orchestration-plan.jsonl | 2 ++ .yoi/tickets/00001KX6Y6ZEA/item.md | 2 +- .yoi/tickets/00001KX6Y6ZEA/thread.md | 25 +++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 .yoi/tickets/00001KX6Y6ZEA/artifacts/orchestration-plan.jsonl diff --git a/.yoi/tickets/00001KX6Y6ZEA/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KX6Y6ZEA/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..00ce5b8c --- /dev/null +++ b/.yoi/tickets/00001KX6Y6ZEA/artifacts/orchestration-plan.jsonl @@ -0,0 +1,2 @@ +{"id":"orch-plan-20260710-220343-1","ticket_id":"00001KX6Y6ZEA","kind":"after","related_ticket":"00001KX6Y2A9Q","note":"Ticket has explicit typed `depends_on` relation to `00001KX6Y2A9Q`. The prerequisite WorkerFilesystemAuthority refactor is currently `inprogress` and establishes the filesystem-authority/workspace-boundary this Ticket builds on. Start only after the prerequisite is approved, merged, and closed or an explicit combined-work decision is made.","author":"orchestrator","at":"2026-07-10T22:03:43Z"} +{"id":"orch-plan-20260710-220349-2","ticket_id":"00001KX6Y6ZEA","kind":"waiting_capacity_note","note":"Dashboard queue routing inspected. This Ticket is concrete but blocked by typed `depends_on` relation to `00001KX6Y2A9Q`, which is currently inprogress. Starting now would conflict with the Worker struct/context/constructor refactor and filesystem-authority boundary. Leave queued and re-route after prerequisite merge/close.","author":"orchestrator","at":"2026-07-10T22:03:49Z"} diff --git a/.yoi/tickets/00001KX6Y6ZEA/item.md b/.yoi/tickets/00001KX6Y6ZEA/item.md index 05cb59fc..13b2b3ae 100644 --- a/.yoi/tickets/00001KX6Y6ZEA/item.md +++ b/.yoi/tickets/00001KX6Y6ZEA/item.md @@ -3,7 +3,7 @@ title: 'Replace Worker workspace_root path with WorkspaceBackend' state: 'queued' priority: 'P1' created_at: '2026-07-10T21:16:22Z' -updated_at: '2026-07-10T22:03:27Z' +updated_at: '2026-07-10T22:03:57Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-10T22:03:27Z' diff --git a/.yoi/tickets/00001KX6Y6ZEA/thread.md b/.yoi/tickets/00001KX6Y6ZEA/thread.md index 7b63f27b..35576404 100644 --- a/.yoi/tickets/00001KX6Y6ZEA/thread.md +++ b/.yoi/tickets/00001KX6Y6ZEA/thread.md @@ -72,4 +72,29 @@ Marked ready by `yoi ticket state`. Ticket を `workspace-panel` が queued にしました。 +--- + + + +## Decision + +Routing decision: blocked_by_dependency + +Reason: +- Dashboard queue authorization was inspected, but this Ticket has an explicit typed `depends_on` relation to `00001KX6Y2A9Q`。 +- `00001KX6Y2A9Q` is currently `inprogress` and is refactoring Worker filesystem authority / cwd removal / no-workdir boundaries。 +- This Ticket replaces Worker `workspace_root` path usage with `WorkspaceBackend`, which builds on the same Worker struct/context/constructor boundary。 +- Starting now would likely conflict with the active filesystem-authority refactor and obscure review boundaries。 +- Therefore this routing pass leaves the Ticket `queued` and does not record `queued -> inprogress`, create a worktree, or spawn role Pods。 + +Evidence checked: +- Ticket body / recent thread / artifacts。 +- `TicketRelationQuery(00001KX6Y6ZEA)`: includes dependency on `00001KX6Y2A9Q`。 +- `TicketList(inprogress)`: `00001KX6Y2A9Q` is the active inprogress Ticket。 +- Orchestrator worktree status: clean。 + +Next action: +- Re-route this Ticket after `00001KX6Y2A9Q` is approved, merged, validated, and closed。 +- Escalate only if a human explicitly requests combined implementation in the same branch/worktree。 + --- From b50b94612a1185dc5bdc935d83d63f9d6ace826b Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 11 Jul 2026 07:30:17 +0900 Subject: [PATCH 02/15] feat: add explicit worker filesystem authority --- crates/worker-runtime/src/worker_backend.rs | 90 +++++-- crates/worker/src/controller.rs | 93 ++++--- crates/worker/src/discovery.rs | 26 +- crates/worker/src/entrypoint.rs | 14 +- crates/worker/src/lib.rs | 5 +- crates/worker/src/prompt/system.rs | 14 +- crates/worker/src/ticket_event_notify.rs | 2 +- crates/worker/src/worker.rs | 250 +++++++++++++----- crates/worker/tests/compact_events_test.rs | 13 +- crates/worker/tests/consolidation_test.rs | 13 +- crates/worker/tests/controller_test.rs | 8 +- crates/worker/tests/session_metrics_test.rs | 39 ++- .../tests/system_prompt_template_test.rs | 10 +- 13 files changed, 422 insertions(+), 155 deletions(-) diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index c62d860c..36c9ed50 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -35,7 +35,7 @@ use session_store::{CombinedStore, FsWorkerStore}; use tokio::runtime::Runtime; use tokio::sync::broadcast; -use worker::{Worker, WorkerController, WorkerHandle}; +use worker::{Worker, WorkerController, WorkerFilesystemAuthority, WorkerHandle}; const DEFAULT_BACKEND_ID: &str = "worker-crate"; const RUNTIME_TASK_TIMEOUT: Duration = Duration::from_secs(10); @@ -298,11 +298,16 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { .as_ref() .map(|binding| binding.root().to_path_buf()) .unwrap_or_else(|| self.profile_base_dir.clone()); - let cwd = request + let filesystem_authority = request .working_directory .as_ref() - .map(|binding| binding.cwd().to_path_buf()) - .unwrap_or_else(|| self.cwd.clone()); + .map(|binding| { + WorkerFilesystemAuthority::local( + binding.root().to_path_buf(), + binding.cwd().to_path_buf(), + ) + }) + .unwrap_or(WorkerFilesystemAuthority::None); let selector = profile.as_deref().unwrap_or("builtin:default"); let archive = self .resolve_profile_source_archive(&request.request.profile_source) @@ -335,9 +340,15 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { })?; let store = CombinedStore::new(session_store, worker_metadata_store); - let worker = Worker::from_manifest_with_context(manifest, store, loader, worker_root, cwd) - .await - .map_err(|err| format!("failed to create Worker from profile: {err}"))?; + let worker = Worker::from_manifest_with_context( + manifest, + store, + loader, + worker_root, + filesystem_authority, + ) + .await + .map_err(|err| format!("failed to create Worker from profile: {err}"))?; let runtime_base = self.runtime_base_dir()?; let (handle, _shutdown_rx) = WorkerController::spawn(worker, &runtime_base) @@ -806,7 +817,7 @@ where #[cfg(test)] mod tests { use super::*; - use std::collections::BTreeMap; + use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::pin::Pin; use std::process::Command; @@ -902,18 +913,27 @@ mod tests { FsStore::new(&self.store_dir).map_err(|err| err.to_string())?, FsWorkerStore::new(&self.worker_metadata_dir).map_err(|err| err.to_string())?, ); - let cwd = request + let filesystem_authority = request .working_directory .as_ref() - .map(|binding| binding.cwd().to_path_buf()) + .map(|binding| { + let cwd = binding.cwd().to_path_buf(); + self.observed_cwds.lock().unwrap().push(cwd.clone()); + WorkerFilesystemAuthority::local(binding.root().to_path_buf(), cwd) + }) + .unwrap_or(WorkerFilesystemAuthority::None); + let scope_root = request + .working_directory + .as_ref() + .map(|binding| binding.root().to_path_buf()) .unwrap_or_else(|| self.cwd.clone()); - self.observed_cwds.lock().unwrap().push(cwd.clone()); - let scope = Scope::writable(&cwd).map_err(|err| err.to_string())?; + let scope = Scope::writable(&scope_root).map_err(|err| err.to_string())?; let worker = Worker::new( manifest, Engine::new(self.client.clone()), store, - cwd, + self.cwd.clone(), + filesystem_authority, scope, ) .await @@ -925,6 +945,20 @@ mod tests { } } + fn core_filesystem_tool_names() -> BTreeSet<&'static str> { + ["Read", "Write", "Edit", "Glob", "Grep", "Bash"] + .into_iter() + .collect() + } + + fn captured_tool_names(client: &MockClient, index: usize) -> BTreeSet { + client.captured.lock().unwrap()[index] + .tools + .iter() + .map(|tool| tool.name.clone()) + .collect() + } + fn simple_text_events() -> Vec { vec![ LlmEvent::text_block_start(0), @@ -1109,7 +1143,7 @@ mod tests { cwd: cwd.path().to_path_buf(), store_dir: store.path().join("sessions"), worker_metadata_dir: store.path().join("workers"), - observed_cwds, + observed_cwds: observed_cwds.clone(), }; let backend = WorkerRuntimeExecutionBackend::new(factory).unwrap(); let runtime = EmbeddedRuntime::with_execution_backend( @@ -1148,6 +1182,14 @@ mod tests { } assert_eq!(client.captured.lock().unwrap().len(), 1); + assert!(observed_cwds.lock().unwrap().is_empty()); + let names = captured_tool_names(&client, 0); + for forbidden in core_filesystem_tool_names() { + assert!( + !names.contains(forbidden), + "no-workdir Worker unexpectedly exposed {forbidden}; tools={names:?}" + ); + } let observations = runtime .read_worker_observation_events(&detail.worker_ref, WorkerObservationCursor::zero()) .unwrap(); @@ -1166,7 +1208,7 @@ mod tests { let store = tempfile::tempdir().unwrap(); let observed_cwds = Arc::new(Mutex::new(Vec::new())); let factory = MockFactory { - client, + client: client.clone(), runtime_base: runtime_base.path().to_path_buf(), cwd: repo.path().to_path_buf(), store_dir: store.path().join("sessions"), @@ -1191,6 +1233,24 @@ mod tests { request.working_directory_request = Some(working_directory_request(repo.path())); let detail = runtime.create_worker(request).unwrap(); + runtime + .send_input(&detail.worker_ref, WorkerInput::user("inspect tools")) + .unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while client.captured.lock().unwrap().is_empty() { + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for materialized-worker request" + ); + std::thread::sleep(Duration::from_millis(20)); + } + let names = captured_tool_names(&client, 0); + for expected in core_filesystem_tool_names() { + assert!( + names.contains(expected), + "local Worker did not expose {expected}; tools={names:?}" + ); + } assert!(detail.execution.working_directory.is_some()); let cwds = observed_cwds.lock().unwrap(); diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 5a98ee54..4741e5f3 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -274,7 +274,9 @@ impl WorkerController { manifest_toml.clone(), greeting, )); - shared_state.set_fs_view(crate::fs_view::WorkerFsView::new(fs_for_view)); + if let Some(fs_for_view) = fs_for_view { + shared_state.set_fs_view(crate::fs_view::WorkerFsView::new(fs_for_view)); + } shared_state.set_workflows( worker .workflow_completions() @@ -528,7 +530,10 @@ fn install_ticket_event_companion_notify_hook( return; } - let Ok(ticket_config) = TicketConfig::load_workspace(worker.cwd()) else { + let Some(local) = worker.local_working_directory() else { + return; + }; + let Ok(ticket_config) = TicketConfig::load_workspace(&local.cwd) else { return; }; let backend_root = ticket_config.backend_root().to_path_buf(); @@ -540,7 +545,7 @@ fn install_ticket_event_companion_notify_hook( worker.worker_metadata_store(), worker.manifest().worker.name.clone(), runtime_base, - worker.cwd().to_path_buf(), + Some(local.cwd.clone()), spawned_registry, ); match discovery.ensure_existing_peer(&companion_worker_name) { @@ -589,7 +594,7 @@ async fn register_worker_tools( spawner_socket: PathBuf, runtime_base: PathBuf, spawned_registry: Arc, -) -> std::io::Result +) -> std::io::Result> where C: LlmClient + Clone + 'static, St: Store + WorkerMetadataStore + Clone + 'static, @@ -597,7 +602,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 cwd = worker.cwd().to_path_buf(); + let local_filesystem = worker.local_working_directory().cloned(); let workspace_root = worker.workspace_root().to_path_buf(); let task_feature = worker.task_feature(); let session_id_for_usage = worker.segment_id().to_string(); @@ -611,24 +616,24 @@ where let worker_metadata_store = worker.store().clone(); let self_parent_socket = worker.callback_socket().cloned(); - // The Worker's SharedScope (already augmented with the bash-output - // Read rule by the caller) is the single source of truth — every - // ScopedFs (builtin tools, fs_view, compact worker) reads from it, - // and any future scope mutation (SpawnWorker-style revoke, future - // GrantScope) propagates through it. - let fs = tools::ScopedFs::with_shared_scope(scope_handle.clone(), cwd.clone()); - let tracker = tools::Tracker::new(); - // Same ScopedFs also powers the IPC `ListCompletions` query — keep - // a clone for the FS view we attach below, since the tools consume - // `fs` itself. - let fs_for_view = fs.clone(); - worker - .engine_mut() - .register_tools(tools::core_builtin_tools( - fs, - tracker.clone(), - bash_output_dir, - )); + // The Worker's SharedScope is the single source of truth for every + // ScopedFs when local filesystem authority exists. No-workdir Workers + // deliberately skip constructing/registering filesystem and Bash tools. + let (fs_for_view, tracker) = if let Some(local) = local_filesystem.as_ref() { + let fs = tools::ScopedFs::with_shared_scope(scope_handle.clone(), local.cwd.clone()); + let tracker = tools::Tracker::new(); + let fs_for_view = fs.clone(); + worker + .engine_mut() + .register_tools(tools::core_builtin_tools( + fs, + tracker.clone(), + bash_output_dir, + )); + (Some(fs_for_view), Some(tracker)) + } else { + (None, None) + }; if feature_config.web.enabled { worker .engine_mut() @@ -649,11 +654,20 @@ where } }; // Ticket tools are typed operations over the currently checked-out work - // tree. Use the Worker cwd rather than the runtime workspace root so a - // dedicated Orchestrator worktree gets its own `.yoi/tickets` backend. + // tree. They require explicit local filesystem authority; workspace_root + // is context only and must not be used as a cwd fallback. + let ticket_cwd = local_filesystem + .as_ref() + .map(|local| &local.cwd) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "ticket tools require local Worker filesystem authority", + ) + })?; feature_registry.add_module( crate::feature::builtin::ticket::ticket_tools_feature_with_options( - &cwd, + ticket_cwd, feature_config.ticket.enabled.then_some(ticket_access), feature_config.ticket_orchestration.enabled, ), @@ -709,12 +723,21 @@ where "[feature.workers].enabled = true requires non-empty [[delegation_scope.allow]]", )); } + let spawner_cwd = local_filesystem + .as_ref() + .map(|local| local.cwd.clone()) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "worker spawn tools require local Worker filesystem authority", + ) + })?; worker.register_tool(spawn_worker_tool( spawner_name.clone(), spawner_socket, runtime_base.clone(), workspace_root.clone(), - cwd.clone(), + spawner_cwd.clone(), spawned_registry.clone(), self_parent_socket, spawner_manifest, @@ -728,7 +751,7 @@ where worker_metadata_store, spawner_name, runtime_base, - cwd, + Some(spawner_cwd), spawned_registry, ); worker.register_tool(list_workers_tool(discovery.clone())); @@ -737,7 +760,9 @@ where } } let _feature_install_report = worker.install_features(feature_registry); - worker.attach_tracker(tracker); + if let Some(tracker) = tracker { + worker.attach_tracker(tracker); + } Ok(fs_for_view) } @@ -774,11 +799,14 @@ async fn controller_loop( .parent() .map(PathBuf::from) .unwrap_or_else(|| runtime_dir.path().to_path_buf()); + let discovery_cwd = worker + .local_working_directory() + .map(|local| local.cwd.clone()); let discovery = WorkerDiscovery::new( worker.store().clone(), spawner_name.clone(), discovery_runtime_base, - worker.cwd().to_path_buf(), + discovery_cwd, spawned_registry.clone(), ); let mut pending: Option = None; @@ -1445,7 +1473,10 @@ where .collect(); protocol::Greeting { worker_name: manifest.worker.name.clone(), - cwd: worker.cwd().display().to_string(), + cwd: worker + .local_working_directory() + .map(|local| local.cwd.display().to_string()) + .unwrap_or_default(), provider: provider_name, model: model_id, scope_summary: worker.scope_snapshot().summary(), diff --git a/crates/worker/src/discovery.rs b/crates/worker/src/discovery.rs index ad182f72..0d7e0a28 100644 --- a/crates/worker/src/discovery.rs +++ b/crates/worker/src/discovery.rs @@ -42,7 +42,7 @@ pub struct WorkerDiscovery { store: St, self_worker_name: String, runtime_base: PathBuf, - cwd: PathBuf, + cwd: Option, store_dir: Option, spawned_registry: Arc, } @@ -55,7 +55,7 @@ where store: St, self_worker_name: String, runtime_base: PathBuf, - cwd: PathBuf, + cwd: Option, spawned_registry: Arc, ) -> Self { let store_dir = store.root_dir(); @@ -432,13 +432,19 @@ where ) -> Result<(), WorkerDiscoveryError> { let runtime_command = WorkerRuntimeCommand::resolve().map_err(WorkerDiscoveryError::RestoreSpawn)?; + let Some(cwd) = &self.cwd else { + return Err(WorkerDiscoveryError::NotRestorable { + worker_name: worker_name.to_string(), + reason: "restore requires local Worker filesystem authority".into(), + }); + }; let mut command = Command::new(runtime_command.program()); command .args(runtime_command.prefix_args()) .arg("--worker") .arg(worker_name) .arg("--require-worker-state") - .current_dir(&self.cwd) + .current_dir(cwd) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -1228,7 +1234,7 @@ mod tests { store.clone(), "parent".into(), runtime_base.clone(), - root.path().to_path_buf(), + Some(root.path().to_path_buf()), registry, ); @@ -1355,7 +1361,7 @@ mod tests { store.clone(), "source".into(), runtime_base.clone(), - root.path().to_path_buf(), + Some(root.path().to_path_buf()), SpawnedWorkerRegistry::new(runtime_dir), ); let result = discovery.register_peer("target").unwrap(); @@ -1390,7 +1396,7 @@ mod tests { store, "source".into(), runtime_base, - root.path().to_path_buf(), + Some(root.path().to_path_buf()), SpawnedWorkerRegistry::new(runtime_dir), ); @@ -1430,7 +1436,7 @@ mod tests { store.clone(), "source".into(), runtime_base, - root.path().to_path_buf(), + Some(root.path().to_path_buf()), SpawnedWorkerRegistry::new(runtime_dir), ); @@ -1481,7 +1487,7 @@ mod tests { store, "source".into(), runtime_base.clone(), - root.path().to_path_buf(), + Some(root.path().to_path_buf()), SpawnedWorkerRegistry::new(runtime_dir), ); @@ -1599,7 +1605,7 @@ mod tests { store, "source".into(), runtime_base.clone(), - root.path().to_path_buf(), + Some(root.path().to_path_buf()), SpawnedWorkerRegistry::new(runtime_dir), ); @@ -1701,7 +1707,7 @@ mod tests { store, "source".into(), runtime_base, - root.path().to_path_buf(), + Some(root.path().to_path_buf()), SpawnedWorkerRegistry::new(runtime_dir), ); diff --git a/crates/worker/src/entrypoint.rs b/crates/worker/src/entrypoint.rs index 7a650074..d23a66de 100644 --- a/crates/worker/src/entrypoint.rs +++ b/crates/worker/src/entrypoint.rs @@ -2,7 +2,7 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::ExitCode; -use crate::{PromptLoader, Worker, WorkerController}; +use crate::{PromptLoader, Worker, WorkerController, WorkerFilesystemAuthority}; use clap::{CommandFactory, FromArgMatches, Parser}; use manifest::{ Permission, ProfileResolveOptions, ProfileResolver, ProfileSelector, ScopeConfig, ScopeRule, @@ -511,6 +511,8 @@ async fn run_cli_inner(cli: Cli) -> ExitCode { } }; let store = CombinedStore::new(session_store, worker_metadata_store); + let filesystem_authority = + WorkerFilesystemAuthority::local(workspace_root.clone(), cwd.clone()); let mut worker = if cli.adopt { let callback = match cli.callback.clone() { @@ -526,7 +528,7 @@ async fn run_cli_inner(cli: Cli) -> ExitCode { loader, callback, workspace_root.clone(), - cwd.clone(), + filesystem_authority.clone(), ) .await { @@ -557,7 +559,7 @@ async fn run_cli_inner(cli: Cli) -> ExitCode { store, loader, workspace_root.clone(), - cwd.clone(), + filesystem_authority.clone(), ) .await { @@ -577,7 +579,7 @@ async fn run_cli_inner(cli: Cli) -> ExitCode { store, loader, workspace_root.clone(), - cwd.clone(), + filesystem_authority.clone(), ) .await { @@ -598,7 +600,7 @@ async fn run_cli_inner(cli: Cli) -> ExitCode { store, loader, workspace_root.clone(), - cwd.clone(), + filesystem_authority.clone(), ) .await { @@ -620,7 +622,7 @@ async fn run_cli_inner(cli: Cli) -> ExitCode { store, loader, workspace_root.clone(), - cwd.clone(), + filesystem_authority.clone(), ) .await { diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index 399dfc4c..592cca40 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -38,4 +38,7 @@ pub use provider::{ProviderError, build_client}; pub use runtime::dir::RuntimeDir; pub use segment_log_sink::SegmentLogSink; pub use shared_state::WorkerSharedState; -pub use worker::{Worker, WorkerError, WorkerRunResult, apply_worker_manifest}; +pub use worker::{ + LocalWorkingDirectory, Worker, WorkerError, WorkerFilesystemAuthority, WorkerRunResult, + apply_worker_manifest, +}; diff --git a/crates/worker/src/prompt/system.rs b/crates/worker/src/prompt/system.rs index 7dfc930b..89fd6574 100644 --- a/crates/worker/src/prompt/system.rs +++ b/crates/worker/src/prompt/system.rs @@ -13,7 +13,9 @@ //! `set_system_prompt`. Subsequent turns and compactions reuse that //! materialised string verbatim. +use std::borrow::Cow; use std::collections::BTreeMap; +#[cfg(test)] use std::path::Path; use std::sync::Arc; @@ -146,7 +148,7 @@ impl std::fmt::Debug for SystemPromptTemplate { /// templates cannot drop them on the floor. pub struct SystemPromptContext<'a> { pub now: DateTime, - pub cwd: &'a Path, + pub cwd: Cow<'a, str>, /// Language policy exposed to instruction templates as `{{ language }}`. pub language: &'a str, pub scope: &'a Scope, @@ -189,7 +191,7 @@ impl<'a> SystemPromptContext<'a> { "datetime".into(), Value::from(self.now.to_rfc3339_opts(SecondsFormat::Secs, true)), ); - root.insert("cwd".into(), Value::from(self.cwd.display().to_string())); + root.insert("cwd".into(), Value::from(self.cwd.as_ref())); root.insert("language".into(), Value::from(self.language)); root.insert( "tools".into(), @@ -442,7 +444,7 @@ mod tests { ) -> SystemPromptContext<'a> { SystemPromptContext { now: fixed_now(), - cwd, + cwd: cwd.display().to_string().into(), language: manifest::defaults::WORKER_LANGUAGE, scope, tool_names: tools, @@ -461,7 +463,7 @@ mod tests { ) -> SystemPromptContext<'a> { SystemPromptContext { now: fixed_now(), - cwd, + cwd: cwd.display().to_string().into(), language: manifest::defaults::WORKER_LANGUAGE, scope, tool_names: Vec::new(), @@ -480,7 +482,7 @@ mod tests { ) -> SystemPromptContext<'a> { SystemPromptContext { now: fixed_now(), - cwd, + cwd: cwd.display().to_string().into(), language: manifest::defaults::WORKER_LANGUAGE, scope, tool_names: Vec::new(), @@ -499,7 +501,7 @@ mod tests { ) -> SystemPromptContext<'a> { SystemPromptContext { now: fixed_now(), - cwd, + cwd: cwd.display().to_string().into(), language: manifest::defaults::WORKER_LANGUAGE, scope, tool_names: Vec::new(), diff --git a/crates/worker/src/ticket_event_notify.rs b/crates/worker/src/ticket_event_notify.rs index bb719d29..1f0a898f 100644 --- a/crates/worker/src/ticket_event_notify.rs +++ b/crates/worker/src/ticket_event_notify.rs @@ -414,7 +414,7 @@ mod tests { store, "orchestrator".into(), runtime_base.clone(), - root.path().to_path_buf(), + Some(root.path().to_path_buf()), SpawnedWorkerRegistry::new(runtime_dir), ), "companion", diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 1673c621..972e455f 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -57,6 +57,41 @@ use tokio::task::JoinHandle; const RESTORE_RECONCILIATION_REACHABILITY_TIMEOUT: Duration = Duration::from_millis(500); +/// Explicit filesystem authority held by a Worker. +/// +/// `None` means the Worker has no local filesystem authority: no cwd, no +/// filesystem view, and no filesystem/Bash tool surface. Workspace context may +/// still exist separately for memory, workflows, and project records. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorkerFilesystemAuthority { + None, + Local(LocalWorkingDirectory), +} + +impl WorkerFilesystemAuthority { + pub fn local(root: PathBuf, cwd: PathBuf) -> Self { + Self::Local(LocalWorkingDirectory { root, cwd }) + } + + pub fn as_local(&self) -> Option<&LocalWorkingDirectory> { + match self { + Self::None => None, + Self::Local(local) => Some(local), + } + } +} + +/// Local filesystem authority for a Worker. +/// +/// `root` is the authority root retained for control-plane semantics; +/// `cwd` is the default working directory used by filesystem tools, Bash, +/// file references, and local worktree-scoped features. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LocalWorkingDirectory { + pub root: PathBuf, + pub cwd: PathBuf, +} + /// `(SessionId, SegmentId)` pair the Worker is currently writing to. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SegmentLocation { @@ -249,8 +284,9 @@ pub struct Worker { /// `segment_id` and append tally. `self.segment_id()` is a thin /// wrapper over `segment_state.segment_id()`. segment_state: Arc, - /// Absolute tool/process working directory of the Worker. - cwd: PathBuf, + /// Explicit local filesystem authority, or `None` for Workers with no + /// local cwd and no filesystem/Bash tool surface. + filesystem_authority: WorkerFilesystemAuthority, /// Absolute runtime workspace root used for project records, workflow, /// memory, Ticket config, Profile context, and spawned-child inheritance. workspace_root: PathBuf, @@ -446,7 +482,7 @@ impl Worker store: self.store.clone(), worker_metadata_writer: None, segment_state: self.segment_state.clone(), - cwd: self.cwd.clone(), + filesystem_authority: self.filesystem_authority.clone(), workspace_root: self.workspace_root.clone(), scope: self.scope.clone(), delegation_scope: self.delegation_scope.clone(), @@ -607,9 +643,10 @@ impl Worker impl Worker { /// Create a new Worker from a pre-built Engine and store. /// - /// Callers must pre-resolve `cwd` (absolute) and build a [`Scope`] + /// Callers must pass explicit filesystem authority and build a [`Scope`] /// — typically via [`Scope::from_config`] when coming from a - /// manifest, or [`Scope::writable`] in tests. + /// manifest, or [`Scope::writable`] in tests. Use + /// [`WorkerFilesystemAuthority::None`] for no-workdir Workers. /// /// Note: this constructor does **not** parse `manifest.worker.system_prompt` /// as a template. `Worker::from_manifest` is the production path for @@ -619,7 +656,8 @@ impl Worker { manifest: WorkerManifest, worker: Engine, store: St, - cwd: PathBuf, + workspace_root: PathBuf, + filesystem_authority: WorkerFilesystemAuthority, scope: Scope, ) -> Result { // Segment creation is deferred to `ensure_segment_head` at first @@ -636,8 +674,8 @@ impl Worker { store, worker_metadata_writer: None, segment_state: SegmentState::new(session_id, segment_id, 0), - workspace_root: cwd.clone(), - cwd, + filesystem_authority, + workspace_root, scope: SharedScope::new(scope), delegation_scope, hook_builder: HookRegistryBuilder::new(), @@ -749,9 +787,14 @@ impl Worker { self.runtime_ticket_role = role; } - /// The Worker's tool/process working directory. - pub fn cwd(&self) -> &Path { - &self.cwd + /// Explicit filesystem authority held by this Worker. + pub fn filesystem_authority(&self) -> &WorkerFilesystemAuthority { + &self.filesystem_authority + } + + /// Local working directory when this Worker has local filesystem authority. + pub fn local_working_directory(&self) -> Option<&LocalWorkingDirectory> { + self.filesystem_authority.as_local() } /// The Worker's runtime workspace root. This stays separate from `cwd` for @@ -1376,9 +1419,13 @@ impl Worker { self.resident_exposure_snapshots(&resident, &resident_workflows); let worker_language = worker_language(&self.manifest.engine); let scope_snapshot = self.scope.snapshot(); + let cwd_for_prompt = self + .local_working_directory() + .map(|local| local.cwd.display().to_string()) + .unwrap_or_else(|| "no local working directory".to_string()); let ctx = SystemPromptContext { now: chrono::Utc::now(), - cwd: &self.cwd, + cwd: cwd_for_prompt.into(), language: worker_language, scope: &scope_snapshot, tool_names, @@ -1622,9 +1669,21 @@ impl Worker { /// unresolved placeholder stays in the flattened user message so the LLM /// still sees the intent. fn resolve_file_refs(&self, segments: &[Segment]) -> Vec { + let Some(local) = self.local_working_directory() else { + for seg in segments { + if let Segment::FileRef { path } = seg { + self.alert( + AlertLevel::Warn, + AlertSource::Worker, + format!("file ref @{path} could not be resolved: Worker has no local filesystem authority"), + ); + } + } + return Vec::new(); + }; let view = crate::fs_view::WorkerFsView::new(tools::ScopedFs::with_shared_scope( self.scope.clone(), - self.cwd.clone(), + local.cwd.clone(), )); let mut out = Vec::new(); for seg in segments { @@ -2549,11 +2608,13 @@ impl Worker { auto_read_budget, ))); - // Build an independent compact worker. Scope and cwd are shared - // with the main Worker (reads go through the same policy) but the - // Tracker is fresh — compact-time reads must not pollute the - // main session's recency list, which feeds `default_refs` above. - let scoped_fs = tools::ScopedFs::with_shared_scope(self.scope.clone(), self.cwd.clone()); + // Build an independent compact worker. When the main Worker has local + // filesystem authority, compact-time reads go through the same scope + // and cwd policy. No-workdir Workers deliberately omit compact-time + // filesystem tools as well. + let scoped_fs = self + .local_working_directory() + .map(|local| tools::ScopedFs::with_shared_scope(self.scope.clone(), local.cwd.clone())); let summary_tracker = tools::Tracker::new(); let summary_client: Box = self.build_compactor_client()?; let summary_system_prompt = self @@ -2591,10 +2652,12 @@ impl Worker { // Tools: read_file (shared scope, fresh tracker), bounded session // history exploration, and compact-specific tools that populate `ctx`. let compact_target_items = Arc::new(items_to_summarise.clone()); - summary_worker.register_tool(tools::read_tool(scoped_fs.clone(), summary_tracker)); + if let Some(scoped_fs) = scoped_fs.clone() { + summary_worker.register_tool(tools::read_tool(scoped_fs.clone(), summary_tracker)); + summary_worker.register_tool(mark_read_required_tool(scoped_fs, ctx.clone())); + } summary_worker.register_tool(search_session_log_tool(compact_target_items.clone())); summary_worker.register_tool(read_session_items_tool(compact_target_items)); - summary_worker.register_tool(mark_read_required_tool(scoped_fs.clone(), ctx.clone())); summary_worker.register_tool(add_reference_tool(ctx.clone())); summary_worker.register_tool(write_summary_tool(ctx.clone())); @@ -2671,8 +2734,12 @@ impl Worker { // logged and skipped inside `render_auto_read` rather than // aborting compaction — a missing / moved file should not fail // the whole compact. - let auto_read_messages = - WorkerFsView::new(scoped_fs.clone()).render_auto_read(&final_ctx.read_required); + let auto_read_messages = scoped_fs + .clone() + .map(|scoped_fs| { + WorkerFsView::new(scoped_fs).render_auto_read(&final_ctx.read_required) + }) + .unwrap_or_default(); // Reference list as a single system message; omitted when empty. let reference_message = (!final_ctx.references.is_empty()).then(|| { @@ -3824,7 +3891,8 @@ where loader: PromptLoader, ) -> Result { let cwd = current_cwd()?; - Self::from_manifest_with_context(manifest, store, loader, cwd.clone(), cwd).await + let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone()); + Self::from_manifest_with_context(manifest, store, loader, cwd, authority).await } pub async fn from_manifest_with_context( @@ -3832,14 +3900,14 @@ where store: St, loader: PromptLoader, workspace_root: PathBuf, - cwd: PathBuf, + filesystem_authority: WorkerFilesystemAuthority, ) -> Result { let mut common = prepare_worker_common_with_context( &manifest, &loader, /* parse_template */ true, workspace_root, - cwd, + filesystem_authority, manifest.scope.clone(), )?; let skill_shadows = std::mem::take(&mut common.skill_shadows); @@ -3878,7 +3946,7 @@ where store, worker_metadata_writer, segment_state: SegmentState::new(session_id, segment_id, 0), - cwd: common.cwd, + filesystem_authority: common.filesystem_authority, workspace_root: common.workspace_root, scope: SharedScope::new(common.scope), delegation_scope: common.delegation_scope, @@ -3938,13 +4006,14 @@ where callback_socket: PathBuf, ) -> Result { let cwd = current_cwd()?; + let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone()); Self::from_manifest_spawned_with_context( manifest, store, loader, callback_socket, - cwd.clone(), cwd, + authority, ) .await } @@ -3955,14 +4024,14 @@ where loader: PromptLoader, callback_socket: PathBuf, workspace_root: PathBuf, - cwd: PathBuf, + filesystem_authority: WorkerFilesystemAuthority, ) -> Result { let mut common = prepare_worker_common_with_context( &manifest, &loader, /* parse_template */ true, workspace_root, - cwd, + filesystem_authority, manifest.scope.clone(), )?; let skill_shadows = std::mem::take(&mut common.skill_shadows); @@ -3988,7 +4057,7 @@ where store, worker_metadata_writer, segment_state: SegmentState::new(session_id, segment_id, 0), - cwd: common.cwd, + filesystem_authority: common.filesystem_authority, workspace_root: common.workspace_root, scope: SharedScope::new(common.scope), delegation_scope: common.delegation_scope, @@ -4044,13 +4113,14 @@ where loader: PromptLoader, ) -> Result { let cwd = current_cwd()?; + let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone()); Self::restore_from_worker_metadata_with_context( worker_name, manifest, store, loader, - cwd.clone(), cwd, + authority, ) .await } @@ -4061,7 +4131,7 @@ where store: St, loader: PromptLoader, workspace_root: PathBuf, - cwd: PathBuf, + filesystem_authority: WorkerFilesystemAuthority, ) -> Result { let metadata = store @@ -4092,7 +4162,7 @@ where store, loader, workspace_root, - cwd, + filesystem_authority, ) .await } @@ -4122,14 +4192,9 @@ where loader: PromptLoader, ) -> Result { let cwd = current_cwd()?; + let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone()); Self::restore_from_manifest_with_context( - session_id, - segment_id, - manifest, - store, - loader, - cwd.clone(), - cwd, + session_id, segment_id, manifest, store, loader, cwd, authority, ) .await } @@ -4141,7 +4206,7 @@ where store: St, loader: PromptLoader, workspace_root: PathBuf, - cwd: PathBuf, + filesystem_authority: WorkerFilesystemAuthority, ) -> Result { // Read raw entries once so we can both reconstruct state and // seed the broadcast sink's mirror with the same prefix that @@ -4159,7 +4224,7 @@ where &loader, /* parse_template */ false, workspace_root, - cwd, + filesystem_authority, scope_config, )?; let skill_shadows = std::mem::take(&mut common.skill_shadows); @@ -4223,7 +4288,7 @@ where store, worker_metadata_writer, segment_state: SegmentState::new(session_id, segment_id, state.entries_count), - cwd: common.cwd, + filesystem_authority: common.filesystem_authority, workspace_root: common.workspace_root, scope: SharedScope::new(common.scope), delegation_scope: common.delegation_scope, @@ -4914,7 +4979,7 @@ pub enum WorkerError { /// [`prepare_worker_common_with_context`] from the resolved manifest and then split into Worker /// fields. struct WorkerCommon { - cwd: PathBuf, + filesystem_authority: WorkerFilesystemAuthority, workspace_root: PathBuf, scope: Scope, delegation_scope: DelegationScope, @@ -5003,7 +5068,7 @@ fn prepare_worker_common_with_context( loader: &PromptLoader, parse_template: bool, workspace_root: PathBuf, - cwd: PathBuf, + filesystem_authority: WorkerFilesystemAuthority, scope_config: ScopeConfig, ) -> Result { let workspace_root = std::fs::canonicalize(&workspace_root).map_err(|source| { @@ -5012,10 +5077,23 @@ fn prepare_worker_common_with_context( source, } })?; - let cwd = std::fs::canonicalize(&cwd).map_err(|source| WorkerError::InvalidCwd { - cwd: cwd.clone(), - source, - })?; + let filesystem_authority = match filesystem_authority { + WorkerFilesystemAuthority::None => WorkerFilesystemAuthority::None, + WorkerFilesystemAuthority::Local(local) => { + let root = std::fs::canonicalize(&local.root).map_err(|source| { + WorkerError::InvalidWorkspaceRoot { + workspace_root: local.root.clone(), + source, + } + })?; + let cwd = + std::fs::canonicalize(&local.cwd).map_err(|source| WorkerError::InvalidCwd { + cwd: local.cwd.clone(), + source, + })?; + WorkerFilesystemAuthority::Local(LocalWorkingDirectory { root, cwd }) + } + }; let mut scope_config = scope_config; if let Some(mem) = manifest.memory.as_ref() { let layout = memory::WorkspaceLayout::resolve(mem, &workspace_root); @@ -5026,7 +5104,14 @@ fn prepare_worker_common_with_context( } scope_config.allow.extend(skill_dir_read_rules(manifest)); let scope = Scope::from_config(&scope_config).map_err(WorkerError::Scope)?; - prepare_worker_common_from_scope(manifest, loader, parse_template, workspace_root, cwd, scope) + prepare_worker_common_from_scope( + manifest, + loader, + parse_template, + workspace_root, + filesystem_authority, + scope, + ) } fn prepare_worker_common_from_scope( @@ -5034,14 +5119,18 @@ fn prepare_worker_common_from_scope( loader: &PromptLoader, parse_template: bool, workspace_root: PathBuf, - cwd: PathBuf, + filesystem_authority: WorkerFilesystemAuthority, scope: Scope, ) -> Result { if !scope.is_readable(&workspace_root) { return Err(WorkerError::WorkspaceRootOutsideScope { workspace_root }); } - if !scope.is_readable(&cwd) { - return Err(WorkerError::CwdOutsideScope { cwd }); + if let Some(local) = filesystem_authority.as_local() { + if !scope.is_readable(&local.cwd) { + return Err(WorkerError::CwdOutsideScope { + cwd: local.cwd.clone(), + }); + } } let delegation_scope = DelegationScope::from_config(&manifest.delegation_scope).map_err(WorkerError::Scope)?; @@ -5070,7 +5159,7 @@ fn prepare_worker_common_from_scope( }; Ok(WorkerCommon { - cwd, + filesystem_authority, workspace_root, scope, delegation_scope, @@ -5174,7 +5263,7 @@ mod spawned_context_tests { &PromptLoader::builtins_only(), false, workspace_root.clone(), - cwd.clone(), + WorkerFilesystemAuthority::local(workspace_root.clone(), cwd.clone()), manifest.scope.clone(), ) .unwrap(); @@ -5183,7 +5272,10 @@ mod spawned_context_tests { common.workspace_root, workspace_root.canonicalize().unwrap() ); - assert_eq!(common.cwd, cwd.canonicalize().unwrap()); + assert_eq!( + common.filesystem_authority.as_local().unwrap().cwd, + cwd.canonicalize().unwrap() + ); assert_eq!( common.memory_layout.as_ref().unwrap().root(), workspace_root.canonicalize().unwrap() @@ -5204,7 +5296,7 @@ mod spawned_context_tests { &PromptLoader::builtins_only(), false, workspace_root.clone(), - cwd.clone(), + WorkerFilesystemAuthority::local(workspace_root.clone(), cwd.clone()), ScopeConfig { allow: vec![ScopeRule { target: cwd.clone(), @@ -5242,7 +5334,7 @@ mod spawned_context_tests { &PromptLoader::builtins_only(), false, workspace_root.clone(), - cwd.clone(), + WorkerFilesystemAuthority::local(workspace_root.clone(), cwd.clone()), ScopeConfig { allow: vec![ScopeRule { target: workspace_root.clone(), @@ -5703,9 +5795,17 @@ mod build_summary_prompt_tests { let cwd = dir.path().join("workspace"); std::fs::create_dir_all(&cwd).unwrap(); let scope = Scope::writable(&cwd).unwrap(); - let mut worker = Worker::new(manifest, Engine::new(NoopClient), store, cwd, scope) - .await - .unwrap(); + let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone()); + let mut worker = Worker::new( + manifest, + Engine::new(NoopClient), + store, + cwd.clone(), + authority, + scope, + ) + .await + .unwrap(); worker.ensure_segment_head().unwrap(); (dir, worker) } @@ -5851,9 +5951,17 @@ mod build_summary_prompt_tests { let cwd = dir.path().join("workspace"); std::fs::create_dir_all(&cwd).unwrap(); let scope = Scope::writable(&cwd).unwrap(); - let mut worker = Worker::new(manifest, Engine::new(NoopClient), store, cwd, scope) - .await - .unwrap(); + let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone()); + let mut worker = Worker::new( + manifest, + Engine::new(NoopClient), + store, + cwd.clone(), + authority, + scope, + ) + .await + .unwrap(); worker.ensure_segment_head().unwrap(); worker.wire_history_persistence(); @@ -5978,9 +6086,17 @@ mod build_summary_prompt_tests { let mut manifest = minimal_manifest_with_skills(vec![]); manifest.memory = memory_config; let scope = Scope::writable(&cwd).unwrap(); - let mut worker = Worker::new(manifest, Engine::new(NoopClient), store, cwd.clone(), scope) - .await - .unwrap(); + let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone()); + let mut worker = Worker::new( + manifest, + Engine::new(NoopClient), + store, + cwd.clone(), + authority, + scope, + ) + .await + .unwrap(); worker.memory_layout = worker .manifest .memory diff --git a/crates/worker/tests/compact_events_test.rs b/crates/worker/tests/compact_events_test.rs index ab6d1fba..2e3da105 100644 --- a/crates/worker/tests/compact_events_test.rs +++ b/crates/worker/tests/compact_events_test.rs @@ -164,9 +164,16 @@ async fn make_worker_with_manifest( std::mem::forget(pwd_tmp); let worker = Engine::new(client); - let mut worker = Worker::new(manifest, worker, store, pwd, scope) - .await - .unwrap(); + let mut worker = Worker::new( + manifest, + worker, + store, + pwd.clone(), + worker::WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()), + scope, + ) + .await + .unwrap(); worker.enable_worker_metadata_write_through().unwrap(); worker } diff --git a/crates/worker/tests/consolidation_test.rs b/crates/worker/tests/consolidation_test.rs index 4b68c64e..1d71fe8d 100644 --- a/crates/worker/tests/consolidation_test.rs +++ b/crates/worker/tests/consolidation_test.rs @@ -170,9 +170,16 @@ async fn make_worker_with( let scope = worker::Scope::writable(&pwd).unwrap(); let worker = Engine::new(client); - Worker::new(manifest, worker, store, pwd, scope) - .await - .unwrap() + Worker::new( + manifest, + worker, + store, + pwd.clone(), + worker::WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()), + scope, + ) + .await + .unwrap() } fn write_n_staging(layout: &WorkspaceLayout, n: usize) -> Vec { diff --git a/crates/worker/tests/controller_test.rs b/crates/worker/tests/controller_test.rs index 097967e2..429209a3 100644 --- a/crates/worker/tests/controller_test.rs +++ b/crates/worker/tests/controller_test.rs @@ -12,7 +12,10 @@ use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use session_store::{CombinedStore, FsWorkerStore}; use session_store::{FsStore, LogEntry}; -use worker::{Event, Method, Worker, WorkerController, WorkerHandle, WorkerManifest, WorkerStatus}; +use worker::{ + Event, Method, Worker, WorkerController, WorkerFilesystemAuthority, WorkerHandle, + WorkerManifest, WorkerStatus, +}; type TestStore = CombinedStore; @@ -186,7 +189,8 @@ async fn make_worker_with_pwd_and_manifest( std::mem::forget(pwd_tmp); let worker = Engine::new(client); - let worker = Worker::new(manifest, worker, store, pwd.clone(), scope) + let authority = WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()); + let worker = Worker::new(manifest, worker, store, pwd.clone(), authority, scope) .await .unwrap(); (worker, pwd) diff --git a/crates/worker/tests/session_metrics_test.rs b/crates/worker/tests/session_metrics_test.rs index d932a1a2..950b90f5 100644 --- a/crates/worker/tests/session_metrics_test.rs +++ b/crates/worker/tests/session_metrics_test.rs @@ -190,9 +190,16 @@ async fn make_worker( let mut worker = Engine::new(client); worker.register_tool(big_content_tool_definition(tool_name)); - let worker = Worker::new(manifest, worker, store, pwd, scope) - .await - .unwrap(); + let worker = Worker::new( + manifest, + worker, + store, + pwd.clone(), + worker::WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()), + scope, + ) + .await + .unwrap(); (worker, store_tmp, pwd_tmp) } @@ -453,9 +460,16 @@ async fn metric_write_failure_emits_warn_alert_and_does_not_abort_run() { // the failure path: at least one metric attempts to write. let client = MockClient::new(vec![text_response_with_cache("hi", 0, 0)]); let worker = Engine::new(client); - let mut worker = Worker::new(manifest, worker, store.clone(), pwd, scope) - .await - .unwrap(); + let mut worker = Worker::new( + manifest, + worker, + store.clone(), + pwd.clone(), + worker::WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()), + scope, + ) + .await + .unwrap(); let (tx, mut rx) = broadcast::channel::(64); let alerter = worker::Alerter::new(tx); @@ -522,9 +536,16 @@ permission = "write" let pwd = pwd_tmp.path().to_path_buf(); let scope = worker::Scope::writable(&pwd).unwrap(); let worker = Engine::new(client); - let mut worker = Worker::new(manifest, worker, store.clone(), pwd, scope) - .await - .unwrap(); + let mut worker = Worker::new( + manifest, + worker, + store.clone(), + pwd.clone(), + worker::WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()), + scope, + ) + .await + .unwrap(); let session_id = worker.session_id(); let segment_id = worker.segment_id(); worker.run_text("hello").await.unwrap(); diff --git a/crates/worker/tests/system_prompt_template_test.rs b/crates/worker/tests/system_prompt_template_test.rs index f4422a88..0094b6be 100644 --- a/crates/worker/tests/system_prompt_template_test.rs +++ b/crates/worker/tests/system_prompt_template_test.rs @@ -123,7 +123,15 @@ async fn make_worker_with_body( std::mem::forget(user_prompts_tmp); let worker = Engine::new(client); - let mut worker = Worker::new(manifest, worker, store, pwd.clone(), scope).await?; + let mut worker = Worker::new( + manifest, + worker, + store, + pwd.clone(), + worker::WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()), + scope, + ) + .await?; let template = SystemPromptTemplate::parse("$user/test", loader) .map_err(|source| WorkerError::InvalidSystemPromptTemplate { source })?; From 44410a0fdd5398416282c1c88f9d9306d62622fd Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 11 Jul 2026 07:31:20 +0900 Subject: [PATCH 03/15] ticket: record worker filesystem authority implementation --- .yoi/tickets/00001KX6Y2A9Q/item.md | 2 +- .yoi/tickets/00001KX6Y2A9Q/thread.md | 40 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KX6Y2A9Q/item.md b/.yoi/tickets/00001KX6Y2A9Q/item.md index f50d8f9b..66e6f816 100644 --- a/.yoi/tickets/00001KX6Y2A9Q/item.md +++ b/.yoi/tickets/00001KX6Y2A9Q/item.md @@ -3,7 +3,7 @@ title: 'Implement WorkerFilesystemAuthority for no-workdir Workers' state: 'inprogress' priority: 'P1' created_at: '2026-07-10T21:13:49Z' -updated_at: '2026-07-10T21:51:33Z' +updated_at: '2026-07-10T22:31:16Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-10T21:51:00Z' diff --git a/.yoi/tickets/00001KX6Y2A9Q/thread.md b/.yoi/tickets/00001KX6Y2A9Q/thread.md index 350aec5e..47a50013 100644 --- a/.yoi/tickets/00001KX6Y2A9Q/thread.md +++ b/.yoi/tickets/00001KX6Y2A9Q/thread.md @@ -101,3 +101,43 @@ Implementation focus: Dashboard queue authorization was inspected, no blockers were found, and an accepted plan was recorded. Moving queued Ticket to inprogress before creating implementation worktree or spawning Pods. --- + + + +## Implementation report + +Implementation progress report: + +- Coder Pod `yoi-coder-00001KX6Y2A9Q-worker-fs-authority` completed implementation in `/home/hare/Projects/yoi/.worktree/00001KX6Y2A9Q-worker-filesystem-authority` on branch `work/00001KX6Y2A9Q-worker-filesystem-authority`。 +- Implementation commit: `b50b94612a1185dc5bdc935d83d63f9d6ace826b feat: add explicit worker filesystem authority`。 +- Orchestrator inspected the worktree status, branch log, commit stats, `git diff --check db3a7165..HEAD`, and grep checks for `worker.cwd()` / Worker `fn cwd`; worktree was clean, diff check passed, and no matches were found。 + +Coder-reported implementation summary: +- Added explicit `WorkerFilesystemAuthority::{None, Local(LocalWorkingDirectory)}` boundary。 +- Removed Worker-level `cwd: PathBuf` and `worker.cwd()` accessor。 +- Local Workers carry authority `root` and `cwd` separately。 +- No-workdir embedded Worker spawn passes `WorkerFilesystemAuthority::None` explicitly instead of falling back to runtime/process cwd。 +- Controller tool registration branches on local filesystem authority: + - Local Workers register `Read`, `Write`, `Edit`, `Glob`, `Grep`, `Bash` and get fs view/completions。 + - No-workdir Workers do not construct/register filesystem or Bash tools。 +- Ticket / worker-spawn / peer-discovery cwd-dependent paths now require local filesystem authority instead of using workspace root as fallback。 +- Tests added/updated for no-workdir tool omission, no cwd fallback, and local/materialized Worker filesystem/Bash behavior。 + +Coder-reported validation passed: +- `cargo test -p worker --lib --tests` +- `cargo test -p worker-runtime --features ws-server,fs-store` +- `cargo test -p yoi-workspace-server --lib` +- `cargo check -p yoi` +- `git diff --check` +- `cargo run -p yoi -- ticket doctor` +- `nix build .#yoi --no-link` +- `rg "worker\\.cwd\\(" crates || true`: no matches。 +- `rg "pub fn cwd\\(" crates/worker/src/worker.rs || true`: no Worker cwd accessor remains。 + +Not run by Coder: +- `cd web/workspace && deno task check && deno task test` because web/API/types were not touched。 + +Next action: +- Route to external Reviewer Pod before merge/close decisions。 + +--- From 19a52742a36fef862d4a77d8de405321673060ff Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 11 Jul 2026 07:40:38 +0900 Subject: [PATCH 04/15] ticket: approve worker filesystem authority implementation --- .yoi/tickets/00001KX6Y2A9Q/item.md | 2 +- .yoi/tickets/00001KX6Y2A9Q/thread.md | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KX6Y2A9Q/item.md b/.yoi/tickets/00001KX6Y2A9Q/item.md index 66e6f816..d85f9730 100644 --- a/.yoi/tickets/00001KX6Y2A9Q/item.md +++ b/.yoi/tickets/00001KX6Y2A9Q/item.md @@ -3,7 +3,7 @@ title: 'Implement WorkerFilesystemAuthority for no-workdir Workers' state: 'inprogress' priority: 'P1' created_at: '2026-07-10T21:13:49Z' -updated_at: '2026-07-10T22:31:16Z' +updated_at: '2026-07-10T22:40:35Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-10T21:51:00Z' diff --git a/.yoi/tickets/00001KX6Y2A9Q/thread.md b/.yoi/tickets/00001KX6Y2A9Q/thread.md index 47a50013..d6d50c22 100644 --- a/.yoi/tickets/00001KX6Y2A9Q/thread.md +++ b/.yoi/tickets/00001KX6Y2A9Q/thread.md @@ -141,3 +141,28 @@ Next action: - Route to external Reviewer Pod before merge/close decisions。 --- + + + +## Review: approve + +External review result: approve + +Evidence: +- `WorkerFilesystemAuthority::{None, Local(LocalWorkingDirectory)}` and separate `root`/`cwd` are present in `crates/worker/src/worker.rs`; `Worker` now stores `filesystem_authority` plus separate `workspace_root`, with no `cwd: PathBuf` Worker field。 +- Core filesystem/Bash registration is gated on `worker.local_working_directory()` in `crates/worker/src/controller.rs:619-636`; no-local Workers return `(None, None)` and do not construct/register `Read`/`Write`/`Edit`/`Glob`/`Grep`/`Bash`。 +- Ticket and worker-spawn surfaces require local authority rather than falling back to `workspace_root` (`controller.rs:647-667`, `719-733`)。 +- Embedded runtime creates `WorkerFilesystemAuthority::Local(binding.root, binding.cwd)` only when a working directory binding exists, otherwise `WorkerFilesystemAuthority::None` (`crates/worker-runtime/src/worker_backend.rs:297-310`)。 +- Tests cover no-workdir omission and no cwd fallback via empty observed materialized cwds plus forbidden core tool names (`worker_backend.rs:1133-1201`), and materialized local Workers exposing core tools with materialized cwd (`worker_backend.rs:1203-1260`)。 +- Grep check found no `worker.cwd()` / Worker `fn cwd`; the only remaining `pub fn cwd` hit is `WorkingDirectory` binding API, not `Worker`。 + +Blockers: none。 + +Validation performed by reviewer: +- Read-only ticket/diff/source/test inspection and grep checks。 +- Reviewer did not rerun cargo/nix tests; coder-reported validation covers the requested test/build set。 + +Non-blocking note: +- Workspace-context surfaces such as memory/MCP/resident prompt loading can still be backed by `workspace_root` when enabled; this was treated as outside this Ticket’s focused core filesystem/Bash authority gate, but may need follow-up if the intended invariant becomes “no local file reads of any kind” for no-workdir Workers。 + +--- From 0f2d7263a82f27731937518154fd62ca8efe07c7 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 11 Jul 2026 07:45:08 +0900 Subject: [PATCH 05/15] ticket: close worker filesystem authority work --- .yoi/tickets/00001KX6Y2A9Q/item.md | 4 +- .yoi/tickets/00001KX6Y2A9Q/resolution.md | 32 +++++++++ .yoi/tickets/00001KX6Y2A9Q/thread.md | 90 ++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 .yoi/tickets/00001KX6Y2A9Q/resolution.md diff --git a/.yoi/tickets/00001KX6Y2A9Q/item.md b/.yoi/tickets/00001KX6Y2A9Q/item.md index d85f9730..9e612c7d 100644 --- a/.yoi/tickets/00001KX6Y2A9Q/item.md +++ b/.yoi/tickets/00001KX6Y2A9Q/item.md @@ -1,9 +1,9 @@ --- title: 'Implement WorkerFilesystemAuthority for no-workdir Workers' -state: 'inprogress' +state: 'closed' priority: 'P1' created_at: '2026-07-10T21:13:49Z' -updated_at: '2026-07-10T22:40:35Z' +updated_at: '2026-07-10T22:45:01Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-10T21:51:00Z' diff --git a/.yoi/tickets/00001KX6Y2A9Q/resolution.md b/.yoi/tickets/00001KX6Y2A9Q/resolution.md new file mode 100644 index 00000000..6bc8d986 --- /dev/null +++ b/.yoi/tickets/00001KX6Y2A9Q/resolution.md @@ -0,0 +1,32 @@ +Worker filesystem authority の明示境界を実装・レビュー・merge・検証した。 + +実装内容: +- `WorkerFilesystemAuthority::{None, Local(LocalWorkingDirectory)}` を追加。 +- `LocalWorkingDirectory` は filesystem authority `root` と default `cwd` を分離して保持する。 +- Worker-level `cwd: PathBuf` field と `worker.cwd()` accessor を削除。 +- core filesystem/Bash tool registration は local filesystem authority がある場合だけ行うようにした。 +- No-workdir Worker では `Read` / `Write` / `Edit` / `Glob` / `Grep` / `Bash` を construct/register/model-visible にしない。 +- Embedded no-workdir Worker spawn は Runtime/process cwd fallback ではなく `WorkerFilesystemAuthority::None` を明示的に渡す。 +- Ticket / worker-spawn / peer-discovery など cwd 依存箇所は local filesystem authority 必須に整理し、workspace root fallback を authority として使わないようにした。 +- tests で no-workdir tool omission、no cwd fallback、local/materialized Worker の filesystem/Bash behavior を確認。 + +Review: +- Reviewer approved with no blockers。 +- Evidence included explicit authority type, no Worker cwd field/accessor, filesystem/Bash registration gate, embedded Runtime authority construction, and tests。 +- Non-blocking note: workspace-context surfaces such as memory/MCP/resident prompt loading can still be backed by `workspace_root` when enabled; this is outside the focused core filesystem/Bash authority gate and may become follow-up if the invariant expands to “no local file reads of any kind”。 + +Merge / validation: +- Merge commit: `c29e9135 merge: worker filesystem authority`。 +- Final validation passed: + - `rg "worker\\.cwd\\(" crates || true` + - `rg "pub fn cwd\\(" crates/worker/src/worker.rs || true` + - `git diff --check` + - `cargo test -p worker --lib --tests` + - `cargo test -p worker-runtime --features ws-server,fs-store` + - `cargo test -p yoi-workspace-server --lib` + - `cargo check -p yoi` + - `cd web/workspace && deno task check` + - `cd web/workspace && deno task test` + - `yoi ticket doctor` + - `nix build .#yoi --no-link` +- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/worker-fs-authority-final-validation-1783723253.txt` \ No newline at end of file diff --git a/.yoi/tickets/00001KX6Y2A9Q/thread.md b/.yoi/tickets/00001KX6Y2A9Q/thread.md index d6d50c22..3e113a1f 100644 --- a/.yoi/tickets/00001KX6Y2A9Q/thread.md +++ b/.yoi/tickets/00001KX6Y2A9Q/thread.md @@ -166,3 +166,93 @@ Non-blocking note: - Workspace-context surfaces such as memory/MCP/resident prompt loading can still be backed by `workspace_root` when enabled; this was treated as outside this Ticket’s focused core filesystem/Bash authority gate, but may need follow-up if the intended invariant becomes “no local file reads of any kind” for no-workdir Workers。 --- + + + +## Implementation report + +Merge / final validation report: + +- Reviewer approved the implementation with no blockers。 +- Merged branch `work/00001KX6Y2A9Q-worker-filesystem-authority` into orchestration branch with merge commit `c29e9135 merge: worker filesystem authority`。 +- Final validation was rerun in `/home/hare/Projects/yoi/.worktree/orchestration` after merge。 + +Validation passed: +- `rg "worker\\.cwd\\(" crates || true`: no matches。 +- `rg "pub fn cwd\\(" crates/worker/src/worker.rs || true`: no Worker cwd accessor matches。 +- `git diff --check` +- `cargo test -p worker --lib --tests` +- `cargo test -p worker-runtime --features ws-server,fs-store` +- `cargo test -p yoi-workspace-server --lib` +- `cargo check -p yoi` +- `cd web/workspace && deno task check` +- `cd web/workspace && deno task test` +- `yoi ticket doctor` +- `nix build .#yoi --no-link` + +Validation log: +- `/run/user/1000/yoi/yoi-orchestrator/bash-output/worker-fs-authority-final-validation-1783723253.txt` + +Reviewed/merged implementation commit: +- `b50b9461 feat: add explicit worker filesystem authority` + +Non-blocking reviewer note: +- Workspace-context surfaces such as memory/MCP/resident prompt loading can still be backed by `workspace_root` when enabled; this was treated as outside this Ticket’s focused core filesystem/Bash authority gate, but may be follow-up material if the intended invariant becomes “no local file reads of any kind” for no-workdir Workers。 + +--- + + + +## State changed + +Implementation was reviewed, merged into orchestration branch, and final validation passed. Moving to done before closure. + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +Worker filesystem authority の明示境界を実装・レビュー・merge・検証した。 + +実装内容: +- `WorkerFilesystemAuthority::{None, Local(LocalWorkingDirectory)}` を追加。 +- `LocalWorkingDirectory` は filesystem authority `root` と default `cwd` を分離して保持する。 +- Worker-level `cwd: PathBuf` field と `worker.cwd()` accessor を削除。 +- core filesystem/Bash tool registration は local filesystem authority がある場合だけ行うようにした。 +- No-workdir Worker では `Read` / `Write` / `Edit` / `Glob` / `Grep` / `Bash` を construct/register/model-visible にしない。 +- Embedded no-workdir Worker spawn は Runtime/process cwd fallback ではなく `WorkerFilesystemAuthority::None` を明示的に渡す。 +- Ticket / worker-spawn / peer-discovery など cwd 依存箇所は local filesystem authority 必須に整理し、workspace root fallback を authority として使わないようにした。 +- tests で no-workdir tool omission、no cwd fallback、local/materialized Worker の filesystem/Bash behavior を確認。 + +Review: +- Reviewer approved with no blockers。 +- Evidence included explicit authority type, no Worker cwd field/accessor, filesystem/Bash registration gate, embedded Runtime authority construction, and tests。 +- Non-blocking note: workspace-context surfaces such as memory/MCP/resident prompt loading can still be backed by `workspace_root` when enabled; this is outside the focused core filesystem/Bash authority gate and may become follow-up if the invariant expands to “no local file reads of any kind”。 + +Merge / validation: +- Merge commit: `c29e9135 merge: worker filesystem authority`。 +- Final validation passed: + - `rg "worker\\.cwd\\(" crates || true` + - `rg "pub fn cwd\\(" crates/worker/src/worker.rs || true` + - `git diff --check` + - `cargo test -p worker --lib --tests` + - `cargo test -p worker-runtime --features ws-server,fs-store` + - `cargo test -p yoi-workspace-server --lib` + - `cargo check -p yoi` + - `cd web/workspace && deno task check` + - `cd web/workspace && deno task test` + - `yoi ticket doctor` + - `nix build .#yoi --no-link` +- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/worker-fs-authority-final-validation-1783723253.txt` + +--- From 83c9d8844da476753d81c3949fb52b5804936ccf Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 11 Jul 2026 08:38:59 +0900 Subject: [PATCH 06/15] ticket: route embedded no-workdir worker work --- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KX6WVNPD/item.md | 4 +- .yoi/tickets/00001KX6WVNPD/thread.md | 86 +++++++++++++++++++ .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KX6Y6ZEA/item.md | 2 +- .yoi/tickets/00001KX6Y6ZEA/thread.md | 26 ++++++ 6 files changed, 117 insertions(+), 3 deletions(-) diff --git a/.yoi/tickets/00001KX6WVNPD/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KX6WVNPD/artifacts/orchestration-plan.jsonl index c5b79f64..860ec394 100644 --- a/.yoi/tickets/00001KX6WVNPD/artifacts/orchestration-plan.jsonl +++ b/.yoi/tickets/00001KX6WVNPD/artifacts/orchestration-plan.jsonl @@ -1,2 +1,3 @@ {"id":"orch-plan-20260710-220020-1","ticket_id":"00001KX6WVNPD","kind":"after","related_ticket":"00001KX6Y2A9Q","note":"Ticket has explicit typed `depends_on` relation to `00001KX6Y2A9Q`. That prerequisite is currently `inprogress` implementing the WorkerFilesystemAuthority boundary this Ticket relies on. Start only after `00001KX6Y2A9Q` is approved, merged, and closed or an explicit combined-work decision is made.","author":"orchestrator","at":"2026-07-10T22:00:20Z"} {"id":"orch-plan-20260710-220024-2","ticket_id":"00001KX6WVNPD","kind":"waiting_capacity_note","note":"Dashboard queue routing inspected. This Ticket is otherwise concrete, but typed relation metadata reports blocker `00001KX6Y2A9Q` in state `inprogress`. That prerequisite introduces/removes the Worker filesystem-authority/cwd boundary used by this Ticket. Starting now would duplicate or conflict with that refactor. Leave queued and re-route after prerequisite merge/close.","author":"orchestrator","at":"2026-07-10T22:00:24Z"} +{"id":"orch-plan-20260710-233758-3","ticket_id":"00001KX6WVNPD","kind":"accepted_plan","accepted_plan":{"summary":"Implement embedded Runtime no-workdir Worker creation policy using the merged `WorkerFilesystemAuthority` boundary: embedded Runtime may create Workers without selected workdir using `WorkerFilesystemAuthority::None`, non-embedded/local-authority paths preserve workdir requirements, no-workdir Workers expose no filesystem/Bash tool surface, and UI/API make workdir optional only for embedded Runtime.","branch":"work/00001KX6WVNPD-embedded-no-workdir-policy","worktree":"/home/hare/Projects/yoi/.worktree/00001KX6WVNPD-embedded-no-workdir-policy","role_plan":"Orchestrator accepts queued Ticket after prerequisite `00001KX6Y2A9Q` was approved, merged, validated, and closed. Use sibling Coder for implementation in a dedicated worktree, then sibling Reviewer for read-only review. Orchestrator retains merge/final-validation/close/worktree cleanup authority. Per current user instruction, do not call StopPod during cleanup."},"author":"orchestrator","at":"2026-07-10T23:37:58Z"} diff --git a/.yoi/tickets/00001KX6WVNPD/item.md b/.yoi/tickets/00001KX6WVNPD/item.md index 1fd6e76b..03ad1cc5 100644 --- a/.yoi/tickets/00001KX6WVNPD/item.md +++ b/.yoi/tickets/00001KX6WVNPD/item.md @@ -1,9 +1,9 @@ --- title: 'Embedded no-workdir Worker authority policy' -state: 'queued' +state: 'inprogress' priority: 'P1' created_at: '2026-07-10T20:52:43Z' -updated_at: '2026-07-10T22:00:33Z' +updated_at: '2026-07-10T23:38:34Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-10T22:00:07Z' diff --git a/.yoi/tickets/00001KX6WVNPD/thread.md b/.yoi/tickets/00001KX6WVNPD/thread.md index 6d4a9a6c..99048fe7 100644 --- a/.yoi/tickets/00001KX6WVNPD/thread.md +++ b/.yoi/tickets/00001KX6WVNPD/thread.md @@ -88,3 +88,89 @@ Next action: - Escalate only if a human explicitly requests combined implementation in the same branch/worktree。 --- + + + +## Decision + +Routing decision: implementation_ready + +Reason: +- 前提 Ticket `00001KX6Y2A9Q` は approved / merged / final-validated / closed 済みで、`WorkerFilesystemAuthority::{None, Local}` と no-workdir core filesystem/Bash tool omission boundary が利用可能になった。 +- この Ticket は embedded Runtime の workdir 未指定 Worker 作成 policy と UI/API 制約に集中しており、目的・binding decision・受け入れ条件が具体的である。 +- `TicketRelationQuery(00001KX6WVNPD)` の blocker view は空で、残る outgoing `depends_on 00001KX6Y2A9Q` は closed prerequisite になっている。 +- `TicketOrchestrationPlanQuery(00001KX6WVNPD)` の既存 waiting note は prerequisite inprogress を理由にしたものだったため、現在は解消済み。 +- Orchestrator worktree は clean。既存 child implementation branch/worktree は見当たらない。 + +Evidence checked: +- Ticket body / thread / relations / orchestration plan。 +- Related closed Ticket `00001KX6Y2A9Q` resolution/review/validation evidence。 +- `TicketList(inprogress)`: 0 件。 +- `git status --short --branch` and `git worktree list` in `/home/hare/Projects/yoi/.worktree/orchestration`。 +- Current queue also contains related `00001KX6Y6ZEA`, but its Worker workspace-backend refactor touches overlapping Worker context/constructor surfaces; it will be held separately with a conflict/migration-boundary reason rather than started in parallel。 + +IntentPacket: + +Intent: +- Embedded Runtime 選択時に、workdir 未指定の guidance/conversation-oriented Worker を作成できるようにする。 +- Embedded no-workdir Worker は `WorkerFilesystemAuthority::None` として作られ、repository filesystem authority / cwd fallback / filesystem tools / Bash を持たない。 + +Binding decisions / invariants: +- no-workdir authority は merged `WorkerFilesystemAuthority::None` を使う。 +- workspace root / process cwd / runtime cwd / manifest override / default scope を filesystem authority fallback にしない。 +- filesystem tools (`Read`, `Write`, `Edit`, `Glob`, `Grep`) と `Bash` は no-workdir embedded Worker では construct/register/model-visible にしない。 +- Bash は filesystem sandbox ではないため、no-workdir embedded では登録しない。 +- non-embedded または local/filesystem-authority-required launch path では、既存の workdir 必須制約または明示 authority requirement を維持する。 +- Worker 直下の `cwd` property / `worker.cwd()` 前提に戻さない。 + +Requirements / acceptance criteria: +- UI/API 経由で embedded Runtime の workdir 未指定 Worker create ができる。 +- embedded + workdir ありは materialized binding を `WorkerFilesystemAuthority::Local` と Worker summary/detail に反映する。 +- embedded + workdir なしは `WorkerFilesystemAuthority::None` になり、model-visible tool surface に fs tools / Bash が出ないことをテストで確認できる。 +- `Glob` / `Grep` path 省略や `Bash` initial directory が no-workdir embedded の権限漏れにならない。 +- Browser/UI では workdir optional が embedded Runtime に限られ、non-embedded/local authority path の制約を緩めない。 + +Implementation latitude: +- API DTO / UI form model / runtime spawn request shape の具体的な整理は coder が既存 pattern に沿って選んでよい。 +- workdir 未指定状態の UI 表示文言・validation placement は、binding invariant を満たす範囲で local tactic としてよい。 +- tests の配置は worker-runtime / workspace-server / web の既存近接 test pattern に合わせてよい。 + +Escalate if: +- embedded no-workdir Worker に workspace API access を付けるために `WorkspaceBackend` / `WorkspaceClient` の先行導入が必須になる場合。 +- no-workdir Worker へ fs/Bash tool の error-only stub や empty-scope fallback を登録したくなる場合。 +- non-embedded Worker の workdir requirement を緩める必要が出る場合。 +- `WorkerFilesystemAuthority` の既存 invariant を変更する必要が出る場合。 + +Validation: +- `git diff --check` +- focused grep/check that no no-workdir path reintroduces `worker.cwd()` or local cwd fallback。 +- `cargo test -p worker --lib --tests` if worker crate is touched。 +- `cargo test -p worker-runtime --features ws-server,fs-store` +- `cargo test -p yoi-workspace-server --lib` +- `cargo check -p yoi` +- `cd web/workspace && deno task check && deno task test` if web/API/types are touched。 +- `yoi ticket doctor` +- `nix build .#yoi --no-link` + +Current code map / likely touch points: +- `crates/worker/src/worker.rs`, `crates/worker/src/controller.rs` for existing authority boundary verification only if needed。 +- `crates/worker-runtime/src/worker_backend.rs` / working-directory binding and embedded spawn paths。 +- `crates/workspace-server/src/server.rs` / API DTOs for Worker create/runtime options。 +- `web/workspace` worker creation UI/types if workdir optionality is exposed there。 + +Critical risks / reviewer focus: +- Hidden filesystem authority through workspace root / process cwd / runtime cwd fallback。 +- no-workdir Worker showing fs tools/Bash in model-visible schema。 +- empty-scope/error-only fs tool registration rather than no registration。 +- UI/API accidentally allowing non-embedded no-workdir Worker where local filesystem authority is still required。 +- regression of embedded workdir-present Worker status/tool cwd behavior。 + +--- + + + +## State changed + +Prerequisite `00001KX6Y2A9Q` is now closed and merged. This Ticket is accepted for implementation before creating a worktree or spawning role Pods. + +--- diff --git a/.yoi/tickets/00001KX6Y6ZEA/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KX6Y6ZEA/artifacts/orchestration-plan.jsonl index 00ce5b8c..f8d318b4 100644 --- a/.yoi/tickets/00001KX6Y6ZEA/artifacts/orchestration-plan.jsonl +++ b/.yoi/tickets/00001KX6Y6ZEA/artifacts/orchestration-plan.jsonl @@ -1,2 +1,3 @@ {"id":"orch-plan-20260710-220343-1","ticket_id":"00001KX6Y6ZEA","kind":"after","related_ticket":"00001KX6Y2A9Q","note":"Ticket has explicit typed `depends_on` relation to `00001KX6Y2A9Q`. The prerequisite WorkerFilesystemAuthority refactor is currently `inprogress` and establishes the filesystem-authority/workspace-boundary this Ticket builds on. Start only after the prerequisite is approved, merged, and closed or an explicit combined-work decision is made.","author":"orchestrator","at":"2026-07-10T22:03:43Z"} {"id":"orch-plan-20260710-220349-2","ticket_id":"00001KX6Y6ZEA","kind":"waiting_capacity_note","note":"Dashboard queue routing inspected. This Ticket is concrete but blocked by typed `depends_on` relation to `00001KX6Y2A9Q`, which is currently inprogress. Starting now would conflict with the Worker struct/context/constructor refactor and filesystem-authority boundary. Leave queued and re-route after prerequisite merge/close.","author":"orchestrator","at":"2026-07-10T22:03:49Z"} +{"id":"orch-plan-20260710-233841-3","ticket_id":"00001KX6Y6ZEA","kind":"waiting_capacity_note","note":"Queue review resumed after prerequisite `00001KX6Y2A9Q` closed. `00001KX6WVNPD` was accepted first because it is the smaller embedded no-workdir policy MVP and explicitly does not require WorkspaceBackend migration. This Ticket remains queued due migration-boundary/conflict risk: it removes Worker `workspace_root` and introduces WorkspaceBackend/WorkspaceClient surfaces in the same Worker context/constructor/runtime code that `00001KX6WVNPD` is now changing. Re-route after `00001KX6WVNPD` is reviewed/merged/closed, unless a human explicitly requests a combined branch.","author":"orchestrator","at":"2026-07-10T23:38:41Z"} diff --git a/.yoi/tickets/00001KX6Y6ZEA/item.md b/.yoi/tickets/00001KX6Y6ZEA/item.md index 13b2b3ae..d2054812 100644 --- a/.yoi/tickets/00001KX6Y6ZEA/item.md +++ b/.yoi/tickets/00001KX6Y6ZEA/item.md @@ -3,7 +3,7 @@ title: 'Replace Worker workspace_root path with WorkspaceBackend' state: 'queued' priority: 'P1' created_at: '2026-07-10T21:16:22Z' -updated_at: '2026-07-10T22:03:57Z' +updated_at: '2026-07-10T23:38:54Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-10T22:03:27Z' diff --git a/.yoi/tickets/00001KX6Y6ZEA/thread.md b/.yoi/tickets/00001KX6Y6ZEA/thread.md index 35576404..913df816 100644 --- a/.yoi/tickets/00001KX6Y6ZEA/thread.md +++ b/.yoi/tickets/00001KX6Y6ZEA/thread.md @@ -98,3 +98,29 @@ Next action: - Escalate only if a human explicitly requests combined implementation in the same branch/worktree。 --- + + + +## Decision + +Routing decision: hold_queued_due_migration_boundary_conflict + +Reason: +- 前提 `00001KX6Y2A9Q` は closed になったため dependency 自体は解消済み。 +- ただし、この routing pass では `00001KX6WVNPD` を先に受理した。 +- `00001KX6WVNPD` は embedded no-workdir policy MVP であり、WorkspaceBackend migration を必要としない小さい境界変更である。 +- この Ticket は Worker `workspace_root` path を `WorkspaceBackend` に置き換える refactor で、Worker struct/context/constructor/runtime spawn/UI API 周辺に触れる。 +- `00001KX6WVNPD` も同じ Worker create / embedded runtime / no-workdir authority surface に触るため、並行 branch で進めると conflict と review boundary 混濁の可能性が高い。 +- よって、この Ticket は `queued` のまま保持し、`00001KX6WVNPD` が reviewed/merged/closed になってから再 routing する。 + +Evidence checked: +- Ticket body / thread / relations / orchestration plan。 +- `TicketRelationQuery(00001KX6Y6ZEA)`: prerequisite `00001KX6Y2A9Q` は closed。 +- `TicketList(inprogress)`: routing pass 中に `00001KX6WVNPD` を accepted for implementation。 +- Orchestrator worktree status: clean。 + +Next action: +- `00001KX6WVNPD` の implementation/review/merge/close を完了後、この Ticket を再 routing する。 +- 人間が combined branch を明示希望する場合のみ、この待機判断を再検討する。 + +--- From 10267869ae0dfe02e186dc69fec14151926f5009 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 11 Jul 2026 08:59:19 +0900 Subject: [PATCH 07/15] feat: enforce embedded no-workdir worker policy --- crates/workspace-server/src/server.rs | 93 +++++++++++++++---- .../src/lib/workspace-sidebar/types.ts | 1 + .../workspace-sidebar/worker-launch.test.ts | 24 ++++- .../w/[workspaceId]/workers/new/+page.svelte | 39 ++++++-- 4 files changed, 127 insertions(+), 30 deletions(-) diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 60b210f9..ecfb012b 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -753,6 +753,7 @@ pub struct WorkerLaunchRuntimeOption { pub display_name: String, pub built_in: bool, pub can_spawn_worker: bool, + pub working_directory_required: bool, pub status: String, pub diagnostics: Vec, } @@ -2642,6 +2643,9 @@ async fn create_workspace_worker( relative_cwd: selection.relative_cwd, }); validate_working_directory_claim_for_browser(resolved_working_directory.as_ref())?; + if resolved_working_directory.is_none() { + reject_no_workdir_for_non_embedded_runtime(&request.runtime_id)?; + } let result = api .runtime .spawn_worker( @@ -2811,11 +2815,36 @@ struct RuntimeConfigBundleAvailabilityQuery { digest: String, } +fn reject_no_workdir_for_non_embedded_runtime( + runtime_id: &str, +) -> std::result::Result<(), ApiError> { + if runtime_id == EMBEDDED_WORKER_RUNTIME_ID { + return Ok(()); + } + Err(ApiError::with_diagnostics( + Error::RuntimeOperationFailed { + runtime_id: runtime_id.to_string(), + code: "workspace_worker_workdir_required".to_string(), + message: "Only the embedded Runtime can launch a Worker without a working directory" + .to_string(), + }, + vec![RuntimeDiagnostic { + code: "workspace_worker_workdir_required".to_string(), + severity: DiagnosticSeverity::Error, + message: "Select a working directory for this Runtime, or choose the embedded Runtime for a conversation-only Worker." + .to_string(), + }], + )) +} + async fn create_runtime_worker( State(api): State, AxumPath(runtime_id): AxumPath, Json(mut request): Json, ) -> ApiResult> { + if request.working_directory_request.is_none() && request.resolved_working_directory.is_none() { + reject_no_workdir_for_non_embedded_runtime(&runtime_id)?; + } request.resolved_working_directory_request = request .working_directory_request .as_ref() @@ -3803,6 +3832,7 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResp display_name: runtime.label, built_in, can_spawn_worker: runtime.capabilities.can_spawn_worker, + working_directory_required: !built_in, status: runtime.status, diagnostics: runtime.diagnostics, } @@ -4735,6 +4765,7 @@ impl IntoResponse for ApiError { || code.starts_with("unsupported_worker_profile") || code.starts_with("working_directory_") || code.starts_with("workspace_cleanup_") + || code == "workspace_worker_workdir_required" || code.ends_with("_already_exists") || code.ends_with("_not_config_managed") || code.ends_with("_unsupported") => @@ -6080,13 +6111,17 @@ mod tests { ); let launch_options = get_json(app.clone(), "/api/workers/launch-options").await; - assert!( - launch_options["runtimes"] - .as_array() - .unwrap() - .iter() - .any(|runtime| runtime["runtime_id"] == "team-runtime") - ); + let runtimes = launch_options["runtimes"].as_array().unwrap(); + let embedded_runtime = runtimes + .iter() + .find(|runtime| runtime["runtime_id"] == EMBEDDED_WORKER_RUNTIME_ID) + .expect("embedded runtime launch option"); + assert_eq!(embedded_runtime["working_directory_required"], false); + let team_runtime = runtimes + .iter() + .find(|runtime| runtime["runtime_id"] == "team-runtime") + .expect("team runtime launch option"); + assert_eq!(team_runtime["working_directory_required"], true); let deleted = request_json( app.clone(), @@ -6137,18 +6172,6 @@ mod tests { ) .await; assert_eq!(added["restart_required"], false); - let created = post_json( - app.clone(), - "/api/workers", - serde_json::json!({ - "runtime_id": "busy-runtime", - "display_name": "Remote Test Worker", - "profile": "runtime_default", - "initial_text": "" - }), - ) - .await; - assert_eq!(created["runtime_id"], "busy-runtime"); let workers = get_json(app.clone(), "/api/workers").await; assert!( workers["items"] @@ -6352,6 +6375,38 @@ mod tests { assert!(!projected.contains("http://")); } + #[tokio::test] + async fn browser_worker_create_rejects_non_embedded_no_workdir() { + let dir = tempfile::tempdir().unwrap(); + let app = test_app(dir.path()).await; + let response = request_json( + app, + "POST", + "/api/workers", + Some(serde_json::json!({ + "runtime_id": "remote-runtime", + "display_name": "Remote Worker", + "profile": "runtime_default", + "initial_text": "" + })), + StatusCode::BAD_REQUEST, + ) + .await; + assert!( + response["message"] + .as_str() + .unwrap_or_default() + .contains("workspace_worker_workdir_required") + ); + assert!( + response["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|diagnostic| { diagnostic["code"] == "workspace_worker_workdir_required" }) + ); + } + #[tokio::test] async fn runtime_worker_spawn_rejects_raw_working_directory_fields() { let dir = tempfile::tempdir().unwrap(); diff --git a/web/workspace/src/lib/workspace-sidebar/types.ts b/web/workspace/src/lib/workspace-sidebar/types.ts index 30cd2b8e..35a39616 100644 --- a/web/workspace/src/lib/workspace-sidebar/types.ts +++ b/web/workspace/src/lib/workspace-sidebar/types.ts @@ -101,6 +101,7 @@ export type WorkerLaunchRuntimeOption = { display_name: string; built_in: boolean; can_spawn_worker: boolean; + working_directory_required: boolean; status: string; diagnostics: Diagnostic[]; }; diff --git a/web/workspace/src/lib/workspace-sidebar/worker-launch.test.ts b/web/workspace/src/lib/workspace-sidebar/worker-launch.test.ts index 375a86e8..116cb8a2 100644 --- a/web/workspace/src/lib/workspace-sidebar/worker-launch.test.ts +++ b/web/workspace/src/lib/workspace-sidebar/worker-launch.test.ts @@ -25,6 +25,7 @@ const options: WorkerLaunchOptionsResponse = { status: "active", can_spawn_worker: true, built_in: false, + working_directory_required: true, diagnostics: [], }, { @@ -32,7 +33,8 @@ const options: WorkerLaunchOptionsResponse = { display_name: "Embedded", status: "active", can_spawn_worker: true, - built_in: false, + built_in: true, + working_directory_required: false, diagnostics: [], }, ], @@ -107,3 +109,23 @@ Deno.test("buildBrowserCreateWorkerRequest sends working_directory id and relati }, }); }); + +Deno.test("buildBrowserCreateWorkerRequest omits working_directory for embedded no-workdir launches", () => { + const request = buildBrowserCreateWorkerRequest({ + runtime_id: "embedded", + display_name: "Worker", + profile: "builtin:companion", + initial_text: "chat", + working_directory_id: "", + working_directory_repository_id: "", + working_directory_selector: "", + relative_cwd: "", + }); + + assertEquals(request, { + runtime_id: "embedded", + display_name: "Worker", + profile: "builtin:companion", + initial_text: "chat", + }); +}); diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte index 506a2a0d..438e4edd 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte @@ -41,7 +41,14 @@ let relativeCwd = $state(''); let creatingWorkingDirectory = $state(false); let isNewWorkingDirectorySelected = $derived(workingDirectoryId === NEW_WORKING_DIRECTORY_VALUE); - let canStartWorker = $derived(Boolean(runtimeId && profile && workingDirectoryId && !isNewWorkingDirectorySelected)); + let selectedRuntime = $derived(options?.runtimes.find((runtime) => runtime.runtime_id === runtimeId)); + let selectedRuntimeAllowsNoWorkdir = $derived(selectedRuntime?.working_directory_required === false); + let hasSelectedExistingWorkdir = $derived(Boolean(workingDirectoryId && !isNewWorkingDirectorySelected)); + let canStartWorker = $derived(Boolean( + runtimeId && + profile && + (hasSelectedExistingWorkdir || (selectedRuntimeAllowsNoWorkdir && !isNewWorkingDirectorySelected)), + )); function workerApiPath(path: string): string { return workspaceApiPath(workspaceId, path); @@ -81,7 +88,7 @@ runtimeId = form.runtime_id; displayName = form.display_name; profile = form.profile; - workingDirectoryId = form.working_directory_id || NEW_WORKING_DIRECTORY_VALUE; + workingDirectoryId = form.working_directory_id; workingDirectoryRepositoryId = form.working_directory_repository_id; workingDirectorySelector = form.working_directory_selector; relativeCwd = form.relative_cwd; @@ -149,8 +156,8 @@ submitError = { message: 'workspace id is unavailable', diagnostics: [] }; return; } - if (isNewWorkingDirectorySelected || !workingDirectoryId) { - submitError = { message: 'select or create a workdir before starting a Worker', diagnostics: [] }; + if (isNewWorkingDirectorySelected || (!workingDirectoryId && !selectedRuntimeAllowsNoWorkdir)) { + submitError = { message: 'select or create a workdir before starting a Worker; only embedded Runtime can start without one', diagnostics: [] }; return; } @@ -220,7 +227,7 @@

New Worker

-

Create a Worker on a selected Runtime and workdir.

+

Create a Worker on a selected Runtime. Workdir-less conversation Workers are only available on embedded Runtime.

Back to workspace
@@ -237,7 +244,11 @@
Run at
+ {#if !selectedRuntimeAllowsNoWorkdir && !workingDirectoryId && !isNewWorkingDirectorySelected} +

This Runtime requires a selected workdir before starting a Worker.

+ {:else if selectedRuntimeAllowsNoWorkdir && !workingDirectoryId} +

No filesystem tools or Bash will be available without a workdir.

+ {/if} + {#if isNewWorkingDirectorySelected}

New workdir

@@ -286,10 +303,12 @@
{/if} - + {#if hasSelectedExistingWorkdir || isNewWorkingDirectorySelected} + + {/if}
From 7223cab0b2e9b7c25ecd14fae7a1549e6834452b Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 11 Jul 2026 09:01:00 +0900 Subject: [PATCH 08/15] ticket: record embedded no-workdir implementation --- .yoi/tickets/00001KX6WVNPD/item.md | 2 +- .yoi/tickets/00001KX6WVNPD/thread.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KX6WVNPD/item.md b/.yoi/tickets/00001KX6WVNPD/item.md index 03ad1cc5..b246dbad 100644 --- a/.yoi/tickets/00001KX6WVNPD/item.md +++ b/.yoi/tickets/00001KX6WVNPD/item.md @@ -3,7 +3,7 @@ title: 'Embedded no-workdir Worker authority policy' state: 'inprogress' priority: 'P1' created_at: '2026-07-10T20:52:43Z' -updated_at: '2026-07-10T23:38:34Z' +updated_at: '2026-07-11T00:00:55Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-10T22:00:07Z' diff --git a/.yoi/tickets/00001KX6WVNPD/thread.md b/.yoi/tickets/00001KX6WVNPD/thread.md index 99048fe7..33c2c713 100644 --- a/.yoi/tickets/00001KX6WVNPD/thread.md +++ b/.yoi/tickets/00001KX6WVNPD/thread.md @@ -174,3 +174,31 @@ Critical risks / reviewer focus: Prerequisite `00001KX6Y2A9Q` is now closed and merged. This Ticket is accepted for implementation before creating a worktree or spawning role Pods. --- + + + +## Implementation report + +Implementation progress report: + +- Coder Pod `yoi-coder-00001KX6WVNPD-no-workdir-policy` completed an implementation pass in `/home/hare/Projects/yoi/.worktree/00001KX6WVNPD-embedded-no-workdir-policy` on branch `work/00001KX6WVNPD-embedded-no-workdir-policy`。 +- Implementation commit: `10267869ae0dfe02e186dc69fec14151926f5009 feat: enforce embedded no-workdir worker policy`。 +- Files touched: + - `crates/workspace-server/src/server.rs` + - `web/workspace/src/lib/workspace-sidebar/types.ts` + - `web/workspace/src/lib/workspace-sidebar/worker-launch.test.ts` + - `web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte` +- Orchestrator inspected worktree status, branch log, commit stats, `git diff --check 83c9d884..HEAD`, and `rg 'worker\\.cwd\\(\\)' .`; worktree was clean, diff check passed, and no `worker.cwd()` matches were found。 + +Implementation summary: +- Added Browser/API launch metadata for whether a runtime requires working directory selection。 +- Embedded Runtime can be marked as workdir-optional; non-embedded Runtime remains workdir-required。 +- Server-side creation path rejects no-workdir Worker creation unless the target runtime is embedded, using a typed diagnostic。 +- Browser Worker creation UI offers “no workdir / embedded conversation only” only when the selected runtime allows it。 +- Relative cwd input is hidden for no-workdir launch and UI text explains no filesystem tools or Bash are available without workdir。 +- Tests were added/updated for launch metadata, non-embedded no-workdir rejection, and web request construction for embedded no-workdir launch。 + +Next action: +- Route to external Reviewer Pod before merge/close decisions。 + +--- From 50eec7885a58791e9213ce63271f09d175c63e62 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 11 Jul 2026 09:12:29 +0900 Subject: [PATCH 09/15] ticket: approve embedded no-workdir policy --- .yoi/tickets/00001KX6WVNPD/item.md | 2 +- .yoi/tickets/00001KX6WVNPD/thread.md | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KX6WVNPD/item.md b/.yoi/tickets/00001KX6WVNPD/item.md index b246dbad..f80925a6 100644 --- a/.yoi/tickets/00001KX6WVNPD/item.md +++ b/.yoi/tickets/00001KX6WVNPD/item.md @@ -3,7 +3,7 @@ title: 'Embedded no-workdir Worker authority policy' state: 'inprogress' priority: 'P1' created_at: '2026-07-10T20:52:43Z' -updated_at: '2026-07-11T00:00:55Z' +updated_at: '2026-07-11T00:12:24Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-10T22:00:07Z' diff --git a/.yoi/tickets/00001KX6WVNPD/thread.md b/.yoi/tickets/00001KX6WVNPD/thread.md index 33c2c713..046df115 100644 --- a/.yoi/tickets/00001KX6WVNPD/thread.md +++ b/.yoi/tickets/00001KX6WVNPD/thread.md @@ -202,3 +202,28 @@ Next action: - Route to external Reviewer Pod before merge/close decisions。 --- + + + +## Review: approve + +External review result: approve + +Evidence: +- UI/API optionality is correctly bounded: launch options expose `working_directory_required` and set it false only for embedded runtime (`crates/workspace-server/src/server.rs:749`, `:3823`), while browser request construction omits `working_directory` for embedded no-workdir mode and tests cover that (`web/workspace/src/lib/workspace-sidebar/worker-launch.test.ts:80`)。 +- Non-embedded no-workdir is rejected in workspace/API launch paths (`server.rs:2639`, `:2839`) with tests covering the 400 behavior (`server.rs:6383`, `:6554`)。 +- Authority boundary is preserved: absent materialized workdir reaches runtime as no working directory (`server.rs:2652`) and worker-runtime maps that to `WorkerFilesystemAuthority::None`; workdir-present maps to `Local(root, cwd)` (`crates/worker-runtime/src/worker_backend.rs:301`)。 +- Tool surface/no fallback is covered by existing runtime tests asserting no cwd observation and omission of `Read`/`Write`/`Edit`/`Glob`/`Grep`/`Bash` for no-workdir workers (`worker_backend.rs:1151`, `:1216`)。 +- Workdir-present behavior remains covered by materialized binding assertions (`worker_backend.rs:1151`) and server worker creation still links/summarizes workdir-present workers。 +- Browser labeling is explicit: embedded mode offers “No working directory — guidance-only Worker; filesystem tools are disabled” and non-embedded paths require selection (`+page.svelte:153`, `:289`)。 + +Validation performed by reviewer: +- `cargo test -p yoi-workspace-server no_workdir -- --nocapture` +- `cargo test -p worker-runtime --features ws-server --lib -- --nocapture` +- `cd web/workspace && deno task test` +- Final `git status --short` was clean。 + +Blockers: none。 +Non-blocking notes: none。 + +--- From 14e704b8582885bd9497fadd195916d40cdd575e Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 11 Jul 2026 09:13:58 +0900 Subject: [PATCH 10/15] ticket: close embedded no-workdir policy work --- .yoi/tickets/00001KX6WVNPD/item.md | 4 +- .yoi/tickets/00001KX6WVNPD/resolution.md | 28 ++++++++ .yoi/tickets/00001KX6WVNPD/thread.md | 81 ++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 .yoi/tickets/00001KX6WVNPD/resolution.md diff --git a/.yoi/tickets/00001KX6WVNPD/item.md b/.yoi/tickets/00001KX6WVNPD/item.md index f80925a6..79247ea7 100644 --- a/.yoi/tickets/00001KX6WVNPD/item.md +++ b/.yoi/tickets/00001KX6WVNPD/item.md @@ -1,9 +1,9 @@ --- title: 'Embedded no-workdir Worker authority policy' -state: 'inprogress' +state: 'closed' priority: 'P1' created_at: '2026-07-10T20:52:43Z' -updated_at: '2026-07-11T00:12:24Z' +updated_at: '2026-07-11T00:13:51Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-10T22:00:07Z' diff --git a/.yoi/tickets/00001KX6WVNPD/resolution.md b/.yoi/tickets/00001KX6WVNPD/resolution.md new file mode 100644 index 00000000..17a40ee4 --- /dev/null +++ b/.yoi/tickets/00001KX6WVNPD/resolution.md @@ -0,0 +1,28 @@ +Embedded no-workdir Worker authority policy を実装・レビュー・merge・検証した。 + +実装内容: +- Runtime launch metadata に `working_directory_required` を追加し、embedded Runtime のみ workdir optional として扱うようにした。 +- Workspace/API Worker create path で、embedded Runtime では no-workdir Worker creation を許可し、non-embedded Runtime では no-workdir creation を typed diagnostic で拒否するようにした。 +- Embedded no-workdir launch は Runtime へ working directory なしで渡され、merged `WorkerFilesystemAuthority` 境界により `WorkerFilesystemAuthority::None` になる。 +- Workdir-present embedded Worker は materialized binding を `WorkerFilesystemAuthority::Local` として保持する既存 behavior を維持。 +- Browser Worker creation UI で embedded Runtime の場合だけ “No working directory — guidance-only Worker; filesystem tools are disabled” を選べるようにした。 +- No-workdir launch では relative cwd input を隠し、filesystem tools / Bash が disabled であることを明示。 +- Tests で launch metadata、non-embedded no-workdir rejection、embedded no-workdir request construction、および existing runtime tests による no-workdir tool omission/no cwd fallback を確認。 + +Review: +- Reviewer approved with no blockers。 +- Evidence included API optionality bounds, non-embedded rejection, authority construction path, no filesystem/Bash tool surface coverage, workdir-present behavior, and Browser labeling。 + +Merge / validation: +- Merge commit: `2f7b8094 merge: embedded no-workdir worker policy`。 +- Final validation passed: + - `rg 'worker\\.cwd\\(' . || true` + - `git diff --check` + - `cargo test -p worker-runtime --features ws-server,fs-store` + - `cargo test -p yoi-workspace-server --lib` + - `cargo check -p yoi` + - `cd web/workspace && deno task check` + - `cd web/workspace && deno task test` + - `yoi ticket doctor` + - `nix build .#yoi --no-link` +- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/embedded-no-workdir-final-validation-1783728763.txt` \ No newline at end of file diff --git a/.yoi/tickets/00001KX6WVNPD/thread.md b/.yoi/tickets/00001KX6WVNPD/thread.md index 046df115..86b6b7c2 100644 --- a/.yoi/tickets/00001KX6WVNPD/thread.md +++ b/.yoi/tickets/00001KX6WVNPD/thread.md @@ -227,3 +227,84 @@ Blockers: none。 Non-blocking notes: none。 --- + + + +## Implementation report + +Merge / final validation report: + +- Reviewer approved the implementation with no blockers。 +- Merged branch `work/00001KX6WVNPD-embedded-no-workdir-policy` into orchestration branch with merge commit `2f7b8094 merge: embedded no-workdir worker policy`。 +- Final validation was rerun in `/home/hare/Projects/yoi/.worktree/orchestration` after merge。 + +Validation passed: +- `rg 'worker\\.cwd\\(' . || true`: no matches。 +- `git diff --check` +- `cargo test -p worker-runtime --features ws-server,fs-store` +- `cargo test -p yoi-workspace-server --lib` +- `cargo check -p yoi` +- `cd web/workspace && deno task check` +- `cd web/workspace && deno task test` +- `yoi ticket doctor` +- `nix build .#yoi --no-link` + +Validation log: +- `/run/user/1000/yoi/yoi-orchestrator/bash-output/embedded-no-workdir-final-validation-1783728763.txt` + +Reviewed/merged implementation commit: +- `10267869 feat: enforce embedded no-workdir worker policy` + +--- + + + +## State changed + +Implementation was reviewed, merged into orchestration branch, and final validation passed. Moving to done before closure. + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +Embedded no-workdir Worker authority policy を実装・レビュー・merge・検証した。 + +実装内容: +- Runtime launch metadata に `working_directory_required` を追加し、embedded Runtime のみ workdir optional として扱うようにした。 +- Workspace/API Worker create path で、embedded Runtime では no-workdir Worker creation を許可し、non-embedded Runtime では no-workdir creation を typed diagnostic で拒否するようにした。 +- Embedded no-workdir launch は Runtime へ working directory なしで渡され、merged `WorkerFilesystemAuthority` 境界により `WorkerFilesystemAuthority::None` になる。 +- Workdir-present embedded Worker は materialized binding を `WorkerFilesystemAuthority::Local` として保持する既存 behavior を維持。 +- Browser Worker creation UI で embedded Runtime の場合だけ “No working directory — guidance-only Worker; filesystem tools are disabled” を選べるようにした。 +- No-workdir launch では relative cwd input を隠し、filesystem tools / Bash が disabled であることを明示。 +- Tests で launch metadata、non-embedded no-workdir rejection、embedded no-workdir request construction、および existing runtime tests による no-workdir tool omission/no cwd fallback を確認。 + +Review: +- Reviewer approved with no blockers。 +- Evidence included API optionality bounds, non-embedded rejection, authority construction path, no filesystem/Bash tool surface coverage, workdir-present behavior, and Browser labeling。 + +Merge / validation: +- Merge commit: `2f7b8094 merge: embedded no-workdir worker policy`。 +- Final validation passed: + - `rg 'worker\\.cwd\\(' . || true` + - `git diff --check` + - `cargo test -p worker-runtime --features ws-server,fs-store` + - `cargo test -p yoi-workspace-server --lib` + - `cargo check -p yoi` + - `cd web/workspace && deno task check` + - `cd web/workspace && deno task test` + - `yoi ticket doctor` + - `nix build .#yoi --no-link` +- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/embedded-no-workdir-final-validation-1783728763.txt` + +--- From 74b07bd787ec9356df9f2f79ea6f1f0d8ccfc2e7 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 11 Jul 2026 09:15:16 +0900 Subject: [PATCH 11/15] ticket: accept workspace backend work --- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KX6Y6ZEA/item.md | 4 +- .yoi/tickets/00001KX6Y6ZEA/thread.md | 97 +++++++++++++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KX6Y6ZEA/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KX6Y6ZEA/artifacts/orchestration-plan.jsonl index f8d318b4..36eb37c8 100644 --- a/.yoi/tickets/00001KX6Y6ZEA/artifacts/orchestration-plan.jsonl +++ b/.yoi/tickets/00001KX6Y6ZEA/artifacts/orchestration-plan.jsonl @@ -1,3 +1,4 @@ {"id":"orch-plan-20260710-220343-1","ticket_id":"00001KX6Y6ZEA","kind":"after","related_ticket":"00001KX6Y2A9Q","note":"Ticket has explicit typed `depends_on` relation to `00001KX6Y2A9Q`. The prerequisite WorkerFilesystemAuthority refactor is currently `inprogress` and establishes the filesystem-authority/workspace-boundary this Ticket builds on. Start only after the prerequisite is approved, merged, and closed or an explicit combined-work decision is made.","author":"orchestrator","at":"2026-07-10T22:03:43Z"} {"id":"orch-plan-20260710-220349-2","ticket_id":"00001KX6Y6ZEA","kind":"waiting_capacity_note","note":"Dashboard queue routing inspected. This Ticket is concrete but blocked by typed `depends_on` relation to `00001KX6Y2A9Q`, which is currently inprogress. Starting now would conflict with the Worker struct/context/constructor refactor and filesystem-authority boundary. Leave queued and re-route after prerequisite merge/close.","author":"orchestrator","at":"2026-07-10T22:03:49Z"} {"id":"orch-plan-20260710-233841-3","ticket_id":"00001KX6Y6ZEA","kind":"waiting_capacity_note","note":"Queue review resumed after prerequisite `00001KX6Y2A9Q` closed. `00001KX6WVNPD` was accepted first because it is the smaller embedded no-workdir policy MVP and explicitly does not require WorkspaceBackend migration. This Ticket remains queued due migration-boundary/conflict risk: it removes Worker `workspace_root` and introduces WorkspaceBackend/WorkspaceClient surfaces in the same Worker context/constructor/runtime code that `00001KX6WVNPD` is now changing. Re-route after `00001KX6WVNPD` is reviewed/merged/closed, unless a human explicitly requests a combined branch.","author":"orchestrator","at":"2026-07-10T23:38:41Z"} +{"id":"orch-plan-20260711-001433-4","ticket_id":"00001KX6Y6ZEA","kind":"accepted_plan","accepted_plan":{"summary":"Replace Worker `workspace_root: PathBuf` identity with path-free workspace identity and Runtime/host-owned WorkspaceBackend/WorkspaceClient boundary. Preserve filesystem authority exclusively under `WorkerFilesystemAuthority::Local`, allow workspace_id/client without local filesystem authority, and migrate workspace-aware Worker references away from local workspace paths where in scope.","branch":"work/00001KX6Y6ZEA-workspace-backend","worktree":"/home/hare/Projects/yoi/.worktree/00001KX6Y6ZEA-workspace-backend","role_plan":"Orchestrator accepts queued Ticket after prerequisites `00001KX6Y2A9Q` and related `00001KX6WVNPD` were approved, merged, validated, and closed. Use sibling Coder for implementation in a dedicated worktree, then sibling Reviewer for read-only review. Orchestrator retains merge/final-validation/close/worktree cleanup authority. Per user instruction, do not call StopPod during cleanup."},"author":"orchestrator","at":"2026-07-11T00:14:33Z"} diff --git a/.yoi/tickets/00001KX6Y6ZEA/item.md b/.yoi/tickets/00001KX6Y6ZEA/item.md index d2054812..6f90b258 100644 --- a/.yoi/tickets/00001KX6Y6ZEA/item.md +++ b/.yoi/tickets/00001KX6Y6ZEA/item.md @@ -1,9 +1,9 @@ --- title: 'Replace Worker workspace_root path with WorkspaceBackend' -state: 'queued' +state: 'inprogress' priority: 'P1' created_at: '2026-07-10T21:16:22Z' -updated_at: '2026-07-10T23:38:54Z' +updated_at: '2026-07-11T00:15:11Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-10T22:03:27Z' diff --git a/.yoi/tickets/00001KX6Y6ZEA/thread.md b/.yoi/tickets/00001KX6Y6ZEA/thread.md index 913df816..0c4870b2 100644 --- a/.yoi/tickets/00001KX6Y6ZEA/thread.md +++ b/.yoi/tickets/00001KX6Y6ZEA/thread.md @@ -124,3 +124,100 @@ Next action: - 人間が combined branch を明示希望する場合のみ、この待機判断を再検討する。 --- + + + +## Decision + +Routing decision: implementation_ready + +Reason: +- 前提 `00001KX6Y2A9Q` は closed 済みで、`WorkerFilesystemAuthority::{None, Local}` と cwd 除去境界が merge 済み。 +- 関連 `00001KX6WVNPD` も closed 済みで、embedded no-workdir Worker policy / UI/API optionality が merge 済み。 +- この Ticket の typed `depends_on 00001KX6Y2A9Q` は解消済み。`00001KX6WVNPD` は `related` であり blocker ではなく、直前の conflict/migration-boundary waiting note もその Ticket の merge/close により解消済み。 +- `TicketList(inprogress)` は 0 件で、Orchestrator worktree は clean。 +- Ticket body/thread に Worker-level local path identity をなくし、Runtime/host-owned backend/client 境界へ移す binding decision が記録されている。 + +Evidence checked: +- Ticket body / thread / relations / orchestration plan。 +- Closed prerequisite `00001KX6Y2A9Q` and closed related policy Ticket `00001KX6WVNPD`。 +- `TicketRelationQuery(00001KX6Y6ZEA)`: outgoing `depends_on 00001KX6Y2A9Q` is closed; other relations are `related` only。 +- `TicketOrchestrationPlanQuery(00001KX6Y6ZEA)`: prior waiting notes were dependency/conflict notes now resolved by prerequisite and related Ticket closure。 +- `TicketList(inprogress)`: 0 件。 +- Orchestrator worktree status: clean。 + +IntentPacket: + +Intent: +- Worker の workspace identity/context を local path (`workspace_root: PathBuf`) から path-free identity (`Option`) と Runtime/host injected workspace client/handle 境界へ移行する。 +- Filesystem authority は `WorkerFilesystemAuthority` に閉じ込め、workspace identity/client が local filesystem authority を意味しないようにする。 + +Binding decisions / invariants: +- Worker struct は durable/context identity として local workspace path を保持しない。 +- Worker-level `workspace_root: PathBuf` field と local path-returning workspace identity accessor を削除する。 +- Worker が `WorkspaceBackendRef` / endpoint / auth / SecretRef / adapter を直接解決しない。 +- Runtime/host 側が backend binding source を保持・解決し、Worker には narrow `WorkspaceClient` / handle を注入する。 +- `workspace_id = Some(...)` かつ `filesystem = None` を表現できること。 +- `workspace_id = None` かつ `filesystem = None` の会話専用 Worker も表現できること。 +- local path が必要な処理は Runtime/host backend adapter または `WorkerFilesystemAuthority::Local` の内側に閉じ込める。 +- capability を導入する場合でも authority source にせず、tool registration / UX / discovery hint として扱い、enforcement は backend 側に置く。 +- `WorkerFilesystemAuthority` / embedded no-workdir policy を崩さない。workspace root fallback や process cwd fallback を filesystem authority として復活させない。 + +Requirements / acceptance criteria: +- Worker context に `workspace_id: Option` equivalent と injected workspace client/handle equivalent を持てる。 +- Worker struct に `workspace_root: PathBuf` が存在しない。 +- workspace-aware features(Ticket / memory / workflow / project records / workspace metadata)は local workspace path authority ではなく injected workspace client/handle 経由へ移行する、または移行が必要な箇所を fail-closed / unavailable diagnostic にする。 +- Runtime/host が `WorkspaceBackendRef` equivalent を保持・解決し、Worker launch/restore/embedded/spawn path へ安全に materialize できる。 +- No-workdir Worker は workspace API access と local filesystem authority の有無を独立に表現できる。 +- Existing local/workdir Workers still work with `WorkerFilesystemAuthority::Local` and local-path-only operations confined there。 +- Tests cover no local workspace path identity on Worker, workspace_id/client injection shape, no filesystem authority mixing, and representative workspace-aware API/tool behavior。 + +Implementation latitude: +- `WorkspaceId`, `WorkspaceBackendRef`, `WorkspaceClient` / handle の exact placement/name/API surface は existing crate boundaries に合わせてよい。 +- Minimal client/handle can support only currently needed workspace-aware operations if broad API migration would exceed Ticket scope, but it must not leave Worker-local path authority as the active path。 +- If a currently path-backed feature cannot be migrated safely in this Ticket, prefer explicit unavailable/fail-closed diagnostic with follow-up note rather than hidden path fallback。 +- Capability discovery is optional unless needed for safe tool registration/UX。 + +Escalate if: +- Removing `workspace_root` from Worker requires changing product semantics for memory/Ticket/workflow availability beyond local refactor。 +- A workspace-aware feature cannot be migrated without introducing a broad external backend/protocol design not captured by this Ticket。 +- You need to reintroduce Worker-held local workspace path as identity/authority。 +- You need to weaken no-workdir fs/Bash tool omission or `WorkerFilesystemAuthority` invariants。 +- Public API/serialization migration requires incompatible durable-state handling beyond existing restore/test coverage。 + +Validation: +- `rg "workspace_root" crates/worker crates/worker-runtime crates/yoi-pod-client crates/yoi-workspace-server` with expected remaining hits only outside Worker identity/authority or documented adapter boundaries。 +- `rg "worker\.workspace_root|workspace_root\(\)" crates` or equivalent showing Worker local path accessor removed。 +- `git diff --check` +- `cargo test -p worker --lib --tests` +- `cargo test -p worker-runtime --features ws-server,fs-store` +- `cargo test -p yoi-workspace-server --lib` +- `cargo check -p yoi` +- `cd web/workspace && deno task check && deno task test` if API/types/UI are touched。 +- `yoi ticket doctor` +- `nix build .#yoi --no-link` + +Current code map / likely touch points: +- `crates/worker/src/worker.rs` for Worker context fields and accessors。 +- `crates/worker/src/controller.rs` for workspace-aware tool/resource setup currently tied to workspace path。 +- `crates/worker-runtime/src/worker_backend.rs` for Runtime/embedded/restore construction and backend binding materialization。 +- workspace-server/client crates if backend/client DTOs or Worker launch summaries expose workspace identity。 +- tests around no-workdir Workers, restore, child spawn, Ticket/memory/workflow availability。 + +Critical risks / reviewer focus: +- Hidden local path authority retained in Worker under a renamed field/accessor。 +- Workspace identity/client being treated as filesystem authority。 +- WorkspaceBackendRef/endpoint/secret leaking into Worker-owned state。 +- Breaking local/workdir Workers or child spawn/restore while removing path identity。 +- Regressing embedded no-workdir policy or filesystem/Bash tool omission。 +- Over-broad migration that invents a large remote-workspace protocol instead of the minimal safe boundary needed here。 + +--- + + + +## State changed + +Prerequisite and prior conflict/migration-boundary Ticket are now closed. This Ticket is accepted for implementation before creating a worktree or spawning role Pods. + +--- From 142b60e1b3ca2ea378435ebaf6401d1bd335ccf6 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 11 Jul 2026 09:58:20 +0900 Subject: [PATCH 12/15] refactor: separate worker workspace identity --- Cargo.lock | 1 + crates/session-store/src/worker_metadata.rs | 49 +++ crates/worker-runtime/Cargo.toml | 1 + crates/worker-runtime/src/worker_backend.rs | 53 ++- crates/worker/src/controller.rs | 41 +- crates/worker/src/discovery.rs | 12 + crates/worker/src/entrypoint.rs | 34 +- crates/worker/src/lib.rs | 2 +- crates/worker/src/ticket_event_notify.rs | 2 + crates/worker/src/worker.rs | 400 +++++++++++++----- crates/worker/tests/compact_events_test.rs | 2 +- crates/worker/tests/consolidation_test.rs | 2 +- crates/worker/tests/controller_test.rs | 15 +- crates/worker/tests/session_metrics_test.rs | 6 +- .../tests/system_prompt_template_test.rs | 2 +- package.nix | 2 +- 16 files changed, 486 insertions(+), 138 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 024dfb14..b0bcade5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5848,6 +5848,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-tungstenite 0.29.0", + "toml", "tower", "worker", ] diff --git a/crates/session-store/src/worker_metadata.rs b/crates/session-store/src/worker_metadata.rs index 9a46be53..ff689e85 100644 --- a/crates/session-store/src/worker_metadata.rs +++ b/crates/session-store/src/worker_metadata.rs @@ -100,8 +100,13 @@ pub struct WorkerMetadata { pub worker_name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub active: Option, + /// Legacy local path hint retained for host/runtime compatibility. It is not + /// Worker workspace identity or authority. #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_root: Option, + /// Path-free workspace identity supplied by the host/runtime boundary. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub spawned_children: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -119,6 +124,7 @@ impl WorkerMetadata { worker_name: worker_name.into(), active, workspace_root: None, + workspace_id: None, spawned_children: Vec::new(), reclaimed_children: Vec::new(), peers: Vec::new(), @@ -130,6 +136,11 @@ impl WorkerMetadata { self.workspace_root = Some(workspace_root); self } + + pub fn with_workspace_id(mut self, workspace_id: impl Into) -> Self { + self.workspace_id = Some(workspace_id.into()); + self + } } /// Sync persistence backend for Worker metadata. @@ -180,6 +191,44 @@ pub trait WorkerMetadataStore: Send + Sync { } /// Set the active pointer and workspace ownership while preserving unrelated fields. + fn set_active_with_workspace_context( + &self, + worker_name: &str, + active: Option, + resolved_manifest_snapshot: Option, + workspace_id: Option, + workspace_root: Option, + ) -> Result { + self.update_by_name(worker_name, |metadata| { + metadata.active = active; + metadata.resolved_manifest_snapshot = resolved_manifest_snapshot; + if let Some(workspace_id) = workspace_id { + metadata.workspace_id = Some(workspace_id); + } + if let Some(workspace_root) = workspace_root { + metadata.workspace_root = Some(workspace_root); + } + }) + } + + /// Set the active pointer and path-free workspace identity while preserving unrelated fields. + fn set_active_with_workspace_id( + &self, + worker_name: &str, + active: Option, + resolved_manifest_snapshot: Option, + workspace_id: Option, + ) -> Result { + self.update_by_name(worker_name, |metadata| { + metadata.active = active; + metadata.resolved_manifest_snapshot = resolved_manifest_snapshot; + if let Some(workspace_id) = workspace_id { + metadata.workspace_id = Some(workspace_id); + } + }) + } + + /// Set the active pointer and legacy local workspace-root hint while preserving unrelated fields. fn set_active_with_workspace_root( &self, worker_name: &str, diff --git a/crates/worker-runtime/Cargo.toml b/crates/worker-runtime/Cargo.toml index 8cf715a3..c135259d 100644 --- a/crates/worker-runtime/Cargo.toml +++ b/crates/worker-runtime/Cargo.toml @@ -32,6 +32,7 @@ reqwest = { version = "0.13", optional = true, default-features = false, feature tar.workspace = true thiserror = { workspace = true } tokio = { workspace = true, features = ["net", "rt", "sync", "time"] } +toml.workspace = true tower = { workspace = true, features = ["util"], optional = true } worker.workspace = true diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 36c9ed50..ff06307b 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -9,7 +9,7 @@ use std::collections::HashMap; use std::future::Future; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, mpsc}; use std::time::Duration; @@ -35,7 +35,10 @@ use session_store::{CombinedStore, FsWorkerStore}; use tokio::runtime::Runtime; use tokio::sync::broadcast; -use worker::{Worker, WorkerController, WorkerFilesystemAuthority, WorkerHandle}; +use worker::{ + Worker, WorkerController, WorkerFilesystemAuthority, WorkerHandle, WorkerWorkspaceContext, + WorkspaceId, +}; const DEFAULT_BACKEND_ID: &str = "worker-crate"; const RUNTIME_TASK_TIMEOUT: Duration = Duration::from_secs(10); @@ -230,6 +233,41 @@ fn sanitize_worker_name_component(value: &str) -> String { .collect() } +#[derive(Debug, Clone)] +enum RuntimeWorkspaceBackendRef { + None, + LocalFilesystem { root: PathBuf }, +} + +impl RuntimeWorkspaceBackendRef { + fn from_working_directory(binding: Option<&WorkingDirectoryBinding>) -> Self { + match binding { + Some(binding) => Self::LocalFilesystem { + root: binding.root().to_path_buf(), + }, + None => Self::None, + } + } + + fn worker_context(&self) -> WorkerWorkspaceContext { + match self { + Self::None => WorkerWorkspaceContext::no_workspace(), + Self::LocalFilesystem { root } => local_workspace_context(root), + } + } +} + +fn local_workspace_context(root: &Path) -> WorkerWorkspaceContext { + WorkerWorkspaceContext::local_filesystem(read_workspace_id_hint(root)) +} + +fn read_workspace_id_hint(root: &Path) -> Option { + let contents = std::fs::read_to_string(root.join(".yoi/workspace.toml")).ok()?; + let value = toml::from_str::(&contents).ok()?; + let id = value.get("id")?.as_str()?.to_string(); + WorkspaceId::new(id).ok() +} + #[cfg(feature = "http-server")] async fn fetch_profile_source_archive_http( location: &ProfileSourceArchiveHttpRef, @@ -308,6 +346,9 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { ) }) .unwrap_or(WorkerFilesystemAuthority::None); + let workspace_backend_ref = + RuntimeWorkspaceBackendRef::from_working_directory(request.working_directory.as_ref()); + let workspace_context = workspace_backend_ref.worker_context(); let selector = profile.as_deref().unwrap_or("builtin:default"); let archive = self .resolve_profile_source_archive(&request.request.profile_source) @@ -344,7 +385,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { manifest, store, loader, - worker_root, + workspace_context, filesystem_authority, ) .await @@ -927,12 +968,16 @@ mod tests { .as_ref() .map(|binding| binding.root().to_path_buf()) .unwrap_or_else(|| self.cwd.clone()); + let workspace_backend_ref = RuntimeWorkspaceBackendRef::from_working_directory( + request.working_directory.as_ref(), + ); + let workspace_context = workspace_backend_ref.worker_context(); let scope = Scope::writable(&scope_root).map_err(|err| err.to_string())?; let worker = Worker::new( manifest, Engine::new(self.client.clone()), store, - self.cwd.clone(), + workspace_context, filesystem_authority, scope, ) diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 4741e5f3..b25ddb46 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -522,17 +522,16 @@ fn install_ticket_event_companion_notify_hook( return; } - let Some(companion_worker_name) = companion_worker_name_for_workspace(worker.workspace_root()) - else { + let Some(local) = worker.local_working_directory() else { + return; + }; + let Some(companion_worker_name) = companion_worker_name_for_workspace(&local.root) else { return; }; if companion_worker_name == worker.manifest().worker.name { return; } - let Some(local) = worker.local_working_directory() else { - return; - }; let Ok(ticket_config) = TicketConfig::load_workspace(&local.cwd) else { return; }; @@ -603,7 +602,7 @@ where // below so the worker borrow doesn't conflict with reads on `worker`. let scope_handle = worker.scope().clone(); let local_filesystem = worker.local_working_directory().cloned(); - let workspace_root = worker.workspace_root().to_path_buf(); + let local_workspace_root = local_filesystem.as_ref().map(|local| local.root.clone()); let task_feature = worker.task_feature(); let session_id_for_usage = worker.segment_id().to_string(); let memory_config = worker.manifest().memory.clone(); @@ -654,8 +653,8 @@ where } }; // Ticket tools are typed operations over the currently checked-out work - // tree. They require explicit local filesystem authority; workspace_root - // is context only and must not be used as a cwd fallback. + // tree. They require explicit local filesystem authority and must not + // use workspace identity as a cwd fallback. let ticket_cwd = local_filesystem .as_ref() .map(|local| &local.cwd) @@ -679,10 +678,12 @@ where ) { feature_registry = feature_registry.with_module(module); } - if let Some(module) = - crate::feature::mcp::discover_stdio_tool_feature(&mcp_config, &workspace_root).await - { - feature_registry = feature_registry.with_module(module); + if let Some(workspace_root) = local_workspace_root.as_ref() { + if let Some(module) = + crate::feature::mcp::discover_stdio_tool_feature(&mcp_config, workspace_root).await + { + feature_registry = feature_registry.with_module(module); + } } { @@ -698,7 +699,13 @@ where "[feature.memory].enabled = true requires a [memory] configuration section", ) })?; - let layout = memory::WorkspaceLayout::resolve(mem, &workspace_root); + let workspace_root = local_workspace_root.as_ref().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "memory tools require local Worker filesystem authority", + ) + })?; + let layout = memory::WorkspaceLayout::resolve(mem, workspace_root); let query_cfg = memory::tool::QueryConfig::from(mem); worker.register_tool(memory::tool::read_tool_with_usage( layout.clone(), @@ -732,11 +739,17 @@ where "worker spawn tools require local Worker filesystem authority", ) })?; + let spawner_workspace_root = local_workspace_root.clone().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "worker spawn tools require local Worker filesystem authority", + ) + })?; worker.register_tool(spawn_worker_tool( spawner_name.clone(), spawner_socket, runtime_base.clone(), - workspace_root.clone(), + spawner_workspace_root, spawner_cwd.clone(), spawned_registry.clone(), self_parent_socket, diff --git a/crates/worker/src/discovery.rs b/crates/worker/src/discovery.rs index 0d7e0a28..ccfa08bd 100644 --- a/crates/worker/src/discovery.rs +++ b/crates/worker/src/discovery.rs @@ -1145,6 +1145,7 @@ mod tests { worker_name: "parent".into(), active: None, workspace_root: None, + workspace_id: None, spawned_children: vec![ child("child-live", &live_socket), child("child-stale", &stale_socket), @@ -1165,6 +1166,7 @@ mod tests { active_child_segment, )), workspace_root: None, + workspace_id: None, spawned_children: Vec::new(), reclaimed_children: Vec::new(), peers: Vec::new(), @@ -1179,6 +1181,7 @@ mod tests { active_child_segment, )), workspace_root: None, + workspace_id: None, spawned_children: Vec::new(), reclaimed_children: Vec::new(), peers: Vec::new(), @@ -1190,6 +1193,7 @@ mod tests { worker_name: "child-pending".into(), active: Some(WorkerActiveSegmentRef::pending_segment(pending_session_id)), workspace_root: None, + workspace_id: None, spawned_children: Vec::new(), reclaimed_children: Vec::new(), peers: Vec::new(), @@ -1204,6 +1208,7 @@ mod tests { new_segment_id(), )), workspace_root: None, + workspace_id: None, spawned_children: Vec::new(), reclaimed_children: Vec::new(), peers: Vec::new(), @@ -1215,6 +1220,7 @@ mod tests { worker_name: "peer".into(), active: None, workspace_root: None, + workspace_id: None, spawned_children: Vec::new(), reclaimed_children: Vec::new(), peers: vec![session_store::WorkerPeer { @@ -1421,6 +1427,7 @@ mod tests { worker_name: "source".into(), active: None, workspace_root: None, + workspace_id: None, spawned_children: Vec::new(), reclaimed_children: Vec::new(), peers: vec![session_store::WorkerPeer { @@ -1461,6 +1468,7 @@ mod tests { worker_name: "source".into(), active: None, workspace_root: None, + workspace_id: None, spawned_children: Vec::new(), reclaimed_children: Vec::new(), peers: vec![session_store::WorkerPeer { @@ -1474,6 +1482,7 @@ mod tests { worker_name: "target".into(), active: None, workspace_root: None, + workspace_id: None, spawned_children: Vec::new(), reclaimed_children: Vec::new(), peers: vec![session_store::WorkerPeer { @@ -1579,6 +1588,7 @@ mod tests { worker_name: "source".into(), active: None, workspace_root: None, + workspace_id: None, spawned_children: Vec::new(), reclaimed_children: Vec::new(), peers: vec![session_store::WorkerPeer { @@ -1592,6 +1602,7 @@ mod tests { worker_name: "target".into(), active: None, workspace_root: None, + workspace_id: None, spawned_children: Vec::new(), reclaimed_children: Vec::new(), peers: vec![session_store::WorkerPeer { @@ -1695,6 +1706,7 @@ mod tests { worker_name: "source".into(), active: None, workspace_root: None, + workspace_id: None, spawned_children: vec![child("target", &socket)], reclaimed_children: Vec::new(), peers: Vec::new(), diff --git a/crates/worker/src/entrypoint.rs b/crates/worker/src/entrypoint.rs index d23a66de..46563da1 100644 --- a/crates/worker/src/entrypoint.rs +++ b/crates/worker/src/entrypoint.rs @@ -2,7 +2,10 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::ExitCode; -use crate::{PromptLoader, Worker, WorkerController, WorkerFilesystemAuthority}; +use crate::{ + PromptLoader, Worker, WorkerController, WorkerFilesystemAuthority, WorkerWorkspaceContext, + WorkspaceId, +}; use clap::{CommandFactory, FromArgMatches, Parser}; use manifest::{ Permission, ProfileResolveOptions, ProfileResolver, ProfileSelector, ScopeConfig, ScopeRule, @@ -103,6 +106,24 @@ fn runtime_workspace_root(cli: &Cli) -> Result { } } +fn runtime_workspace_context(workspace_root: &Path) -> WorkerWorkspaceContext { + WorkerWorkspaceContext::local_filesystem(read_workspace_id_hint(workspace_root)) +} + +fn read_workspace_id_hint(workspace_root: &Path) -> Option { + let path = workspace_root.join(".yoi/workspace.toml"); + let contents = std::fs::read_to_string(path).ok()?; + let value = toml::from_str::(&contents).ok()?; + let id = value.get("id")?.as_str()?.to_string(); + match WorkspaceId::new(id) { + Ok(id) => Some(id), + Err(err) => { + tracing::warn!("ignoring invalid workspace id in .yoi/workspace.toml: {err}"); + None + } + } +} + fn runtime_worker_name(cli: &Cli, workspace_root: &Path) -> String { cli.worker .as_deref() @@ -513,6 +534,7 @@ async fn run_cli_inner(cli: Cli) -> ExitCode { let store = CombinedStore::new(session_store, worker_metadata_store); let filesystem_authority = WorkerFilesystemAuthority::local(workspace_root.clone(), cwd.clone()); + let workspace_context = runtime_workspace_context(&workspace_root); let mut worker = if cli.adopt { let callback = match cli.callback.clone() { @@ -527,7 +549,7 @@ async fn run_cli_inner(cli: Cli) -> ExitCode { store, loader, callback, - workspace_root.clone(), + workspace_context.clone(), filesystem_authority.clone(), ) .await @@ -558,7 +580,7 @@ async fn run_cli_inner(cli: Cli) -> ExitCode { manifest, store, loader, - workspace_root.clone(), + workspace_context.clone(), filesystem_authority.clone(), ) .await @@ -578,7 +600,7 @@ async fn run_cli_inner(cli: Cli) -> ExitCode { manifest, store, loader, - workspace_root.clone(), + workspace_context.clone(), filesystem_authority.clone(), ) .await @@ -599,7 +621,7 @@ async fn run_cli_inner(cli: Cli) -> ExitCode { manifest, store, loader, - workspace_root.clone(), + workspace_context.clone(), filesystem_authority.clone(), ) .await @@ -621,7 +643,7 @@ async fn run_cli_inner(cli: Cli) -> ExitCode { manifest, store, loader, - workspace_root.clone(), + workspace_context.clone(), filesystem_authority.clone(), ) .await diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index 592cca40..ed45f169 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -40,5 +40,5 @@ pub use segment_log_sink::SegmentLogSink; pub use shared_state::WorkerSharedState; pub use worker::{ LocalWorkingDirectory, Worker, WorkerError, WorkerFilesystemAuthority, WorkerRunResult, - apply_worker_manifest, + WorkerWorkspaceContext, WorkspaceClient, WorkspaceId, WorkspaceIdError, apply_worker_manifest, }; diff --git a/crates/worker/src/ticket_event_notify.rs b/crates/worker/src/ticket_event_notify.rs index 1f0a898f..65c176b7 100644 --- a/crates/worker/src/ticket_event_notify.rs +++ b/crates/worker/src/ticket_event_notify.rs @@ -384,6 +384,7 @@ mod tests { worker_name: "orchestrator".into(), active: None, workspace_root: None, + workspace_id: None, spawned_children: Vec::new(), reclaimed_children: Vec::new(), peers: Vec::new(), @@ -395,6 +396,7 @@ mod tests { worker_name: "companion".into(), active: None, workspace_root: None, + workspace_id: None, spawned_children: Vec::new(), reclaimed_children: Vec::new(), peers: Vec::new(), diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 972e455f..8ad93cb5 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -1,4 +1,6 @@ -use std::path::{Path, PathBuf}; +#[cfg(test)] +use std::path::Path; +use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -92,6 +94,113 @@ pub struct LocalWorkingDirectory { pub cwd: PathBuf, } +/// Path-free workspace identity carried by a Worker. +/// +/// The value is intentionally opaque to Worker code: Runtime/host layers own +/// backend lookup, endpoint/auth/secret materialisation, and any mapping from a +/// local checkout path to an id. Worker code may only compare/log the id and pass +/// it through to narrow workspace-aware handles. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct WorkspaceId(String); + +impl WorkspaceId { + pub fn new(id: impl Into) -> Result { + let id = id.into(); + if id.trim().is_empty() { + return Err(WorkspaceIdError::Empty); + } + Ok(Self(id)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)] +pub enum WorkspaceIdError { + #[error("workspace id must not be empty")] + Empty, +} + +/// Narrow path-free workspace API handle injected by Runtime/host code. +/// +/// This is deliberately not a filesystem authority surface. A Worker may have a +/// workspace client without local filesystem authority, or neither. Local +/// path-backed implementations are represented only as a capability marker here; +/// the actual paths remain under [`WorkerFilesystemAuthority::Local`] or in host +/// adapter code. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorkspaceClient { + /// Runtime/host supplied a workspace API handle. The string is an opaque + /// diagnostic/backend kind, not an endpoint, path, or secret-bearing value. + Available { kind: String }, + /// Workspace-aware operations must fail closed or stay disabled. + Unavailable { reason: String }, +} + +impl WorkspaceClient { + pub fn available(kind: impl Into) -> Self { + Self::Available { kind: kind.into() } + } + + pub fn unavailable(reason: impl Into) -> Self { + Self::Unavailable { + reason: reason.into(), + } + } + + pub fn local_filesystem() -> Self { + Self::available("local-filesystem") + } + + pub fn is_available(&self) -> bool { + matches!(self, Self::Available { .. }) + } +} + +/// Workspace context supplied to a Worker separately from filesystem authority. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkerWorkspaceContext { + workspace_id: Option, + client: WorkspaceClient, +} + +impl WorkerWorkspaceContext { + pub fn no_workspace() -> Self { + Self { + workspace_id: None, + client: WorkspaceClient::unavailable("no workspace configured"), + } + } + + pub fn unavailable(workspace_id: Option, reason: impl Into) -> Self { + Self { + workspace_id, + client: WorkspaceClient::unavailable(reason), + } + } + + pub fn with_client(workspace_id: Option, client: WorkspaceClient) -> Self { + Self { + workspace_id, + client, + } + } + + pub fn local_filesystem(workspace_id: Option) -> Self { + Self::with_client(workspace_id, WorkspaceClient::local_filesystem()) + } + + pub fn workspace_id(&self) -> Option<&WorkspaceId> { + self.workspace_id.as_ref() + } + + pub fn client(&self) -> &WorkspaceClient { + &self.client + } +} + /// `(SessionId, SegmentId)` pair the Worker is currently writing to. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SegmentLocation { @@ -109,10 +218,11 @@ where let store = store.clone(); Arc::new(move |metadata| { store - .set_active_with_workspace_root( + .set_active_with_workspace_context( &metadata.worker_name, metadata.active, metadata.resolved_manifest_snapshot, + metadata.workspace_id, metadata.workspace_root, ) .map(|_| ()) @@ -287,9 +397,9 @@ pub struct Worker { /// Explicit local filesystem authority, or `None` for Workers with no /// local cwd and no filesystem/Bash tool surface. filesystem_authority: WorkerFilesystemAuthority, - /// Absolute runtime workspace root used for project records, workflow, - /// memory, Ticket config, Profile context, and spawned-child inheritance. - workspace_root: PathBuf, + /// 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 out to `ScopedFs` instances (builtin tools, fs_view, /// compact worker) so scope updates propagate to every consumer @@ -483,7 +593,7 @@ impl Worker worker_metadata_writer: None, segment_state: self.segment_state.clone(), filesystem_authority: self.filesystem_authority.clone(), - workspace_root: self.workspace_root.clone(), + workspace_context: self.workspace_context.clone(), scope: self.scope.clone(), delegation_scope: self.delegation_scope.clone(), hook_builder: HookRegistryBuilder::new(), @@ -643,10 +753,10 @@ impl Worker impl Worker { /// Create a new Worker from a pre-built Engine and store. /// - /// Callers must pass explicit filesystem authority and build a [`Scope`] - /// — typically via [`Scope::from_config`] when coming from a - /// manifest, or [`Scope::writable`] in tests. Use - /// [`WorkerFilesystemAuthority::None`] for no-workdir Workers. + /// Callers must pass path-free workspace context separately from explicit + /// filesystem authority and build a [`Scope`] — typically via + /// [`Scope::from_config`] when coming from a manifest, or [`Scope::writable`] + /// in tests. Use [`WorkerFilesystemAuthority::None`] for no-workdir Workers. /// /// Note: this constructor does **not** parse `manifest.worker.system_prompt` /// as a template. `Worker::from_manifest` is the production path for @@ -656,7 +766,7 @@ impl Worker { manifest: WorkerManifest, worker: Engine, store: St, - workspace_root: PathBuf, + workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, scope: Scope, ) -> Result { @@ -675,7 +785,7 @@ impl Worker { worker_metadata_writer: None, segment_state: SegmentState::new(session_id, segment_id, 0), filesystem_authority, - workspace_root, + workspace_context, scope: SharedScope::new(scope), delegation_scope, hook_builder: HookRegistryBuilder::new(), @@ -797,10 +907,16 @@ impl Worker { self.filesystem_authority.as_local() } - /// The Worker's runtime workspace root. This stays separate from `cwd` for - /// spawned children whose SpawnWorker `cwd` only changes tool defaults. - pub fn workspace_root(&self) -> &Path { - &self.workspace_root + /// Path-free workspace identity, if Runtime/host associated this Worker + /// with a workspace. + pub fn workspace_id(&self) -> Option<&WorkspaceId> { + self.workspace_context.workspace_id() + } + + /// Narrow workspace client/availability handle injected by Runtime/host. + /// This never grants local filesystem authority. + pub fn workspace_client(&self) -> &WorkspaceClient { + self.workspace_context.client() } pub(crate) fn worker_metadata_store(&self) -> St @@ -988,7 +1104,14 @@ impl Worker { } fn worker_metadata(&self, active: Option) -> WorkerMetadata { - worker_metadata_for_manifest(&self.manifest, &self.workspace_root, active) + worker_metadata_for_manifest( + &self.manifest, + self.workspace_id(), + self.filesystem_authority + .as_local() + .map(|local| local.root.as_path()), + active, + ) } fn write_worker_metadata_pending(&self) -> Result<(), WorkerError> { @@ -1363,10 +1486,15 @@ impl Worker { .map(|d| d.name) .collect() }; - let agents_md_read = read_agents_md(&self.workspace_root); - for warning in agents_md_read.warnings { - if let Some(n) = alerter.as_ref() { - n.alert(AlertLevel::Warn, AlertSource::AgentsMd, warning); + let agents_md_read = self + .filesystem_authority + .as_local() + .map(|local| read_agents_md(&local.root)); + if let Some(read) = agents_md_read.as_ref() { + for warning in &read.warnings { + if let Some(n) = alerter.as_ref() { + n.alert(AlertLevel::Warn, AlertSource::AgentsMd, warning.clone()); + } } } // Resident-injection collection. Each resident section has its own @@ -1429,7 +1557,7 @@ impl Worker { language: worker_language, scope: &scope_snapshot, tool_names, - agents_md: agents_md_read.body, + agents_md: agents_md_read.and_then(|read| read.body), resident_summary: resident_summary.as_deref(), resident_knowledge: resident_slice, resident_workflows: resident_workflow_slice, @@ -2963,6 +3091,15 @@ impl Worker { total_now.saturating_sub(total_at_pointer) } + fn local_memory_layout( + &self, + memory_cfg: &manifest::MemoryConfig, + ) -> Option { + self.filesystem_authority + .as_local() + .map(|local| memory::WorkspaceLayout::resolve(memory_cfg, &local.root)) + } + /// extract (memory.extract) post-run trigger. /// /// Called by the Controller before spawning the background memory task so @@ -2979,10 +3116,13 @@ impl Worker { let Some(memory_cfg) = self.manifest.memory.clone() else { return Ok(()); }; + let Some(layout) = self.local_memory_layout(&memory_cfg) else { + tracing::debug!("workspace memory extract unavailable: no local filesystem authority"); + return Ok(()); + }; // `Some(0)` means disabled, same as `None`. Otherwise the // `tokens_since >= 0` comparison would fire on every post-run. let Some(threshold) = memory_cfg.extract_threshold.filter(|n| *n > 0) else { - let layout = memory::WorkspaceLayout::resolve(&memory_cfg, &self.workspace_root); let model = memory_cfg .extract_model .as_ref() @@ -3012,7 +3152,6 @@ impl Worker { .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .is_err() { - let layout = memory::WorkspaceLayout::resolve(&memory_cfg, &self.workspace_root); let model = memory_cfg .extract_model .as_ref() @@ -3068,7 +3207,10 @@ impl Worker { ) -> Result { use memory::extract; - let layout = memory::WorkspaceLayout::resolve(memory_cfg, &self.workspace_root); + let Some(layout) = self.local_memory_layout(memory_cfg) else { + tracing::debug!("workspace memory extract unavailable: no local filesystem authority"); + return Ok(ExtractDecision::Skipped); + }; let model = memory_cfg .extract_model .as_ref() @@ -3383,6 +3525,12 @@ impl Worker { let Some(memory_cfg) = self.manifest.memory.clone() else { return Ok(()); }; + let Some(layout) = self.local_memory_layout(&memory_cfg) else { + tracing::debug!( + "workspace memory consolidation unavailable: no local filesystem authority" + ); + return Ok(()); + }; // `Some(0)` collapses to `None` — staging count / bytes always // satisfies `>= 0`, which would fire consolidation on every post-run. // Treating zero as disabled lines up with `extract_threshold` and @@ -3391,7 +3539,6 @@ impl Worker { let files_threshold = memory_cfg.consolidation_threshold_files.filter(|n| *n > 0); let bytes_threshold = memory_cfg.consolidation_threshold_bytes.filter(|n| *n > 0); if files_threshold.is_none() && bytes_threshold.is_none() { - let layout = memory::WorkspaceLayout::resolve(&memory_cfg, &self.workspace_root); let model = memory_cfg .consolidation_model .as_ref() @@ -3419,7 +3566,6 @@ impl Worker { .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .is_err() { - let layout = memory::WorkspaceLayout::resolve(&memory_cfg, &self.workspace_root); let model = memory_cfg .consolidation_model .as_ref() @@ -3472,7 +3618,12 @@ impl Worker { ) -> Result { use memory::consolidate; - let layout = memory::WorkspaceLayout::resolve(memory_cfg, &self.workspace_root); + let Some(layout) = self.local_memory_layout(memory_cfg) else { + tracing::debug!( + "workspace memory consolidation unavailable: no local filesystem authority" + ); + return Ok(ConsolidateDecision::Skipped); + }; let model = memory_cfg .consolidation_model .as_ref() @@ -3892,21 +4043,23 @@ where ) -> Result { let cwd = current_cwd()?; let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone()); - Self::from_manifest_with_context(manifest, store, loader, cwd, authority).await + let workspace_context = WorkerWorkspaceContext::local_filesystem(None); + Self::from_manifest_with_context(manifest, store, loader, workspace_context, authority) + .await } pub async fn from_manifest_with_context( manifest: WorkerManifest, store: St, loader: PromptLoader, - workspace_root: PathBuf, + workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, ) -> Result { let mut common = prepare_worker_common_with_context( &manifest, &loader, /* parse_template */ true, - workspace_root, + workspace_context, filesystem_authority, manifest.scope.clone(), )?; @@ -3947,7 +4100,7 @@ where worker_metadata_writer, segment_state: SegmentState::new(session_id, segment_id, 0), filesystem_authority: common.filesystem_authority, - workspace_root: common.workspace_root, + workspace_context: common.workspace_context, scope: SharedScope::new(common.scope), delegation_scope: common.delegation_scope, hook_builder: HookRegistryBuilder::new(), @@ -4007,12 +4160,13 @@ where ) -> Result { let cwd = current_cwd()?; let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone()); + let workspace_context = WorkerWorkspaceContext::local_filesystem(None); Self::from_manifest_spawned_with_context( manifest, store, loader, callback_socket, - cwd, + workspace_context, authority, ) .await @@ -4023,14 +4177,14 @@ where store: St, loader: PromptLoader, callback_socket: PathBuf, - workspace_root: PathBuf, + workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, ) -> Result { let mut common = prepare_worker_common_with_context( &manifest, &loader, /* parse_template */ true, - workspace_root, + workspace_context, filesystem_authority, manifest.scope.clone(), )?; @@ -4058,7 +4212,7 @@ where worker_metadata_writer, segment_state: SegmentState::new(session_id, segment_id, 0), filesystem_authority: common.filesystem_authority, - workspace_root: common.workspace_root, + workspace_context: common.workspace_context, scope: SharedScope::new(common.scope), delegation_scope: common.delegation_scope, hook_builder: HookRegistryBuilder::new(), @@ -4114,12 +4268,13 @@ where ) -> Result { let cwd = current_cwd()?; let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone()); + let workspace_context = WorkerWorkspaceContext::local_filesystem(None); Self::restore_from_worker_metadata_with_context( worker_name, manifest, store, loader, - cwd, + workspace_context, authority, ) .await @@ -4130,7 +4285,7 @@ where manifest: WorkerManifest, store: St, loader: PromptLoader, - workspace_root: PathBuf, + workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, ) -> Result { let metadata = @@ -4161,7 +4316,7 @@ where manifest, store, loader, - workspace_root, + workspace_context, filesystem_authority, ) .await @@ -4193,8 +4348,15 @@ where ) -> Result { let cwd = current_cwd()?; let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone()); + let workspace_context = WorkerWorkspaceContext::local_filesystem(None); Self::restore_from_manifest_with_context( - session_id, segment_id, manifest, store, loader, cwd, authority, + session_id, + segment_id, + manifest, + store, + loader, + workspace_context, + authority, ) .await } @@ -4205,7 +4367,7 @@ where manifest: WorkerManifest, store: St, loader: PromptLoader, - workspace_root: PathBuf, + workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, ) -> Result { // Read raw entries once so we can both reconstruct state and @@ -4223,7 +4385,7 @@ where &manifest, &loader, /* parse_template */ false, - workspace_root, + workspace_context, filesystem_authority, scope_config, )?; @@ -4289,7 +4451,7 @@ where worker_metadata_writer, segment_state: SegmentState::new(session_id, segment_id, state.entries_count), filesystem_authority: common.filesystem_authority, - workspace_root: common.workspace_root, + workspace_context: common.workspace_context, scope: SharedScope::new(common.scope), delegation_scope: common.delegation_scope, hook_builder: HookRegistryBuilder::new(), @@ -4441,11 +4603,17 @@ fn request_config_from_engine_manifest(wm: &manifest::EngineManifest) -> Request fn worker_metadata_for_manifest( manifest: &WorkerManifest, - workspace_root: &Path, + workspace_id: Option<&WorkspaceId>, + local_workspace_root: Option<&std::path::Path>, active: Option, ) -> WorkerMetadata { - let mut metadata = WorkerMetadata::new(manifest.worker.name.clone(), active) - .with_workspace_root(workspace_root.to_path_buf()); + let mut metadata = WorkerMetadata::new(manifest.worker.name.clone(), active); + if let Some(workspace_id) = workspace_id { + metadata = metadata.with_workspace_id(workspace_id.as_str().to_owned()); + } + if let Some(local_workspace_root) = local_workspace_root { + metadata = metadata.with_workspace_root(local_workspace_root.to_path_buf()); + } if should_persist_resolved_manifest_snapshot(manifest) { metadata.resolved_manifest_snapshot = serde_json::to_value(manifest).ok(); } @@ -4875,15 +5043,15 @@ pub enum WorkerError { #[error(transparent)] Scope(ScopeError), - #[error("workspace root is not readable under the configured scope: {}", .workspace_root.display())] - WorkspaceRootOutsideScope { workspace_root: PathBuf }, + #[error("local filesystem authority root is not readable under the configured scope: {}", .root.display())] + LocalFilesystemRootOutsideScope { root: PathBuf }, #[error("cwd is not readable under the configured scope: {}", .cwd.display())] CwdOutsideScope { cwd: PathBuf }, - #[error("failed to resolve workspace root {}: {source}", .workspace_root.display())] - InvalidWorkspaceRoot { - workspace_root: PathBuf, + #[error("failed to resolve local filesystem authority root {}: {source}", .root.display())] + InvalidLocalFilesystemRoot { + root: PathBuf, #[source] source: std::io::Error, }, @@ -4974,13 +5142,13 @@ pub enum WorkerError { } /// Bundle of resources that every high-level Worker constructor needs: -/// cwd, runtime workspace root, scope, an LLM client, the prompt catalog, +/// filesystem authority, path-free workspace context, scope, an LLM client, the prompt catalog, /// and (optionally) a parsed system-prompt template. Built once by /// [`prepare_worker_common_with_context`] from the resolved manifest and then split into Worker /// fields. struct WorkerCommon { filesystem_authority: WorkerFilesystemAuthority, - workspace_root: PathBuf, + workspace_context: WorkerWorkspaceContext, scope: Scope, delegation_scope: DelegationScope, client: Box, @@ -5067,22 +5235,16 @@ fn prepare_worker_common_with_context( manifest: &WorkerManifest, loader: &PromptLoader, parse_template: bool, - workspace_root: PathBuf, + workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, scope_config: ScopeConfig, ) -> Result { - let workspace_root = std::fs::canonicalize(&workspace_root).map_err(|source| { - WorkerError::InvalidWorkspaceRoot { - workspace_root: workspace_root.clone(), - source, - } - })?; let filesystem_authority = match filesystem_authority { WorkerFilesystemAuthority::None => WorkerFilesystemAuthority::None, WorkerFilesystemAuthority::Local(local) => { let root = std::fs::canonicalize(&local.root).map_err(|source| { - WorkerError::InvalidWorkspaceRoot { - workspace_root: local.root.clone(), + WorkerError::InvalidLocalFilesystemRoot { + root: local.root.clone(), source, } })?; @@ -5095,8 +5257,8 @@ fn prepare_worker_common_with_context( } }; let mut scope_config = scope_config; - if let Some(mem) = manifest.memory.as_ref() { - let layout = memory::WorkspaceLayout::resolve(mem, &workspace_root); + if let (Some(mem), Some(local)) = (manifest.memory.as_ref(), filesystem_authority.as_local()) { + let layout = memory::WorkspaceLayout::resolve(mem, &local.root); scope_config.deny.extend(memory::deny_write_rules(&layout)); scope_config .deny @@ -5108,7 +5270,7 @@ fn prepare_worker_common_with_context( manifest, loader, parse_template, - workspace_root, + workspace_context, filesystem_authority, scope, ) @@ -5118,14 +5280,16 @@ fn prepare_worker_common_from_scope( manifest: &WorkerManifest, loader: &PromptLoader, parse_template: bool, - workspace_root: PathBuf, + workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, scope: Scope, ) -> Result { - if !scope.is_readable(&workspace_root) { - return Err(WorkerError::WorkspaceRootOutsideScope { workspace_root }); - } if let Some(local) = filesystem_authority.as_local() { + if !scope.is_readable(&local.root) { + return Err(WorkerError::LocalFilesystemRootOutsideScope { + root: local.root.clone(), + }); + } if !scope.is_readable(&local.cwd) { return Err(WorkerError::CwdOutsideScope { cwd: local.cwd.clone(), @@ -5137,10 +5301,11 @@ fn prepare_worker_common_from_scope( let client = provider::build_client(&manifest.model)?; let prompts = PromptCatalog::load(loader, manifest.worker.prompt_pack.as_deref())?; - let memory_layout = manifest - .memory - .as_ref() - .map(|mem| memory::WorkspaceLayout::resolve(mem, &workspace_root)); + let memory_layout = manifest.memory.as_ref().and_then(|mem| { + filesystem_authority + .as_local() + .map(|local| memory::WorkspaceLayout::resolve(mem, &local.root)) + }); let mut workflow_registry = match memory_layout.as_ref() { Some(layout) => { workflow_crate::load_workflows(layout).map_err(WorkerError::WorkflowLoad)? @@ -5160,7 +5325,7 @@ fn prepare_worker_common_from_scope( Ok(WorkerCommon { filesystem_authority, - workspace_root, + workspace_context, scope, delegation_scope, client, @@ -5249,7 +5414,7 @@ mod spawned_context_tests { use super::*; #[test] - fn spawn_worker_context_keeps_workspace_root_separate_from_tool_pwd() { + fn spawn_worker_context_separates_workspace_identity_from_tool_pwd() { let tmp = tempfile::tempdir().unwrap(); let workspace_root = tmp.path().join("workspace-root"); let cwd = tmp.path().join("child-worktree"); @@ -5262,14 +5427,21 @@ mod spawned_context_tests { &manifest, &PromptLoader::builtins_only(), false, - workspace_root.clone(), + WorkerWorkspaceContext::local_filesystem(Some(WorkspaceId::new("ws-test").unwrap())), WorkerFilesystemAuthority::local(workspace_root.clone(), cwd.clone()), manifest.scope.clone(), ) .unwrap(); assert_eq!( - common.workspace_root, + common + .workspace_context + .workspace_id() + .map(WorkspaceId::as_str), + Some("ws-test") + ); + assert_eq!( + common.filesystem_authority.as_local().unwrap().root, workspace_root.canonicalize().unwrap() ); assert_eq!( @@ -5283,7 +5455,42 @@ mod spawned_context_tests { } #[test] - fn prepare_context_reports_workspace_root_when_workspace_root_is_unreadable() { + fn workspace_identity_and_client_do_not_grant_filesystem_authority() { + let tmp = tempfile::tempdir().unwrap(); + let workspace_root = tmp.path().join("workspace-root"); + let cwd = workspace_root.join("nested"); + std::fs::create_dir_all(&cwd).unwrap(); + let mut manifest = minimal_manifest_for_context_test(&workspace_root, &cwd); + manifest.memory = Some(manifest::MemoryConfig::default()); + let loader = PromptLoader::new(None, Some(workspace_root.clone())); + let workspace_id = WorkspaceId::new("ws-api-only").unwrap(); + let common = prepare_worker_common_with_context( + &manifest, + &loader, + false, + WorkerWorkspaceContext::with_client( + Some(workspace_id.clone()), + WorkspaceClient::available("test-api"), + ), + WorkerFilesystemAuthority::None, + manifest.scope.clone(), + ) + .unwrap(); + + assert_eq!(common.filesystem_authority, WorkerFilesystemAuthority::None); + assert_eq!( + common + .workspace_context + .workspace_id() + .map(WorkspaceId::as_str), + Some(workspace_id.as_str()) + ); + assert!(common.workspace_context.client().is_available()); + assert!(common.memory_layout.is_none()); + } + + #[test] + fn prepare_context_reports_local_filesystem_root_when_unreadable() { let tmp = tempfile::tempdir().unwrap(); let workspace_root = tmp.path().join("workspace-root"); let cwd = tmp.path().join("child-worktree"); @@ -5295,7 +5502,7 @@ mod spawned_context_tests { &manifest, &PromptLoader::builtins_only(), false, - workspace_root.clone(), + WorkerWorkspaceContext::local_filesystem(Some(WorkspaceId::new("ws-test").unwrap())), WorkerFilesystemAuthority::local(workspace_root.clone(), cwd.clone()), ScopeConfig { allow: vec![ScopeRule { @@ -5306,17 +5513,15 @@ mod spawned_context_tests { deny: Vec::new(), }, ) { - Ok(_) => panic!("expected workspace-root scope error"), + Ok(_) => panic!("expected local filesystem root scope error"), Err(err) => err, }; match err { - WorkerError::WorkspaceRootOutsideScope { - workspace_root: got, - } => { + WorkerError::LocalFilesystemRootOutsideScope { root: got } => { assert_eq!(got, workspace_root.canonicalize().unwrap()); } - other => panic!("expected workspace-root scope error, got {other:?}"), + other => panic!("expected local filesystem root scope error, got {other:?}"), } } @@ -5333,7 +5538,7 @@ mod spawned_context_tests { &manifest, &PromptLoader::builtins_only(), false, - workspace_root.clone(), + WorkerWorkspaceContext::local_filesystem(Some(WorkspaceId::new("ws-test").unwrap())), WorkerFilesystemAuthority::local(workspace_root.clone(), cwd.clone()), ScopeConfig { allow: vec![ScopeRule { @@ -5390,23 +5595,15 @@ mod worker_metadata_restore_manifest_tests { use super::*; #[test] - fn metadata_writer_persists_workspace_root_through_store_update() { + fn metadata_writer_persists_workspace_id_through_store_update() { let temp = tempfile::tempdir().unwrap(); let store = session_store::FsWorkerStore::new(temp.path().join("workers")).unwrap(); - let workspace_root = temp.path().join("workspace-root"); - std::fs::create_dir_all(&workspace_root).unwrap(); let writer = worker_metadata_writer_for_store(&store); - writer( - WorkerMetadata::new("runtime-worker", None).with_workspace_root(workspace_root.clone()), - ) - .unwrap(); + writer(WorkerMetadata::new("runtime-worker", None).with_workspace_id("ws-test")).unwrap(); let stored = store.read_by_name("runtime-worker").unwrap().unwrap(); - assert_eq!( - stored.workspace_root.as_deref(), - Some(workspace_root.as_path()) - ); + assert_eq!(stored.workspace_id.as_deref(), Some("ws-test")); } #[test] @@ -5503,7 +5700,7 @@ permission = "read" .unwrap(); assert!(manifest.profile.is_none()); assert!( - worker_metadata_for_manifest(&manifest, Path::new("/snapshot/workspace"), None) + worker_metadata_for_manifest(&manifest, None, None, None) .resolved_manifest_snapshot .is_none() ); @@ -5540,8 +5737,7 @@ permission = "read" config: None, }]; - let metadata = - worker_metadata_for_manifest(&manifest, Path::new("/snapshot/workspace"), None); + let metadata = worker_metadata_for_manifest(&manifest, None, None, None); let snapshot = metadata .resolved_manifest_snapshot .expect("plugin-resolved manifest should be snapshotted"); @@ -5800,7 +5996,7 @@ mod build_summary_prompt_tests { manifest, Engine::new(NoopClient), store, - cwd.clone(), + WorkerWorkspaceContext::local_filesystem(None), authority, scope, ) @@ -5956,7 +6152,7 @@ mod build_summary_prompt_tests { manifest, Engine::new(NoopClient), store, - cwd.clone(), + WorkerWorkspaceContext::local_filesystem(None), authority, scope, ) @@ -6091,7 +6287,7 @@ mod build_summary_prompt_tests { manifest, Engine::new(NoopClient), store, - cwd.clone(), + WorkerWorkspaceContext::local_filesystem(None), authority, scope, ) diff --git a/crates/worker/tests/compact_events_test.rs b/crates/worker/tests/compact_events_test.rs index 2e3da105..933b7e85 100644 --- a/crates/worker/tests/compact_events_test.rs +++ b/crates/worker/tests/compact_events_test.rs @@ -168,7 +168,7 @@ async fn make_worker_with_manifest( manifest, worker, store, - pwd.clone(), + worker::WorkerWorkspaceContext::local_filesystem(None), worker::WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()), scope, ) diff --git a/crates/worker/tests/consolidation_test.rs b/crates/worker/tests/consolidation_test.rs index 1d71fe8d..ecf02a4f 100644 --- a/crates/worker/tests/consolidation_test.rs +++ b/crates/worker/tests/consolidation_test.rs @@ -174,7 +174,7 @@ async fn make_worker_with( manifest, worker, store, - pwd.clone(), + worker::WorkerWorkspaceContext::local_filesystem(None), worker::WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()), scope, ) diff --git a/crates/worker/tests/controller_test.rs b/crates/worker/tests/controller_test.rs index 429209a3..e5d08346 100644 --- a/crates/worker/tests/controller_test.rs +++ b/crates/worker/tests/controller_test.rs @@ -14,7 +14,7 @@ use session_store::{FsStore, LogEntry}; use worker::{ Event, Method, Worker, WorkerController, WorkerFilesystemAuthority, WorkerHandle, - WorkerManifest, WorkerStatus, + WorkerManifest, WorkerStatus, WorkerWorkspaceContext, }; type TestStore = CombinedStore; @@ -190,9 +190,16 @@ async fn make_worker_with_pwd_and_manifest( let worker = Engine::new(client); let authority = WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()); - let worker = Worker::new(manifest, worker, store, pwd.clone(), authority, scope) - .await - .unwrap(); + let worker = Worker::new( + manifest, + worker, + store, + WorkerWorkspaceContext::local_filesystem(None), + authority, + scope, + ) + .await + .unwrap(); (worker, pwd) } diff --git a/crates/worker/tests/session_metrics_test.rs b/crates/worker/tests/session_metrics_test.rs index 950b90f5..25bc3364 100644 --- a/crates/worker/tests/session_metrics_test.rs +++ b/crates/worker/tests/session_metrics_test.rs @@ -194,7 +194,7 @@ async fn make_worker( manifest, worker, store, - pwd.clone(), + worker::WorkerWorkspaceContext::local_filesystem(None), worker::WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()), scope, ) @@ -464,7 +464,7 @@ async fn metric_write_failure_emits_warn_alert_and_does_not_abort_run() { manifest, worker, store.clone(), - pwd.clone(), + worker::WorkerWorkspaceContext::local_filesystem(None), worker::WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()), scope, ) @@ -540,7 +540,7 @@ permission = "write" manifest, worker, store.clone(), - pwd.clone(), + worker::WorkerWorkspaceContext::local_filesystem(None), worker::WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()), scope, ) diff --git a/crates/worker/tests/system_prompt_template_test.rs b/crates/worker/tests/system_prompt_template_test.rs index 0094b6be..e746fb0f 100644 --- a/crates/worker/tests/system_prompt_template_test.rs +++ b/crates/worker/tests/system_prompt_template_test.rs @@ -127,7 +127,7 @@ async fn make_worker_with_body( manifest, worker, store, - pwd.clone(), + worker::WorkerWorkspaceContext::local_filesystem(None), worker::WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()), scope, ) diff --git a/package.nix b/package.nix index b86162f6..dfe5d9e9 100644 --- a/package.nix +++ b/package.nix @@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec { filter = sourceFilter; }; - cargoHash = "sha256-vM2ea6W3hqnmzqOXfzQo+ZYrQBoy2Kh69vz1fkU+DIs="; + cargoHash = "sha256-av+Iix50MMLcrzxw8UDTT0tUqBFIPkSN1i23WYegNcM="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint, From 0736de6451035ea856bb552f296becb5f2d442bd Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 11 Jul 2026 10:00:03 +0900 Subject: [PATCH 13/15] ticket: record workspace backend implementation --- .yoi/tickets/00001KX6Y6ZEA/item.md | 2 +- .yoi/tickets/00001KX6Y6ZEA/thread.md | 52 ++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KX6Y6ZEA/item.md b/.yoi/tickets/00001KX6Y6ZEA/item.md index 6f90b258..be869b98 100644 --- a/.yoi/tickets/00001KX6Y6ZEA/item.md +++ b/.yoi/tickets/00001KX6Y6ZEA/item.md @@ -3,7 +3,7 @@ title: 'Replace Worker workspace_root path with WorkspaceBackend' state: 'inprogress' priority: 'P1' created_at: '2026-07-10T21:16:22Z' -updated_at: '2026-07-11T00:15:11Z' +updated_at: '2026-07-11T00:59:55Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-10T22:03:27Z' diff --git a/.yoi/tickets/00001KX6Y6ZEA/thread.md b/.yoi/tickets/00001KX6Y6ZEA/thread.md index 0c4870b2..85f32f1e 100644 --- a/.yoi/tickets/00001KX6Y6ZEA/thread.md +++ b/.yoi/tickets/00001KX6Y6ZEA/thread.md @@ -221,3 +221,55 @@ Critical risks / reviewer focus: Prerequisite and prior conflict/migration-boundary Ticket are now closed. This Ticket is accepted for implementation before creating a worktree or spawning role Pods. --- + + + +## Implementation report + +Implementation progress report: + +- Coder Pod `yoi-coder-00001KX6Y6ZEA-workspace-backend` completed implementation in `/home/hare/Projects/yoi/.worktree/00001KX6Y6ZEA-workspace-backend` on branch `work/00001KX6Y6ZEA-workspace-backend`。 +- Implementation commit: `142b60e1b3ca refactor: separate worker workspace identity`。 +- Orchestrator inspected worktree status, branch log, commit stats, `git diff --check 74b07bd7..HEAD`, and workspace-root accessor grep. Worktree was clean and diff check passed。 + +Implementation summary: +- Replaced Worker-held `workspace_root: PathBuf` identity with path-free `WorkerWorkspaceContext`, `WorkspaceId`, and `WorkspaceClient`。 +- Kept local filesystem authority under `WorkerFilesystemAuthority::{None, Local(LocalWorkingDirectory)}`。 +- Updated Worker constructors / restore / spawn paths to receive workspace context separately from filesystem authority。 +- Added runtime-side `RuntimeWorkspaceBackendRef` as host-owned local backend binding that injects narrow Worker workspace context。 +- No-filesystem Workers keep memory/workflow local layouts unavailable/fail-closed rather than falling back to workspace paths。 +- Preserved legacy metadata `workspace_root` only as a local filesystem hint derived from `WorkerFilesystemAuthority::Local`, not as Worker identity。 +- Updated tests for path-free workspace identity/client separation from local tool cwd/root, workspace client without filesystem authority, no local path-returning Worker workspace accessor, and constructor usage。 + +Files touched: +- `crates/worker/src/worker.rs` +- `crates/worker/src/controller.rs` +- `crates/worker/src/entrypoint.rs` +- `crates/worker/src/lib.rs` +- `crates/worker/src/discovery.rs` +- `crates/worker/src/ticket_event_notify.rs` +- `crates/worker/tests/*` +- `crates/worker-runtime/src/worker_backend.rs` +- `crates/worker-runtime/Cargo.toml` +- `crates/session-store/src/worker_metadata.rs` +- `Cargo.lock` +- `package.nix` + +Coder-reported validation passed: +- `rg -n "workspace_root" crates/worker crates/worker-runtime crates/client crates/workspace-server` with remaining hits as legacy metadata/local filesystem adapter or host/runtime local backend boundaries plus tests。 +- `rg -n "worker\\.workspace_root|pub fn workspace_root|fn workspace_root" crates/worker crates/worker-runtime crates/client crates/workspace-server || true`; no Worker `workspace_root()` accessor hits. Orchestrator grep observed an unrelated `crates/workspace-server/src/records.rs` project-record accessor hit outside Worker identity。 +- `git diff --check` +- `cargo test -p worker --lib --tests` +- `cargo test -p worker-runtime --features ws-server,fs-store` +- `cargo test -p yoi-workspace-server --lib` +- `cargo check -p yoi` +- `yoi ticket doctor` +- `nix build .#yoi --no-link` + +Not run by Coder: +- `cd web/workspace && deno task check && deno task test` because web/API/types were not touched。 + +Next action: +- Route to external Reviewer Pod before merge/close decisions。 + +--- From 5d4cb9b3fe4ac1e5de554bfadf49f68665825e22 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 11 Jul 2026 10:07:12 +0900 Subject: [PATCH 14/15] ticket: approve workspace backend implementation --- .yoi/tickets/00001KX6Y6ZEA/item.md | 2 +- .yoi/tickets/00001KX6Y6ZEA/thread.md | 30 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KX6Y6ZEA/item.md b/.yoi/tickets/00001KX6Y6ZEA/item.md index be869b98..7e4eb2cf 100644 --- a/.yoi/tickets/00001KX6Y6ZEA/item.md +++ b/.yoi/tickets/00001KX6Y6ZEA/item.md @@ -3,7 +3,7 @@ title: 'Replace Worker workspace_root path with WorkspaceBackend' state: 'inprogress' priority: 'P1' created_at: '2026-07-10T21:16:22Z' -updated_at: '2026-07-11T00:59:55Z' +updated_at: '2026-07-11T01:07:06Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-10T22:03:27Z' diff --git a/.yoi/tickets/00001KX6Y6ZEA/thread.md b/.yoi/tickets/00001KX6Y6ZEA/thread.md index 85f32f1e..b22ade57 100644 --- a/.yoi/tickets/00001KX6Y6ZEA/thread.md +++ b/.yoi/tickets/00001KX6Y6ZEA/thread.md @@ -273,3 +273,33 @@ Next action: - Route to external Reviewer Pod before merge/close decisions。 --- + + + +## Review: approve + +External review result: approve + +Evidence: +- Worker identity is path-free: `WorkerWorkspaceContext` carries `Option` plus narrow `WorkspaceClient`/unavailable state, while `Worker` stores `workspace_context` and `filesystem_authority` instead of a `workspace_root: PathBuf` (`crates/worker/src/worker.rs:61-109`, `384-405`)。 +- No `worker.workspace_root()` / local path-returning workspace accessor remains; local paths are exposed only through `WorkerFilesystemAuthority::Local` helpers such as `local_working_directory()` (`worker.rs:741-840`)。 +- Runtime/host boundary now resolves local workspace backend context before constructing Workers (`crates/worker-runtime/src/worker_backend.rs:321-390`), and Worker construction accepts injected workspace context + filesystem authority (`worker.rs:4219-4260`)。 +- Workspace/client without filesystem and no-workspace/no-filesystem Workers are represented and covered by tests (`worker.rs:5441-5530`; worker-runtime tests around `901+`)。 +- Workspace-aware local features fail closed when no local filesystem authority is available; Ticket/memory/workflow/spawn-dependent surfaces require `filesystem_authority.local()` and otherwise return unavailable errors rather than cwd/root fallback (`crates/worker/src/controller.rs:630-672`, `709-779`)。 +- Legacy `workspace_root` metadata is written only as a compatibility/local hint from local filesystem authority, not as Worker identity (`worker.rs:4604-4621`; `session-store/src/worker_metadata.rs`)。 + +Validation performed: +- `git diff --check 142b60e1^ 142b60e1` +- Focused grep for forbidden `workspace_root()` accessor: no matches。 +- Focused tests: + - `cargo test -p worker spawned_context_tests --lib` + - `cargo test -p worker-runtime worker_spawn_receives_materialized_workspace_cwd_instead_of_source_repo --features ws-server,fs-store` + - `cargo test -p worker-runtime adapter_dispatches_user_input_through_worker_run_lifecycle --features ws-server,fs-store` + +Blockers: none。 + +Non-blocking notes: +- Remaining `workspace_root` hits are local runtime/entrypoint/spawn-tool adapter, legacy metadata, or test contexts; reviewer did not find Worker identity/accessor use。 +- `set_active_with_workspace_context` preserves existing metadata fields when passed `None`; acceptable for this Ticket as compatibility metadata, but future no-workspace reuse of an existing Pod name may want explicit clearing semantics to avoid stale UI hints。 + +--- From 7d3b3647286d67dac3a77b883d3fd0baf27c4e57 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 11 Jul 2026 10:10:05 +0900 Subject: [PATCH 15/15] ticket: close workspace backend work --- .yoi/tickets/00001KX6Y6ZEA/item.md | 4 +- .yoi/tickets/00001KX6Y6ZEA/resolution.md | 32 +++++++++ .yoi/tickets/00001KX6Y6ZEA/thread.md | 90 ++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 .yoi/tickets/00001KX6Y6ZEA/resolution.md diff --git a/.yoi/tickets/00001KX6Y6ZEA/item.md b/.yoi/tickets/00001KX6Y6ZEA/item.md index 7e4eb2cf..b555d218 100644 --- a/.yoi/tickets/00001KX6Y6ZEA/item.md +++ b/.yoi/tickets/00001KX6Y6ZEA/item.md @@ -1,9 +1,9 @@ --- title: 'Replace Worker workspace_root path with WorkspaceBackend' -state: 'inprogress' +state: 'closed' priority: 'P1' created_at: '2026-07-10T21:16:22Z' -updated_at: '2026-07-11T01:07:06Z' +updated_at: '2026-07-11T01:09:59Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-10T22:03:27Z' diff --git a/.yoi/tickets/00001KX6Y6ZEA/resolution.md b/.yoi/tickets/00001KX6Y6ZEA/resolution.md new file mode 100644 index 00000000..59e440d6 --- /dev/null +++ b/.yoi/tickets/00001KX6Y6ZEA/resolution.md @@ -0,0 +1,32 @@ +Worker workspace identity / WorkspaceBackend boundary を実装・レビュー・merge・検証した。 + +実装内容: +- Worker-held `workspace_root: PathBuf` identity を削除し、path-free `WorkerWorkspaceContext`, `WorkspaceId`, `WorkspaceClient` に置き換えた。 +- Worker は `workspace_context` と `filesystem_authority` を分離して保持し、local filesystem authority は `WorkerFilesystemAuthority::{None, Local(LocalWorkingDirectory)}` に閉じ込めた。 +- Worker constructors / restore / spawn paths を workspace context と filesystem authority の分離形に更新した。 +- Runtime 側に `RuntimeWorkspaceBackendRef` を追加し、host-owned local backend binding から narrow Worker workspace context を注入するようにした。 +- `workspace_id/client` が存在しても local filesystem authority を意味しないようにした。 +- no-filesystem Workers では memory/workflow local layouts を workspace path fallback せず unavailable/fail-closed にした。 +- legacy metadata `workspace_root` は compatibility/local filesystem hint として、`WorkerFilesystemAuthority::Local` 由来の場合だけ扱うようにした。 +- tests で path-free workspace identity/client と local cwd/root の分離、filesystem authority なしの workspace client、local path-returning Worker workspace accessor 不在、constructor/restore/spawn behavior を確認した。 + +Review: +- Reviewer approved with no blockers。 +- Evidence included path-free Worker context, no Worker `workspace_root()` accessor, Runtime/host backend materialization boundary, workspace-client-without-filesystem tests, fail-closed local workspace features, and legacy metadata confinement。 +- Non-blocking note: `set_active_with_workspace_context` preserves existing metadata fields when passed `None`; future no-workspace reuse of an existing Pod name may want explicit clearing semantics to avoid stale UI hints。 + +Merge / validation: +- Merge commit: `391f11fc merge: workspace backend worker context`。 +- Final validation passed: + - `rg -n 'workspace_root' crates/worker crates/worker-runtime crates/client crates/workspace-server || true` + - `rg -n 'worker\\.workspace_root|pub fn workspace_root|fn workspace_root' crates/worker crates/worker-runtime crates/client crates/workspace-server || true` + - `git diff --check` + - `cargo test -p worker --lib --tests` + - `cargo test -p worker-runtime --features ws-server,fs-store` + - `cargo test -p yoi-workspace-server --lib` + - `cargo check -p yoi` + - `cd web/workspace && deno task check` + - `cd web/workspace && deno task test` + - `yoi ticket doctor` + - `nix build .#yoi --no-link` +- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/workspace-backend-final-validation-1783732052.txt` \ No newline at end of file diff --git a/.yoi/tickets/00001KX6Y6ZEA/thread.md b/.yoi/tickets/00001KX6Y6ZEA/thread.md index b22ade57..7128b463 100644 --- a/.yoi/tickets/00001KX6Y6ZEA/thread.md +++ b/.yoi/tickets/00001KX6Y6ZEA/thread.md @@ -303,3 +303,93 @@ Non-blocking notes: - `set_active_with_workspace_context` preserves existing metadata fields when passed `None`; acceptable for this Ticket as compatibility metadata, but future no-workspace reuse of an existing Pod name may want explicit clearing semantics to avoid stale UI hints。 --- + + + +## Implementation report + +Merge / final validation report: + +- Reviewer approved the implementation with no blockers。 +- Merged branch `work/00001KX6Y6ZEA-workspace-backend` into orchestration branch with merge commit `391f11fc merge: workspace backend worker context`。 +- Final validation was rerun in `/home/hare/Projects/yoi/.worktree/orchestration` after merge。 + +Validation passed: +- `rg -n 'workspace_root' crates/worker crates/worker-runtime crates/client crates/workspace-server || true` with remaining hits limited to legacy metadata/local adapter/test contexts。 +- `rg -n 'worker\\.workspace_root|pub fn workspace_root|fn workspace_root' crates/worker crates/worker-runtime crates/client crates/workspace-server || true`; no Worker identity/accessor hits were found. The expected unrelated workspace-server project-record accessor remains outside Worker identity。 +- `git diff --check` +- `cargo test -p worker --lib --tests` +- `cargo test -p worker-runtime --features ws-server,fs-store` +- `cargo test -p yoi-workspace-server --lib` +- `cargo check -p yoi` +- `cd web/workspace && deno task check` +- `cd web/workspace && deno task test` +- `yoi ticket doctor` +- `nix build .#yoi --no-link` + +Validation log: +- `/run/user/1000/yoi/yoi-orchestrator/bash-output/workspace-backend-final-validation-1783732052.txt` + +Reviewed/merged implementation commit: +- `142b60e1 refactor: separate worker workspace identity` + +Non-blocking reviewer note: +- `set_active_with_workspace_context` preserves existing metadata fields when passed `None`; acceptable for this Ticket as compatibility metadata, but future no-workspace reuse of an existing Pod name may want explicit clearing semantics to avoid stale UI hints。 + +--- + + + +## State changed + +Implementation was reviewed, merged into orchestration branch, and final validation passed. Moving to done before closure. + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +Worker workspace identity / WorkspaceBackend boundary を実装・レビュー・merge・検証した。 + +実装内容: +- Worker-held `workspace_root: PathBuf` identity を削除し、path-free `WorkerWorkspaceContext`, `WorkspaceId`, `WorkspaceClient` に置き換えた。 +- Worker は `workspace_context` と `filesystem_authority` を分離して保持し、local filesystem authority は `WorkerFilesystemAuthority::{None, Local(LocalWorkingDirectory)}` に閉じ込めた。 +- Worker constructors / restore / spawn paths を workspace context と filesystem authority の分離形に更新した。 +- Runtime 側に `RuntimeWorkspaceBackendRef` を追加し、host-owned local backend binding から narrow Worker workspace context を注入するようにした。 +- `workspace_id/client` が存在しても local filesystem authority を意味しないようにした。 +- no-filesystem Workers では memory/workflow local layouts を workspace path fallback せず unavailable/fail-closed にした。 +- legacy metadata `workspace_root` は compatibility/local filesystem hint として、`WorkerFilesystemAuthority::Local` 由来の場合だけ扱うようにした。 +- tests で path-free workspace identity/client と local cwd/root の分離、filesystem authority なしの workspace client、local path-returning Worker workspace accessor 不在、constructor/restore/spawn behavior を確認した。 + +Review: +- Reviewer approved with no blockers。 +- Evidence included path-free Worker context, no Worker `workspace_root()` accessor, Runtime/host backend materialization boundary, workspace-client-without-filesystem tests, fail-closed local workspace features, and legacy metadata confinement。 +- Non-blocking note: `set_active_with_workspace_context` preserves existing metadata fields when passed `None`; future no-workspace reuse of an existing Pod name may want explicit clearing semantics to avoid stale UI hints。 + +Merge / validation: +- Merge commit: `391f11fc merge: workspace backend worker context`。 +- Final validation passed: + - `rg -n 'workspace_root' crates/worker crates/worker-runtime crates/client crates/workspace-server || true` + - `rg -n 'worker\\.workspace_root|pub fn workspace_root|fn workspace_root' crates/worker crates/worker-runtime crates/client crates/workspace-server || true` + - `git diff --check` + - `cargo test -p worker --lib --tests` + - `cargo test -p worker-runtime --features ws-server,fs-store` + - `cargo test -p yoi-workspace-server --lib` + - `cargo check -p yoi` + - `cd web/workspace && deno task check` + - `cd web/workspace && deno task test` + - `yoi ticket doctor` + - `nix build .#yoi --no-link` +- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/workspace-backend-final-validation-1783732052.txt` + +---