From 533477c2e724b98d13a25ec0c7ad5ef5975272a1 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 22 Oct 2026 01:29:39 +0900 Subject: [PATCH] feat: unify headless execution under the Standalone Host Expose an optional private same-uid Unix operator adapter and JSONL serve/attach CLI using the production Standalone Profile, store, restore lease and shutdown lifecycle. Remove direct Worker subprocess/discovery/readiness/controller sockets and parent socket callbacks; retain metadata-only peer registration and legacy safety readers. Validated real Host/client input, events, provenance, disconnect, admitted shutdown and shared production restore/duplicate exclusion; normal standalone/worker/manifest/protocol/client/yoi/tui/server tests, root check, rustdoc, fmt and state-contract audit. Runtime lib: 476 passed, damaged_checkout_does_not_block_restore_but_is_rejected_when_used fails at missing_git diagnostic; unchanged restore logic, not relaxed or retried. Runtime bins/docs pass. --- Cargo.lock | 3 +- crates/manifest/src/paths.rs | 39 +- crates/protocol/src/lib.rs | 27 +- crates/standalone/Cargo.toml | 2 +- crates/standalone/README.md | 3 +- crates/standalone/src/host.rs | 49 +- crates/standalone/src/lib.rs | 38 + .../journal/tests/connected_compaction.rs | 1 - crates/standalone/src/unix.rs | 296 +++ crates/standalone/tests/host.rs | 229 ++ crates/worker-runtime/src/worker_backend.rs | 37 +- crates/worker/Cargo.toml | 1 - crates/worker/src/bootstrap.rs | 35 +- crates/worker/src/controller.rs | 738 +------ .../src/controller_subject_release_tests.rs | 1 - crates/worker/src/discovery.rs | 1861 ----------------- crates/worker/src/entrypoint.rs | 1099 ---------- crates/worker/src/ipc/event.rs | 68 +- crates/worker/src/ipc/mod.rs | 1 - crates/worker/src/ipc/server.rs | 224 -- crates/worker/src/lib.rs | 8 +- crates/worker/src/peer.rs | 186 ++ crates/worker/src/profile_manifest.rs | 183 ++ crates/worker/src/runtime/dir.rs | 310 +-- .../src/runtime/worker_allocation/table.rs | 48 +- crates/worker/src/runtime_command.rs | 165 -- crates/worker/src/spawn/comm_tools.rs | 181 -- crates/worker/src/spawn/mod.rs | 1 - crates/worker/src/subjektiv.rs | 8 +- crates/worker/src/worker.rs | 176 +- crates/worker/tests/controller_test.rs | 489 ++--- crates/worker/tests/worker_events_test.rs | 40 +- crates/workspace-server/src/server.rs | 1 - .../src/server_assignment_attention_tests.rs | 1 - .../src/server_assignment_delivery_tests.rs | 1 - crates/yoi/Cargo.toml | 2 +- crates/yoi/src/cli_connection.rs | 8 +- crates/yoi/src/local_cli.rs | 273 +++ crates/yoi/src/main.rs | 353 ++-- crates/yoi/src/worker_cleanup_cli.rs | 7 +- 40 files changed, 1830 insertions(+), 5363 deletions(-) create mode 100644 crates/standalone/src/unix.rs delete mode 100644 crates/worker/src/discovery.rs delete mode 100644 crates/worker/src/entrypoint.rs delete mode 100644 crates/worker/src/ipc/server.rs create mode 100644 crates/worker/src/peer.rs create mode 100644 crates/worker/src/profile_manifest.rs delete mode 100644 crates/worker/src/runtime_command.rs delete mode 100644 crates/worker/src/spawn/comm_tools.rs create mode 100644 crates/yoi/src/local_cli.rs diff --git a/Cargo.lock b/Cargo.lock index fa56fd6d7..fb65b50ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6521,7 +6521,6 @@ dependencies = [ "axum", "base64 0.22.1", "chrono", - "clap", "client", "config-source", "dotenv", @@ -6687,6 +6686,7 @@ dependencies = [ "futures", "manifest", "project-record", + "protocol", "reqwest", "serde", "serde_json", @@ -6702,7 +6702,6 @@ dependencies = [ "tui", "uuid", "workdir", - "worker", ] [[package]] diff --git a/crates/manifest/src/paths.rs b/crates/manifest/src/paths.rs index abd79e343..e0c8cd736 100644 --- a/crates/manifest/src/paths.rs +++ b/crates/manifest/src/paths.rs @@ -100,21 +100,6 @@ pub fn worker_allocation_path() -> Option { worker_allocation_path_from_runtime_dir(runtime_dir()) } -/// `//` — per-Worker runtime directory. -pub fn worker_runtime_dir(worker_name: &str) -> Option { - worker_runtime_dir_from_runtime_dir(runtime_dir(), worker_name) -} - -/// `//sock` — Worker's Unix socket path. -/// -/// The `worker` crate's `RuntimeDir::socket_path()` creates the socket inside the -/// Worker process. This function **predicts** the path for external callers that -/// know the Worker name, such as the TUI attach flow. Both should return the same -/// path. -pub fn worker_socket_path(worker_name: &str) -> Option { - worker_socket_path_from_runtime_dir(runtime_dir(), worker_name) -} - // ---- internals -------------------------------------------------------------- fn resolve_config_dir_from_parts( @@ -204,20 +189,6 @@ fn worker_allocation_path_from_runtime_dir(runtime_dir: Option) -> Opti Some(runtime_dir?.join("workers.json")) } -fn worker_runtime_dir_from_runtime_dir( - runtime_dir: Option, - worker_name: &str, -) -> Option { - Some(runtime_dir?.join(worker_name)) -} - -fn worker_socket_path_from_runtime_dir( - runtime_dir: Option, - worker_name: &str, -) -> Option { - Some(worker_runtime_dir_from_runtime_dir(runtime_dir, worker_name)?.join("sock")) -} - /// Treats empty environment values as unset. `std::env::var` distinguishes /// `Ok("")` from `Err(NotPresent)`, but path resolution treats both as unset /// for intuitive behavior. @@ -455,16 +426,8 @@ mod tests { PathBuf::from("/sand/sessions") ); assert_eq!( - worker_allocation_path_from_runtime_dir(runtime_dir.clone()).unwrap(), + worker_allocation_path_from_runtime_dir(runtime_dir).unwrap(), PathBuf::from("/sand/run/workers.json") ); - assert_eq!( - worker_runtime_dir_from_runtime_dir(runtime_dir.clone(), "foo").unwrap(), - PathBuf::from("/sand/run/foo") - ); - assert_eq!( - worker_socket_path_from_runtime_dir(runtime_dir, "foo").unwrap(), - PathBuf::from("/sand/run/foo/sock") - ); } } diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 8be8c3d9a..ae54c455e 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -52,7 +52,7 @@ pub enum AuthenticatedInputSource { /// Assigned whenever a serialized tracked method crosses an untrusted /// protocol boundary. Receivers must handle it exactly like public input. UntrustedWire, - /// Stamped only by the operator-owned standalone in-process transport. + /// Stamped only by the operator-owned Standalone Host after local admission. /// This is not a Backend Account or authority accepted from wire fields. LocalOperator, Account { @@ -399,15 +399,12 @@ pub enum Method { /// Typed lifecycle events sent from a child Worker to its parent. /// -/// Delivered as `Method::WorkerEvent` over the parent's Unix socket. The -/// parent Controller always applies variant-specific side effects -/// (registry / worker-allocation updates). Agent-visible variants are also -/// queued into the notification buffer; control-plane-only variants are -/// not injected into the parent's LLM context. +/// Delivered in-process to the parent Controller's protocol intake. Agent-visible variants +/// are queued into the notification buffer. Reports are advisory: receivers must check the +/// actual child session before treating its work as complete. /// -/// Transport is fire-and-forget; receivers must tolerate out-of-order -/// delivery (e.g. `TurnEnded` arriving after `ShutDown` for the same -/// child Worker). +/// Retained legacy control-only variants can be decoded from saved history, but do not +/// register live children, delegate scope, or establish a socket callback. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] @@ -429,16 +426,8 @@ pub enum WorkerEvent { /// Child has stopped (controller loop is exiting). ShutDown { worker_name: String }, - /// Child SubWorker sub-delegated scope to a grandchild SubWorker via `SubWorkerSpawn`. - /// - /// Control-plane only: receivers apply registry side effects and - /// propagate upward, but do not expose this as an agent notification. - /// - /// The parent uses this to add the grandchild to its own - /// `spawned_workers.json` so it can manage the grandchild directly - /// even if the intermediate child dies. The parent then re-fires - /// this event upward (if it has a parent of its own) to maintain - /// the chain to root. + /// Legacy process-child scope report retained for saved Session decoding only. + /// Live Controllers ignore it; only the Host-owned Internal registry grants child scope. ScopeSubDelegated { /// Sub-delegating Worker (= the sender itself). parent_worker: String, diff --git a/crates/standalone/Cargo.toml b/crates/standalone/Cargo.toml index bf05d8375..599d4a565 100644 --- a/crates/standalone/Cargo.toml +++ b/crates/standalone/Cargo.toml @@ -23,7 +23,7 @@ serde.workspace = true serde_json.workspace = true session-store.workspace = true thiserror.workspace = true -tokio = { workspace = true, features = ["rt", "sync", "time"] } +tokio = { workspace = true, features = ["rt", "sync", "time", "net", "io-util", "io-std", "signal"] } uuid = { workspace = true, features = ["v7"] } worker.workspace = true diff --git a/crates/standalone/README.md b/crates/standalone/README.md index 2425ec0fe..fdf71d85c 100644 --- a/crates/standalone/README.md +++ b/crates/standalone/README.md @@ -4,7 +4,8 @@ A Host that starts existing Workers in the same process. Assembles interactive W ## Reference index -- [`host`] — bootstrap and confirmed shutdown. +- [`host`] — bootstrap and confirmed shutdown, with or without a TUI. +- [`unix`] — optional operator transport; disconnect does not release execution ownership. - [`launch`] — Profile, cwd, and Subject validation before launch. - [`store`] — Worker records and leases. - [`jobs`] — Job execution and independent result and cleanup lifetimes. diff --git a/crates/standalone/src/host.rs b/crates/standalone/src/host.rs index 6d55edb67..070a3e214 100644 --- a/crates/standalone/src/host.rs +++ b/crates/standalone/src/host.rs @@ -24,7 +24,6 @@ use session_store::{ }; use thiserror::Error; use worker::bootstrap::{WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout}; -use worker::controller::WorkerControllerTransport; use worker::ipc::protocol_session::{ WorkerProtocolSessionStreams, dispatch_worker_protocol_method, live_log_entry_event, subscribe_worker_protocol_session, @@ -222,7 +221,6 @@ impl StandaloneHost { workspace_context, filesystem_authority, WorkerBootstrapLayout::Direct { runtime_base }, - WorkerControllerTransport::InProcess, ); if let Some(subject) = &subject { bootstrap = bootstrap.with_subjektiv_host(subject.host.clone()); @@ -430,7 +428,6 @@ impl StandaloneHost { workspace_context, filesystem_authority, WorkerBootstrapLayout::Direct { runtime_base }, - WorkerControllerTransport::InProcess, ); if let Some(subject) = &subject { bootstrap = bootstrap.with_subjektiv_host(subject.host.clone()); @@ -520,10 +517,7 @@ impl StandaloneHost { /// Working events, committed session entries, alert snapshots, and the /// initial history snapshot are merged behind the client boundary. pub fn connect(&self) -> Client { - let streams = subscribe_worker_protocol_session(&self.handle); - let (socket, peer) = InProcessSocket::pair(); - tokio::spawn(run_protocol_session(self.handle.clone(), streams, peer)); - Client::new(socket) + Client::new(connect_protocol(self.handle.clone())) } /// Logical Subject disconnection, not Worker shutdown or completion of its represented work. @@ -565,6 +559,35 @@ impl StandaloneHost { } } + /// Run without a TUI until an admitted protocol Shutdown, controller exit, or process signal. + /// Client EOF only disconnects that client. All exits confirm the same production shutdown + /// and lease release as the in-process UI; an unconfirmed shutdown retains the lease. + pub async fn serve_unix( + self, + endpoint: crate::unix::UnixEndpoint, + ) -> Result<(), crate::HeadlessError> { + let mut adapter = crate::unix::UnixAdapter::start(endpoint, self.handle.clone()); + let signal = async { + let mut terminate = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?; + tokio::select! { + result = tokio::signal::ctrl_c() => result, + _ = terminate.recv() => Ok(()), + } + }; + let result = tokio::select! { + _ = self.handle.protocol_closed() => Ok(()), + result = signal => result, + result = adapter.ended() => result, + }; + // Reclaim the public entry and every connection before releasing execution ownership. + let transport = adapter.shutdown().await; + self.shutdown().await?; + result?; + transport?; + Ok(()) + } + pub fn with_shutdown_timeout(mut self, shutdown_timeout: Duration) -> Self { self.shutdown_timeout = shutdown_timeout; self @@ -647,9 +670,8 @@ impl Drop for StandaloneHost { } } -// The in-process channel is owned by the local operator's Host, not an -// arbitrary socket or Backend account. decode_method discards wire provenance; -// only this boundary can stamp accepted local human input. +// Both in-process views and credential-checked Unix clients are admitted by this operator's +// Host. decode_method discards wire provenance; only this boundary stamps local human input. fn authorize_local_input(method: protocol::Method) -> protocol::Method { use protocol::{AuthenticatedInputSource, Method}; match method { @@ -692,6 +714,13 @@ fn authorize_local_input(method: protocol::Method) -> protocol::Method { } } +pub(crate) fn connect_protocol(handle: worker::WorkerHandle) -> InProcessSocket { + let streams = subscribe_worker_protocol_session(&handle); + let (socket, peer) = InProcessSocket::pair(); + tokio::spawn(run_protocol_session(handle, streams, peer)); + socket +} + async fn run_protocol_session( handle: worker::WorkerHandle, streams: WorkerProtocolSessionStreams, diff --git a/crates/standalone/src/lib.rs b/crates/standalone/src/lib.rs index f58e536a4..92e0e539f 100644 --- a/crates/standalone/src/lib.rs +++ b/crates/standalone/src/lib.rs @@ -7,6 +7,44 @@ pub mod launch; pub mod store; pub mod subjektiv; mod subjektiv_sessions; +pub mod unix; + +pub use unix::{UnixEndpoint, relay_unix_client, run_unix_client}; + +/// Restore reads the saved cwd and Profile, not a newly resolved launch recipe. +pub enum HeadlessLaunch { + Fresh(ResolvedStandaloneLaunch), + Restore { + state_dir: std::path::PathBuf, + worker_id: WorkerId, + }, +} + +#[derive(Debug, thiserror::Error)] +pub enum HeadlessError { + #[error(transparent)] + Transport(#[from] std::io::Error), + #[error(transparent)] + Startup(#[from] StandaloneStartupError), + #[error(transparent)] + Shutdown(#[from] StandaloneShutdownError), +} + +/// Reserve the optional public transport before creating or restoring execution state. +pub async fn run_headless( + launch: HeadlessLaunch, + endpoint: std::path::PathBuf, +) -> Result<(), HeadlessError> { + let endpoint = UnixEndpoint::bind(endpoint)?; + let host = match launch { + HeadlessLaunch::Fresh(launch) => StandaloneHost::start(launch).await?, + HeadlessLaunch::Restore { + state_dir, + worker_id, + } => StandaloneHost::restore(state_dir, worker_id).await?, + }; + host.serve_unix(endpoint).await +} pub use host::{ StandaloneHost, StandaloneShutdownError, StandaloneStartupError, diff --git a/crates/standalone/src/subjektiv/journal/tests/connected_compaction.rs b/crates/standalone/src/subjektiv/journal/tests/connected_compaction.rs index b3295945b..50ad7cfbc 100644 --- a/crates/standalone/src/subjektiv/journal/tests/connected_compaction.rs +++ b/crates/standalone/src/subjektiv/journal/tests/connected_compaction.rs @@ -127,7 +127,6 @@ async fn local_host_compaction_freezes_pending_journal_through_request_and_durab worker::WorkerWorkspaceContext::no_workspace(), worker::WorkerFilesystemAuthority::None, worker::WorkerBootstrapLayout::Direct { runtime_base: fixture._temp.path().join("runtime") }, - worker::WorkerControllerTransport::InProcess, ).with_model_client(model.clone()).with_subjektiv_host(fixture.host().clone()).prepare_restored("journal-test").await.unwrap(); assert_eq!(prepared.worker().session_id().to_string(), fixture.context.session_id); let source_segment = prepared.worker().segment_id(); diff --git a/crates/standalone/src/unix.rs b/crates/standalone/src/unix.rs new file mode 100644 index 000000000..ae0881eed --- /dev/null +++ b/crates/standalone/src/unix.rs @@ -0,0 +1,296 @@ +//! Optional operator transport owned by the Standalone Host, never by a Worker. +//! +//! The endpoint's existing parent must be private to the current Unix user. Binding never +//! removes an existing entry, including stale sockets. Recovery is an explicit operator action. +//! Peers must have that same uid; admitted methods still cross the normal local protocol +//! authorization boundary. Disconnect only drops a view, not the Host or its execution lease. + +use std::io; +use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt}; +use std::path::{Path, PathBuf}; + +use client::transport::Socket; +use protocol::stream::{JsonLineWriter, decode_event, decode_method}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::net::{UnixListener, UnixStream}; +use tokio::sync::oneshot; +use tokio::task::{JoinHandle, JoinSet}; + +/// A bound endpoint reservation. Drop removes only the exact socket created by this bind. +/// It can be reserved before starting a Host, so bind failures do not allocate Workers. +pub struct UnixEndpoint { + listener: UnixListener, + path: PathBuf, + device: u64, + inode: u64, +} + +impl UnixEndpoint { + pub fn bind(path: impl AsRef) -> io::Result { + let path = path.as_ref(); + let name = path.file_name().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "endpoint requires a filename") + })?; + let parent = std::fs::canonicalize( + path.parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")), + )?; + let metadata = std::fs::metadata(&parent)?; + // SAFETY: geteuid has no preconditions and does not mutate process identity. + let uid = unsafe { libc::geteuid() }; + if !metadata.is_dir() || metadata.uid() != uid || metadata.mode() & 0o077 != 0 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "endpoint parent must be a current-user-owned private directory (mode 0700)", + )); + } + let path = parent.join(name); + let listener = UnixListener::bind(&path)?; + let metadata = std::fs::symlink_metadata(&path)?; + let endpoint = Self { + listener, + path, + device: metadata.dev(), + inode: metadata.ino(), + }; + std::fs::set_permissions(&endpoint.path, std::fs::Permissions::from_mode(0o600))?; + Ok(endpoint) + } + + pub fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for UnixEndpoint { + fn drop(&mut self) { + if let Ok(current) = std::fs::symlink_metadata(&self.path) + && current.file_type().is_socket() + && current.dev() == self.device + && current.ino() == self.inode + { + let _ = std::fs::remove_file(&self.path); + } + } +} + +pub(crate) struct UnixAdapter { + task: Option>>, + stop: Option>, +} + +impl UnixAdapter { + pub(crate) fn start(endpoint: UnixEndpoint, handle: worker::WorkerHandle) -> Self { + let (stop, mut stopped) = oneshot::channel(); + let task = tokio::spawn(async move { + let mut connections = JoinSet::new(); + let result = loop { + tokio::select! { + _ = &mut stopped => break Ok(()), + accepted = endpoint.listener.accept(), if connections.len() < 64 => { + match accepted { + Ok((stream, _)) => { + // The filesystem gate protects connection establishment; credentials + // also fence open connections to the Host's operator identity. + let allowed = stream.peer_cred().is_ok_and(|cred| { + // SAFETY: geteuid has no preconditions. + cred.uid() == unsafe { libc::geteuid() } + }); + if allowed { + let socket = crate::host::connect_protocol(handle.clone()); + connections.spawn(bridge(stream, socket)); + } + } + Err(error) => break Err(error), + } + } + _ = connections.join_next(), if !connections.is_empty() => {} + } + }; + connections.shutdown().await; + // Keep the bound endpoint alive until every connection has been reclaimed. + drop(endpoint); + result + }); + Self { + task: Some(task), + stop: Some(stop), + } + } + + pub(crate) async fn ended(&mut self) -> io::Result<()> { + let result = self.task.as_mut().expect("adapter task").await; + self.task.take(); + result.map_err(io::Error::other)? + } + + pub(crate) async fn shutdown(mut self) -> io::Result<()> { + if let Some(stop) = self.stop.take() { + let _ = stop.send(()); + } + if self.task.is_some() { + self.ended().await?; + } + Ok(()) + } +} + +impl Drop for UnixAdapter { + fn drop(&mut self) { + if let Some(stop) = self.stop.take() { + let _ = stop.send(()); + } + // Sending stop lets the task join its connection set and drop its endpoint. + // A Host dropped without confirmed shutdown keeps the production execution lease. + } +} + +async fn bridge( + stream: UnixStream, + mut socket: client::transport::in_process::Socket, +) -> io::Result<()> { + let (reader, writer) = stream.into_split(); + let mut reader = BufReader::new(reader).lines(); + let mut writer = JsonLineWriter::new(writer); + loop { + tokio::select! { + line = reader.next_line() => { + let Some(message) = line? else { return Ok(()); }; + if message.trim().is_empty() { continue; } + socket.send(message).await.map_err(io::Error::other)?; + } + event = socket.next() => { + let Some(message) = event.map_err(io::Error::other)? else { return Ok(()); }; + let event = decode_event(&message).map_err(io::Error::other)?; + writer.write_event(&event).await?; + } + } + } +} + +/// Connect a JSONL operator client. EOF disconnects without issuing Shutdown. +/// Input is decoded as Worker Methods and output contains Worker Events only. +pub async fn run_unix_client(endpoint: PathBuf) -> io::Result<()> { + relay_unix_client(endpoint, tokio::io::stdin(), tokio::io::stdout()).await +} + +/// The CLI's stream relay, also usable by embedders that own their input/output streams. +pub async fn relay_unix_client(endpoint: PathBuf, input: R, output: W) -> io::Result<()> +where + R: tokio::io::AsyncRead + Unpin, + W: tokio::io::AsyncWrite + Unpin, +{ + let stream = UnixStream::connect(endpoint).await?; + let (reader, writer) = stream.into_split(); + let mut input = BufReader::new(input).lines(); + let mut to_host = JsonLineWriter::new(writer); + let mut from_host = BufReader::new(reader).lines(); + let mut output = JsonLineWriter::new(output); + loop { + tokio::select! { + line = input.next_line() => { + let Some(line) = line? else { return Ok(()); }; + if line.trim().is_empty() { continue; } + let method = decode_method(&line).map_err(io::Error::other)?; + to_host.write(&method).await?; + } + line = from_host.next_line() => { + let Some(line) = line? else { return Ok(()); }; + let event = decode_event(&line).map_err(io::Error::other)?; + output.write_event(&event).await?; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn endpoint_requires_private_parent_and_is_owner_only() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("host.sock"); + std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o755)).unwrap(); + let error = UnixEndpoint::bind(&path) + .err() + .expect("public parent rejected"); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + assert!(!path.exists()); + std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let endpoint = UnixEndpoint::bind(&path).unwrap(); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + let client = UnixStream::connect(&path).await.unwrap(); + let (server, _) = endpoint.listener.accept().await.unwrap(); + assert_eq!( + client.peer_cred().unwrap().uid(), + server.peer_cred().unwrap().uid() + ); + drop(endpoint); + assert!(!path.exists()); + } + + #[tokio::test] + async fn bind_failure_precedes_worker_state_and_startup_failure_releases_only_its_endpoint() { + let directory = tempfile::tempdir().unwrap(); + std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let path = directory.path().join("host.sock"); + let owner = UnixEndpoint::bind(&path).unwrap(); + let state_dir = directory.path().join("must-not-be-opened"); + let error = crate::run_headless( + crate::HeadlessLaunch::Restore { + state_dir: state_dir.clone(), + worker_id: protocol::WorkerId::now_v7(), + }, + path.clone(), + ) + .await + .unwrap_err(); + assert!(matches!(error, crate::HeadlessError::Transport(_))); + assert!(!state_dir.exists()); + assert!(path.exists()); + drop(owner); + let error = crate::run_headless( + crate::HeadlessLaunch::Restore { + state_dir, + worker_id: protocol::WorkerId::now_v7(), + }, + path.clone(), + ) + .await + .unwrap_err(); + assert!(matches!(error, crate::HeadlessError::Startup(_))); + assert!( + !path.exists(), + "a failed Host startup must release its own bind" + ); + } + + #[tokio::test] + async fn bind_failure_and_old_endpoint_drop_preserve_another_owner() { + let directory = tempfile::tempdir().unwrap(); + std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let path = directory.path().join("host.sock"); + let first = UnixEndpoint::bind(&path).unwrap(); + let first_inode = std::fs::metadata(&path).unwrap().ino(); + assert_eq!( + UnixEndpoint::bind(&path).err().unwrap().kind(), + io::ErrorKind::AddrInUse + ); + assert_eq!(std::fs::metadata(&path).unwrap().ino(), first_inode); + std::fs::rename(&path, directory.path().join("retained.sock")).unwrap(); + let second = UnixEndpoint::bind(&path).unwrap(); + let second_inode = std::fs::metadata(&path).unwrap().ino(); + assert_ne!(first_inode, second_inode); + drop(first); + assert_eq!(std::fs::metadata(&path).unwrap().ino(), second_inode); + let _client = UnixStream::connect(&path).await.unwrap(); + let _connection = second.listener.accept().await.unwrap(); + drop(second); + assert!(!path.exists()); + } +} diff --git a/crates/standalone/tests/host.rs b/crates/standalone/tests/host.rs index a5a632a5e..4670410eb 100644 --- a/crates/standalone/tests/host.rs +++ b/crates/standalone/tests/host.rs @@ -608,6 +608,235 @@ async fn standalone_metadata_fails_closed_on_incomplete_or_newer_records() -> Te Ok(()) } +#[tokio::test] +async fn headless_unix_client_uses_production_history_lease_and_local_authority() -> TestResult { + use protocol::stream::{JsonLineReader, encode_method}; + use std::os::unix::fs::PermissionsExt; + use tokio::io::AsyncWriteExt; + + let temp = tempfile::tempdir()?; + // Only the transport endpoint needs a short private pathname. Persistence remains the + // same production store exercised by the in-process Host tests above. + std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o700))?; + let endpoint_path = temp.path().join("operator.sock"); + let endpoint = standalone::UnixEndpoint::bind(&endpoint_path)?; + let state_dir = temp.path().join("state"); + let launch = StandaloneLaunchConfig::new( + temp.path(), + &state_dir, + manifest::ProfileSelector::Default, + "headless-operator", + ) + .resolve()?; + let model = ScriptedClient::new(vec![vec![ + LlmEvent::text_block_start(0), + LlmEvent::text_delta(0, "headless production response"), + LlmEvent::text_block_stop(0, Some(StopReason::EndTurn)), + ]]); + let inspection = model.clone(); + let host = StandaloneHost::start_with_model_client(launch, model).await?; + let worker_id = host.worker_id(); + let session_id = host.record().active_session_id; + let profile = host.record().manifest.clone(); + let serve = tokio::spawn(host.serve_unix(endpoint)); + + // Exercise the real external client's stdin/stdout relay over the actual Unix adapter. + let (mut input, relay_input) = tokio::io::duplex(64 * 1024); + let (relay_output, output) = tokio::io::duplex(64 * 1024); + let relay = tokio::spawn(standalone::relay_unix_client( + endpoint_path.clone(), + relay_input, + relay_output, + )); + let mut events = JsonLineReader::new(output); + let snapshot = tokio::time::timeout(Duration::from_secs(10), events.next::()) + .await?? + .expect("snapshot"); + assert!(matches!(snapshot, Event::Snapshot { .. })); + let request = Method::SubmitTracked { + submission_request_id: protocol::new_submission_request_id(), + input: vec![protocol::Segment::text("headless production input")], + source: protocol::AuthenticatedInputSource::Backend { + operation_id: "forged-backend-source".into(), + }, + }; + let wire = encode_method(&request)?; + println!("headless input: {wire}"); + input.write_all(wire.as_bytes()).await?; + input.write_all(b"\n").await?; + let mut saw_user = false; + let mut saw_response = false; + let mut saw_state = false; + tokio::time::timeout(Duration::from_secs(20), async { + loop { + let event = events.next::().await?.expect("live event"); + match &event { + Event::UserMessage { segments, .. } => { + saw_user |= format!("{segments:?}").contains("headless production input"); + } + Event::TextDelta { text } => { + saw_response |= text == "headless production response"; + } + Event::WorkerState { .. } => saw_state = true, + Event::RunEnd { .. } => break, + _ => {} + } + println!( + "headless output: {}", + protocol::stream::encode_event(&event)? + ); + } + Ok::<_, Box>(()) + }) + .await??; + assert!(saw_user && saw_response && saw_state); + assert_eq!(inspection.requests().len(), 1); + assert!(matches!( + StandaloneHost::restore_with_model_client( + state_dir.clone(), + worker_id, + ScriptedClient::new(vec![]) + ) + .await, + Err(StandaloneStartupError::WorkerActive) + )); + + // EOF is a disconnect, not a shutdown; another view can reconnect to the same history. + drop(input); + tokio::time::timeout(Duration::from_secs(10), relay).await???; + assert!(!serve.is_finished()); + let mut malformed = tokio::net::UnixStream::connect(&endpoint_path).await?; + malformed.write_all(b"not-json\n").await?; + let mut discarded = Vec::new(); + use tokio::io::AsyncReadExt; + tokio::time::timeout( + Duration::from_secs(10), + malformed.read_to_end(&mut discarded), + ) + .await??; + assert!( + !serve.is_finished(), + "invalid client input must not stop the Host" + ); + let mut socket_client = + Client::new(client::transport::unix_socket::Socket::connect(&endpoint_path).await?); + let snapshot = tokio::time::timeout(Duration::from_secs(10), async { + loop { + let event = socket_client.next_event().await?.expect("reconnect event"); + if matches!(event, Event::Snapshot { .. }) { + break Ok::<_, Box>(event); + } + } + }) + .await??; + let snapshot_text = format!("{snapshot:?}"); + assert!( + snapshot_text.contains("headless production input"), + "{snapshot_text}" + ); + assert!( + snapshot_text.contains("headless production response"), + "{snapshot_text}" + ); + socket_client + .send(&Method::Shutdown { + command: protocol::WorkerCommandEnvelope::new(0), + }) + .await?; + tokio::time::timeout(Duration::from_secs(10), async { + loop { + if let Some(Event::CommandAcknowledged { acknowledgement }) = + socket_client.next_event().await? + { + assert_eq!( + acknowledgement.disposition, + protocol::WorkerCommandDisposition::StaleCommandId + ); + break; + } + } + Ok::<_, Box>(()) + }) + .await??; + assert!( + !serve.is_finished(), + "stale Shutdown must not bypass Controller admission" + ); + socket_client + .send(&Method::Shutdown { + command: protocol::WorkerCommandEnvelope::new(100), + }) + .await?; + tokio::time::timeout(Duration::from_secs(15), serve).await???; + assert!(!endpoint_path.exists()); + let store = StandaloneWorkerStore::open(&state_dir)?; + let saved = store.load(worker_id)?; + assert_eq!(saved.status, StandaloneWorkerStatus::Stopped); + use session_store::Store; + let history = session_store::FsStore::new(store.sessions_dir(worker_id))?.read_all( + saved.active_session_id, + saved.active_segment_id.expect("active segment"), + )?; + let origins = history + .iter() + .filter_map(|entry| match entry { + session_store::LogEntry::AnnotatedUserInput { history, .. } => Some(history), + _ => None, + }) + .flatten() + .map(|entry| &entry.metadata.origin) + .collect::>(); + assert!(origins.contains(&&session_store::LoggedSessionHistoryOrigin::LocalHumanInput)); + assert!(!format!("{origins:?}").contains("forged-backend-source")); + + // This is the same restoration path called by the TUI, not a headless catalog/import. + let restored = StandaloneHost::restore_with_model_client( + state_dir.clone(), + worker_id, + ScriptedClient::new(vec![]), + ) + .await?; + assert_eq!(restored.worker_id(), worker_id); + assert_eq!(restored.record().active_session_id, session_id); + assert_eq!( + serde_json::to_value(&restored.record().manifest)?, + serde_json::to_value(&profile)? + ); + assert_eq!( + restored.record().cwd.canonical_path, + temp.path().canonicalize()? + ); + let mut in_process = restored.connect(); + let snapshot = tokio::time::timeout(Duration::from_secs(10), async { + loop { + let event = in_process + .next_event() + .await? + .expect("in-process restored event"); + if matches!(event, Event::Snapshot { .. }) { + break Ok::<_, Box>(event); + } + } + }) + .await??; + assert!(format!("{snapshot:?}").contains("headless production response")); + assert!( + !endpoint_path.exists(), + "in-process restore must not recreate the adapter" + ); + let runtime = state_dir + .join(worker_id.to_string()) + .join("runtime") + .join(format!("standalone-{worker_id}")); + assert!(!runtime.join("sock").exists()); + assert!(!runtime.join("pid").exists()); + restored.shutdown().await?; + println!( + "verdict: input/state/response/disconnect/explicit shutdown, shared production restore and concurrent lease exclusion passed for {worker_id}" + ); + Ok(()) +} + async fn wait_for_run_end(client: &mut Client) -> TestResult { tokio::time::timeout(Duration::from_secs(10), async { loop { diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index a482902c6..9b603ff29 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -146,9 +146,9 @@ use worker::feature::builtin::{ use worker::ipc::protocol_session::{WorkerProtocolSessionStreams, live_log_entry_event}; use worker::{ PreparedWorker, PromptCatalogSource, SegmentLogSink, SubjektivSessionAttributionLifecycle, - Worker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout, - WorkerControllerTransport, WorkerError, WorkerFilesystemAuthority, WorkerHandle, - WorkerSharedState, WorkerWorkspaceContext, WorkspaceClient, WorkspaceId, + Worker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout, WorkerError, + WorkerFilesystemAuthority, WorkerHandle, WorkerSharedState, WorkerWorkspaceContext, + WorkspaceClient, WorkspaceId, }; #[cfg(feature = "ws-server")] @@ -508,7 +508,6 @@ pub struct ProfileRuntimeWorkerFactory { worker_mutation_identity: Option, workspace_request_clients: Arc>, embedded_worker_mutation_dispatcher: Option>, - controller_transport: WorkerControllerTransport, failed_restore_sessions: Arc< Mutex< HashMap< @@ -536,7 +535,6 @@ impl ProfileRuntimeWorkerFactory { worker_mutation_identity: None, workspace_request_clients: Arc::new(HashMap::new()), embedded_worker_mutation_dispatcher: None, - controller_transport: WorkerControllerTransport::InProcess, failed_restore_sessions: Arc::new(Mutex::new(HashMap::new())), } } @@ -585,14 +583,6 @@ impl ProfileRuntimeWorkerFactory { self } - pub fn with_controller_transport( - mut self, - controller_transport: WorkerControllerTransport, - ) -> Self { - self.controller_transport = controller_transport; - self - } - pub fn with_runtime_store_dir(mut self, runtime_store_dir: impl Into) -> Self { self.worker_aggregate_root = Some(runtime_store_dir.into().join("workers")); self @@ -1598,13 +1588,13 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { .resolve_profile(selector, &worker_root, &worker_name) .map_err(|err| format!("failed to resolve profile source archive: {err}"))?; if has_local_filesystem { - worker::entrypoint::resolve_runtime_profile_manifest_from_manifest( + worker::profile_manifest::resolve_runtime_profile_manifest_from_manifest( manifest, &worker_root, &worker_name, )? } else { - worker::entrypoint::resolve_runtime_profile_manifest_from_manifest_without_filesystem( + worker::profile_manifest::resolve_runtime_profile_manifest_from_manifest_without_filesystem( manifest, &worker_root, &worker_name, @@ -1651,7 +1641,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { WorkerBootstrapLayout::RuntimeManagedRun { run_dir: run_dir.clone(), }, - self.controller_transport, ); let bootstrap = match &self.spawn_model_client_factory { Some(factory) => bootstrap.with_model_client(factory()), @@ -2004,7 +1993,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { WorkerBootstrapLayout::RuntimeManagedRun { run_dir: run_dir.clone(), }, - self.controller_transport, ) .start() .await @@ -6515,8 +6503,7 @@ mod tests { let backend = WorkerRuntimeExecutionBackend::new( ProfileRuntimeWorkerFactory::new(root.path().join("missing-profiles")) .with_runtime_store_dir(&store) - .with_runtime_id("runtime-subjektiv-test") - .with_controller_transport(WorkerControllerTransport::InProcess), + .with_runtime_id("runtime-subjektiv-test"), ) .unwrap(); backend.preflight_restore(&request).unwrap(); @@ -9132,17 +9119,6 @@ mod tests { assert!(error.contains("replacement Worker is required"), "{error}"); } - #[test] - fn profile_runtime_factory_defaults_to_in_process_controller_transport() { - let root = tempfile::tempdir().unwrap(); - let factory = ProfileRuntimeWorkerFactory::new(root.path()); - - assert!(matches!( - factory.controller_transport, - WorkerControllerTransport::InProcess - )); - } - #[tokio::test] #[serial_test::serial(worker_allocation)] async fn in_process_restore_does_not_bind_unix_socket_under_overlong_store_path() { @@ -9205,7 +9181,6 @@ mod tests { let controller = ProfileRuntimeWorkerFactory::new(root.path()) .with_runtime_store_dir(&runtime_store_dir) - .with_controller_transport(WorkerControllerTransport::InProcess) .restore_controller(WorkerExecutionRestoreRequest { operation_id: crate::execution::WorkerLifecycleOperationId::new(), worker_ref: worker_ref.clone(), diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index a96239ba3..368ec7662 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -11,7 +11,6 @@ default = [] [dependencies] async-trait = { workspace = true } -clap = { version = "4.6.0", features = ["derive"] } agen = { workspace = true, features = ["codex"] } session-store = { workspace = true } secrets = { workspace = true } diff --git a/crates/worker/src/bootstrap.rs b/crates/worker/src/bootstrap.rs index d7871660b..8bbc9ed52 100644 --- a/crates/worker/src/bootstrap.rs +++ b/crates/worker/src/bootstrap.rs @@ -6,9 +6,7 @@ use thiserror::Error; use workdir::WorkdirSessionHandle; use crate::PromptCatalogSource; -use crate::controller::{ - ShutdownReceiver, WorkerController, WorkerControllerTransport, WorkerHandle, -}; +use crate::controller::{ShutdownReceiver, WorkerController, WorkerHandle}; use crate::worker::{Worker, WorkerError, WorkerFilesystemAuthority, WorkerWorkspaceContext}; use manifest::WorkerManifest; @@ -30,7 +28,6 @@ pub struct WorkerBootstrap { workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, layout: WorkerBootstrapLayout, - transport: WorkerControllerTransport, model_client: Option>, workdir_session: Option, subjektiv_host: Option>, @@ -41,7 +38,6 @@ pub struct WorkerBootstrap { pub struct PreparedWorker { worker: Worker, layout: WorkerBootstrapLayout, - transport: WorkerControllerTransport, } /// Live controller returned only after Worker construction and feature @@ -68,7 +64,6 @@ impl WorkerBootstrap where St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, { - #[allow(clippy::too_many_arguments)] pub fn new( manifest: WorkerManifest, store: St, @@ -76,7 +71,6 @@ where workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, layout: WorkerBootstrapLayout, - transport: WorkerControllerTransport, ) -> Self { Self { manifest, @@ -85,7 +79,6 @@ where workspace_context, filesystem_authority, layout, - transport, model_client: None, workdir_session: None, subjektiv_host: None, @@ -165,7 +158,7 @@ where ) .map_err(WorkerBootstrapError::Worker)?; } - Ok(PreparedWorker::new(worker, self.layout, self.transport)) + Ok(PreparedWorker::new(worker, self.layout)) } pub async fn prepare_restored( @@ -206,7 +199,7 @@ where ) .map_err(WorkerBootstrapError::Worker)?; } - Ok(PreparedWorker::new(worker, self.layout, self.transport)) + Ok(PreparedWorker::new(worker, self.layout)) } pub async fn start(self) -> Result { @@ -221,16 +214,8 @@ where { /// Wrap a restored Worker in the same pre-exposure lifecycle used by fresh /// bootstraps. - pub fn new( - worker: Worker, - layout: WorkerBootstrapLayout, - transport: WorkerControllerTransport, - ) -> Self { - Self { - worker, - layout, - transport, - } + pub fn new(worker: Worker, layout: WorkerBootstrapLayout) -> Self { + Self { worker, layout } } pub fn worker(&self) -> &Worker { @@ -242,7 +227,7 @@ where } pub async fn start(self) -> Result { - start_worker_controller(self.worker, self.layout, self.transport).await + start_worker_controller(self.worker, self.layout).await } } @@ -252,7 +237,6 @@ where pub async fn start_worker_controller( worker: Worker, layout: WorkerBootstrapLayout, - transport: WorkerControllerTransport, ) -> Result where C: LlmClient + Clone + 'static, @@ -261,13 +245,10 @@ where let cleanup_sessions = worker.workdir_sessions(); let controller = match layout { WorkerBootstrapLayout::Direct { runtime_base } => { - WorkerController::spawn_with_transport_owned(worker, &runtime_base, transport).await + WorkerController::spawn_owned(worker, &runtime_base).await } WorkerBootstrapLayout::RuntimeManagedRun { run_dir } => { - WorkerController::spawn_runtime_managed_run_with_transport_owned( - worker, &run_dir, transport, - ) - .await + WorkerController::spawn_runtime_managed_run_owned(worker, &run_dir).await } }; diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index df6a0475d..b96359f8d 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -10,12 +10,10 @@ use session_store::WorkerMetadataStore; use session_store::{LogEntry, Store}; use tokio::sync::{broadcast, mpsc, oneshot}; -use crate::discovery::WorkerDiscovery; use crate::feature::FeatureRegistryBuilder; use crate::in_flight::{InFlightEvents, snapshot_from_guard}; use crate::ipc::alerter::Alerter; use crate::ipc::notify_buffer::NotifyBuffer; -use crate::ipc::server::SocketServer; use crate::runtime::dir::RuntimeDir; use crate::segment_log_sink::SegmentLogSink; use crate::shared_state::{WorkerCommandAdmission, WorkerSharedState}; @@ -317,7 +315,7 @@ impl WorkerHandle { } } - /// Broadcast an event to all listeners (including socket clients). + /// Broadcast an event to all listeners. pub fn send_event(&self, event: Event) -> Result> { self.working_event_tx.send(event) } @@ -436,12 +434,10 @@ fn reject_invalid_command_state( async fn set_controller_state( shared_state: &Arc, - runtime_dir: &RuntimeDir, working_event_tx: &broadcast::Sender, state: WorkerState, ) -> protocol::WorkerStateSnapshot { let snapshot = shared_state.transition(state); - let _ = runtime_dir.write_status(shared_state).await; let _ = working_event_tx.send(Event::WorkerState { snapshot: snapshot.clone(), }); @@ -461,7 +457,6 @@ fn publish_context_usage( async fn set_controller_status( shared_state: &Arc, - runtime_dir: &RuntimeDir, working_event_tx: &broadcast::Sender, status: WorkerStatus, ) { @@ -470,13 +465,12 @@ async fn set_controller_status( WorkerStatus::Running => WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)), WorkerStatus::Paused => WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Paused)), }; - set_controller_state(shared_state, runtime_dir, working_event_tx, state).await; + set_controller_state(shared_state, working_event_tx, state).await; } async fn finish_controller_run( worker: &mut Worker, shared_state: &Arc, - runtime_dir: &RuntimeDir, working_event_tx: &broadcast::Sender, new_status: WorkerStatus, finished_submission_request_id: Option, @@ -509,10 +503,9 @@ async fn finish_controller_run( WorkerStatus::Paused => WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Paused)), }; let snapshot = shared_state.finish_submission_run(state, submission_request_id); - let _ = runtime_dir.write_status(shared_state).await; let _ = working_event_tx.send(Event::WorkerState { snapshot }); } else { - set_controller_status(shared_state, runtime_dir, working_event_tx, new_status).await; + set_controller_status(shared_state, working_event_tx, new_status).await; } } @@ -653,39 +646,12 @@ fn prepare_pending_run( }) } -impl PendingRun { - /// Whether this turn was kicked off by the parent (via `Method::Submit` - /// or `Method::Resume`). Used by [`drive_turn`] to gate upward - /// `WorkerEvent::TurnEnded` / `WorkerEvent::Errored` reports so the parent - /// only sees completion signals for work it actually delegated. - /// `RunForNotification` covers self-initiated turns kicked from the - /// notify buffer (Notify / inbound WorkerEvent) and stays silent. - fn is_parent_originated(&self) -> bool { - match self { - PendingRun::Submit(_) | PendingRun::Resume => true, - PendingRun::RunForNotification { .. } => false, - } - } -} - // --------------------------------------------------------------------------- // WorkerController — actor that owns a Worker // --------------------------------------------------------------------------- pub type ShutdownReceiver = oneshot::Receiver<()>; -/// Client transport exposed by a Worker controller. -/// -/// Process-hosted Workers use a Unix socket for external attach clients. Runtimes -/// that retain the returned [`WorkerHandle`] in the same process can disable that -/// redundant listener and drive the controller directly through its channels. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum WorkerControllerTransport { - #[default] - UnixSocket, - InProcess, -} - pub struct WorkerController; impl WorkerController { @@ -697,50 +663,24 @@ impl WorkerController { C: LlmClient + Clone + 'static, St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, { - let (handle, shutdown, _task) = Self::spawn_inner( - worker, - runtime_base, - false, - None, - WorkerControllerTransport::UnixSocket, - ) - .await?; + let (handle, shutdown, _task) = Self::spawn_inner(worker, runtime_base, None).await?; Ok((handle, shutdown)) } - /// Spawn a direct Worker while letting an in-process host select the - /// controller transport explicitly. - pub async fn spawn_with_transport( + pub(crate) async fn spawn_owned( worker: Worker, runtime_base: &Path, - - transport: WorkerControllerTransport, - ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> - where - C: LlmClient + Clone + 'static, - St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, - { - let (handle, shutdown, _task) = - Self::spawn_with_transport_owned(worker, runtime_base, transport).await?; - Ok((handle, shutdown)) - } - - pub(crate) async fn spawn_with_transport_owned( - worker: Worker, - runtime_base: &Path, - - transport: WorkerControllerTransport, ) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error> where C: LlmClient + Clone + 'static, St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, { - Self::spawn_inner(worker, runtime_base, false, None, transport).await + Self::spawn_inner(worker, runtime_base, None).await } /// Spawn a Worker owned by `worker-runtime`. /// - /// The controller uses an ephemeral directory for Unix sockets. Bash output is + /// The controller uses an ephemeral directory for run artifacts. Bash output is /// owned by its live Feature instance, independently of filesystem layout. /// Runtime-managed Workers do not write legacy pid/status/manifest liveness /// projections. @@ -752,14 +692,7 @@ impl WorkerController { C: LlmClient + Clone + 'static, St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, { - let (handle, shutdown, _task) = Self::spawn_inner( - worker, - runtime_base, - true, - None, - WorkerControllerTransport::UnixSocket, - ) - .await?; + let (handle, shutdown, _task) = Self::spawn_inner(worker, runtime_base, None).await?; Ok((handle, shutdown)) } @@ -768,41 +701,18 @@ impl WorkerController { worker: Worker, run_dir: &Path, ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> - where - C: LlmClient + Clone + 'static, - St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, - { - Self::spawn_runtime_managed_run_with_transport( - worker, - run_dir, - WorkerControllerTransport::UnixSocket, - ) - .await - } - - /// Spawn into an exact persistent `runs/` directory using the - /// requested client transport. - pub async fn spawn_runtime_managed_run_with_transport( - worker: Worker, - run_dir: &Path, - - transport: WorkerControllerTransport, - ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> where C: LlmClient + Clone + 'static, St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, { let (handle, shutdown, _task) = - Self::spawn_runtime_managed_run_with_transport_owned(worker, run_dir, transport) - .await?; + Self::spawn_runtime_managed_run_owned(worker, run_dir).await?; Ok((handle, shutdown)) } - pub(crate) async fn spawn_runtime_managed_run_with_transport_owned( + pub(crate) async fn spawn_runtime_managed_run_owned( worker: Worker, run_dir: &Path, - - transport: WorkerControllerTransport, ) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error> where C: LlmClient + Clone + 'static, @@ -811,30 +721,20 @@ impl WorkerController { let parent = run_dir .parent() .ok_or_else(|| std::io::Error::other("run path has no parent"))?; - Self::spawn_inner(worker, parent, true, Some(run_dir), transport).await + Self::spawn_inner(worker, parent, Some(run_dir)).await } async fn spawn_inner( worker: Worker, runtime_base: &Path, - - runtime_managed: bool, runtime_run: Option<&Path>, - transport: WorkerControllerTransport, ) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error> where C: LlmClient + Clone + 'static, St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, { let sessions = worker.workdir_sessions(); - let result = Self::spawn_initialized( - worker, - runtime_base, - runtime_managed, - runtime_run, - transport, - ) - .await; + let result = Self::spawn_initialized(worker, runtime_base, runtime_run).await; if result.is_err() && let Err(error) = sessions.close_all().await { @@ -846,10 +746,7 @@ impl WorkerController { async fn spawn_initialized( mut worker: Worker, runtime_base: &Path, - - runtime_managed: bool, runtime_run: Option<&Path>, - transport: WorkerControllerTransport, ) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error> where C: LlmClient + Clone + 'static, @@ -868,19 +765,14 @@ impl WorkerController { let in_flight = InFlightEvents::new(working_event_tx.clone()); worker.attach_in_flight_events(in_flight.clone()); - // Runtime directory is created before tool registration and before - // initial status/history writes consume - // the greeting we build after registration is complete. + // Retain run artifacts before registering the Worker tools. let runtime_dir = Arc::new(if let Some(run_dir) = runtime_run { RuntimeDir::create_worker_run(run_dir).await? - } else if runtime_managed { - RuntimeDir::create_transient(runtime_base, &worker.manifest().worker.name).await? } else { RuntimeDir::create(runtime_base, &worker.manifest().worker.name).await? }); let spawner_name = worker.manifest().worker.name.clone(); - let self_parent_socket = worker.callback_socket().cloned(); let loaded_registry = SpawnedWorkerRegistry::load_from_worker_state_with_reclaim( runtime_dir.clone(), worker.store().clone(), @@ -959,8 +851,7 @@ impl WorkerController { .map_err(|error| std::io::Error::other(error.to_string()))?; } - // === 4. Initial runtime files + WorkerSharedState + WorkerHandle + - // SocketServer === + // === 4. WorkerSharedState + WorkerHandle === let manifest_toml = toml::to_string_pretty(worker.manifest()).unwrap_or_default(); worker .recover_unfinished_compaction() @@ -970,7 +861,7 @@ impl WorkerController { let shared_state = Arc::new(WorkerSharedState::new( worker.manifest().worker.name.clone(), worker.segment_id(), - manifest_toml.clone(), + manifest_toml, greeting, )); shared_state.set_feature_invocations(worker.feature_invocations()); @@ -987,8 +878,6 @@ impl WorkerController { if let Some(fs_for_view) = fs_for_view.as_ref() { shared_state.set_fs_view(crate::fs_view::WorkerFsView::new(fs_for_view.clone())); } - runtime_dir.write_manifest(&manifest_toml).await?; - runtime_dir.write_status(&shared_state).await?; let artifact_store: Arc = Arc::new(worker.store().clone()); let session_id = worker.session_id(); @@ -1014,26 +903,10 @@ impl WorkerController { pending_activations, }; - // Do not start an observer before fallible head/recovery/runtime-file - // initialization. The only remaining fallible step is socket startup, - // whose error path must abort AND join this task before returning. + // Start the observer only after fallible head/recovery initialization. let command_observer = fs_for_view .as_ref() .and_then(|session| wire_workdir_command_events(session, &in_flight)); - let socket_server = match transport { - WorkerControllerTransport::UnixSocket => match SocketServer::start(&handle).await { - Ok(server) => Some(server), - Err(error) => { - if let Some(observer) = command_observer { - observer.abort(); - let _ = observer.await; - } - return Err(error); - } - }, - WorkerControllerTransport::InProcess => None, - }; - // === 5. controller_loop === // Clone cancel sender and notification buffer before moving worker // into the controller task so the in-flight turn can be reached @@ -1051,11 +924,8 @@ impl WorkerController { cancel_tx, pause_tx, notify_buffer, - self_parent_socket, - spawner_name, spawned_registry, shutdown_tx, - socket_server, shutdown_after_idle, command_observer, )); @@ -2290,15 +2160,12 @@ async fn controller_loop( mut method_rx: mpsc::Receiver, working_event_tx: broadcast::Sender, shared_state: Arc, - runtime_dir: Arc, + _runtime_dir: Arc, cancel_tx: mpsc::Sender<()>, pause_tx: mpsc::Sender<()>, notify_buffer: NotifyBuffer, - self_parent_socket: Option, - spawner_name: String, spawned_registry: Arc, shutdown_tx: oneshot::Sender<()>, - socket_server: Option, shutdown_after_idle: ShutdownAfterIdleRequest, mut command_observer: Option>, ) where @@ -2311,25 +2178,6 @@ async fn controller_loop( .expect("Controller release authority installed") .clone(); let _release_lifetime = crate::subject_release::ControllerLifetime(release_control.clone()); - // Hold an optional external attach server alive for the controller lifetime. - // In-process runtimes retain and drive the WorkerHandle directly. - let _socket_server = socket_server; - - let discovery_runtime_base = runtime_dir - .path() - .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, - discovery_cwd, - spawned_registry.clone(), - ); let notification_coalesce_delay = worker.notification_coalesce_delay(); let pending_submissions = worker.pending_submission_handle(); let mut pending = match prepare_pending_run(&pending_submissions, ¬ify_buffer, None, false) { @@ -2368,20 +2216,14 @@ async fn controller_loop( // clear at run start prevents stale partial output left by an older // interrupted/error turn from being carried into the next snapshot. worker.clear_in_flight_events(); - let parent_originated = run.is_parent_originated(); let finished_submission_request_id = match &run { PendingRun::Submit(submission) => Some(submission.submission_request_id.clone()), PendingRun::RunForNotification { .. } | PendingRun::Resume => None, }; let user_input_submit = matches!(&run, PendingRun::Submit(_)); if !user_input_submit { - set_controller_status( - &shared_state, - &runtime_dir, - &working_event_tx, - WorkerStatus::Running, - ) - .await; + set_controller_status(&shared_state, &working_event_tx, WorkerStatus::Running) + .await; } let (mut new_status, shutdown, may_drain_pending) = match run { PendingRun::Submit(submission) => { @@ -2404,14 +2246,9 @@ async fn controller_loop( &cancel_tx, &pause_tx, &shared_state, - &runtime_dir, Some((input_commit_rx, committed_submission)), ¬ify_buffer, &pending_submissions, - self_parent_socket.as_ref(), - &spawner_name, - &spawned_registry, - parent_originated, ) .await } @@ -2423,14 +2260,9 @@ async fn controller_loop( &cancel_tx, &pause_tx, &shared_state, - &runtime_dir, None, ¬ify_buffer, &pending_submissions, - self_parent_socket.as_ref(), - &spawner_name, - &spawned_registry, - parent_originated, ) .await } @@ -2442,14 +2274,9 @@ async fn controller_loop( &cancel_tx, &pause_tx, &shared_state, - &runtime_dir, None, ¬ify_buffer, &pending_submissions, - self_parent_socket.as_ref(), - &spawner_name, - &spawned_registry, - parent_originated, ) .await } @@ -2475,7 +2302,6 @@ async fn controller_loop( finish_controller_run( &mut worker, &shared_state, - &runtime_dir, &working_event_tx, new_status, finished_submission_request_id, @@ -2607,13 +2433,7 @@ async fn controller_loop( if worker.has_pending_compaction_cleanup() { match worker.retry_pending_compaction_cleanup().await { Ok(()) => { - set_controller_state( - &shared_state, - &runtime_dir, - &working_event_tx, - WorkerState::Idle, - ) - .await; + set_controller_state(&shared_state, &working_event_tx, WorkerState::Idle).await; } Err(error) => { if matches!(&method, Method::Shutdown { .. }) { @@ -2847,7 +2667,6 @@ async fn controller_loop( } set_controller_state( &shared_state, - &runtime_dir, &working_event_tx, WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)), ) @@ -2889,7 +2708,6 @@ async fn controller_loop( } set_controller_state( &shared_state, - &runtime_dir, &working_event_tx, WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Cancelling)), ) @@ -2904,18 +2722,12 @@ async fn controller_loop( match worker.cancel_paused_turn() { Ok(()) => { worker.clear_in_flight_events(); - set_controller_state( - &shared_state, - &runtime_dir, - &working_event_tx, - WorkerState::Idle, - ) - .await; + set_controller_state(&shared_state, &working_event_tx, WorkerState::Idle) + .await; } Err(error) => { set_controller_state( &shared_state, - &runtime_dir, &working_event_tx, WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Paused)), ) @@ -2973,7 +2785,6 @@ async fn controller_loop( } set_controller_state( &shared_state, - &runtime_dir, &working_event_tx, WorkerState::Busy(WorkerBusyState::Maintenance( WorkerMaintenanceState::Compacting, @@ -3076,13 +2887,7 @@ async fn controller_loop( | Err(WorkerError::SegmentActivationIncomplete { .. }) | Err(WorkerError::CompactionCleanupPending { .. }) ) { - set_controller_state( - &shared_state, - &runtime_dir, - &working_event_tx, - WorkerState::Idle, - ) - .await; + set_controller_state(&shared_state, &working_event_tx, WorkerState::Idle).await; } if let Err(error) = result && !matches!(&error, WorkerError::CompactCancelled) @@ -3164,80 +2969,45 @@ async fn controller_loop( break; } - Method::ListWorkers => match discovery.list_visible().await { - Ok(workers) => match serde_json::to_value(workers) { - Ok(workers) => { - let _ = working_event_tx.send(Event::WorkersListed { workers }); - } + Method::ListWorkers | Method::RestoreWorker { .. } => { + let _ = working_event_tx.send(Event::Error { + code: ErrorCode::InvalidRequest, + message: "Worker discovery and restore are owned by the Host or Runtime".into(), + }); + } + + Method::RegisterPeer { name } => { + match crate::peer::register_peer( + worker.store(), + &worker.manifest().worker.name, + &name, + ) { + Ok(result) => match serde_json::to_value(result) { + Ok(result) => { + let _ = working_event_tx.send(Event::PeerRegistered { result }); + } + Err(error) => { + let _ = working_event_tx.send(Event::Error { + code: ErrorCode::Internal, + message: format!("serialize peer registration result: {error}"), + }); + } + }, Err(error) => { let _ = working_event_tx.send(Event::Error { - code: ErrorCode::Internal, - message: format!("serialize visible workers: {error}"), + code: ErrorCode::InvalidRequest, + message: error.to_string(), }); } - }, - Err(error) => { - let _ = working_event_tx.send(Event::Error { - code: ErrorCode::InvalidRequest, - message: error.to_string(), - }); } - }, + } - Method::RestoreWorker { name } => match discovery.restore(&name).await { - Ok(result) => match serde_json::to_value(result) { - Ok(result) => { - let _ = working_event_tx.send(Event::WorkerRestored { result }); - } - Err(error) => { - let _ = working_event_tx.send(Event::Error { - code: ErrorCode::Internal, - message: format!("serialize worker restore result: {error}"), - }); - } - }, - Err(error) => { - let _ = working_event_tx.send(Event::Error { - code: ErrorCode::InvalidRequest, - message: error.to_string(), - }); - } - }, - - Method::RegisterPeer { name } => match discovery.register_peer(&name) { - Ok(result) => match serde_json::to_value(result) { - Ok(result) => { - let _ = working_event_tx.send(Event::PeerRegistered { result }); - } - Err(error) => { - let _ = working_event_tx.send(Event::Error { - code: ErrorCode::Internal, - message: format!("serialize peer registration result: {error}"), - }); - } - }, - Err(error) => { - let _ = working_event_tx.send(Event::Error { - code: ErrorCode::InvalidRequest, - message: error.to_string(), - }); - } - }, - - // ListCompletions is handled at the socket layer (direct + // ListCompletions is handled by the Host protocol adapter (direct // response). If it reaches the controller, ignore it. Method::ListCompletions { .. } => {} Method::WorkerEvent(event) => { - if handle_inbound_worker_event( - event, - &spawned_registry, - &spawner_name, - self_parent_socket.as_ref(), - ¬ify_buffer, - ) - .await - { + if handle_inbound_worker_event(event, ¬ify_buffer) { // Auto-kick a turn if the Worker is idle so the // notification is not stranded. Matches the // `Method::Notify` idle path. @@ -3256,20 +3026,6 @@ async fn controller_loop( // senders then fail instead of accepting work that this execution can no // longer process. drop(method_rx); - let had_socket_server = _socket_server.is_some(); - if let Some(socket_server) = _socket_server { - socket_server.shutdown().await; - } - while had_socket_server { - match runtime_dir.close_socket().await { - Ok(()) => break, - Err(error) => { - tracing::warn!(%error, "Worker runtime socket cleanup failed; retrying"); - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - } - } - } - // Feature callbacks and tasks share the Worker scope. Stop them before // Memory/Workdir teardown so they cannot observe a partially closed Worker. worker.stop_feature_runtime("controller shutdown").await; @@ -3302,49 +3058,11 @@ async fn controller_loop( let _ = observer.await; } - // Report upward that this Worker is stopping before the controller - // task exits. Awaited (not fire-and-forget): after `shutdown_tx.send` - // the process may exit quickly, and a spawned task would be killed - // mid-send. The `connect_and_send` helper enforces a 5 s timeout so - // a stuck parent cannot block process exit indefinitely. - if let Some(parent) = self_parent_socket.as_ref() { - if let Err(e) = crate::ipc::event::send_worker_event( - parent, - protocol::WorkerEvent::ShutDown { - worker_name: spawner_name.clone(), - }, - ) - .await - { - tracing::warn!(error = %e, "ShutDown WorkerEvent send failed"); - } - } - let _ = shutdown_tx.send(()); } -/// Apply an inbound child `WorkerEvent` exactly once. -/// -/// Side effects are control-plane state updates and upward propagation; they -/// run for every event. Only agent-visible events are staged on the notify -/// buffer. The caller owns lifecycle-dependent follow-up such as idle -/// `RunForNotification` auto-kick. -async fn handle_inbound_worker_event( - event: protocol::WorkerEvent, - spawned_registry: &Arc, - self_name: &str, - parent_socket: Option<&PathBuf>, - notify_buffer: &NotifyBuffer, -) -> bool { - let self_parent_socket = parent_socket.cloned(); - crate::ipc::event::apply_event_side_effects( - &event, - spawned_registry, - self_name, - &self_parent_socket, - ) - .await; - +/// Stage an agent-visible Internal SubWorker lifecycle event. +fn handle_inbound_worker_event(event: protocol::WorkerEvent, notify_buffer: &NotifyBuffer) -> bool { let notify_agent = event.should_notify_agent(); if notify_agent { notify_buffer.push_worker_event(event); @@ -3354,19 +3072,7 @@ async fn handle_inbound_worker_event( /// Drives a Worker future (one in-flight turn) while concurrently /// processing incoming methods through an inner select! arm. Returns -/// `(final_status, shutdown_requested)`. -/// -/// `parent_socket` / `self_name` drive upward `WorkerEvent` reports -/// (`TurnEnded` on a clean Finished, `Errored` on a worker failure). -/// `None` parent skips the send (top-level Worker). Transient method -/// rejections such as `AlreadyRunning` are intentionally NOT reported -/// as `Errored` — only the worker-execution `Err` branch below fires. -/// -/// `parent_originated` further restricts both upward reports to turns -/// the parent actually delegated (`Method::Submit` / `Method::Resume`). -/// `Method::Notify` / inbound `WorkerEvent` auto-kicks complete silently -/// so the parent's history does not get flooded with child-internal -/// turn boundaries. +/// `(final_status, shutdown_requested, may_drain_pending)`. #[allow(clippy::too_many_arguments)] async fn drive_turn( worker_future: F, @@ -3375,14 +3081,9 @@ async fn drive_turn( cancel_tx: &mpsc::Sender<()>, pause_tx: &mpsc::Sender<()>, shared_state: &Arc, - runtime_dir: &RuntimeDir, mut input_commit: Option<(oneshot::Receiver<()>, crate::worker::PendingSubmission)>, notify_buffer: &NotifyBuffer, pending_submissions: &crate::worker::PendingSubmissionHandle, - parent_socket: Option<&PathBuf>, - self_name: &str, - spawned_registry: &Arc, - parent_originated: bool, ) -> (WorkerStatus, bool, bool) where F: std::future::Future>, @@ -3418,7 +3119,6 @@ where } set_controller_status( shared_state, - runtime_dir, working_event_tx, WorkerStatus::Running, ) @@ -3468,35 +3168,14 @@ where code, message: message.clone(), }); - if parent_originated { - crate::ipc::event::fire_and_forget( - parent_socket.cloned(), - protocol::WorkerEvent::Errored { - worker_name: self_name.to_string(), - message, - }, - ); - } return (WorkerStatus::Idle, shutdown_requested, false); } }; let _ = working_event_tx.send(Event::RunEnd { result: run_result }); - if parent_originated && matches!(run_result, RunResult::Finished) { - crate::ipc::event::fire_and_forget( - parent_socket.cloned(), - protocol::WorkerEvent::TurnEnded { - worker_name: self_name.to_string(), - }, - ); - } (status, shutdown_requested, may_drain_pending) } Err(WorkerError::Engine(EngineError::Cancelled)) if pause_requested => { - // User-initiated Pause. Report the transition to - // clients as a normal Paused run-end, and - // intentionally skip `WorkerEvent::Errored` upward: - // that channel is reserved for worker runtime - // failures, not deliberate interruptions. + // User-initiated Pause is a normal Paused run-end. let _ = working_event_tx.send(Event::RunEnd { result: RunResult::Paused }); (WorkerStatus::Paused, shutdown_requested, false) } @@ -3515,17 +3194,8 @@ where let message = e.to_string(); let _ = working_event_tx.send(Event::Error { code, - message: message.clone(), + message, }); - if parent_originated { - crate::ipc::event::fire_and_forget( - parent_socket.cloned(), - protocol::WorkerEvent::Errored { - worker_name: self_name.to_string(), - message, - }, - ); - } } (status, shutdown_requested, false) } @@ -3560,7 +3230,6 @@ where } set_controller_state( shared_state, - runtime_dir, working_event_tx, WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Cancelling)), ) @@ -3602,7 +3271,6 @@ where pause_requested = true; set_controller_state( shared_state, - runtime_dir, working_event_tx, WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Pausing)), ) @@ -3630,7 +3298,6 @@ where shutdown_requested = true; set_controller_state( shared_state, - runtime_dir, working_event_tx, WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Cancelling)), ) @@ -3852,12 +3519,8 @@ where // from the notify buffer on its next history append. handle_inbound_worker_event( event, - spawned_registry, - self_name, - parent_socket, notify_buffer, - ) - .await; + ); } None => { let _ = cancel_tx.try_send(()); @@ -4029,10 +3692,8 @@ mod subject_release_tests; mod tests { use super::*; use protocol::WorkerEvent; - use protocol::stream::{JsonLineReader, JsonLineWriter}; use std::time::Duration; use tempfile::TempDir; - use tokio::net::UnixListener; mod engine_alerts { use super::*; @@ -4418,19 +4079,8 @@ mod tests { assert!(materialize < exposure); let observer = startup.find("let command_observer = fs_for_view").unwrap(); let recover = startup.find(".recover_unfinished_compaction()").unwrap(); - let files = startup - .find("runtime_dir.write_status(&shared_state).await?") - .unwrap(); let loop_start = startup.find("tokio::spawn(controller_loop(").unwrap(); - assert!(recover < observer && files < observer && observer < loop_start); - let socket_startup = &startup[observer..loop_start]; - let abort = socket_startup.find("observer.abort()").unwrap(); - let join = socket_startup.find("observer.await").unwrap(); - let failure = socket_startup.find("return Err(error)").unwrap(); - assert!( - abort < join && join < failure, - "startup cannot detach its observer on error" - ); + assert!(recover < observer && observer < loop_start); assert!( startup[materialize_gate..materialize] .contains("needs_initial_session_head_materialization") @@ -4643,17 +4293,6 @@ mod tests { assert!(!model_supports_image_attachments(&anthropic)); } - #[test] - fn pending_run_parent_origin_table() { - assert!(PendingRun::Resume.is_parent_originated()); - assert!( - !PendingRun::RunForNotification { - invoke_kind: protocol::InvokeKind::Notify, - } - .is_parent_originated() - ); - } - struct DriveTurnEnv { // Held to keep the channel alive; without this `method_rx.recv()` // would observe channel-closed and confuse the select! arm. @@ -4667,19 +4306,11 @@ mod tests { shared_state: Arc, notify_buffer: NotifyBuffer, pending_submissions: crate::worker::PendingSubmissionHandle, - spawned_registry: Arc, - parent_socket_path: PathBuf, - runtime_dir: Arc, _temp: TempDir, } async fn make_env() -> DriveTurnEnv { let temp = tempfile::tempdir().expect("tempdir"); - let runtime_dir = Arc::new( - RuntimeDir::create(temp.path(), "child-worker") - .await - .expect("runtime dir create"), - ); let (method_tx, method_rx) = mpsc::channel::(16); let (working_event_tx, _) = broadcast::channel::(16); let (cancel_tx, cancel_rx) = mpsc::channel::<()>(1); @@ -4704,8 +4335,6 @@ mod tests { let notify_buffer = NotifyBuffer::new(); let pending_submissions = crate::worker::PendingSubmissionHandle::for_test(&temp.path().join("pending-sessions")); - let spawned_registry = SpawnedWorkerRegistry::new(runtime_dir.clone()); - let parent_socket_path = temp.path().join("parent.sock"); DriveTurnEnv { _method_tx: method_tx, @@ -4718,54 +4347,10 @@ mod tests { shared_state, notify_buffer, pending_submissions, - spawned_registry, - parent_socket_path, - runtime_dir, _temp: temp, } } - /// Listen on a bound UnixListener for one inbound connection and - /// return the first `Method::WorkerEvent` read from it. Returns `None` - /// on timeout / EOF / non-WorkerEvent. - async fn recv_worker_event(listener: UnixListener, timeout: Duration) -> Option { - let accept = async { - let (stream, _) = listener.accept().await.ok()?; - let (r, w) = stream.into_split(); - let mut writer = JsonLineWriter::new(w); - writer - .write(&Event::Snapshot { - session: protocol::SessionSnapshot { - pending_submissions: protocol::PendingSubmissionsSnapshot::default(), - entries: Vec::new(), - }, - greeting: protocol::Greeting { - worker_name: "parent".into(), - cwd: "/tmp".into(), - provider: "test".into(), - model: "test".into(), - scope_summary: String::new(), - tools: Vec::new(), - context_window: 200_000, - context_tokens: 0, - reasoning: None, - context_usage: None, - }, - state: WorkerStatus::Idle.into(), - in_flight: Default::default(), - internal_workers: Vec::new(), - }) - .await - .ok()?; - let mut reader = JsonLineReader::new(r); - match reader.next::().await { - Ok(Some(Method::WorkerEvent(e))) => Some(e), - _ => None, - } - }; - tokio::time::timeout(timeout, accept).await.ok().flatten() - } - #[tokio::test] async fn idle_only_input_is_rejected_in_busy_maintenance_admission() { let env = make_env().await; @@ -4796,43 +4381,6 @@ mod tests { ); } - #[tokio::test] - async fn parent_originated_finished_fires_turn_ended() { - let mut env = make_env().await; - let listener = UnixListener::bind(&env.parent_socket_path).expect("bind listener"); - let recv = tokio::spawn(recv_worker_event(listener, Duration::from_secs(2))); - - let worker_future = async { Ok::<_, WorkerError>(WorkerRunResult::Finished) }; - let (status, shutdown, _) = drive_turn( - worker_future, - &mut env.method_rx, - &env.working_event_tx, - &env.cancel_tx, - &env.pause_tx, - &env.shared_state, - &env.runtime_dir, - None, - &env.notify_buffer, - &env.pending_submissions, - Some(&env.parent_socket_path), - "child-worker", - &env.spawned_registry, - true, - ) - .await; - assert_eq!(status, WorkerStatus::Idle); - assert!(!shutdown); - - let event = recv - .await - .expect("recv task") - .expect("WorkerEvent received"); - match event { - WorkerEvent::TurnEnded { worker_name } => assert_eq!(worker_name, "child-worker"), - other => panic!("expected TurnEnded, got {other:?}"), - } - } - #[test] fn busy_submit_is_durable_and_idempotent_before_shutdown() { let dir = tempfile::tempdir().unwrap(); @@ -4957,14 +4505,9 @@ mod tests { &env.cancel_tx, &env.pause_tx, &env.shared_state, - &env.runtime_dir, None, &env.notify_buffer, &env.pending_submissions, - None, - "worker", - &env.spawned_registry, - false, ) .await; @@ -5006,14 +4549,9 @@ mod tests { &env.cancel_tx, &env.pause_tx, &env.shared_state, - &env.runtime_dir, None, &env.notify_buffer, &env.pending_submissions, - None, - "worker", - &env.spawned_registry, - false, ) .await; @@ -5051,14 +4589,9 @@ mod tests { &env.cancel_tx, &env.pause_tx, &env.shared_state, - &env.runtime_dir, None, &env.notify_buffer, &env.pending_submissions, - None, - "child-worker", - &env.spawned_registry, - true, ) .await; @@ -5069,90 +4602,9 @@ mod tests { assert!(env._cancel_rx.try_recv().is_err()); } - #[tokio::test] - async fn non_parent_originated_finished_stays_silent() { - let mut env = make_env().await; - let listener = UnixListener::bind(&env.parent_socket_path).expect("bind listener"); - - let worker_future = async { Ok::<_, WorkerError>(WorkerRunResult::Finished) }; - let (status, _, _) = drive_turn( - worker_future, - &mut env.method_rx, - &env.working_event_tx, - &env.cancel_tx, - &env.pause_tx, - &env.shared_state, - &env.runtime_dir, - None, - &env.notify_buffer, - &env.pending_submissions, - Some(&env.parent_socket_path), - "child-worker", - &env.spawned_registry, - false, - ) - .await; - assert_eq!(status, WorkerStatus::Idle); - - // Wait long enough for any (incorrect) fire-and-forget send to - // land; expect the accept to time out. - let accept = tokio::time::timeout(Duration::from_millis(200), listener.accept()).await; - assert!( - accept.is_err(), - "expected no WorkerEvent for non-parent-originated turn" - ); - } - - #[tokio::test] - async fn parent_originated_worker_error_fires_errored() { - let mut env = make_env().await; - let listener = UnixListener::bind(&env.parent_socket_path).expect("bind listener"); - let recv = tokio::spawn(recv_worker_event(listener, Duration::from_secs(2))); - - let worker_future = async { - Err::(WorkerError::Engine(EngineError::Aborted( - "boom from test".into(), - ))) - }; - let (status, _, _) = drive_turn( - worker_future, - &mut env.method_rx, - &env.working_event_tx, - &env.cancel_tx, - &env.pause_tx, - &env.shared_state, - &env.runtime_dir, - None, - &env.notify_buffer, - &env.pending_submissions, - Some(&env.parent_socket_path), - "child-worker", - &env.spawned_registry, - true, - ) - .await; - assert_eq!(status, WorkerStatus::Idle); - - let event = recv - .await - .expect("recv task") - .expect("WorkerEvent received"); - match event { - WorkerEvent::Errored { - worker_name, - message, - } => { - assert_eq!(worker_name, "child-worker"); - assert!(message.contains("boom from test"), "got message: {message}"); - } - other => panic!("expected Errored, got {other:?}"), - } - } - #[tokio::test] async fn delivered_run_failure_is_not_reported_again_by_controller() { let mut env = make_env().await; - let listener = UnixListener::bind(&env.parent_socket_path).expect("bind listener"); let mut events = env.working_event_tx.subscribe(); let worker_future = async { Err::(WorkerError::RunFailureAlreadyDelivered { @@ -5167,26 +4619,15 @@ mod tests { &env.cancel_tx, &env.pause_tx, &env.shared_state, - &env.runtime_dir, None, &env.notify_buffer, &env.pending_submissions, - Some(&env.parent_socket_path), - "child-worker", - &env.spawned_registry, - true, ) .await; assert_eq!(status, WorkerStatus::Idle); assert!(!may_drain_pending, "failed runs must not become successful"); assert!(events.try_recv().is_err(), "must not emit a generic Error"); - assert!( - tokio::time::timeout(Duration::from_millis(200), listener.accept()) - .await - .is_err(), - "must not send a second parent Errored event" - ); } #[tokio::test] @@ -5209,14 +4650,9 @@ mod tests { &env.cancel_tx, &env.pause_tx, &env.shared_state, - &env.runtime_dir, None, &env.notify_buffer, &env.pending_submissions, - None, - "child-worker", - &env.spawned_registry, - false, ) .await; @@ -5230,42 +4666,6 @@ mod tests { assert!(events.try_recv().is_err(), "must report once"); } - #[tokio::test] - async fn non_parent_originated_worker_error_stays_silent() { - let mut env = make_env().await; - let listener = UnixListener::bind(&env.parent_socket_path).expect("bind listener"); - - let worker_future = async { - Err::(WorkerError::Engine(EngineError::Aborted( - "boom from notify".into(), - ))) - }; - let (status, _, _) = drive_turn( - worker_future, - &mut env.method_rx, - &env.working_event_tx, - &env.cancel_tx, - &env.pause_tx, - &env.shared_state, - &env.runtime_dir, - None, - &env.notify_buffer, - &env.pending_submissions, - Some(&env.parent_socket_path), - "child-worker", - &env.spawned_registry, - false, - ) - .await; - assert_eq!(status, WorkerStatus::Idle); - - let accept = tokio::time::timeout(Duration::from_millis(200), listener.accept()).await; - assert!( - accept.is_err(), - "expected no WorkerEvent for notification-originated worker error" - ); - } - #[tokio::test] async fn running_legacy_scope_callback_has_no_registry_authority_or_notify() { let mut env = make_env().await; @@ -5290,14 +4690,9 @@ mod tests { &env.cancel_tx, &env.pause_tx, &env.shared_state, - &env.runtime_dir, None, &env.notify_buffer, &env.pending_submissions, - Some(&env.parent_socket_path), - "parent", - &env.spawned_registry, - false, ) .await; @@ -5330,14 +4725,9 @@ mod tests { &env.cancel_tx, &env.pause_tx, &env.shared_state, - &env.runtime_dir, None, &env.notify_buffer, &env.pending_submissions, - Some(&env.parent_socket_path), - "parent", - &env.spawned_registry, - false, ) .await; @@ -5368,14 +4758,9 @@ mod tests { &env.cancel_tx, &env.pause_tx, &env.shared_state, - &env.runtime_dir, None, &env.notify_buffer, &env.pending_submissions, - Some(&env.parent_socket_path), - "parent", - &env.spawned_registry, - false, ) .await; @@ -5410,14 +4795,9 @@ mod tests { &env.cancel_tx, &env.pause_tx, &env.shared_state, - &env.runtime_dir, None, &env.notify_buffer, &env.pending_submissions, - Some(&env.parent_socket_path), - "child-worker", - &env.spawned_registry, - false, ) .await; assert_eq!(status, WorkerStatus::Idle); diff --git a/crates/worker/src/controller_subject_release_tests.rs b/crates/worker/src/controller_subject_release_tests.rs index d57dc4530..5e843bb2e 100644 --- a/crates/worker/src/controller_subject_release_tests.rs +++ b/crates/worker/src/controller_subject_release_tests.rs @@ -65,7 +65,6 @@ async fn prepared( WorkerBootstrapLayout::Direct { runtime_base: temp.path().join("runtime"), }, - WorkerControllerTransport::InProcess, ) .with_model_client(client) .prepare() diff --git a/crates/worker/src/discovery.rs b/crates/worker/src/discovery.rs deleted file mode 100644 index dd7ecb8e8..000000000 --- a/crates/worker/src/discovery.rs +++ /dev/null @@ -1,1861 +0,0 @@ -//! Worker-state-backed discovery and restore tools. -//! -//! This surface deliberately does not enumerate every Worker on the host. The -//! listing path starts from the caller's visibility set (the caller itself and -//! Workers it spawned according to durable Worker state) and only then reads each -//! Worker's own state. Name-targeted operations distinguish missing state from -//! state that exists but is outside that visibility set. - -use std::collections::BTreeMap; -use std::fmt; -use std::io; -use std::path::{Path, PathBuf}; -use std::process::Stdio; -use std::sync::Arc; -use std::time::Duration; - -use crate::runtime_command::WorkerRuntimeCommand; -use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; -use async_trait::async_trait; -use manifest::{Permission, ScopeRule}; -use protocol::stream::JsonLineReader; -use protocol::{Event, Method, WorkerStatus}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use session_store::{SegmentId, SessionId}; -use session_store::{ - WorkerActiveSegmentRef, WorkerMetadata, WorkerMetadataStore, validate_worker_name, -}; -use tokio::net::UnixStream; -use tokio::process::Command; - -use crate::runtime::worker_allocation; -use crate::spawn::comm_tools::connect_and_send; -use crate::spawn::registry::SpawnedWorkerRegistry; - -const PROBE_TIMEOUT: Duration = Duration::from_millis(500); -const RESTORE_START_TIMEOUT: Duration = Duration::from_secs(20); - -#[derive(Clone)] -pub struct WorkerDiscovery { - store: St, - self_worker_name: String, - runtime_base: PathBuf, - cwd: Option, - store_dir: Option, -} - -impl WorkerDiscovery -where - St: WorkerMetadataStore + Clone + Send + Sync + 'static, -{ - pub fn new( - store: St, - self_worker_name: String, - runtime_base: PathBuf, - cwd: Option, - _spawned_registry: Arc, - ) -> Self { - let store_dir = store.root_dir(); - Self { - store, - self_worker_name, - runtime_base, - cwd, - store_dir, - } - } - - pub async fn list_visible(&self) -> Result, WorkerDiscoveryError> { - let visibility = self.visibility().await?; - let mut items = Vec::with_capacity(visibility.visible.len()); - for worker_name in visibility.visible.keys() { - items.push( - self.build_item_for_visible_name(worker_name, &visibility) - .await, - ); - } - Ok(items) - } - - pub async fn inspect(&self, worker_name: &str) -> Result { - let visibility = self.visibility().await?; - let known_names = self.store.list_names()?; - let state_exists = known_names.iter().any(|n| n == worker_name); - if !state_exists { - return Err(WorkerDiscoveryError::StateMissing { - worker_name: worker_name.to_string(), - }); - } - if !visibility.visible.contains_key(worker_name) { - return Err(WorkerDiscoveryError::NotVisible { - worker_name: worker_name.to_string(), - }); - } - - match self.store.read_by_name(worker_name)? { - Some(metadata) => Ok(self.detail_from_metadata(metadata, &visibility).await), - None => Err(WorkerDiscoveryError::StateMissing { - worker_name: worker_name.to_string(), - }), - } - } - - pub async fn restore(&self, worker_name: &str) -> Result { - match self.plan_restore(worker_name).await? { - RestorePlan::AlreadyLive { - worker_name, - socket_path, - status, - } => Ok(RestoreResult::AlreadyLive { - worker_name, - socket_path, - status, - }), - RestorePlan::Restore { - worker_name, - socket_path, - } => { - self.spawn_restore_process(&worker_name, &socket_path) - .await?; - Ok(RestoreResult::Restored { - worker_name, - socket_path, - }) - } - } - } - - pub async fn plan_restore( - &self, - worker_name: &str, - ) -> Result { - let detail = self.inspect(worker_name).await?; - if detail.live.reachable { - return Ok(RestorePlan::AlreadyLive { - worker_name: worker_name.to_string(), - socket_path: detail.live.socket_path, - status: detail.live.status, - }); - } - - let active = detail - .active - .ok_or_else(|| WorkerDiscoveryError::NotRestorable { - worker_name: worker_name.to_string(), - reason: "worker state has no active session".into(), - })?; - let segment_id = active - .segment_id - .ok_or_else(|| WorkerDiscoveryError::NotRestorable { - worker_name: worker_name.to_string(), - reason: "worker state has an active session but no active segment yet".into(), - })?; - - if let Some(lock) = lookup_segment_lock(segment_id)? { - let lock_live = probe_socket(&lock.socket).await; - return if lock_live.reachable { - Ok(RestorePlan::AlreadyLive { - worker_name: lock.worker_name, - socket_path: lock.socket, - status: lock_live.status, - }) - } else { - Err(WorkerDiscoveryError::LockConflict { - worker_name: worker_name.to_string(), - segment_id, - owner_worker: lock.worker_name, - socket_path: lock.socket, - pid: lock.pid, - }) - }; - } - - Ok(RestorePlan::Restore { - worker_name: worker_name.to_string(), - socket_path: self.default_socket_path(worker_name), - }) - } - - pub fn register_peer( - &self, - peer_name: &str, - ) -> Result { - self.ensure_existing_peer(peer_name)? - .ok_or_else(|| WorkerDiscoveryError::MissingWorker { - worker_name: peer_name.to_string(), - }) - } - - pub fn ensure_existing_peer( - &self, - peer_name: &str, - ) -> Result, WorkerDiscoveryError> { - validate_worker_name(peer_name)?; - if peer_name == self.self_worker_name { - return Err(WorkerDiscoveryError::SelfPeer { - worker_name: peer_name.to_string(), - }); - } - let self_metadata = self - .store - .read_by_name(&self.self_worker_name)? - .ok_or_else(|| WorkerDiscoveryError::StateMissing { - worker_name: self.self_worker_name.clone(), - })?; - let prior_self_peers = self_metadata.peers.clone(); - if self.store.read_by_name(peer_name)?.is_none() { - return Ok(None); - } - - self.store.add_peer(&self.self_worker_name, peer_name)?; - if let Err(error) = self.store.add_peer(peer_name, &self.self_worker_name) { - let _ = self - .store - .set_peers(&self.self_worker_name, prior_self_peers); - return Err(WorkerDiscoveryError::WorkerStore(error)); - } - - Ok(Some(PeerRegistrationResult { - source: self.self_worker_name.clone(), - peer: peer_name.to_string(), - })) - } - - async fn visibility(&self) -> Result { - let mut visible = BTreeMap::new(); - let mut child_sockets = BTreeMap::new(); - let mut comm_registry = BTreeMap::new(); - visible.insert(self.self_worker_name.clone(), VisibilityReason::SelfWorker); - - // Durable parent -> child state is the primary visibility source. - if let Some(metadata) = self.store.read_by_name(&self.self_worker_name)? { - for child in metadata.spawned_children { - visible - .entry(child.worker_name.clone()) - .or_insert(VisibilityReason::SpawnedChild); - child_sockets.insert(child.worker_name.clone(), child.socket_path.clone()); - comm_registry.insert( - child.worker_name.clone(), - comm_info_from_spawned_child(&child), - ); - } - for peer in metadata.peers { - visible - .entry(peer.worker_name) - .or_insert(VisibilityReason::Peer); - } - } - - Ok(VisibilitySet { - visible, - child_sockets, - comm_registry, - }) - } - - async fn build_item_for_visible_name( - &self, - worker_name: &str, - visibility: &VisibilitySet, - ) -> VisibleWorkerItem { - let visibility_reason = visibility.reason_for(worker_name); - match self.store.read_by_name(worker_name) { - Ok(Some(metadata)) => { - let detail = self.detail_from_metadata(metadata, visibility).await; - VisibleWorkerItem { - worker_name: worker_name.to_string(), - visibility: visibility_reason, - state: WorkerStateStatus::Readable, - active: detail.active, - live: detail.live, - restore: detail.restore, - comm_registry: detail.comm_registry, - spawned_children: detail.spawned_children, - error: None, - } - } - Ok(None) => VisibleWorkerItem { - worker_name: worker_name.to_string(), - visibility: visibility_reason, - state: WorkerStateStatus::Missing, - active: None, - live: self.live_for_name(worker_name, None).await, - restore: RestoreInfo::not_possible("worker state missing"), - comm_registry: visibility.comm_info_for(worker_name), - spawned_children: SpawnedChildrenSummary::default(), - error: None, - }, - Err(error) => VisibleWorkerItem { - worker_name: worker_name.to_string(), - visibility: visibility_reason, - state: WorkerStateStatus::Corrupt, - active: None, - live: self.live_for_name(worker_name, None).await, - restore: RestoreInfo::not_possible("worker state is unreadable"), - comm_registry: visibility.comm_info_for(worker_name), - spawned_children: SpawnedChildrenSummary::default(), - error: Some(error.to_string()), - }, - } - } - - async fn detail_from_metadata( - &self, - metadata: WorkerMetadata, - visibility: &VisibilitySet, - ) -> WorkerDetail { - let child_socket = visibility.child_socket_for(&metadata.worker_name); - let live = self - .live_for_name(&metadata.worker_name, child_socket.as_deref()) - .await; - let restore = self - .restore_info(&metadata.worker_name, metadata.active.as_ref()) - .await; - let spawned_children = summarize_spawned_children(&metadata.spawned_children).await; - WorkerDetail { - worker_name: metadata.worker_name.clone(), - visibility: visibility.reason_for(&metadata.worker_name), - active: metadata.active.map(ActivePointer::from), - live, - restore, - comm_registry: visibility.comm_info_for(&metadata.worker_name), - spawned_children, - } - } - - async fn restore_info( - &self, - worker_name: &str, - active: Option<&WorkerActiveSegmentRef>, - ) -> RestoreInfo { - let Some(active) = active else { - return RestoreInfo::not_possible("worker state has no active session"); - }; - let Some(segment_id) = active.segment_id else { - return RestoreInfo::not_possible("active segment is not known yet"); - }; - match lookup_segment_lock(segment_id) { - Ok(Some(lock)) => RestoreInfo { - possible: false, - restore_name: Some(worker_name.to_string()), - reason: Some(format!( - "segment is currently locked by `{}` (pid {})", - lock.worker_name, lock.pid - )), - }, - Ok(None) => RestoreInfo { - possible: true, - restore_name: Some(worker_name.to_string()), - reason: None, - }, - Err(error) => RestoreInfo { - possible: false, - restore_name: Some(worker_name.to_string()), - reason: Some(format!("lock lookup failed: {error}")), - }, - } - } - - pub async fn send_weak_notify_to_live_peer( - &self, - peer_name: &str, - message: String, - ) -> WeakNotifyDelivery { - let detail = match self.inspect(peer_name).await { - Ok(detail) => detail, - Err( - WorkerDiscoveryError::StateMissing { .. } - | WorkerDiscoveryError::MissingWorker { .. }, - ) => { - return WeakNotifyDelivery::SkippedMissing; - } - Err(WorkerDiscoveryError::NotVisible { .. }) => { - return WeakNotifyDelivery::SkippedNotVisible; - } - Err(error) => { - return WeakNotifyDelivery::SendFailed { - error: error.to_string(), - }; - } - }; - if detail.visibility != VisibilityReason::Peer { - return WeakNotifyDelivery::SkippedNotPeer { - visibility: detail.visibility, - }; - } - if !detail.live.reachable { - return WeakNotifyDelivery::SkippedNotLive { - reason: detail.live.error, - }; - } - match send_notify(&detail.live.socket_path, message).await { - Ok(()) => WeakNotifyDelivery::Delivered, - Err(error) => WeakNotifyDelivery::SendFailed { - error: error.to_string(), - }, - } - } - - async fn live_for_name(&self, worker_name: &str, socket_override: Option<&Path>) -> LiveInfo { - let socket_path = socket_override - .map(Path::to_path_buf) - .unwrap_or_else(|| self.default_socket_path(worker_name)); - probe_socket(&socket_path).await - } - - fn default_socket_path(&self, worker_name: &str) -> PathBuf { - self.runtime_base.join(worker_name).join("sock") - } - - async fn spawn_restore_process( - &self, - worker_name: &str, - socket_path: &Path, - ) -> 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(cwd) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .process_group(0); - if let Some(store_dir) = &self.store_dir { - command.arg("--store").arg(store_dir); - } - - let mut child = - command - .spawn() - .map_err(|source| WorkerDiscoveryError::RestoreLaunchFailed { - command: runtime_command.clone(), - source, - })?; - let deadline = tokio::time::Instant::now() + RESTORE_START_TIMEOUT; - loop { - if probe_socket(socket_path).await.reachable { - tokio::spawn(async move { - let _ = child.wait().await; - }); - return Ok(()); - } - if let Some(status) = child - .try_wait() - .map_err(WorkerDiscoveryError::RestoreSpawn)? - { - return Err(WorkerDiscoveryError::RestoreExited { status }); - } - if tokio::time::Instant::now() >= deadline { - let _ = child.start_kill(); - let _ = child.wait().await; - return Err(WorkerDiscoveryError::RestoreTimeout); - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum VisibilityReason { - SelfWorker, - SpawnedChild, - Peer, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum WorkerStateStatus { - Readable, - Missing, - Corrupt, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ActivePointer { - pub session_id: SessionId, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub segment_id: Option, -} - -impl From for ActivePointer { - fn from(value: WorkerActiveSegmentRef) -> Self { - Self { - session_id: value.session_id, - segment_id: value.segment_id, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct LiveInfo { - pub socket_path: PathBuf, - pub reachable: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub status: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RestoreInfo { - pub possible: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub restore_name: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reason: Option, -} - -impl RestoreInfo { - fn not_possible(reason: impl Into) -> Self { - Self { - possible: false, - restore_name: None, - reason: Some(reason.into()), - } - } -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -pub struct SpawnedChildrenSummary { - pub count: usize, - pub reachable: usize, - pub unreachable: usize, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct CommRegistryInfo { - pub registered: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub socket_path: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub scope_delegated: Vec, -} - -impl CommRegistryInfo { - fn missing() -> Self { - Self { - registered: false, - socket_path: None, - scope_delegated: Vec::new(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct VisibleWorkerItem { - pub worker_name: String, - pub visibility: VisibilityReason, - pub state: WorkerStateStatus, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub active: Option, - pub live: LiveInfo, - pub restore: RestoreInfo, - pub comm_registry: CommRegistryInfo, - pub spawned_children: SpawnedChildrenSummary, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct WorkerDetail { - pub worker_name: String, - pub visibility: VisibilityReason, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub active: Option, - pub live: LiveInfo, - pub restore: RestoreInfo, - pub comm_registry: CommRegistryInfo, - pub spawned_children: SpawnedChildrenSummary, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "action", rename_all = "snake_case")] -pub enum RestorePlan { - AlreadyLive { - worker_name: String, - socket_path: PathBuf, - #[serde(default, skip_serializing_if = "Option::is_none")] - status: Option, - }, - Restore { - worker_name: String, - socket_path: PathBuf, - }, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "action", rename_all = "snake_case")] -pub enum RestoreResult { - AlreadyLive { - worker_name: String, - socket_path: PathBuf, - #[serde(default, skip_serializing_if = "Option::is_none")] - status: Option, - }, - Restored { - worker_name: String, - socket_path: PathBuf, - }, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct PeerRegistrationResult { - pub source: String, - pub peer: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum WeakNotifyDelivery { - Delivered, - SkippedMissing, - SkippedNotVisible, - SkippedNotPeer { visibility: VisibilityReason }, - SkippedNotLive { reason: Option }, - SendFailed { error: String }, -} - -impl WeakNotifyDelivery { - pub fn delivered(&self) -> bool { - matches!(self, WeakNotifyDelivery::Delivered) - } -} - -impl fmt::Display for WeakNotifyDelivery { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - WeakNotifyDelivery::Delivered => write!(f, "delivered"), - WeakNotifyDelivery::SkippedMissing => { - write!(f, "skipped: target worker metadata is missing") - } - WeakNotifyDelivery::SkippedNotVisible => { - write!(f, "skipped: target worker is not visible") - } - WeakNotifyDelivery::SkippedNotPeer { visibility } => { - write!( - f, - "skipped: target worker is visible as {visibility:?}, not peer" - ) - } - WeakNotifyDelivery::SkippedNotLive { reason } => { - if let Some(reason) = reason { - write!(f, "skipped: target peer is not live/reachable ({reason})") - } else { - write!(f, "skipped: target peer is not live/reachable") - } - } - WeakNotifyDelivery::SendFailed { error } => write!(f, "send failed: {error}"), - } - } -} - -#[derive(Debug, thiserror::Error)] -pub enum WorkerDiscoveryError { - #[error("worker state missing for `{worker_name}`")] - StateMissing { worker_name: String }, - #[error("worker `{worker_name}` is not visible to this Worker")] - NotVisible { worker_name: String }, - #[error("worker `{worker_name}` is not restorable: {reason}")] - NotRestorable { worker_name: String, reason: String }, - #[error("worker `{worker_name}` cannot be registered as a peer of itself")] - SelfPeer { worker_name: String }, - #[error("worker `{worker_name}` does not exist")] - MissingWorker { worker_name: String }, - #[error( - "worker `{worker_name}` segment {segment_id} is locked by `{owner_worker}` pid {pid} at {socket_path}" - )] - LockConflict { - worker_name: String, - segment_id: SegmentId, - owner_worker: String, - socket_path: PathBuf, - pid: u32, - }, - #[error("session store error: {0}")] - Store(#[from] session_store::StoreError), - #[error("worker store error: {0}")] - WorkerStore(#[from] session_store::WorkerStoreError), - #[error("scope lock error: {0}")] - ScopeLock(#[from] worker_allocation::ScopeLockError), - #[error("failed to launch restore process: {0}")] - RestoreSpawn(io::Error), - #[error("failed to launch restore runtime command `{command}`: {source}")] - RestoreLaunchFailed { - command: WorkerRuntimeCommand, - #[source] - source: io::Error, - }, - #[error("restore process exited before socket became reachable: {status}")] - RestoreExited { status: std::process::ExitStatus }, - #[error("restore process did not become reachable before timeout")] - RestoreTimeout, -} - -struct VisibilitySet { - visible: BTreeMap, - child_sockets: BTreeMap, - comm_registry: BTreeMap, -} - -impl VisibilitySet { - fn reason_for(&self, worker_name: &str) -> VisibilityReason { - self.visible - .get(worker_name) - .cloned() - .unwrap_or(VisibilityReason::SpawnedChild) - } - - fn child_socket_for(&self, worker_name: &str) -> Option { - self.child_sockets.get(worker_name).cloned() - } - - fn comm_info_for(&self, worker_name: &str) -> CommRegistryInfo { - self.comm_registry - .get(worker_name) - .cloned() - .unwrap_or_else(CommRegistryInfo::missing) - } -} - -fn comm_info_from_spawned_child(child: &session_store::WorkerSpawnedChild) -> CommRegistryInfo { - let scope_delegated = child - .scope_delegated - .iter() - .filter_map(|rule| { - let permission = match rule.permission.as_str() { - "read" => Permission::Read, - "write" => Permission::Write, - _ => return None, - }; - Some(ScopeRule { - target: rule.target.clone(), - permission, - recursive: rule.recursive, - symlink_policy: rule.symlink_policy, - }) - }) - .collect(); - CommRegistryInfo { - registered: true, - socket_path: Some(child.socket_path.clone()), - scope_delegated, - } -} - -async fn summarize_spawned_children( - children: &[session_store::WorkerSpawnedChild], -) -> SpawnedChildrenSummary { - let mut summary = SpawnedChildrenSummary { - count: children.len(), - ..Default::default() - }; - for child in children { - if probe_socket(&child.socket_path).await.reachable { - summary.reachable += 1; - } else { - summary.unreachable += 1; - } - } - summary -} - -async fn probe_socket(socket_path: &Path) -> LiveInfo { - match tokio::time::timeout(PROBE_TIMEOUT, UnixStream::connect(socket_path)).await { - Ok(Ok(stream)) => { - let (r, _w) = stream.into_split(); - let mut reader = JsonLineReader::new(r); - let mut status = None; - loop { - match tokio::time::timeout(PROBE_TIMEOUT, reader.next::()).await { - Ok(Ok(Some(Event::Snapshot { - state: snapshot_state, - .. - }))) => { - status = Some(snapshot_state.catalog_status()); - break; - } - Ok(Ok(Some(Event::Alert(_)))) => continue, - Ok(Ok(Some(_))) | Ok(Ok(None)) | Ok(Err(_)) | Err(_) => break, - } - } - LiveInfo { - socket_path: socket_path.to_path_buf(), - reachable: true, - status, - error: None, - } - } - Ok(Err(error)) => LiveInfo { - socket_path: socket_path.to_path_buf(), - reachable: false, - status: None, - error: Some(error.to_string()), - }, - Err(_) => LiveInfo { - socket_path: socket_path.to_path_buf(), - reachable: false, - status: None, - error: Some("connect timed out".into()), - }, - } -} - -fn lookup_segment_lock( - segment_id: SegmentId, -) -> Result, worker_allocation::ScopeLockError> { - worker_allocation::lookup_segment(segment_id) -} - -#[derive(Debug, Deserialize, JsonSchema)] -struct WorkerNameInput { - /// Worker name to restore. - name: String, -} - -#[derive(Debug, Deserialize, JsonSchema)] -struct SendToPeerWorkerInput { - /// Target peer Worker name. - name: String, - /// Text delivered to the peer as a peer notification. - message: String, -} - -struct ListWorkersTool { - discovery: WorkerDiscovery, -} - -#[async_trait] -impl Tool for ListWorkersTool -where - St: WorkerMetadataStore + Clone + Send + Sync + 'static, -{ - async fn execute( - &self, - _input_json: &str, - _ctx: agen::tool::ToolExecutionContext, - ) -> Result { - let items = self - .discovery - .list_visible() - .await - .map_err(discovery_error_to_tool_error)?; - let summary = format!("{} visible worker(s)", items.len()); - Ok(ToolOutput { - summary, - content: Some(json_content(&items)?), - attachments: Vec::new(), - }) - } -} - -struct RestoreWorkerTool { - discovery: WorkerDiscovery, -} - -#[async_trait] -impl Tool for RestoreWorkerTool -where - St: WorkerMetadataStore + Clone + Send + Sync + 'static, -{ - async fn execute( - &self, - input_json: &str, - _ctx: agen::tool::ToolExecutionContext, - ) -> Result { - let input: WorkerNameInput = serde_json::from_str(input_json) - .map_err(|e| ToolError::InvalidArgument(format!("invalid RestoreWorker input: {e}")))?; - let result = self - .discovery - .restore(&input.name) - .await - .map_err(discovery_error_to_tool_error)?; - let summary = match &result { - RestoreResult::AlreadyLive { worker_name, .. } => { - format!("worker `{worker_name}` is already live") - } - RestoreResult::Restored { worker_name, .. } => { - format!("worker `{worker_name}` restored from worker state") - } - }; - Ok(ToolOutput { - summary, - content: Some(json_content(&result)?), - - attachments: Vec::new(), - }) - } -} - -pub fn list_workers_tool(discovery: WorkerDiscovery) -> ToolDefinition -where - St: WorkerMetadataStore + Clone + Send + Sync + 'static, -{ - Arc::new(move || { - let meta = ToolMeta::new("ListWorkers") - .description( - "List Workers visible to this Worker from durable Worker state, peer metadata, and the spawned-child registry. This does not expose the host-wide Worker universe.", - ) - .input_schema(serde_json::json!({ - "type": "object", - "properties": {}, - "additionalProperties": false, - })); - let tool: Arc = Arc::new(ListWorkersTool { - discovery: discovery.clone(), - }); - (meta, tool) - }) -} - -pub fn restore_worker_tool(discovery: WorkerDiscovery) -> ToolDefinition -where - St: WorkerMetadataStore + Clone + Send + Sync + 'static, -{ - Arc::new(move || { - let meta = ToolMeta::new("RestoreWorker") - .description( - "Restore a visible stopped/restorable Worker, or report that a visible Worker is already live. Missing state is an error.", - ) - .input_schema(serde_json::to_value(schemars::schema_for!(WorkerNameInput)).unwrap()); - let tool: Arc = Arc::new(RestoreWorkerTool { - discovery: discovery.clone(), - }); - (meta, tool) - }) -} - -const SEND_TO_PEER_POD_DESCRIPTION: &str = "Send a text message to a peer Worker made visible by explicit reciprocal peer metadata. The message is delivered as a peer notification through the target Worker's durable notification/history path. This does not grant delegated scope, create a spawned-child output cursor, imply parent ownership, or produce child completion notifications. Fails clearly if the target is not a visible live peer; it does not auto-restore stopped peers."; - -struct SendToPeerWorkerTool { - discovery: WorkerDiscovery, -} - -#[async_trait] -impl Tool for SendToPeerWorkerTool -where - St: WorkerMetadataStore + Clone + Send + Sync + 'static, -{ - async fn execute( - &self, - input_json: &str, - _ctx: agen::tool::ToolExecutionContext, - ) -> Result { - let input: SendToPeerWorkerInput = serde_json::from_str(input_json).map_err(|e| { - ToolError::InvalidArgument(format!("invalid SendToPeerWorker input: {e}")) - })?; - let detail = self - .discovery - .inspect(&input.name) - .await - .map_err(discovery_error_to_tool_error)?; - if detail.visibility != VisibilityReason::Peer { - return Err(ToolError::InvalidArgument(format!( - "worker `{}` is visible as {:?}, not as a peer", - input.name, detail.visibility - ))); - } - if !detail.live.reachable { - return Err(ToolError::ExecutionFailed(format!( - "peer worker `{}` is not live/reachable; restore it before sending", - input.name - ))); - } - - let message = format!( - "[Peer message from `{}`]\n{}", - self.discovery.self_worker_name, input.message - ); - send_peer_notify(&detail.live.socket_path, message) - .await - .map_err(|error| { - ToolError::ExecutionFailed(format!("send to peer `{}`: {error}", input.name)) - })?; - - Ok(ToolOutput { - summary: format!("sent peer message to `{}`", input.name), - content: None, - attachments: Vec::new(), - }) - } -} - -pub fn send_to_peer_worker_tool(discovery: WorkerDiscovery) -> ToolDefinition -where - St: WorkerMetadataStore + Clone + Send + Sync + 'static, -{ - Arc::new(move || { - let meta = ToolMeta::new("SendToPeerWorker") - .description(SEND_TO_PEER_POD_DESCRIPTION) - .input_schema( - serde_json::to_value(schemars::schema_for!(SendToPeerWorkerInput)).unwrap(), - ); - let tool: Arc = Arc::new(SendToPeerWorkerTool { - discovery: discovery.clone(), - }); - (meta, tool) - }) -} - -async fn send_peer_notify(socket_path: &Path, message: String) -> io::Result<()> { - send_notify(socket_path, message).await -} - -async fn send_notify(socket_path: &Path, message: String) -> io::Result<()> { - let notification_request_id = protocol::new_submission_request_id(); - connect_and_send( - socket_path, - &Method::NotifyTracked { - notification_request_id: notification_request_id.clone(), - message, - source: protocol::AuthenticatedInputSource::Backend { - operation_id: notification_request_id, - }, - }, - ) - .await -} - -fn json_content(value: &T) -> Result { - serde_json::to_string_pretty(value) - .map_err(|e| ToolError::Internal(format!("serialize worker discovery output: {e}"))) -} - -fn discovery_error_to_tool_error(error: WorkerDiscoveryError) -> ToolError { - match error { - WorkerDiscoveryError::StateMissing { .. } - | WorkerDiscoveryError::NotVisible { .. } - | WorkerDiscoveryError::NotRestorable { .. } - | WorkerDiscoveryError::SelfPeer { .. } - | WorkerDiscoveryError::MissingWorker { .. } => { - ToolError::InvalidArgument(error.to_string()) - } - WorkerDiscoveryError::LockConflict { .. } - | WorkerDiscoveryError::Store(_) - | WorkerDiscoveryError::WorkerStore(_) - | WorkerDiscoveryError::ScopeLock(_) - | WorkerDiscoveryError::RestoreSpawn(_) - | WorkerDiscoveryError::RestoreLaunchFailed { .. } - | WorkerDiscoveryError::RestoreExited { .. } - | WorkerDiscoveryError::RestoreTimeout => ToolError::ExecutionFailed(error.to_string()), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use manifest::{Permission, ScopeRule}; - use protocol::stream::JsonLineWriter; - use protocol::{Alert, AlertLevel, AlertSource}; - use session_store::{ - FsWorkerStore, WorkerSpawnedChild, WorkerSpawnedScopeRule, WorkerStoreError, - }; - use session_store::{new_segment_id, new_session_id}; - use tempfile::TempDir; - use tokio::net::UnixListener; - - use crate::runtime::dir::RuntimeDir; - use crate::runtime::worker_allocation::test_util::RuntimeDirSandbox; - - #[derive(Clone)] - struct FailTargetPeerStore { - inner: FsWorkerStore, - } - - impl WorkerMetadataStore for FailTargetPeerStore { - fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> { - if metadata.worker_name == "target" - && metadata - .peers - .iter() - .any(|peer| peer.worker_name == "source") - { - return Err(WorkerStoreError::Io(io::Error::other( - "injected target-side peer write failure", - ))); - } - self.inner.write(metadata) - } - - fn read_by_name( - &self, - worker_name: &str, - ) -> Result, WorkerStoreError> { - self.inner.read_by_name(worker_name) - } - - fn list_names(&self) -> Result, WorkerStoreError> { - self.inner.list_names() - } - - fn root_dir(&self) -> Option { - self.inner.root_dir() - } - - fn delete_by_name(&self, worker_name: &str) -> Result<(), WorkerStoreError> { - self.inner.delete_by_name(worker_name) - } - } - - #[tokio::test(flavor = "current_thread")] - async fn state_backed_visibility_and_restore_planning() { - let root = TempDir::new().unwrap(); - let store_dir = root.path().join("store"); - let runtime_base = root.path().join("runtime"); - std::fs::create_dir_all(&runtime_base).unwrap(); - let _runtime_sandbox = RuntimeDirSandbox::new(&runtime_base); - - let store = FsWorkerStore::new(&store_dir).unwrap(); - let session_id = new_session_id(); - let active_child_segment = new_segment_id(); - let pending_session_id = new_session_id(); - let live_socket = runtime_base.join("child-live").join("sock"); - std::fs::create_dir_all(live_socket.parent().unwrap()).unwrap(); - let live_listener = spawn_snapshot_socket(&live_socket).await; - - let stale_socket = runtime_base.join("child-stale").join("sock"); - let pending_socket = runtime_base.join("child-pending").join("sock"); - let parent = WorkerMetadata { - 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), - child("child-pending", &pending_socket), - ], - reclaimed_children: Vec::new(), - peers: vec![session_store::WorkerPeer { - worker_name: "peer".into(), - }], - resolved_manifest_snapshot: None, - subjektiv_session_attribution: None, - }; - store.write(&parent).unwrap(); - store - .write(&WorkerMetadata { - worker_name: "child-live".into(), - active: Some(WorkerActiveSegmentRef::active_segment( - session_id, - active_child_segment, - )), - workspace_root: None, - workspace_id: None, - spawned_children: Vec::new(), - reclaimed_children: Vec::new(), - peers: Vec::new(), - resolved_manifest_snapshot: None, - subjektiv_session_attribution: None, - }) - .unwrap(); - store - .write(&WorkerMetadata { - worker_name: "child-stale".into(), - active: Some(WorkerActiveSegmentRef::active_segment( - session_id, - active_child_segment, - )), - workspace_root: None, - workspace_id: None, - spawned_children: Vec::new(), - reclaimed_children: Vec::new(), - peers: Vec::new(), - resolved_manifest_snapshot: None, - subjektiv_session_attribution: None, - }) - .unwrap(); - store - .write(&WorkerMetadata { - 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(), - resolved_manifest_snapshot: None, - subjektiv_session_attribution: None, - }) - .unwrap(); - store - .write(&WorkerMetadata { - worker_name: "hidden".into(), - active: Some(WorkerActiveSegmentRef::active_segment( - session_id, - new_segment_id(), - )), - workspace_root: None, - workspace_id: None, - spawned_children: Vec::new(), - reclaimed_children: Vec::new(), - peers: Vec::new(), - resolved_manifest_snapshot: None, - subjektiv_session_attribution: None, - }) - .unwrap(); - store - .write(&WorkerMetadata { - 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 { - worker_name: "parent".into(), - }], - resolved_manifest_snapshot: None, - subjektiv_session_attribution: None, - }) - .unwrap(); - - // RuntimeDir creates parent runtime files; discovery must still use - // Worker state when spawned_workers.json is absent. - let runtime_dir = Arc::new(RuntimeDir::create(&runtime_base, "parent").await.unwrap()); - let runtime_file = runtime_dir.path().join("spawned_workers.json"); - assert!(!runtime_file.exists()); - let registry = SpawnedWorkerRegistry::new(runtime_dir); - let discovery = WorkerDiscovery::new( - store.clone(), - "parent".into(), - runtime_base.clone(), - Some(root.path().to_path_buf()), - registry, - ); - - let list_tool_def = list_workers_tool(discovery.clone()); - let (list_meta, _) = list_tool_def(); - assert_eq!(list_meta.name, "ListWorkers"); - let restore_tool_def = restore_worker_tool(discovery.clone()); - let (restore_meta, _) = restore_tool_def(); - assert_eq!(restore_meta.name, "RestoreWorker"); - let send_peer_tool_def = send_to_peer_worker_tool(discovery.clone()); - let (send_peer_meta, _) = send_peer_tool_def(); - assert_eq!(send_peer_meta.name, "SendToPeerWorker"); - - let list = discovery.list_visible().await.unwrap(); - let names: Vec<_> = list.iter().map(|p| p.worker_name.as_str()).collect(); - assert_eq!( - names, - vec![ - "child-live", - "child-pending", - "child-stale", - "parent", - "peer" - ] - ); - assert!(!names.contains(&"hidden")); - assert_eq!( - list.iter() - .find(|p| p.worker_name == "peer") - .unwrap() - .visibility, - VisibilityReason::Peer - ); - assert_eq!( - list.iter() - .find(|p| p.worker_name == "child-live") - .unwrap() - .visibility, - VisibilityReason::SpawnedChild - ); - assert!( - list.iter() - .find(|p| p.worker_name == "child-live") - .unwrap() - .live - .reachable - ); - assert!( - !list - .iter() - .find(|p| p.worker_name == "child-stale") - .unwrap() - .live - .reachable - ); - - let pending = list - .iter() - .find(|p| p.worker_name == "child-pending") - .unwrap(); - assert_eq!(pending.state, WorkerStateStatus::Readable); - assert_eq!( - pending.active.as_ref().unwrap().session_id, - pending_session_id - ); - assert_eq!(pending.active.as_ref().unwrap().segment_id, None); - assert!(!pending.restore.possible); - - let hidden_err = discovery.inspect("hidden").await.unwrap_err(); - assert!(matches!( - hidden_err, - WorkerDiscoveryError::NotVisible { .. } - )); - let missing_err = discovery.inspect("missing").await.unwrap_err(); - assert!(matches!( - missing_err, - WorkerDiscoveryError::StateMissing { .. } - )); - let hidden_restore_err = discovery.plan_restore("hidden").await.unwrap_err(); - assert!(matches!( - hidden_restore_err, - WorkerDiscoveryError::NotVisible { .. } - )); - - let live_plan = discovery.plan_restore("child-live").await.unwrap(); - assert!(matches!(live_plan, RestorePlan::AlreadyLive { .. })); - let restore_plan = discovery.plan_restore("child-stale").await.unwrap(); - assert!(matches!(restore_plan, RestorePlan::Restore { .. })); - - let lock_socket = runtime_base.join("lock-owner.sock"); - let _guard = worker_allocation::install_top_level( - "lock-owner".into(), - std::process::id(), - lock_socket.clone(), - vec![ScopeRule { - target: root.path().to_path_buf(), - permission: Permission::Read, - recursive: true, - symlink_policy: Default::default(), - }], - active_child_segment, - ) - .unwrap(); - let locked_err = discovery.plan_restore("child-stale").await.unwrap_err(); - assert!(matches!( - locked_err, - WorkerDiscoveryError::LockConflict { .. } - )); - - live_listener.abort(); - } - - #[tokio::test(flavor = "current_thread")] - async fn register_peer_persists_reciprocal_metadata() { - let root = TempDir::new().unwrap(); - let store_dir = root.path().join("store"); - let runtime_base = root.path().join("runtime"); - std::fs::create_dir_all(&runtime_base).unwrap(); - let store = FsWorkerStore::new(&store_dir).unwrap(); - store.write(&WorkerMetadata::new("source", None)).unwrap(); - store.write(&WorkerMetadata::new("target", None)).unwrap(); - let runtime_dir = Arc::new(RuntimeDir::create(&runtime_base, "source").await.unwrap()); - - let discovery = WorkerDiscovery::new( - store.clone(), - "source".into(), - runtime_base.clone(), - Some(root.path().to_path_buf()), - SpawnedWorkerRegistry::new(runtime_dir), - ); - let result = discovery.register_peer("target").unwrap(); - assert_eq!(result.source, "source"); - assert_eq!(result.peer, "target"); - - let source = store.read_by_name("source").unwrap().unwrap(); - let target = store.read_by_name("target").unwrap().unwrap(); - assert_eq!(source.peers[0].worker_name, "target"); - assert_eq!(target.peers[0].worker_name, "source"); - - let list = discovery.list_visible().await.unwrap(); - assert_eq!( - list.iter() - .find(|item| item.worker_name == "target") - .unwrap() - .visibility, - VisibilityReason::Peer - ); - } - - #[tokio::test(flavor = "current_thread")] - async fn register_peer_rejects_self_and_missing_target() { - let root = TempDir::new().unwrap(); - let store_dir = root.path().join("store"); - let runtime_base = root.path().join("runtime"); - std::fs::create_dir_all(&runtime_base).unwrap(); - let store = FsWorkerStore::new(&store_dir).unwrap(); - store.write(&WorkerMetadata::new("source", None)).unwrap(); - let runtime_dir = Arc::new(RuntimeDir::create(&runtime_base, "source").await.unwrap()); - let discovery = WorkerDiscovery::new( - store, - "source".into(), - runtime_base, - Some(root.path().to_path_buf()), - SpawnedWorkerRegistry::new(runtime_dir), - ); - - let self_err = discovery.register_peer("source").unwrap_err(); - assert!(matches!(self_err, WorkerDiscoveryError::SelfPeer { .. })); - let missing_err = discovery.register_peer("missing").unwrap_err(); - assert!(matches!( - missing_err, - WorkerDiscoveryError::MissingWorker { .. } - )); - } - - #[tokio::test(flavor = "current_thread")] - async fn register_peer_target_failure_preserves_existing_source_peer() { - let root = TempDir::new().unwrap(); - let store_dir = root.path().join("store"); - let runtime_base = root.path().join("runtime"); - std::fs::create_dir_all(&runtime_base).unwrap(); - let inner = FsWorkerStore::new(&store_dir).unwrap(); - inner - .write(&WorkerMetadata { - 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 { - worker_name: "target".into(), - }], - resolved_manifest_snapshot: None, - subjektiv_session_attribution: None, - }) - .unwrap(); - inner.write(&WorkerMetadata::new("target", None)).unwrap(); - let store = FailTargetPeerStore { inner }; - let runtime_dir = Arc::new(RuntimeDir::create(&runtime_base, "source").await.unwrap()); - let discovery = WorkerDiscovery::new( - store.clone(), - "source".into(), - runtime_base, - Some(root.path().to_path_buf()), - SpawnedWorkerRegistry::new(runtime_dir), - ); - - let err = discovery.register_peer("target").unwrap_err(); - assert!(matches!(err, WorkerDiscoveryError::WorkerStore(_))); - let source = store.read_by_name("source").unwrap().unwrap(); - assert_eq!(source.peers.len(), 1); - assert_eq!(source.peers[0].worker_name, "target"); - let target = store.read_by_name("target").unwrap().unwrap(); - assert!(target.peers.is_empty()); - } - - #[tokio::test(flavor = "current_thread")] - async fn send_to_peer_worker_delivers_notify_without_child_registry() { - let root = TempDir::new().unwrap(); - let store_dir = root.path().join("store"); - let runtime_base = root.path().join("runtime"); - std::fs::create_dir_all(runtime_base.join("target")).unwrap(); - let store = FsWorkerStore::new(&store_dir).unwrap(); - store - .write(&WorkerMetadata { - 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 { - worker_name: "target".into(), - }], - resolved_manifest_snapshot: None, - subjektiv_session_attribution: None, - }) - .unwrap(); - store - .write(&WorkerMetadata { - 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 { - worker_name: "source".into(), - }], - resolved_manifest_snapshot: None, - subjektiv_session_attribution: None, - }) - .unwrap(); - let runtime_dir = Arc::new(RuntimeDir::create(&runtime_base, "source").await.unwrap()); - let discovery = WorkerDiscovery::new( - store, - "source".into(), - runtime_base.clone(), - Some(root.path().to_path_buf()), - SpawnedWorkerRegistry::new(runtime_dir), - ); - - let socket = runtime_base.join("target").join("sock"); - let listener = UnixListener::bind(&socket).unwrap(); - let (tx, mut rx) = tokio::sync::mpsc::channel(1); - let target = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut writer = JsonLineWriter::new(stream); - writer - .write(&Event::Snapshot { - session: protocol::SessionSnapshot { - pending_submissions: protocol::PendingSubmissionsSnapshot::default(), - entries: Vec::new(), - }, - greeting: protocol::Greeting { - worker_name: "target".into(), - cwd: "/tmp".into(), - provider: "test".into(), - model: "test".into(), - scope_summary: String::new(), - tools: Vec::new(), - context_window: 0, - context_tokens: 0, - reasoning: None, - context_usage: None, - }, - state: WorkerStatus::Idle.into(), - in_flight: Default::default(), - internal_workers: Vec::new(), - }) - .await - .unwrap(); - - let (stream, _) = listener.accept().await.unwrap(); - let (reader_half, writer_half) = stream.into_split(); - let mut reader = JsonLineReader::new(reader_half); - let mut writer = JsonLineWriter::new(writer_half); - writer - .write(&Event::Alert(Alert { - id: None, - position: None, - level: AlertLevel::Warn, - source: AlertSource::Worker, - message: "connect-time alert".into(), - timestamp_ms: 0, - })) - .await - .unwrap(); - writer - .write(&Event::Snapshot { - session: protocol::SessionSnapshot { - pending_submissions: protocol::PendingSubmissionsSnapshot::default(), - entries: Vec::new(), - }, - greeting: protocol::Greeting { - worker_name: "target".into(), - cwd: "/tmp".into(), - provider: "test".into(), - model: "test".into(), - scope_summary: String::new(), - tools: Vec::new(), - context_window: 0, - context_tokens: 0, - reasoning: None, - context_usage: None, - }, - state: WorkerStatus::Idle.into(), - in_flight: Default::default(), - internal_workers: Vec::new(), - }) - .await - .unwrap(); - let method = reader.next::().await.unwrap().unwrap(); - if let Method::NotifyTracked { message, .. } = method { - tx.send(message).await.unwrap(); - } else { - panic!("expected Notify, got {method:?}"); - } - }); - - let (_, tool) = send_to_peer_worker_tool(discovery)(); - let output = tool - .execute(r#"{"name":"target","message":"hello"}"#, Default::default()) - .await - .unwrap(); - assert_eq!(output.summary, "sent peer message to `target`"); - let message = rx.recv().await.unwrap(); - assert_eq!(message, "[Peer message from `source`]\nhello"); - target.await.unwrap(); - } - - #[tokio::test(flavor = "current_thread")] - async fn weak_notify_to_live_peer_uses_realtime_notify_and_noops_when_missing() { - let root = TempDir::new().unwrap(); - let store_dir = root.path().join("store"); - let runtime_base = root.path().join("runtime"); - std::fs::create_dir_all(runtime_base.join("target")).unwrap(); - let store = FsWorkerStore::new(&store_dir).unwrap(); - store - .write(&WorkerMetadata { - 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 { - worker_name: "target".into(), - }], - resolved_manifest_snapshot: None, - subjektiv_session_attribution: None, - }) - .unwrap(); - store - .write(&WorkerMetadata { - 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 { - worker_name: "source".into(), - }], - resolved_manifest_snapshot: None, - subjektiv_session_attribution: None, - }) - .unwrap(); - let runtime_dir = Arc::new(RuntimeDir::create(&runtime_base, "source").await.unwrap()); - let discovery = WorkerDiscovery::new( - store, - "source".into(), - runtime_base.clone(), - Some(root.path().to_path_buf()), - SpawnedWorkerRegistry::new(runtime_dir), - ); - - let socket = runtime_base.join("target").join("sock"); - let listener = UnixListener::bind(&socket).unwrap(); - let (tx, mut rx) = tokio::sync::mpsc::channel(1); - let target = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut writer = JsonLineWriter::new(stream); - writer - .write(&Event::Snapshot { - session: protocol::SessionSnapshot { - pending_submissions: protocol::PendingSubmissionsSnapshot::default(), - entries: Vec::new(), - }, - greeting: protocol::Greeting { - worker_name: "target".into(), - cwd: "/tmp".into(), - provider: "test".into(), - model: "test".into(), - scope_summary: String::new(), - tools: Vec::new(), - context_window: 0, - context_tokens: 0, - reasoning: None, - context_usage: None, - }, - state: WorkerStatus::Idle.into(), - in_flight: Default::default(), - internal_workers: Vec::new(), - }) - .await - .unwrap(); - - let (stream, _) = listener.accept().await.unwrap(); - let (reader_half, writer_half) = stream.into_split(); - let mut reader = JsonLineReader::new(reader_half); - let mut writer = JsonLineWriter::new(writer_half); - writer - .write(&Event::Snapshot { - session: protocol::SessionSnapshot { - pending_submissions: protocol::PendingSubmissionsSnapshot::default(), - entries: Vec::new(), - }, - greeting: protocol::Greeting { - worker_name: "target".into(), - cwd: "/tmp".into(), - provider: "test".into(), - model: "test".into(), - scope_summary: String::new(), - tools: Vec::new(), - context_window: 0, - context_tokens: 0, - reasoning: None, - context_usage: None, - }, - state: WorkerStatus::Idle.into(), - in_flight: Default::default(), - internal_workers: Vec::new(), - }) - .await - .unwrap(); - let method = reader.next::().await.unwrap().unwrap(); - if let Method::NotifyTracked { message, .. } = method { - tx.send(message).await.unwrap(); - } else { - panic!("expected Notify, got {method:?}"); - } - }); - - assert_eq!( - discovery - .send_weak_notify_to_live_peer("target", "weak event".into()) - .await, - WeakNotifyDelivery::Delivered - ); - assert_eq!(rx.recv().await.unwrap(), "weak event"); - target.await.unwrap(); - - assert_eq!( - discovery - .send_weak_notify_to_live_peer("missing", "no-op".into()) - .await, - WeakNotifyDelivery::SkippedMissing - ); - } - - #[tokio::test(flavor = "current_thread")] - async fn weak_notify_does_not_send_to_spawned_child_visibility() { - let root = TempDir::new().unwrap(); - let store_dir = root.path().join("store"); - let runtime_base = root.path().join("runtime"); - std::fs::create_dir_all(runtime_base.join("target")).unwrap(); - let store = FsWorkerStore::new(&store_dir).unwrap(); - let socket = runtime_base.join("target").join("sock"); - store - .write(&WorkerMetadata { - 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(), - resolved_manifest_snapshot: None, - subjektiv_session_attribution: None, - }) - .unwrap(); - store.write(&WorkerMetadata::new("target", None)).unwrap(); - let runtime_dir = Arc::new(RuntimeDir::create(&runtime_base, "source").await.unwrap()); - let discovery = WorkerDiscovery::new( - store, - "source".into(), - runtime_base, - Some(root.path().to_path_buf()), - SpawnedWorkerRegistry::new(runtime_dir), - ); - - assert_eq!( - discovery - .send_weak_notify_to_live_peer("target", "must not send".into()) - .await, - WeakNotifyDelivery::SkippedNotPeer { - visibility: VisibilityReason::SpawnedChild - } - ); - } - - #[tokio::test(flavor = "current_thread")] - async fn probe_socket_reads_status_after_replayed_alert() { - let root = TempDir::new().unwrap(); - let socket = root.path().join("worker.sock"); - let listener = UnixListener::bind(&socket).unwrap(); - let handle = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut writer = JsonLineWriter::new(stream); - writer - .write(&Event::Alert(Alert { - id: None, - position: None, - level: AlertLevel::Warn, - source: AlertSource::Worker, - message: "replayed alert".into(), - timestamp_ms: 0, - })) - .await - .unwrap(); - writer - .write(&Event::Snapshot { - session: protocol::SessionSnapshot { - pending_submissions: protocol::PendingSubmissionsSnapshot::default(), - entries: Vec::new(), - }, - greeting: protocol::Greeting { - worker_name: "alerted".into(), - cwd: "/tmp".into(), - provider: "test".into(), - model: "test".into(), - scope_summary: String::new(), - tools: Vec::new(), - context_window: 0, - context_tokens: 0, - reasoning: None, - context_usage: None, - }, - state: WorkerStatus::Paused.into(), - in_flight: Default::default(), - internal_workers: Vec::new(), - }) - .await - .unwrap(); - }); - - let info = probe_socket(&socket).await; - assert!(info.reachable); - assert!(matches!(info.status, Some(WorkerStatus::Paused))); - handle.await.unwrap(); - } - - fn child(name: &str, socket_path: &Path) -> WorkerSpawnedChild { - WorkerSpawnedChild { - worker_name: name.to_string(), - socket_path: socket_path.to_path_buf(), - scope_delegated: vec![WorkerSpawnedScopeRule { - target: PathBuf::from("/tmp"), - permission: "read".into(), - recursive: true, - symlink_policy: Default::default(), - }], - callback_address: PathBuf::from("/tmp/parent.sock"), - } - } - - async fn spawn_snapshot_socket(socket_path: &Path) -> tokio::task::JoinHandle<()> { - let _ = std::fs::remove_file(socket_path); - let listener = UnixListener::bind(socket_path).unwrap(); - tokio::spawn(async move { - loop { - let Ok((stream, _)) = listener.accept().await else { - break; - }; - tokio::spawn(async move { - let mut writer = JsonLineWriter::new(stream); - let _ = writer - .write(&Event::Snapshot { - session: protocol::SessionSnapshot { - pending_submissions: protocol::PendingSubmissionsSnapshot::default( - ), - entries: Vec::new(), - }, - greeting: protocol::Greeting { - worker_name: "child-live".into(), - cwd: "/tmp".into(), - provider: "test".into(), - model: "test".into(), - scope_summary: String::new(), - tools: Vec::new(), - context_window: 0, - context_tokens: 0, - reasoning: None, - context_usage: None, - }, - state: WorkerStatus::Idle.into(), - in_flight: Default::default(), - internal_workers: Vec::new(), - }) - .await; - }); - } - }) - } -} diff --git a/crates/worker/src/entrypoint.rs b/crates/worker/src/entrypoint.rs deleted file mode 100644 index 15f15407b..000000000 --- a/crates/worker/src/entrypoint.rs +++ /dev/null @@ -1,1099 +0,0 @@ -use std::ffi::OsString; -use std::path::{Path, PathBuf}; -use std::process::ExitCode; - -use crate::{ - PromptCatalogSource, Worker, WorkerBootstrapLayout, WorkerControllerTransport, - WorkerFilesystemAuthority, WorkerWorkspaceContext, start_worker_controller, -}; -use clap::{CommandFactory, FromArgMatches, Parser}; -use manifest::{Permission, ScopeConfig, ScopeRule, WorkerManifest, WorkerManifestConfig, paths}; -use session_store::{CombinedStore, FsWorkerStore, WorkerMetadataStore}; -use session_store::{FsStore, SegmentId, Store}; -use ticket::config::TicketRole; - -#[derive(Debug, Parser)] -#[command(about = "Spawn a Worker process from a manifest or builtin defaults")] -struct Cli { - /// Legacy profile selector. Direct worker startup no longer resolves - /// workspace-local profiles; pass a Backend-resolved manifest instead. - #[arg( - long, - value_name = "PROFILE", - conflicts_with_all = ["manifest", "project", "session", "adopt"] - )] - profile: Option, - - /// Process root/cwd context for direct worker startup. This is not Workspace - /// authority and does not enable `.yoi` discovery. Defaults to current dir. - #[arg(long, value_name = "PATH")] - workspace: Option, - - /// Manifest TOML to use directly as a one-file compatibility/debug input. - /// This bypasses profile discovery but still applies builtin defaults and - /// the same required-field validation boundary. - #[arg(long, value_name = "PATH", conflicts_with_all = ["project"])] - manifest: Option, - - /// Deprecated manifest-cascade project root flag. Ambient project/user - /// manifest discovery has been removed; configure/select a profile instead. - #[arg(long, value_name = "PATH")] - project: Option, - - /// Internal resolved manifest config for delegated child Worker spawning. - #[arg( - long, - value_name = "JSON", - requires = "adopt", - conflicts_with_all = ["profile", "manifest", "project", "worker", "session"], - hide = true - )] - spawn_config_json: Option, - - /// Directory for session persistence. Defaults to - /// `/sessions/` (see `manifest::paths`). - #[arg(short, long)] - store: Option, - - /// Claim a scope allocation pre-registered by a spawning Worker, rather - /// than installing a new top-level allocation. Used only when this - /// process is launched by `SubWorkerSpawn`; end users should never pass it. - #[arg(long)] - adopt: bool, - - /// Socket path of the spawning Worker, for delivering `Method::Notify` - /// callbacks upward. Required alongside `--adopt`. - #[arg(long, value_name = "PATH", requires = "adopt")] - callback: Option, - - /// Process-local Ticket role marker supplied by the Ticket role launcher. - #[arg(long, hide = true)] - ticket_role: Option, - - /// Resume or create a Worker by name. If name-keyed Worker state exists, - /// the active session/segment recorded there is restored; otherwise a - /// fresh top-level Worker is created with this name. - #[arg(long, value_name = "NAME", conflicts_with_all = ["adopt"])] - worker: Option, - - /// Require `--worker` to restore existing Worker state instead of creating a - /// fresh Worker when no state exists. Used by Worker discovery restore flows. - #[arg(long, requires = "worker")] - require_worker_state: bool, - - /// Restore a Worker from an existing session. The Worker re-uses the - /// given session id and appends new turns to the same jsonl; - /// concurrent writers are prevented by the worker-allocation. - /// Mutually exclusive with `--adopt` (spawned children always start - /// fresh). - #[arg(long, value_name = "UUID", conflicts_with_all = ["adopt"])] - session: Option, -} - -fn runtime_workspace_root(cli: &Cli) -> Result { - let raw = cli.workspace.as_deref().unwrap_or_else(|| Path::new(".")); - if raw.is_absolute() { - Ok(raw.to_path_buf()) - } else { - std::env::current_dir() - .map_err(|e| format!("failed to resolve current directory for workspace: {e}")) - .map(|cwd| cwd.join(raw)) - } -} - -fn runtime_workspace_context() -> WorkerWorkspaceContext { - WorkerWorkspaceContext::no_workspace() -} - -fn runtime_worker_name(cli: &Cli, workspace_root: &Path) -> String { - cli.worker - .as_deref() - .map(str::to_string) - .unwrap_or_else(|| default_worker_name_for_workspace(workspace_root)) -} - -fn default_worker_name_for_workspace(workspace_root: &Path) -> String { - let raw = workspace_root - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("workspace"); - sanitise_worker_name(raw) -} - -fn sanitise_worker_name(raw: &str) -> String { - let name: String = raw - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { - c - } else { - '-' - } - }) - .collect(); - if name.chars().any(|c| c.is_ascii_alphanumeric()) { - name - } else { - "workspace".to_string() - } -} - -fn resolve_manifest(cli: &Cli) -> Result<(WorkerManifest, PromptCatalogSource), String> { - let process_root = runtime_workspace_root(cli)?; - let runtime_worker_name = runtime_worker_name(cli, &process_root); - let ((mut manifest, loader), apply_direct_launch_policy) = if let Some(config_json) = - cli.spawn_config_json.as_deref() - { - (load_spawn_config_json(config_json)?, false) - } else if let Some(profile) = &cli.profile { - return Err(format!( - "--profile {profile} requires Backend-resolved manifest authority; direct worker startup does not discover workspace-local profiles" - )); - } else if let Some(path) = &cli.manifest { - ( - load_single_manifest(path, cli.worker.as_deref(), &runtime_worker_name)?, - false, - ) - } else { - if cli.project.is_some() { - return Err( - "--project is no longer supported; direct worker startup uses --manifest, \ - --spawn-config-json, or builtin defaults only" - .to_string(), - ); - } - (load_builtin_default_manifest(&runtime_worker_name)?, true) - }; - - if apply_direct_launch_policy { - apply_profile_launch_policy(&mut manifest, &process_root, cli.ticket_role.as_deref())?; - } - apply_session_restore_overrides(&mut manifest, cli)?; - Ok((manifest, loader)) -} - -fn apply_session_restore_overrides(manifest: &mut WorkerManifest, cli: &Cli) -> Result<(), String> { - if let Some(worker_name) = cli.worker.as_deref() { - manifest.worker.name = worker_name.to_string(); - } - Ok(()) -} - -fn load_spawn_config_json( - config_json: &str, -) -> Result<(WorkerManifest, PromptCatalogSource), String> { - let config = serde_json::from_str::(config_json) - .map_err(|e| format!("failed to parse --spawn-config-json: {e}"))?; - let manifest = - WorkerManifest::try_from(WorkerManifestConfig::resolution_defaults().merge(config)) - .map_err(|e| format!("failed to resolve --spawn-config-json: {e}"))?; - Ok((manifest, PromptCatalogSource::builtins_only())) -} - -fn load_builtin_default_manifest( - worker_name: &str, -) -> Result<(WorkerManifest, PromptCatalogSource), String> { - let mut config = WorkerManifestConfig::resolution_defaults(); - config.worker.name = Some(worker_name.to_string()); - let manifest = WorkerManifest::try_from(config) - .map_err(|e| format!("failed to resolve builtin worker defaults: {e}"))?; - Ok((manifest, PromptCatalogSource::builtins_only())) -} - -pub fn resolve_runtime_profile_manifest( - _profile: Option<&str>, - _workspace_root: &Path, - _worker_name: &str, -) -> Result<(WorkerManifest, PromptCatalogSource), String> { - Err( - "runtime profile resolution requires a pre-resolved manifest/profile archive from Backend authority" - .to_string(), - ) -} - -pub fn resolve_runtime_profile_manifest_from_manifest( - mut manifest: WorkerManifest, - workspace_root: &Path, - worker_name: &str, -) -> Result<(WorkerManifest, PromptCatalogSource), String> { - if manifest.worker.name.is_empty() { - manifest.worker.name = worker_name.to_string(); - } - apply_profile_launch_policy(&mut manifest, workspace_root, None)?; - // Do not run plugin discovery here: runtime-created Workers receive their - // resolved manifest/profile archive from Backend authority, not by scanning - // materialized workdir-local plugin stores. - Ok((manifest, PromptCatalogSource::builtins_only())) -} - -pub fn resolve_runtime_profile_manifest_from_manifest_without_filesystem( - mut manifest: WorkerManifest, - _workspace_root: &Path, - worker_name: &str, -) -> Result<(WorkerManifest, PromptCatalogSource), String> { - if manifest.worker.name.is_empty() { - manifest.worker.name = worker_name.to_string(); - } - manifest.scope = ScopeConfig::default(); - manifest.delegation_scope = ScopeConfig::default(); - // Same as the filesystem-capable runtime path: no local discovery. - Ok((manifest, PromptCatalogSource::builtins_only())) -} - -fn load_single_manifest( - path: &Path, - explicit_worker_name: Option<&str>, - default_worker_name: &str, -) -> Result<(WorkerManifest, PromptCatalogSource), String> { - let toml = std::fs::read_to_string(path) - .map_err(|e| format!("failed to read manifest {}: {e}", path.display()))?; - let absolute_path = if path.is_absolute() { - path.to_path_buf() - } else { - std::env::current_dir() - .map_err(|e| format!("failed to resolve current directory: {e}"))? - .join(path) - }; - let base_dir = absolute_path.parent().ok_or_else(|| { - format!( - "manifest path {} has no parent directory", - absolute_path.display() - ) - })?; - let mut config = WorkerManifestConfig::resolution_defaults().merge( - WorkerManifestConfig::from_toml(&toml) - .map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))? - .resolve_paths(base_dir), - ); - if let Some(worker_name) = explicit_worker_name { - config.worker.name = Some(worker_name.to_string()); - } else if config.worker.name.is_none() { - config.worker.name = Some(default_worker_name.to_string()); - } - let manifest = WorkerManifest::try_from(config) - .map_err(|e| format!("failed to resolve manifest {}: {e}", path.display()))?; - if manifest.scope.allow.is_empty() { - return Err(format!( - "manifest {} must declare scope.allow; profile launches receive concrete scope from launch policy", - path.display() - )); - } - Ok((manifest, PromptCatalogSource::builtins_only())) -} - -fn read_rule(target: PathBuf) -> ScopeRule { - ScopeRule { - target, - permission: Permission::Read, - recursive: true, - symlink_policy: Default::default(), - } -} - -fn write_rule(target: PathBuf) -> ScopeRule { - ScopeRule { - target, - permission: Permission::Write, - recursive: true, - symlink_policy: Default::default(), - } -} - -fn workspace_scope( - workspace_root: &Path, - permission: Permission, - deny_write: &[PathBuf], -) -> ScopeConfig { - let allow_rule = ScopeRule { - target: workspace_root.to_path_buf(), - permission, - recursive: true, - symlink_policy: Default::default(), - }; - let deny = deny_write - .iter() - .cloned() - .map(write_rule) - .collect::>(); - ScopeConfig { - allow: vec![allow_rule], - deny, - } -} - -fn workspace_worktree_delegation(workspace_root: &Path) -> ScopeConfig { - ScopeConfig { - allow: vec![ - read_rule(workspace_root.to_path_buf()), - write_rule(workspace_root.join(".worktree")), - ], - deny: Vec::new(), - } -} - -fn append_missing_rules(target: &mut Vec, defaults: Vec) { - for rule in defaults { - if !target.contains(&rule) { - target.push(rule); - } - } -} - -fn apply_scope_launch_defaults(scope: &mut ScopeConfig, defaults: ScopeConfig) { - // Profile resolution has already applied explicit profile/workspace override scope rules. - // Launch policy contributes runtime defaults on top rather than replacing those grants. - append_missing_rules(&mut scope.allow, defaults.allow); - append_missing_rules(&mut scope.deny, defaults.deny); -} - -fn apply_profile_launch_policy( - manifest: &mut WorkerManifest, - workspace_root: &Path, - ticket_role: Option<&str>, -) -> Result<(), String> { - let role = match ticket_role { - Some(raw) => { - Some(TicketRole::parse(raw).ok_or_else(|| format!("invalid ticket role `{raw}`"))?) - } - None => None, - }; - match role { - Some(TicketRole::Orchestrator) => { - let default_scope = workspace_scope(workspace_root, Permission::Read, &[]); - apply_scope_launch_defaults(&mut manifest.scope, default_scope); - manifest.delegation_scope = workspace_worktree_delegation(workspace_root); - } - Some(TicketRole::Intake) | Some(TicketRole::Reviewer) => { - let default_scope = workspace_scope(workspace_root, Permission::Read, &[]); - apply_scope_launch_defaults(&mut manifest.scope, default_scope); - manifest.delegation_scope = ScopeConfig::default(); - } - // Work assignment is not a filesystem or delegation grant. The selected - // Profile and explicit resource bindings remain the authority. - Some(TicketRole::Worker) => {} - None => { - let worktree_root = workspace_root.join(".worktree"); - let default_scope = workspace_scope( - workspace_root, - Permission::Write, - std::slice::from_ref(&worktree_root), - ); - apply_scope_launch_defaults(&mut manifest.scope, default_scope); - manifest.delegation_scope = workspace_worktree_delegation(workspace_root); - } - } - Ok(()) -} - -pub async fn run_cli() -> ExitCode { - run_cli_from("yoi worker", std::env::args_os().skip(1)).await -} - -pub async fn run_cli_from(bin_name: &'static str, args: I) -> ExitCode -where - I: IntoIterator, - T: Into, -{ - let cli = match parse_cli_from(bin_name, args) { - Ok(cli) => cli, - Err(err) => { - let code = err.exit_code(); - if let Err(print_err) = err.print() { - eprintln!("error: failed to write CLI error: {print_err}"); - } - return exit_code_from_i32(code); - } - }; - - run_cli_inner(cli).await -} - -fn parse_cli_from(bin_name: &'static str, args: I) -> Result -where - I: IntoIterator, - T: Into, -{ - let argv = std::iter::once(OsString::from(bin_name)) - .chain(args.into_iter().map(Into::into)) - .collect::>(); - let matches = Cli::command() - .name(bin_name) - .bin_name(bin_name) - .try_get_matches_from(argv)?; - Cli::from_arg_matches(&matches) -} - -fn exit_code_from_i32(code: i32) -> ExitCode { - match code { - 0 => ExitCode::SUCCESS, - 1 => ExitCode::FAILURE, - code => ExitCode::from(code.clamp(0, u8::MAX as i32) as u8), - } -} - -async fn run_cli_inner(cli: Cli) -> ExitCode { - let cwd = match std::env::current_dir() { - Ok(path) => path, - Err(e) => { - eprintln!("error: failed to resolve current directory: {e}"); - return ExitCode::FAILURE; - } - }; - let workspace_root = match runtime_workspace_root(&cli) { - Ok(root) => root, - Err(e) => { - eprintln!("error: {e}"); - return ExitCode::FAILURE; - } - }; - let (mut manifest, loader) = match resolve_manifest(&cli) { - Ok(pair) => pair, - Err(e) => { - eprintln!("error: {e}"); - return ExitCode::FAILURE; - } - }; - // Initialize persistent store. `paths::sessions_dir()` only - // returns None when none of YOI_DATA_DIR / YOI_HOME / - // XDG_DATA_HOME / HOME is set — surface that as a hard error to match the - // runtime-dir resolution below, rather than silently writing to a - // relative path under cwd. - let store_dir = match cli.store.clone() { - Some(p) => p, - None => match paths::sessions_dir() { - Some(d) => d, - None => { - eprintln!( - "error: could not resolve sessions directory \ - (set --store, YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME)" - ); - return ExitCode::FAILURE; - } - }, - }; - let session_store = match FsStore::new(&store_dir) { - Ok(s) => s, - Err(e) => { - eprintln!("error: failed to initialize session store at {store_dir:?}: {e}"); - return ExitCode::FAILURE; - } - }; - let worker_metadata_dir = match paths::data_dir() { - Some(data_dir) => data_dir.join("workers"), - None => store_dir - .parent() - .map(|parent| parent.join("workers")) - .unwrap_or_else(|| PathBuf::from("workers")), - }; - let worker_metadata_store = match FsWorkerStore::new(&worker_metadata_dir) { - Ok(s) => s, - Err(e) => { - eprintln!("error: failed to initialize worker store at {worker_metadata_dir:?}: {e}"); - return ExitCode::FAILURE; - } - }; - 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(); - - let mut worker = if cli.adopt { - let callback = match cli.callback.clone() { - Some(p) => p, - None => { - eprintln!("error: --adopt requires --callback"); - return ExitCode::FAILURE; - } - }; - match Worker::from_manifest_spawned_with_context( - manifest, - store, - loader, - callback, - workspace_context.clone(), - filesystem_authority.clone(), - ) - .await - { - Ok(p) => p, - Err(e) => { - eprintln!("error: failed to create spawned worker: {e}"); - return ExitCode::FAILURE; - } - } - } else if let Some(source_segment_id) = cli.session { - let source_session_id = match store.lookup_session_of(source_segment_id) { - Ok(Some(sid)) => sid, - Ok(None) => { - eprintln!( - "error: --session {source_segment_id}: segment is not registered to any session" - ); - return ExitCode::FAILURE; - } - Err(e) => { - eprintln!("error: lookup_session_of failed: {e}"); - return ExitCode::FAILURE; - } - }; - match Worker::restore_from_manifest_with_context( - source_session_id, - source_segment_id, - manifest, - store, - loader, - workspace_context.clone(), - filesystem_authority.clone(), - ) - .await - { - Ok(p) => p, - Err(e) => { - eprintln!("error: failed to restore worker: {e}"); - return ExitCode::FAILURE; - } - } - } else if let Some(worker_name) = cli.worker.as_deref() { - manifest.worker.name = worker_name.to_string(); - match store.read_by_name(worker_name) { - Ok(Some(_)) => { - match Worker::restore_from_worker_metadata_with_context( - worker_name, - manifest, - store, - loader, - workspace_context.clone(), - filesystem_authority.clone(), - ) - .await - { - Ok(p) => p, - Err(e) => { - eprintln!("error: failed to restore worker {worker_name}: {e}"); - return ExitCode::FAILURE; - } - } - } - Ok(None) if cli.require_worker_state => { - eprintln!("error: worker state missing for {worker_name}"); - return ExitCode::FAILURE; - } - Ok(None) => { - match Worker::from_manifest_with_context( - manifest, - store, - loader, - workspace_context.clone(), - filesystem_authority.clone(), - ) - .await - { - Ok(p) => p, - Err(e) => { - eprintln!("error: failed to create worker {worker_name}: {e}"); - return ExitCode::FAILURE; - } - } - } - Err(e) => { - eprintln!("error: failed to read worker state for {worker_name}: {e}"); - return ExitCode::FAILURE; - } - } - } else { - match Worker::from_manifest_with_context( - manifest, - store, - loader, - workspace_context.clone(), - filesystem_authority.clone(), - ) - .await - { - Ok(p) => p, - Err(e) => { - eprintln!("error: failed to create worker: {e}"); - return ExitCode::FAILURE; - } - } - }; - if let Some(role) = cli.ticket_role.clone() { - if TicketRole::parse(&role).is_none() { - eprintln!("error: invalid --ticket-role {role:?}"); - return ExitCode::FAILURE; - } - worker.set_runtime_ticket_role(Some(role)); - } - let worker_name = worker.manifest().worker.name.clone(); - // Spawn the controller (starts socket server) - let runtime_base = match paths::runtime_dir() { - Some(d) => d, - None => { - eprintln!( - "error: could not resolve runtime directory \ - (set YOI_HOME, YOI_RUNTIME_DIR, XDG_RUNTIME_DIR, or HOME)" - ); - return ExitCode::FAILURE; - } - }; - - let started = match start_worker_controller( - worker, - WorkerBootstrapLayout::Direct { - runtime_base: runtime_base.clone(), - }, - WorkerControllerTransport::UnixSocket, - ) - .await - { - Ok(started) => started, - Err(e) => { - eprintln!("error: failed to start worker controller: {e}"); - return ExitCode::FAILURE; - } - }; - let (handle, shutdown_rx) = (started.handle, started.shutdown); - - let socket_path = handle.runtime_dir.socket_path(); - // Machine-readable ready line for parents that spawned this Worker - // (e.g. the TUI's interactive `spawn` flow). Tab-separated so a - // worker name with spaces still parses cleanly. Emit before the - // human line so a stderr-watching parent sees it first. - eprintln!("YOI-READY\t{worker_name}\t{}", socket_path.display()); - eprintln!("worker: {worker_name} listening on {:?}", socket_path); - - tokio::select! { - _ = tokio::signal::ctrl_c() => { - eprintln!("worker: {worker_name} shutting down (signal)"); - } - _ = shutdown_rx => { - eprintln!("worker: {worker_name} shutting down (client request)"); - } - } - - drop(handle); - ExitCode::SUCCESS -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - fn write(path: &Path, contents: &str) { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).unwrap(); - } - std::fs::write(path, contents).unwrap(); - } - - fn manifest_toml(name: &str, scope: &Path) -> String { - format!( - r#" -[worker] -name = "{name}" - -[model] -scheme = "anthropic" -model_id = "test-model" - -[engine] - -[[scope.allow]] -target = "{scope}" -permission = "write" -"#, - scope = scope.display() - ) - } - - fn scope_rule(target: &Path, permission: Permission) -> ScopeRule { - ScopeRule { - target: target.to_path_buf(), - permission, - recursive: true, - symlink_policy: Default::default(), - } - } - - fn assert_scope_contains(rules: &[ScopeRule], target: &Path, permission: Permission) { - let expected = scope_rule(target, permission); - assert!( - rules.contains(&expected), - "expected scope rules to contain {expected:?}; got {rules:?}" - ); - } - - #[test] - fn user_manifest_flag_is_not_accepted() { - let err = - Cli::try_parse_from(["yoi worker", "--user-manifest", "manifest.toml"]).unwrap_err(); - assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); - } - - #[test] - fn subcommand_help_uses_yoi_worker_invocation() { - let err = parse_cli_from("yoi worker", ["--help"]).unwrap_err(); - assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp); - let help = err.to_string(); - assert!(help.contains("Usage: yoi worker"), "{help}"); - assert!(help.contains("--worker "), "{help}"); - } - - #[test] - fn manifest_conflicts_with_project() { - let project_err = Cli::try_parse_from([ - "yoi worker", - "--manifest", - "manifest.toml", - "--project", - ".", - ]) - .unwrap_err(); - assert_eq!(project_err.kind(), clap::error::ErrorKind::ArgumentConflict); - } - - #[test] - fn overlay_flag_is_not_accepted() { - let err = - Cli::try_parse_from(["yoi worker", "--overlay", "worker.name = 'x'"]).unwrap_err(); - assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); - } - - #[test] - fn manifest_loads_single_file_without_user_or_workspace_prompt_loader() { - let tmp = TempDir::new().unwrap(); - let manifest = tmp.path().join("manifest.toml"); - write(&manifest, &manifest_toml("single", tmp.path())); - let cli = - Cli::try_parse_from(["yoi worker", "--manifest", manifest.to_str().unwrap()]).unwrap(); - - let (manifest, _loader) = resolve_manifest(&cli).unwrap(); - - assert_eq!(manifest.worker.name, "single"); - } - - #[test] - fn manifest_mode_does_not_apply_workspace_local_override() { - let tmp = TempDir::new().unwrap(); - let yoi_dir = tmp.path().join(".yoi"); - std::fs::create_dir_all(&yoi_dir).unwrap(); - write( - &yoi_dir.join("ignored-local-file.toml"), - r#" -[worker] -name = "from-local-override" -[engine] -language = "override" -"#, - ); - let manifest_path = tmp.path().join("manifest.toml"); - write( - &manifest_path, - &format!( - r#" -[worker] -name = "from-single-file" - -[model] -scheme = "anthropic" -model_id = "test-model" - -[engine] -language = "manifest" - -[[scope.allow]] -target = "{}" -permission = "write" -"#, - tmp.path().display() - ), - ); - - let cli = - Cli::try_parse_from(["yoi worker", "--manifest", manifest_path.to_str().unwrap()]) - .unwrap(); - let (manifest, _loader) = resolve_manifest(&cli).unwrap(); - - assert_eq!(manifest.worker.name, "from-single-file"); - assert_eq!(manifest.engine.language, "manifest"); - } - - #[test] - fn profile_resolution_is_rejected_for_direct_worker_startup() { - let cli = Cli::try_parse_from(["yoi worker", "--profile", "project:coder"]).unwrap(); - - let err = resolve_manifest(&cli).unwrap_err(); - - assert!( - err.contains("--profile project:coder requires Backend-resolved manifest authority") - ); - } - - #[test] - fn clean_workspace_manifest_resolution_does_not_create_repository_local_yoi() { - let tmp = TempDir::new().unwrap(); - let workspace = tmp.path().join("runtime-workspace"); - std::fs::create_dir(&workspace).unwrap(); - let cli = Cli::try_parse_from(["yoi worker", "--workspace", workspace.to_str().unwrap()]) - .unwrap(); - - let (manifest, _loader) = resolve_manifest(&cli).unwrap(); - - assert_eq!(manifest.worker.name, "runtime-workspace"); - assert!(!workspace.join(".yoi").exists()); - } - - #[test] - fn normal_startup_uses_builtin_defaults_without_local_profile_discovery() { - let tmp = TempDir::new().unwrap(); - let workspace = tmp.path().join("runtime-workspace"); - let yoi_dir = workspace.join(".yoi"); - std::fs::create_dir_all(&yoi_dir).unwrap(); - write( - &yoi_dir.join("ignored-local-file.toml"), - r#" -[worker] -name = "from-local-override" - -[engine] -language = "override" -"#, - ); - let cli = Cli::try_parse_from(["yoi worker", "--workspace", workspace.to_str().unwrap()]) - .unwrap(); - - let (manifest, _loader) = resolve_manifest(&cli).unwrap(); - - assert_eq!(manifest.worker.name, "runtime-workspace"); - assert_ne!(manifest.engine.language, "override"); - assert_scope_contains(&manifest.scope.allow, &workspace, Permission::Write); - } - - #[test] - fn ticket_worker_role_preserves_profile_scope_and_delegation_without_adding_grants() { - let tmp = TempDir::new().unwrap(); - let workspace = tmp.path().join("workspace"); - let explicit_scope = ScopeConfig { - allow: vec![scope_rule(&workspace.join("documents"), Permission::Write)], - deny: vec![scope_rule( - &workspace.join("documents/private"), - Permission::Write, - )], - }; - let explicit_delegation = ScopeConfig { - allow: vec![scope_rule(&workspace.join("reference"), Permission::Read)], - deny: vec![scope_rule( - &workspace.join("reference/private"), - Permission::Read, - )], - }; - for (scope, delegation) in [ - (ScopeConfig::default(), ScopeConfig::default()), - (explicit_scope, explicit_delegation), - ] { - let (mut manifest, _) = load_builtin_default_manifest("ticket-worker").unwrap(); - manifest.scope = scope.clone(); - manifest.delegation_scope = delegation.clone(); - - apply_profile_launch_policy(&mut manifest, &workspace, Some("worker")).unwrap(); - - assert_eq!(manifest.scope.allow, scope.allow); - assert_eq!(manifest.scope.deny, scope.deny); - assert_eq!(manifest.delegation_scope.allow, delegation.allow); - assert_eq!(manifest.delegation_scope.deny, delegation.deny); - } - } - - #[test] - fn orchestrator_direct_startup_gets_read_root_and_worktree_delegation_from_launch_policy() { - let tmp = TempDir::new().unwrap(); - let workspace = tmp.path().join("original-workspace"); - std::fs::create_dir(&workspace).unwrap(); - let cli = Cli::try_parse_from([ - "yoi worker", - "--workspace", - workspace.to_str().unwrap(), - "--ticket-role", - "orchestrator", - ]) - .unwrap(); - - let (manifest, _loader) = resolve_manifest(&cli).unwrap(); - - assert_scope_contains(&manifest.scope.allow, &workspace, Permission::Read); - assert!(manifest.scope.deny.is_empty()); - assert_eq!(manifest.delegation_scope.allow.len(), 2); - assert_eq!(manifest.delegation_scope.allow[0].target, workspace); - assert_eq!( - manifest.delegation_scope.allow[0].permission, - Permission::Read - ); - assert_eq!( - manifest.delegation_scope.allow[1].target, - tmp.path().join("original-workspace/.worktree") - ); - assert_eq!( - manifest.delegation_scope.allow[1].permission, - Permission::Write - ); - } - - #[test] - fn project_flag_no_longer_enables_ambient_manifest_cascade() { - let cli = Cli::try_parse_from(["yoi worker", "--project", "."]).unwrap(); - let err = resolve_manifest(&cli).unwrap_err(); - assert!(err.contains("--project is no longer supported")); - } - - #[test] - fn worker_flag_is_runtime_identity_for_session_restore() { - let tmp = TempDir::new().unwrap(); - let workspace = tmp.path().join("explicit-workspace"); - let store = tmp.path().join("sessions"); - std::fs::create_dir(&workspace).unwrap(); - let segment_id = session_store::new_segment_id().to_string(); - let cli = Cli::try_parse_from([ - "yoi worker", - "--workspace", - workspace.to_str().unwrap(), - "--session", - &segment_id, - "--worker", - "explicit-name", - "--store", - store.to_str().unwrap(), - ]) - .unwrap(); - - assert_eq!(cli.session.unwrap().to_string(), segment_id); - assert_eq!(cli.worker.as_deref(), Some("explicit-name")); - assert_eq!(runtime_worker_name(&cli, &workspace), "explicit-name"); - } - - #[test] - fn worker_flag_sets_requested_name_after_manifest_resolution() { - let tmp = TempDir::new().unwrap(); - let manifest = tmp.path().join("manifest.toml"); - write(&manifest, &manifest_toml("from-file", tmp.path())); - let cli = Cli::try_parse_from([ - "yoi worker", - "--manifest", - manifest.to_str().unwrap(), - "--worker", - "from-flag", - ]) - .unwrap(); - - let (manifest, _loader) = resolve_manifest(&cli).unwrap(); - - assert_eq!(manifest.worker.name, "from-flag"); - } - - #[test] - fn worker_flag_supplies_missing_name_for_single_manifest() { - let tmp = TempDir::new().unwrap(); - let manifest = tmp.path().join("manifest.toml"); - write( - &manifest, - r#" -[engine] - -[model] -scheme = "anthropic" -model_id = "test-model" - -[[scope.allow]] -target = "." -permission = "write" -"#, - ); - let cli = Cli::try_parse_from([ - "yoi worker", - "--manifest", - manifest.to_str().unwrap(), - "--worker", - "from-flag", - ]) - .unwrap(); - - let (manifest, _loader) = resolve_manifest(&cli).unwrap(); - - assert_eq!(manifest.worker.name, "from-flag"); - assert_eq!(manifest.scope.allow[0].target, tmp.path()); - } - - #[test] - fn worker_flag_with_no_manifest_creates_from_builtin_defaults_with_typed_name() { - let cli = Cli::try_parse_from(["yoi worker", "--worker", "agent"]).unwrap(); - - let (manifest, _loader) = resolve_manifest(&cli).unwrap(); - - assert_eq!(manifest.worker.name, "agent"); - } - - #[test] - fn profile_conflicts_with_manifest_and_restore_modes() { - let segment_id = session_store::new_segment_id().to_string(); - for args in [ - vec!["yoi worker", "--profile", "p.toml", "--manifest", "m.toml"], - vec![ - "yoi worker", - "--profile", - "p.toml", - "--session", - &segment_id, - ], - ] { - let err = Cli::try_parse_from(args).unwrap_err(); - assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); - } - } - - #[test] - fn profile_and_worker_are_independent_startup_inputs() { - let cli = Cli::try_parse_from(["yoi worker", "--profile", "p.toml", "--worker", "agent"]) - .unwrap(); - assert_eq!(cli.profile.as_deref(), Some("p.toml")); - assert_eq!(cli.worker.as_deref(), Some("agent")); - } - - #[test] - fn old_session_worker_name_identity_alias_is_rejected() { - let segment_id = session_store::new_segment_id().to_string(); - let err = Cli::try_parse_from([ - "yoi worker", - "--session", - &segment_id, - "--session-worker-name", - "agent", - ]) - .unwrap_err(); - assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); - } - - #[test] - fn removed_profile_worker_name_alias_is_rejected() { - let err = - Cli::try_parse_from(["yoi worker", "--profile-worker-name", "agent"]).unwrap_err(); - assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); - } - - #[test] - fn manifest_mode_loads_single_file_with_minimal_prompt_loader() { - let tmp = TempDir::new().unwrap(); - let single_manifest = tmp.path().join("single.toml"); - write(&single_manifest, &manifest_toml("single-file", tmp.path())); - std::fs::create_dir_all(tmp.path().join("prompts")).unwrap(); - std::fs::create_dir_all(tmp.path().join(".yoi").join("prompts")).unwrap(); - let cli = Cli::try_parse_from([ - "yoi worker", - "--manifest", - single_manifest.to_str().unwrap(), - ]) - .unwrap(); - - let (manifest, _loader) = resolve_manifest(&cli).unwrap(); - - assert_eq!(manifest.worker.name, "single-file"); - } -} diff --git a/crates/worker/src/ipc/event.rs b/crates/worker/src/ipc/event.rs index 18c9da908..8fb6090f5 100644 --- a/crates/worker/src/ipc/event.rs +++ b/crates/worker/src/ipc/event.rs @@ -1,57 +1,6 @@ -//! `WorkerEvent` send / receive helpers. -//! -//! This module owns the parent-facing lifecycle-event primitive -//! (`WorkerEvent`) that children fire upward on turn-end / error / -//! shutdown / scope-sub-delegation. Three responsibilities live here: -//! -//! - **Send** a `Method::WorkerEvent` to the parent socket, fire-and-forget, -//! logging failures without blocking the child. -//! - **Render** agent-visible variants into human-readable strings for the -//! parent's notification buffer. Control-plane-only variants may still have -//! a renderer for diagnostics, but receive-side classification keeps them -//! out of LLM history/context. -//! - **Apply side effects** on the parent (registry / worker-allocation -//! updates) so that the receive path is idempotent and tolerant of -//! out-of-order delivery. -//! -//! Transport is fire-and-forget — the ticket's decision is that -//! callbacks are an optimisation and `ListWorkers` + `reclaim_stale` are -//! the real fallback. This module is allowed to drop events on the -//! floor (with a warn log) rather than retry. -//! -//! `apply_event_side_effects` takes its dependencies (registry, scope -//! lock path, self identity) by reference so the caller owns lifetime -//! and locking concerns. +//! Agent-visible rendering of typed Internal SubWorker lifecycle events. -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use protocol::{Method, WorkerEvent}; - -use crate::spawn::comm_tools::connect_and_send; -use crate::spawn::registry::SpawnedWorkerRegistry; - -/// Connect to `socket`, send a single `Method::WorkerEvent(event)`, and -/// return. Used by children to report up to their parent. -/// -/// This is a synchronous helper — callers that want fire-and-forget -/// semantics should wrap the call in `tokio::spawn` themselves. -pub async fn send_worker_event(socket: &Path, event: WorkerEvent) -> std::io::Result<()> { - connect_and_send(socket, &Method::WorkerEvent(event)).await -} - -/// Spawn a fire-and-forget task that sends `event` to `socket`. If -/// `socket` is `None`, no send happens (top-level Workers have no parent). -/// Any send failure is logged at warn level but otherwise ignored — -/// the parent is treated as best-effort. -pub fn fire_and_forget(socket: Option, event: WorkerEvent) { - let Some(socket) = socket else { return }; - tokio::spawn(async move { - if let Err(e) = send_worker_event(&socket, event).await { - tracing::warn!(error = %e, socket = %socket.display(), "WorkerEvent send failed"); - } - }); -} +use protocol::WorkerEvent; /// Render a variant into a one-line human-readable string. /// @@ -83,16 +32,3 @@ pub fn render_event(event: &WorkerEvent) -> String { } } } - -/// Legacy process callback events have no SubWorker registry authority. -/// -/// Internal SubWorker lifecycle is applied directly through typed session handles. A callback from -/// an externally adopted Worker may still be rendered for diagnostics, but it cannot add/remove -/// Internal children or transfer filesystem authority. -pub async fn apply_event_side_effects( - _event: &WorkerEvent, - _registry: &Arc, - _self_name: &str, - _self_parent_socket: &Option, -) { -} diff --git a/crates/worker/src/ipc/mod.rs b/crates/worker/src/ipc/mod.rs index 053bbe355..412657b55 100644 --- a/crates/worker/src/ipc/mod.rs +++ b/crates/worker/src/ipc/mod.rs @@ -1,7 +1,6 @@ pub mod alerter; pub mod event; pub mod protocol_session; -pub mod server; pub(crate) mod interceptor; pub(crate) mod notify_buffer; diff --git a/crates/worker/src/ipc/server.rs b/crates/worker/src/ipc/server.rs deleted file mode 100644 index 03f0c52a8..000000000 --- a/crates/worker/src/ipc/server.rs +++ /dev/null @@ -1,224 +0,0 @@ -use std::io; -use std::io::ErrorKind; -use std::path::PathBuf; - -use protocol::stream::{JsonLineReader, JsonLineWriter}; -use tokio::net::UnixListener; -use tokio::sync::oneshot; -use tokio::task::{JoinHandle, JoinSet}; - -use crate::controller::WorkerHandle; -use crate::ipc::protocol_session::{ - dispatch_worker_protocol_method, live_log_entry_event, subscribe_worker_protocol_session, -}; -use protocol::{ErrorCode, Event}; - -/// Unix socket server for Worker Protocol. -/// -/// Listens on the Worker's runtime directory socket path. -/// Each client connection gets bidirectional JSONL: -/// - Client writes Method lines → forwarded to WorkerController -/// - Worker events → written as Event lines to all connected clients -pub struct SocketServer { - accept_task: Option>, - shutdown: Option>, - path: PathBuf, -} - -impl SocketServer { - /// Start listening on the WorkerHandle's socket path. - pub async fn start(handle: &WorkerHandle) -> Result { - let path = handle.runtime_dir.socket_path(); - - // Remove stale socket file if it exists - let _ = tokio::fs::remove_file(&path).await; - - let listener = UnixListener::bind(&path)?; - let handle = handle.clone(); - let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); - - let accept_task = tokio::spawn(async move { - let mut connections = JoinSet::new(); - loop { - tokio::select! { - _ = &mut shutdown_rx => break, - accepted = listener.accept() => match accepted { - Ok((stream, _)) => { - let handle = handle.clone(); - connections.spawn(handle_connection(stream, handle)); - } - Err(_) => break, - }, - completed = connections.join_next(), if !connections.is_empty() => { - let _ = completed; - } - } - } - connections.shutdown().await; - }); - - Ok(Self { - accept_task: Some(accept_task), - shutdown: Some(shutdown_tx), - path, - }) - } - - /// Stop accepting connections and join the server task. Dropping its - /// `JoinSet` cancels every active connection task before this returns. - pub async fn shutdown(mut self) { - if let Some(shutdown) = self.shutdown.take() { - let _ = shutdown.send(()); - } - if let Some(task) = self.accept_task.take() { - let _ = task.await; - } - let _ = tokio::fs::remove_file(&self.path).await; - } - - /// The socket file path. - pub fn path(&self) -> &std::path::Path { - &self.path - } -} - -impl Drop for SocketServer { - fn drop(&mut self) { - if let Some(shutdown) = self.shutdown.take() { - let _ = shutdown.send(()); - } - let _ = self.accept_task.take(); - let _ = std::fs::remove_file(&self.path); - } -} - -fn is_peer_disconnect_read_error(error: &io::Error) -> bool { - matches!( - error.kind(), - ErrorKind::ConnectionReset - | ErrorKind::ConnectionAborted - | ErrorKind::BrokenPipe - | ErrorKind::UnexpectedEof - ) -} - -async fn handle_connection(stream: tokio::net::UnixStream, handle: WorkerHandle) { - let (reader, writer) = stream.into_split(); - let mut reader = JsonLineReader::new(reader); - let mut writer = JsonLineWriter::new(writer); - - // Hold the in-flight stream lock while taking the session-log mirror - // snapshot. `LogEntry::AnnotatedAssistantItem` is mirror-only for live clients, - // so a finalized assistant block must be observed either as an already - // committed entry or as the still-present in-flight block. This lock - // order matches `append_entry` (in-flight clear before sink publish) and - // keeps the snapshot/live boundary gap-free. - let mut streams = subscribe_worker_protocol_session(&handle); - for alert in streams.alert_snapshot { - if writer.write_event(&Event::Alert(alert)).await.is_err() { - return; - } - } - - // Send the typed snapshot up front so late attachers can - // reconstruct view state without an extra round trip. - if writer.write_event(&streams.snapshot_event).await.is_err() { - return; - } - - loop { - tokio::select! { - // Live session-log entries → dispatched as the role-specific - // wire events. `SegmentLogSink` only broadcasts committed log - // entries with live UI meaning; `UserInput` travels this lane so - // the visible user line is ordered with `SegmentStart` rotation. - entry = streams.log_entries.recv() => { - match entry { - Ok(entry) => { - if let Some(event) = live_log_entry_event(entry) { - if writer.write_event(&event).await.is_err() { - break; - } - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { - // Slow client fell behind the broadcast buffer. - // Drop the connection so the next reconnect - // re-seeds the prefix via subscribe_with_snapshot. - break; - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, - } - } - // Broadcast events → this client - event = streams.events.recv() => { - match event { - Ok(event) => { - let Ok(event) = handle.alerter.freshen_event(event) else { - // The queued Alert was evicted; reconnect must rebuild - // the bounded prefix rather than forward a stale anchor. - break; - }; - if writer.write_event(&event).await.is_err() { - break; - } - } - Err(_) => break, - } - } - // Client methods → handle or forward to controller - method = reader.next_method() => { - match method { - Ok(Some(method)) => { - if let Some(response) = dispatch_worker_protocol_method(&handle, method).await - && writer.write_event(&response).await.is_err() - { - break; - } - } - Ok(None) => break, - Err(e) if is_peer_disconnect_read_error(&e) => break, - Err(e) => { - if writer - .write(&Event::Error { - code: ErrorCode::InvalidRequest, - message: format!("invalid method: {e}"), - }) - .await - .is_err() - { - break; - } - } - } - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn peer_disconnect_read_errors_are_connection_close() { - for kind in [ - ErrorKind::ConnectionReset, - ErrorKind::ConnectionAborted, - ErrorKind::BrokenPipe, - ErrorKind::UnexpectedEof, - ] { - let error = io::Error::new(kind, "peer disconnected"); - assert!( - is_peer_disconnect_read_error(&error), - "{kind:?} should be treated as a normal peer disconnect" - ); - } - } - - #[test] - fn invalid_data_is_not_peer_disconnect() { - let error = io::Error::new(ErrorKind::InvalidData, "malformed method"); - assert!(!is_peer_disconnect_read_error(&error)); - } -} diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index 0037fa8cb..8c096a7d8 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -7,8 +7,6 @@ pub mod bootstrap; pub mod checkout; pub mod compact; pub mod controller; -pub mod discovery; -pub mod entrypoint; pub mod feature; mod file_operation; pub mod fs_view; @@ -19,9 +17,9 @@ pub mod job; mod metadata_lifecycle; pub mod model_client; mod paste_artifact_tool; +pub mod profile_manifest; pub mod prompt; pub mod runtime; -pub mod runtime_command; pub mod segment_log_sink; mod session_capture; mod session_history; @@ -37,6 +35,7 @@ pub mod wip; mod internal_worker; mod interrupt_prep; +mod peer; mod permission; mod worker; @@ -45,10 +44,9 @@ pub use bootstrap::{ WorkerBootstrapLayout, start_worker_controller, }; pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate}; -pub use controller::{ShutdownReceiver, WorkerController, WorkerControllerTransport, WorkerHandle}; +pub use controller::{ShutdownReceiver, WorkerController, WorkerHandle}; pub use hook::{Hook, HookEventKind, HookRegistryBuilder}; pub use ipc::alerter::Alerter; -pub use ipc::server::SocketServer; pub use manifest::{ AuthRef, ModelManifest, SchemeKind, Scope, WorkerManifest, WorkerManifestConfig, WorkerMetaConfig, diff --git a/crates/worker/src/peer.rs b/crates/worker/src/peer.rs new file mode 100644 index 000000000..6120ebc09 --- /dev/null +++ b/crates/worker/src/peer.rs @@ -0,0 +1,186 @@ +//! Reciprocal peer metadata registration, independent of discovery and transport. +//! +//! Registering a peer does not start a Worker or grant filesystem, observation, or +//! messaging authority. The two metadata writes are not a transaction: a failed +//! reciprocal write triggers best-effort restoration of the source's prior peers. + +use serde::Serialize; +use session_store::{WorkerMetadataStore, WorkerStoreError, validate_worker_name}; + +#[derive(Debug, Serialize, PartialEq, Eq)] +pub(crate) struct PeerRegistrationResult { + pub source: String, + pub peer: String, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum PeerRegistrationError { + #[error("worker state missing for `{worker_name}`")] + StateMissing { worker_name: String }, + #[error("worker `{worker_name}` cannot be registered as a peer of itself")] + SelfPeer { worker_name: String }, + #[error("worker `{worker_name}` does not exist")] + MissingWorker { worker_name: String }, + #[error("worker store error: {0}")] + WorkerStore(#[from] WorkerStoreError), +} + +pub(crate) fn register_peer( + store: &impl WorkerMetadataStore, + source: &str, + peer: &str, +) -> Result { + ensure_existing_peer(store, source, peer)?.ok_or_else(|| PeerRegistrationError::MissingWorker { + worker_name: peer.to_string(), + }) +} + +fn ensure_existing_peer( + store: &impl WorkerMetadataStore, + source: &str, + peer: &str, +) -> Result, PeerRegistrationError> { + validate_worker_name(peer)?; + if peer == source { + return Err(PeerRegistrationError::SelfPeer { + worker_name: peer.to_string(), + }); + } + let prior_source_peers = store + .read_by_name(source)? + .ok_or_else(|| PeerRegistrationError::StateMissing { + worker_name: source.to_string(), + })? + .peers; + if store.read_by_name(peer)?.is_none() { + return Ok(None); + } + + store.add_peer(source, peer)?; + if let Err(error) = store.add_peer(peer, source) { + let _ = store.set_peers(source, prior_source_peers); + return Err(PeerRegistrationError::WorkerStore(error)); + } + Ok(Some(PeerRegistrationResult { + source: source.to_string(), + peer: peer.to_string(), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use session_store::{FsWorkerStore, WorkerMetadata}; + + #[test] + fn register_peer_persists_reciprocal_metadata_without_duplicates() { + let root = tempfile::tempdir().unwrap(); + let store = FsWorkerStore::new(root.path()).unwrap(); + store.write(&WorkerMetadata::new("source", None)).unwrap(); + store.write(&WorkerMetadata::new("target", None)).unwrap(); + for _ in 0..2 { + let result = register_peer(&store, "source", "target").unwrap(); + assert_eq!( + serde_json::to_value(result).unwrap(), + serde_json::json!({ + "source": "source", "peer": "target", + }) + ); + } + // Reopen the real store rather than asserting only the write calls. + let persisted = FsWorkerStore::new(root.path()).unwrap(); + let source = persisted.read_by_name("source").unwrap().unwrap(); + let target = persisted.read_by_name("target").unwrap().unwrap(); + assert_eq!(source.peers.len(), 1); + assert_eq!(source.peers[0].worker_name, "target"); + assert_eq!(target.peers.len(), 1); + assert_eq!(target.peers[0].worker_name, "source"); + } + + #[test] + fn invalid_self_or_missing_peer_leaves_metadata_unchanged() { + let root = tempfile::tempdir().unwrap(); + let store = FsWorkerStore::new(root.path()).unwrap(); + store.write(&WorkerMetadata::new("source", None)).unwrap(); + assert!(matches!( + register_peer(&store, "source", "source"), + Err(PeerRegistrationError::SelfPeer { .. }) + )); + assert!(matches!( + register_peer(&store, "source", "missing"), + Err(PeerRegistrationError::MissingWorker { .. }) + )); + assert!(matches!( + register_peer(&store, "source", "../escape"), + Err(PeerRegistrationError::WorkerStore(_)) + )); + assert!(matches!( + register_peer(&store, "missing-source", "source"), + Err(PeerRegistrationError::StateMissing { .. }) + )); + assert!( + store + .read_by_name("source") + .unwrap() + .unwrap() + .peers + .is_empty() + ); + assert_eq!(store.list_names().unwrap(), vec!["source"]); + } + + struct FailTargetPeerStore(FsWorkerStore); + + impl WorkerMetadataStore for FailTargetPeerStore { + fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> { + if metadata.worker_name == "target" && !metadata.peers.is_empty() { + return Err(WorkerStoreError::Io(std::io::Error::other( + "target write failed", + ))); + } + self.0.write(metadata) + } + + fn read_by_name(&self, name: &str) -> Result, WorkerStoreError> { + self.0.read_by_name(name) + } + + fn list_names(&self) -> Result, WorkerStoreError> { + self.0.list_names() + } + + fn delete_by_name(&self, name: &str) -> Result<(), WorkerStoreError> { + self.0.delete_by_name(name) + } + } + + #[test] + fn reciprocal_write_failure_restores_exact_prior_source_peers() { + let root = tempfile::tempdir().unwrap(); + let inner = FsWorkerStore::new(root.path()).unwrap(); + for name in ["source", "target", "existing"] { + inner.write(&WorkerMetadata::new(name, None)).unwrap(); + } + inner.add_peer("source", "existing").unwrap(); + for had_target in [false, true] { + if had_target { + inner.add_peer("source", "target").unwrap(); + } + let before = inner.read_by_name("source").unwrap().unwrap().peers; + let store = FailTargetPeerStore(inner.clone()); + assert!(matches!( + register_peer(&store, "source", "target"), + Err(PeerRegistrationError::WorkerStore(_)) + )); + assert_eq!(inner.read_by_name("source").unwrap().unwrap().peers, before); + assert!( + inner + .read_by_name("target") + .unwrap() + .unwrap() + .peers + .is_empty() + ); + } + } +} diff --git a/crates/worker/src/profile_manifest.rs b/crates/worker/src/profile_manifest.rs new file mode 100644 index 000000000..f029cf420 --- /dev/null +++ b/crates/worker/src/profile_manifest.rs @@ -0,0 +1,183 @@ +//! Runtime manifest preparation from Backend-resolved profiles. +//! +//! This module has no process entrypoint or local profile discovery authority. + +use std::path::{Path, PathBuf}; + +use crate::PromptCatalogSource; +use manifest::{Permission, ScopeConfig, ScopeRule, WorkerManifest}; + +pub fn resolve_runtime_profile_manifest_from_manifest( + mut manifest: WorkerManifest, + workspace_root: &Path, + worker_name: &str, +) -> Result<(WorkerManifest, PromptCatalogSource), String> { + if manifest.worker.name.is_empty() { + manifest.worker.name = worker_name.to_string(); + } + apply_profile_launch_policy(&mut manifest, workspace_root); + // Do not run plugin discovery here: runtime-created Workers receive their + // resolved manifest/profile archive from Backend authority, not by scanning + // materialized workdir-local plugin stores. + Ok((manifest, PromptCatalogSource::builtins_only())) +} + +pub fn resolve_runtime_profile_manifest_from_manifest_without_filesystem( + mut manifest: WorkerManifest, + _workspace_root: &Path, + worker_name: &str, +) -> Result<(WorkerManifest, PromptCatalogSource), String> { + if manifest.worker.name.is_empty() { + manifest.worker.name = worker_name.to_string(); + } + manifest.scope = ScopeConfig::default(); + manifest.delegation_scope = ScopeConfig::default(); + // Same as the filesystem-capable runtime path: no local discovery. + Ok((manifest, PromptCatalogSource::builtins_only())) +} + +fn read_rule(target: PathBuf) -> ScopeRule { + ScopeRule { + target, + permission: Permission::Read, + recursive: true, + symlink_policy: Default::default(), + } +} + +fn write_rule(target: PathBuf) -> ScopeRule { + ScopeRule { + target, + permission: Permission::Write, + recursive: true, + symlink_policy: Default::default(), + } +} + +fn workspace_scope( + workspace_root: &Path, + permission: Permission, + deny_write: &[PathBuf], +) -> ScopeConfig { + let allow_rule = ScopeRule { + target: workspace_root.to_path_buf(), + permission, + recursive: true, + symlink_policy: Default::default(), + }; + let deny = deny_write + .iter() + .cloned() + .map(write_rule) + .collect::>(); + ScopeConfig { + allow: vec![allow_rule], + deny, + } +} + +fn workspace_worktree_delegation(workspace_root: &Path) -> ScopeConfig { + ScopeConfig { + allow: vec![ + read_rule(workspace_root.to_path_buf()), + write_rule(workspace_root.join(".worktree")), + ], + deny: Vec::new(), + } +} + +fn append_missing_rules(target: &mut Vec, defaults: Vec) { + for rule in defaults { + if !target.contains(&rule) { + target.push(rule); + } + } +} + +fn apply_scope_launch_defaults(scope: &mut ScopeConfig, defaults: ScopeConfig) { + // Profile resolution has already applied explicit profile/workspace override scope rules. + // Launch policy contributes runtime defaults on top rather than replacing those grants. + append_missing_rules(&mut scope.allow, defaults.allow); + append_missing_rules(&mut scope.deny, defaults.deny); +} + +fn apply_profile_launch_policy(manifest: &mut WorkerManifest, workspace_root: &Path) { + let worktree_root = workspace_root.join(".worktree"); + let default_scope = workspace_scope( + workspace_root, + Permission::Write, + std::slice::from_ref(&worktree_root), + ); + apply_scope_launch_defaults(&mut manifest.scope, default_scope); + manifest.delegation_scope = workspace_worktree_delegation(workspace_root); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn manifest() -> WorkerManifest { + let mut config = manifest::WorkerManifestConfig::resolution_defaults(); + config.worker.name = Some("resolved".into()); + WorkerManifest::try_from(config).unwrap() + } + + #[test] + fn runtime_launch_preserves_explicit_scope_without_repository_discovery() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join(".yoi")).unwrap(); + std::fs::write(root.path().join(".yoi/manifest.toml"), "invalid TOML").unwrap(); + let mut original = manifest(); + let explicit = read_rule(root.path().join("reference")); + original.scope.allow.push(explicit.clone()); + let (resolved, _) = + resolve_runtime_profile_manifest_from_manifest(original, root.path(), "fallback") + .unwrap(); + assert_eq!(resolved.worker.name, "resolved"); + assert!(resolved.scope.allow.contains(&explicit)); + assert!( + resolved + .scope + .allow + .contains(&write_rule(root.path().to_path_buf())) + ); + assert!( + resolved + .scope + .deny + .contains(&write_rule(root.path().join(".worktree"))) + ); + assert_eq!( + resolved.delegation_scope.allow, + vec![ + read_rule(root.path().to_path_buf()), + write_rule(root.path().join(".worktree")), + ] + ); + } + + #[test] + fn runtime_without_filesystem_clears_scope_and_delegation_grants() { + let mut original = manifest(); + original.worker.name.clear(); + original + .scope + .allow + .push(write_rule(PathBuf::from("/work"))); + original + .delegation_scope + .allow + .push(write_rule(PathBuf::from("/work"))); + let (resolved, _) = resolve_runtime_profile_manifest_from_manifest_without_filesystem( + original, + Path::new("/unused"), + "runtime-worker", + ) + .unwrap(); + assert_eq!(resolved.worker.name, "runtime-worker"); + assert!(resolved.scope.allow.is_empty()); + assert!(resolved.scope.deny.is_empty()); + assert!(resolved.delegation_scope.allow.is_empty()); + assert!(resolved.delegation_scope.deny.is_empty()); + } +} diff --git a/crates/worker/src/runtime/dir.rs b/crates/worker/src/runtime/dir.rs index ec09099d4..95606b320 100644 --- a/crates/worker/src/runtime/dir.rs +++ b/crates/worker/src/runtime/dir.rs @@ -1,17 +1,12 @@ use std::io; use std::path::{Path, PathBuf}; -use manifest::{ScopeRule, paths}; +use manifest::ScopeRule; use serde::{Deserialize, Serialize}; use tokio::fs; -use crate::shared_state::WorkerSharedState; - -/// One spawned-child record mirrored to `spawned_workers.json`. -/// -/// Written by the spawner after registry changes so runtime-local tools -/// have a materialised snapshot. Durable restore uses Worker state metadata; -/// this file is not the authoritative source. +/// Legacy process-child projection used only by persisted scope reclamation. +/// Socket fields retain the stored format; they are never connected to. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SpawnedWorkerRecord { /// Spawned Worker's identity. @@ -25,56 +20,33 @@ pub struct SpawnedWorkerRecord { pub callback_address: PathBuf, } -/// Manages the Worker's runtime directory on tmpfs. +/// Artifact directory retained by an in-process Controller. /// -/// ```text -/// /{worker_name}/ -/// ├── pid -/// ├── status.json -/// ├── manifest.toml -/// ├── history.json -/// └── sock (created by socket listener, not by RuntimeDir) -/// ``` -/// -/// `` is resolved via [`manifest::paths::runtime_dir`]. -/// Files are written atomically (write tmp → rename). -/// The directory is removed on drop. +/// Worker liveness and configuration belong to the Host, not filesystem projections. +/// Persistent run directories and pre-existing data are never removed on drop. pub struct RuntimeDir { path: PathBuf, - write_legacy_snapshots: bool, preserve_on_drop: bool, - socket_file_name: &'static str, } impl RuntimeDir { - /// Create the runtime directory and write the PID file. + /// Create an ephemeral directory without PID, status, manifest or socket files. pub async fn create(base: &Path, worker_name: &str) -> Result { - let path = base.join(worker_name); - fs::create_dir_all(&path).await?; - - let pid = std::process::id().to_string(); - fs::write(path.join("pid"), pid.as_bytes()).await?; - - Ok(Self { - path, - write_legacy_snapshots: true, - preserve_on_drop: false, - socket_file_name: "sock", - }) + Self::create_transient(base, worker_name).await } - /// Create an ephemeral Runtime-owned artifact directory. - /// - /// Runtime-managed Workers keep their status in the owning Runtime and do - /// not materialize legacy pid/status/manifest projections. + /// Preserve pre-existing directories rather than claiming their contents for cleanup. pub async fn create_transient(base: &Path, worker_name: &str) -> Result { + fs::create_dir_all(base).await?; let path = base.join(worker_name); - fs::create_dir_all(&path).await?; + let preserve_on_drop = match fs::create_dir(&path).await { + Ok(()) => false, + Err(error) if error.kind() == io::ErrorKind::AlreadyExists && path.is_dir() => true, + Err(error) => return Err(error), + }; Ok(Self { path, - write_legacy_snapshots: false, - preserve_on_drop: false, - socket_file_name: "sock", + preserve_on_drop, }) } @@ -100,39 +72,11 @@ impl RuntimeDir { std::fs::File::open(parent)?.sync_all()?; Ok(Self { path: path.to_path_buf(), - write_legacy_snapshots: false, preserve_on_drop: true, - socket_file_name: "worker.sock", }) } - /// Create in the default base directory resolved via - /// [`manifest::paths::runtime_dir`]. - pub async fn create_default(worker_name: &str) -> Result { - let base = default_base()?; - Self::create(&base, worker_name).await - } - - /// Write status.json atomically. - pub async fn write_status(&self, state: &WorkerSharedState) -> Result<(), io::Error> { - if !self.write_legacy_snapshots { - return Ok(()); - } - let content = state.status_json(); - atomic_write(&self.path.join("status.json"), content.as_bytes()).await - } - - /// Write manifest.toml (typically once at startup). - pub async fn write_manifest(&self, toml: &str) -> Result<(), io::Error> { - if !self.write_legacy_snapshots { - return Ok(()); - } - atomic_write(&self.path.join("manifest.toml"), toml.as_bytes()).await - } - - /// Write `spawned_workers.json` atomically. The entries are the full - /// set of spawned children known to this Worker — callers pass the - /// replacement list, no incremental merge. + /// Rewrite the legacy projection during persisted process-child reclamation. pub async fn write_spawned_workers( &self, records: &[SpawnedWorkerRecord], @@ -145,26 +89,10 @@ impl RuntimeDir { pub fn path(&self) -> &Path { &self.path } - - /// Path where the Unix socket should be created. External callers - /// that only know the worker name (e.g. the TUI's attach flow) - /// predict the same path via [`manifest::paths::worker_socket_path`]. - pub fn socket_path(&self) -> PathBuf { - self.path.join(self.socket_file_name) - } - - pub async fn close_socket(&self) -> Result<(), io::Error> { - match fs::remove_file(self.socket_path()).await { - Ok(()) => Ok(()), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error), - } - } } impl Drop for RuntimeDir { fn drop(&mut self) { - let _ = std::fs::remove_file(self.socket_path()); if !self.preserve_on_drop { let _ = std::fs::remove_dir_all(&self.path); } @@ -179,156 +107,72 @@ async fn atomic_write(target: &Path, content: &[u8]) -> Result<(), io::Error> { Ok(()) } -/// Resolve the default base directory for runtime data. -/// -/// Thin wrapper over [`manifest::paths::runtime_dir`] that converts a -/// missing-env situation into an `io::Error`. -pub fn default_base() -> Result { - paths::runtime_dir().ok_or_else(|| { - io::Error::new( - io::ErrorKind::NotFound, - "could not resolve runtime directory (no YOI_HOME / \ - YOI_RUNTIME_DIR / XDG_RUNTIME_DIR / HOME)", - ) - }) -} - #[cfg(test)] mod tests { use super::*; - use crate::shared_state::WorkerSharedState; - - fn test_state() -> WorkerSharedState { - WorkerSharedState::new( - "test-worker".into(), - session_store::new_segment_id(), - "[engine]\nname = \"test-worker\"".into(), - protocol::Greeting { - worker_name: "test-worker".into(), - cwd: "/tmp".into(), - provider: "anthropic".into(), - model: "claude".into(), - scope_summary: String::new(), - tools: Vec::new(), - context_window: 200_000, - context_tokens: 0, - reasoning: None, - context_usage: None, - }, - ) - } #[tokio::test] - async fn creates_directory_and_pid() { - let tmp = tempfile::tempdir().unwrap(); - let rt = RuntimeDir::create(tmp.path(), "my-worker").await.unwrap(); - - assert!(rt.path().join("pid").exists()); - let pid = std::fs::read_to_string(rt.path().join("pid")).unwrap(); - assert_eq!(pid, std::process::id().to_string()); - } - - #[tokio::test] - async fn write_status_creates_file() { - let tmp = tempfile::tempdir().unwrap(); - let rt = RuntimeDir::create(tmp.path(), "my-worker").await.unwrap(); - let state = test_state(); - - rt.write_status(&state).await.unwrap(); - - let content = std::fs::read_to_string(rt.path().join("status.json")).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); - assert_eq!(parsed["state"], "idle"); - assert_eq!(parsed["worker_name"], "test-worker"); - } - - #[tokio::test] - async fn write_status_reflects_changes() { - let tmp = tempfile::tempdir().unwrap(); - let rt = RuntimeDir::create(tmp.path(), "my-worker").await.unwrap(); - let state = test_state(); - - state.transition(protocol::WorkerState::Busy(protocol::WorkerBusyState::Run( - protocol::WorkerRunState::Running, - ))); - rt.write_status(&state).await.unwrap(); - - let content = std::fs::read_to_string(rt.path().join("status.json")).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); - assert_eq!(parsed["state"], "running"); - } - - #[tokio::test] - async fn write_manifest_creates_file() { - let tmp = tempfile::tempdir().unwrap(); - let rt = RuntimeDir::create(tmp.path(), "my-worker").await.unwrap(); - - rt.write_manifest("[engine]\nname = \"test\"") - .await - .unwrap(); - - let content = std::fs::read_to_string(rt.path().join("manifest.toml")).unwrap(); - assert_eq!(content, "[engine]\nname = \"test\""); - } - - #[tokio::test] - async fn transient_directory_does_not_write_liveness_snapshots() { - let tmp = tempfile::tempdir().unwrap(); - let rt = RuntimeDir::create_transient(tmp.path(), "runtime-worker") - .await - .unwrap(); - - rt.write_status(&test_state()).await.unwrap(); - rt.write_manifest("[worker]\nname = \"runtime-worker\"") - .await - .unwrap(); - - assert!(!rt.path().join("pid").exists()); - assert!(!rt.path().join("status.json").exists()); - assert!(!rt.path().join("manifest.toml").exists()); - } - - #[tokio::test] - async fn write_spawned_workers_creates_file() { - use manifest::{Permission, ScopeRule}; - let tmp = tempfile::tempdir().unwrap(); - let rt = RuntimeDir::create(tmp.path(), "my-worker").await.unwrap(); - - let records = vec![SpawnedWorkerRecord { - worker_name: "child".into(), - socket_path: "/run/yoi/child/sock".into(), - scope_delegated: vec![ScopeRule { - target: "/tmp/work".into(), - permission: Permission::Write, - recursive: true, - symlink_policy: Default::default(), - }], - callback_address: "/run/yoi/my-worker/sock".into(), - }]; - rt.write_spawned_workers(&records).await.unwrap(); - - let content = std::fs::read_to_string(rt.path().join("spawned_workers.json")).unwrap(); - let parsed: Vec = serde_json::from_str(&content).unwrap(); - assert_eq!(parsed.len(), 1); - assert_eq!(parsed[0].worker_name, "child"); - } - - #[tokio::test] - async fn socket_path() { - let tmp = tempfile::tempdir().unwrap(); - let rt = RuntimeDir::create(tmp.path(), "my-worker").await.unwrap(); - assert_eq!(rt.socket_path(), rt.path().join("sock")); - } - - #[tokio::test] - async fn drop_removes_directory() { - let tmp = tempfile::tempdir().unwrap(); - let dir_path; + async fn transient_controller_directory_has_no_liveness_projections_and_is_released() { + let root = tempfile::tempdir().unwrap(); + let path; { - let rt = RuntimeDir::create(tmp.path(), "my-worker").await.unwrap(); - dir_path = rt.path().to_owned(); - assert!(dir_path.exists()); + let runtime = RuntimeDir::create(root.path(), "worker").await.unwrap(); + path = runtime.path().to_owned(); + assert_eq!(std::fs::read_dir(&path).unwrap().count(), 0); } - assert!(!dir_path.exists()); + assert!(!path.exists()); + } + + #[tokio::test] + async fn preexisting_data_is_not_overwritten_or_removed_on_drop() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("worker"); + std::fs::create_dir(&path).unwrap(); + std::fs::write(path.join("pid"), "retained legacy data").unwrap(); + { + let _runtime = RuntimeDir::create(root.path(), "worker").await.unwrap(); + } + assert_eq!( + std::fs::read_to_string(path.join("pid")).unwrap(), + "retained legacy data" + ); + } + + #[tokio::test] + async fn persistent_run_preserves_logs_and_rejects_directory_reuse() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("runs/1"); + { + let _runtime = RuntimeDir::create_worker_run(&path).await.unwrap(); + for name in ["artifacts", "spawned", "worker.out.log", "worker.err.log"] { + assert!(path.join(name).exists(), "missing {name}"); + } + } + assert!(path.exists()); + let error = RuntimeDir::create_worker_run(&path).await.err().unwrap(); + assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); + } + + #[tokio::test] + async fn legacy_reclamation_projection_retains_its_stored_format() { + let root = tempfile::tempdir().unwrap(); + let runtime = RuntimeDir::create(root.path(), "worker").await.unwrap(); + let record: SpawnedWorkerRecord = serde_json::from_value(serde_json::json!({ + "worker_name": "legacy-child", "socket_path": "/old/child/sock", + "scope_delegated": [], "callback_address": "/old/parent/sock", + })) + .unwrap(); + runtime.write_spawned_workers(&[record]).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice( + &std::fs::read(runtime.path().join("spawned_workers.json")).unwrap(), + ) + .unwrap(); + assert_eq!(value[0]["socket_path"], "/old/child/sock"); + assert_eq!(value[0]["callback_address"], "/old/parent/sock"); + runtime.write_spawned_workers(&[]).await.unwrap(); + assert_eq!( + std::fs::read_to_string(runtime.path().join("spawned_workers.json")).unwrap(), + "[]" + ); } } diff --git a/crates/worker/src/runtime/worker_allocation/table.rs b/crates/worker/src/runtime/worker_allocation/table.rs index b9d0a16ae..571a36fb1 100644 --- a/crates/worker/src/runtime/worker_allocation/table.rs +++ b/crates/worker/src/runtime/worker_allocation/table.rs @@ -72,7 +72,8 @@ pub struct Allocation { pub worker_name: String, /// Owning process. Checked with `kill(pid, 0)` for stale detection. pub pid: u32, - /// Worker's Unix socket path. + /// Legacy stored endpoint hint. Empty means the Host owns connectivity. + /// Allocation admission and stale reclamation use identity/PID, never this path. pub socket: PathBuf, /// Allow rules granted to this Worker (write + read). pub scope_allow: Vec, @@ -338,6 +339,51 @@ mod tests { assert_eq!(guard.data().allocations[0].worker_name, "a"); } + #[test] + fn empty_legacy_socket_roundtrips_without_path_resolution_or_cleanup() { + let root = TempDir::new().unwrap(); + let path = root.path().join("workers.json"); + let retained = root.path().join("retained-data"); + fs::write(&retained, "do not touch").unwrap(); + let segment = sid(); + { + let mut guard = open_empty(&path); + register_worker( + &mut guard, + "in-process".into(), + std::process::id(), + PathBuf::new(), + Vec::new(), + segment, + ) + .unwrap(); + } + let stored: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + assert_eq!(stored["allocations"][0]["socket"], ""); + let mut guard = LockFileGuard::open(&path).unwrap(); + assert!(guard.data().allocations[0].socket.as_os_str().is_empty()); + super::super::reclaim_stale_with(&mut guard, |pid| { + assert_eq!(pid, std::process::id()); + true + }); + assert_eq!( + guard.data().allocations.len(), + 1, + "missing endpoint is not stale ownership" + ); + assert!(guard.data().find_by_segment(segment).is_some()); + super::super::reclaim_stale_with(&mut guard, |_| false); + drop(guard); + assert!( + LockFileGuard::open(&path) + .unwrap() + .data() + .allocations + .is_empty() + ); + assert_eq!(fs::read_to_string(retained).unwrap(), "do not touch"); + } + #[test] fn atomic_save_reopens_complete_table_at_both_rename_boundaries() { let dir = TempDir::new().unwrap(); diff --git a/crates/worker/src/runtime_command.rs b/crates/worker/src/runtime_command.rs deleted file mode 100644 index d594d0723..000000000 --- a/crates/worker/src/runtime_command.rs +++ /dev/null @@ -1,165 +0,0 @@ -use std::ffi::OsString; -use std::fmt; -use std::io; -use std::path::{Path, PathBuf}; - -const POD_RUNTIME_COMMAND_ENV: &str = "YOI_POD_RUNTIME_COMMAND"; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct WorkerRuntimeCommand { - pub program: PathBuf, - pub prefix_args: Vec, -} - -impl WorkerRuntimeCommand { - pub fn new(program: impl Into, prefix_args: Vec) -> Self { - Self { - program: program.into(), - prefix_args, - } - } - - pub fn for_current_exe() -> io::Result { - Ok(Self::for_executable(std::env::current_exe()?)) - } - - pub fn for_executable(program: impl Into) -> Self { - Self::new(program, vec![OsString::from("worker")]) - } - - /// Resolve the Worker runtime command used for subprocess launches. - /// - /// The default launch path is always the current `yoi` executable plus - /// the unified `worker` prefix argument. During development, a non-empty - /// `YOI_POD_RUNTIME_COMMAND` value replaces only the executable path; - /// the `worker` prefix is still added here and the env value is not parsed as a - /// shell command. - pub fn resolve() -> io::Result { - Self::resolve_from_env_value( - std::env::var_os(POD_RUNTIME_COMMAND_ENV), - std::env::current_exe, - ) - } - - fn resolve_from_env_value( - override_program: Option, - current_exe: F, - ) -> io::Result - where - F: FnOnce() -> io::Result, - { - if let Some(program) = override_program.filter(|program| !program.as_os_str().is_empty()) { - return Ok(Self::for_executable(program)); - } - - Ok(Self::for_executable(current_exe()?)) - } - - pub fn program(&self) -> &Path { - &self.program - } - - pub fn prefix_args(&self) -> &[OsString] { - &self.prefix_args - } - - pub fn argv_with(&self, args: I) -> Vec - where - I: IntoIterator, - S: Into, - { - let mut argv = self.prefix_args.clone(); - argv.extend(args.into_iter().map(Into::into)); - argv - } -} - -impl fmt::Display for WorkerRuntimeCommand { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.program.display())?; - for arg in &self.prefix_args { - write!(f, " {}", arg.to_string_lossy())?; - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn yoi_binary_defaults_to_worker_prefix() { - let command = WorkerRuntimeCommand::for_executable("/opt/yoi/bin/yoi"); - - assert_eq!(command.program(), Path::new("/opt/yoi/bin/yoi")); - assert_eq!(command.prefix_args(), [OsString::from("worker")]); - assert_eq!( - command.argv_with(["--worker", "agent"]), - vec!["worker", "--worker", "agent"] - .into_iter() - .map(OsString::from) - .collect::>() - ); - } - - #[test] - fn any_runtime_executable_gets_worker_prefix() { - let command = WorkerRuntimeCommand::for_executable("/opt/yoi/bin/custom-runtime"); - - assert_eq!(command.program(), Path::new("/opt/yoi/bin/custom-runtime")); - assert_eq!(command.prefix_args(), [OsString::from("worker")]); - assert_eq!( - command.argv_with(["--worker", "agent"]), - vec!["worker", "--worker", "agent"] - .into_iter() - .map(OsString::from) - .collect::>() - ); - } - - #[test] - fn resolve_uses_current_exe_when_override_is_unset() { - let command = WorkerRuntimeCommand::resolve_from_env_value(None, || { - Ok(PathBuf::from("/opt/yoi/bin/yoi")) - }) - .unwrap(); - - assert_eq!( - command, - WorkerRuntimeCommand::for_executable("/opt/yoi/bin/yoi") - ); - } - - #[test] - fn resolve_uses_current_exe_when_override_is_empty() { - let command = WorkerRuntimeCommand::resolve_from_env_value(Some(OsString::new()), || { - Ok(PathBuf::from("/opt/yoi/bin/yoi")) - }) - .unwrap(); - - assert_eq!( - command, - WorkerRuntimeCommand::for_executable("/opt/yoi/bin/yoi") - ); - } - - #[test] - fn resolve_override_replaces_only_program_and_keeps_worker_prefix() { - let command = WorkerRuntimeCommand::resolve_from_env_value( - Some(OsString::from("/tmp/rebuilt yoi")), - || panic!("override must not inspect current_exe"), - ) - .unwrap(); - - assert_eq!(command.program(), Path::new("/tmp/rebuilt yoi")); - assert_eq!(command.prefix_args(), [OsString::from("worker")]); - assert_eq!( - command.argv_with(["--worker", "agent"]), - vec!["worker", "--worker", "agent"] - .into_iter() - .map(OsString::from) - .collect::>() - ); - } -} diff --git a/crates/worker/src/spawn/comm_tools.rs b/crates/worker/src/spawn/comm_tools.rs deleted file mode 100644 index 59ca3c167..000000000 --- a/crates/worker/src/spawn/comm_tools.rs +++ /dev/null @@ -1,181 +0,0 @@ -//! Socket communication retained for the legacy top-level Worker callback protocol. -//! -//! Direct Internal SubWorker lifecycle is exposed through `worker.control` and the -//! canonical Worker tools, not a second SubWorker-specific tool family. - -use std::path::Path; -use std::time::Duration; - -use protocol::stream::{JsonLineReader, JsonLineWriter}; -use protocol::{Event, Method}; -use tokio::net::UnixStream; - -/// Timeout applied to each socket-level operation — connect, write, -/// read. Kept short so a stuck child doesn't block the spawner's turn. -const SOCKET_OP_TIMEOUT: Duration = Duration::from_secs(5); - -/// Connect with a timeout, drain the server's connect-time snapshot, -/// write one `Method` line, flush, and close. -/// -/// The Worker socket protocol sends replayed alerts and an initial -/// `Event::Snapshot` before it starts reading client methods. Send-only -/// callers must consume that prefix; otherwise a large snapshot can block -/// the server's writer before it reaches the method-read branch. Any -/// socket error maps to an `io::Error`; the caller decides whether to -/// surface it to the LLM or treat it as "worker stopped". -pub(crate) async fn connect_and_send(socket: &Path, method: &Method) -> std::io::Result<()> { - let stream = tokio::time::timeout(SOCKET_OP_TIMEOUT, UnixStream::connect(socket)) - .await - .map_err(|_| std::io::Error::new(std::io::ErrorKind::TimedOut, "connect timed out"))??; - let (r, w) = stream.into_split(); - let mut reader = JsonLineReader::new(r); - let mut writer = JsonLineWriter::new(w); - - drain_initial_snapshot(&mut reader).await?; - - tokio::time::timeout(SOCKET_OP_TIMEOUT, writer.write(method)) - .await - .map_err(|_| std::io::Error::new(std::io::ErrorKind::TimedOut, "write timed out"))??; - Ok(()) -} - -async fn drain_initial_snapshot(reader: &mut JsonLineReader) -> std::io::Result<()> -where - R: tokio::io::AsyncBufRead + Unpin, -{ - loop { - let event = tokio::time::timeout(SOCKET_OP_TIMEOUT, reader.next::()) - .await - .map_err(|_| std::io::Error::new(std::io::ErrorKind::TimedOut, "read timed out"))??; - match event { - Some(Event::Snapshot { .. }) => return Ok(()), - Some(_) => continue, - None => { - return Err(std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, - "worker closed connection before Snapshot event", - )); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use protocol::{Alert, AlertLevel, AlertSource, Greeting, WorkerEvent, WorkerStatus}; - use tempfile::TempDir; - use tokio::net::UnixListener; - use tokio::task::JoinHandle; - - fn snapshot(entries: Vec) -> Event { - Event::Snapshot { - session: protocol::SessionSnapshot { - pending_submissions: protocol::PendingSubmissionsSnapshot::default(), - entries: entries - .into_iter() - .enumerate() - .map(|(index, value)| protocol::SessionSnapshotEntry { - use_policy: None, - entry_id: format!("test-{index}"), - timestamp: index as u64, - provenance: protocol::SessionEntryProvenance::LegacyUnknown, - derived_from: Vec::new(), - data: protocol::SessionSnapshotEntryData::RunError { - message: value.to_string(), - failure: None, - }, - }) - .collect(), - }, - greeting: Greeting { - worker_name: "server".into(), - cwd: "/tmp".into(), - provider: "test".into(), - model: "test".into(), - scope_summary: String::new(), - tools: Vec::new(), - context_window: 200_000, - context_tokens: 0, - reasoning: None, - context_usage: None, - }, - state: WorkerStatus::Idle.into(), - in_flight: Default::default(), - internal_workers: Vec::new(), - } - } - - fn serve_initial_events_then_method( - listener: UnixListener, - events: Vec, - ) -> JoinHandle> { - tokio::spawn(async move { - let (stream, _) = listener.accept().await.ok()?; - let (r, w) = stream.into_split(); - let mut reader = JsonLineReader::new(r); - let mut writer = JsonLineWriter::new(w); - for event in events { - writer.write(&event).await.ok()?; - } - reader.next::().await.ok().flatten() - }) - } - - #[tokio::test] - async fn connect_and_send_drains_initial_alert_and_snapshot_before_method() { - let tmp = TempDir::new().unwrap(); - let socket = tmp.path().join("worker.sock"); - let listener = UnixListener::bind(&socket).unwrap(); - let received = serve_initial_events_then_method( - listener, - vec![ - Event::Alert(Alert { - id: None, - position: None, - level: AlertLevel::Warn, - source: AlertSource::Worker, - message: "replayed alert".into(), - timestamp_ms: 0, - }), - snapshot(Vec::new()), - ], - ); - - let method = Method::Shutdown { - command: protocol::WorkerCommandEnvelope::new(1), - }; - connect_and_send(&socket, &method).await.unwrap(); - - let method = received.await.unwrap().expect("expected method"); - assert!(matches!(method, Method::Shutdown { .. })); - } - - #[tokio::test] - async fn connect_and_send_delivers_method_after_large_initial_snapshot() { - let tmp = TempDir::new().unwrap(); - let socket = tmp.path().join("worker.sock"); - let listener = UnixListener::bind(&socket).unwrap(); - let large_payload = "x".repeat(2 * 1024 * 1024); - let received = serve_initial_events_then_method( - listener, - vec![snapshot(vec![ - serde_json::json!({ "payload": large_payload }), - ])], - ); - let expected = Method::WorkerEvent(WorkerEvent::TurnEnded { - worker_name: "child".into(), - }); - - connect_and_send(&socket, &expected).await.unwrap(); - - let method = received.await.unwrap().expect("expected method"); - match method { - Method::WorkerEvent(WorkerEvent::TurnEnded { worker_name }) => { - assert_eq!(worker_name, "child") - } - other => panic!("expected TurnEnded WorkerEvent, got {other:?}"), - } - } -} diff --git a/crates/worker/src/spawn/mod.rs b/crates/worker/src/spawn/mod.rs index 23cb51ad3..ec3a77e8c 100644 --- a/crates/worker/src/spawn/mod.rs +++ b/crates/worker/src/spawn/mod.rs @@ -1,4 +1,3 @@ -pub mod comm_tools; mod published; pub mod registry; pub mod tool; diff --git a/crates/worker/src/subjektiv.rs b/crates/worker/src/subjektiv.rs index 3d5666d5e..3f3cfcc2a 100644 --- a/crates/worker/src/subjektiv.rs +++ b/crates/worker/src/subjektiv.rs @@ -678,8 +678,8 @@ pub(crate) fn test_connection(client: Arc) -> SubjektivHost mod tests { use super::*; use crate::{ - Method, WorkerBootstrap, WorkerBootstrapLayout, WorkerControllerTransport, - WorkerFilesystemAuthority, WorkerManifest, WorkerWorkspaceContext, + Method, WorkerBootstrap, WorkerBootstrapLayout, WorkerFilesystemAuthority, WorkerManifest, + WorkerWorkspaceContext, }; use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent}; use agen::llm_client::{ClientError, LlmClient, Request}; @@ -944,7 +944,6 @@ enabled = true WorkerBootstrapLayout::Direct { runtime_base: temp.path().join("runtime"), }, - WorkerControllerTransport::InProcess, ) .with_model_client(TaskScriptClient::default()) .with_subjektiv_host(host) @@ -1137,7 +1136,6 @@ enabled = true WorkerBootstrapLayout::Direct { runtime_base: temp.path().join("runtime"), }, - WorkerControllerTransport::InProcess, ) .with_model_client(ScriptedClient(tx)) .with_subjektiv_host(host.clone()) @@ -1318,7 +1316,6 @@ enabled = true WorkerBootstrapLayout::Direct { runtime_base: temp.path().join("runtime"), }, - WorkerControllerTransport::InProcess, ) .with_model_client(ScriptedClient(tx)) .with_subjektiv_host(host.clone()) @@ -1377,7 +1374,6 @@ enabled = true WorkerBootstrapLayout::Direct { runtime_base: temp.path().join("runtime"), }, - WorkerControllerTransport::InProcess, ) .with_model_client(ScriptedClient(tx)); let bootstrap = if attach { diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index ac9d44d6e..2f609b735 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -531,7 +531,6 @@ use crate::prompt::agents_md::read_agents_md; use crate::prompt::catalog::{CatalogError, PromptCatalog, WorkspacePromptProjection}; use crate::prompt::source::PromptCatalogSource; use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate}; -use crate::runtime::dir; use crate::runtime::worker_allocation::{ self, ScopeAllocationGuard, ScopeLockError, SegmentActivationGuard, }; @@ -3456,18 +3455,13 @@ pub struct Worker { /// authority before the outer allocation guard reacquires the same lock. failed_activation_authority: Mutex>, /// Scope allocation in the machine-wide lock file. `Some` for - /// Workers built via `from_manifest` / `from_manifest_spawned` / + /// Workers built via `from_manifest` / /// `restore_from_manifest` (production paths); `None` for the /// low-level `Worker::new` constructor used in tests, which bypasses /// the registry. Kept purely for its `Drop` impl, which releases /// the allocation when the Worker is dropped. #[allow(dead_code)] scope_allocation: Option, - /// Socket path of the spawning Worker. `Some` only for Workers built via - /// `from_manifest_spawned`. Consumed by the controller to fire - /// `Method::WorkerEvent` reports upward (turn end, error, shutdown, - /// scope sub-delegation). - callback_socket: Option, /// Transient launch role for Ticket role sessions. This is process-local /// runtime identity used by controller policy; it is not model-visible and /// is not persisted into Ticket claim/session records. @@ -3751,7 +3745,6 @@ impl Worker { pending_committed_history: Arc::new(Mutex::new(std::collections::VecDeque::new())), failed_activation_authority: Mutex::new(None), scope_allocation: None, - callback_socket: None, runtime_ticket_role: None, prompts, inject_resident_summary: true, @@ -3916,8 +3909,8 @@ impl Worker { self.runtime_ticket_role.as_deref() } - /// Set the process-local Ticket role marker. Intended for entrypoint - /// launch metadata, not for model-visible prompts or durable claims. + /// Set the process-local Ticket role marker supplied by the Host at launch, + /// not by model-visible prompts or durable claims. pub fn set_runtime_ticket_role(&mut self, role: Option) { self.runtime_ticket_role = role; } @@ -4788,15 +4781,6 @@ impl Worker { self.pending_notifies.clone() } - /// Parent callback socket set by `from_manifest_spawned`. - /// - /// Consumed by the Controller to fire `Method::WorkerEvent` upward on - /// lifecycle transitions. `None` for top-level Workers, in which case - /// the Controller silently skips the send. - pub fn callback_socket(&self) -> Option<&PathBuf> { - self.callback_socket.as_ref() - } - pub(crate) fn install_compaction_budget( &mut self, budget: Option, @@ -8304,14 +8288,10 @@ where // Register this Worker in the machine-wide worker-allocation // before building anything else, so a spawn that conflicts on // scope fails fast. - let socket_path = dir::default_base() - .map_err(ScopeLockError::from)? - .join(&manifest.worker.name) - .join("sock"); let scope_allocation = worker_allocation::install_top_level( manifest.worker.name.clone(), std::process::id(), - socket_path, + PathBuf::new(), // Reserved legacy allocation field; no Worker endpoint exists. common.scope.allow_rules(), segment_id, )?; @@ -8372,7 +8352,6 @@ where pending_committed_history: Arc::new(Mutex::new(std::collections::VecDeque::new())), failed_activation_authority: Mutex::new(None), scope_allocation: Some(scope_allocation), - callback_socket: None, runtime_ticket_role: None, prompts: common.prompts, inject_resident_summary: true, @@ -8773,7 +8752,6 @@ where pending_committed_history: Arc::new(Mutex::new(std::collections::VecDeque::new())), failed_activation_authority: Mutex::new(None), scope_allocation: None, - callback_socket: None, runtime_ticket_role: None, prompts: common.prompts, inject_resident_summary: true, @@ -8796,141 +8774,6 @@ where Ok(worker) } - /// Build a Worker spawned by another Worker (sibling process). - /// - /// Behaves like [`Worker::from_manifest`] but claims the scope - /// allocation that the spawner pre-registered via - /// [`worker_allocation::delegate_scope`], rather than installing a new - /// top-level entry. `callback_socket` carries the spawner's - /// Unix-socket path so the spawned Worker can send `Method::Notify` - /// back to the spawner. - pub async fn from_manifest_spawned( - manifest: WorkerManifest, - store: St, - loader: PromptCatalogSource, - callback_socket: PathBuf, - ) -> 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, - workspace_context, - authority, - ) - .await - } - - pub async fn from_manifest_spawned_with_context( - manifest: WorkerManifest, - store: St, - loader: PromptCatalogSource, - callback_socket: PathBuf, - workspace_context: WorkerWorkspaceContext, - filesystem_authority: WorkerFilesystemAuthority, - ) -> Result { - let common = prepare_worker_common_with_context( - &manifest, - &loader, - /* parse_template */ true, - workspace_context, - filesystem_authority, - manifest.scope.clone(), - )?; - - // A spawned child starts its own conversation, so it mints a - // fresh Session rather than joining the spawner's. - let session_id = session_store::new_session_id(); - let segment_id = session_store::new_segment_id(); - let scope_allocation = worker_allocation::adopt_allocation( - manifest.worker.name.clone(), - std::process::id(), - segment_id, - )?; - - let mut worker = - Engine::, Mutable, SessionHistoryMetadata>::new_annotated( - common.client, - ); - apply_worker_manifest(&mut worker, &manifest.engine); - worker.set_cache_key(Some(segment_id.to_string())); - let worker_metadata_writer = Some(worker_metadata_writer_for_store(&store)); - let worker_metadata_segment_cas = Some(worker_metadata_segment_cas_for_store(&store)); - let scope = SharedScope::new(common.scope); - let workdir_sessions = - workdir_sessions_from_authority(&common.filesystem_authority, &scope); - - let mut worker = Self { - backend_job: std::sync::OnceLock::new(), - manifest, - engine: Some(worker), - session: WorkerSession::new(session_id, Vec::new()), - last_run_interrupted: false, - store, - worker_metadata_writer, - worker_metadata_segment_cas, - segment_state: SegmentState::new(session_id, segment_id, 0), - filesystem_authority: common.filesystem_authority, - workdir_sessions, - workspace_context: common.workspace_context, - flow_runtime_state: Arc::new(Mutex::new(None)), - flow_feature_enabled: false, - scope, - hook_builder: HookRegistryBuilder::new(), - hook_registry: None, - feature_background_tasks: FeatureBackgroundTaskRegistry::default(), - feature_invocations: FeatureInvocationRegistry::default(), - manifest_lifecycle_features_enabled: true, - interceptor_installed: false, - compaction_service_budget: None, - compact_state: None, - usage_tracker: Arc::new(UsageTracker::new()), - metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()), - usage_history: Arc::new(Mutex::new(Vec::new())), - tracker: None, - task_feature: TaskFeature::new(), - worker_observation_provider: None, - system_prompt_template: common.system_prompt_template, - feature_instructions: common.feature_instructions, - alerter: None, - working_event_tx: None, - internal_worker_registry: None, - pending_compaction_cleanup: Mutex::new(None), - in_flight: None, - ai_activity_counter: Arc::new(AtomicUsize::new(0)), - pending_notifies: NotifyBuffer::new(), - notification_coalesce_delay: DEFAULT_NOTIFICATION_COALESCE_DELAY, - pending_attachments: Arc::new(Mutex::new(Vec::::new())), - pending_committed_history: Arc::new(Mutex::new(std::collections::VecDeque::new())), - failed_activation_authority: Mutex::new(None), - scope_allocation: Some(scope_allocation), - callback_socket: Some(callback_socket), - runtime_ticket_role: None, - prompts: common.prompts, - inject_resident_summary: true, - feature_resident_summary_source: None, - session_context_source: None, - prepared_initial_seed: None, - prepared_initial_prompt_completion: None, - subjektiv_host: None, - feature_installation: crate::feature::FeatureInstallation::Activate, - restore_feature_lifecycle_pending: false, - feature_system_prompt_override: None, - user_segments: Vec::new(), - sink: SegmentLogSink::new(), - history_persistence_wired: false, - log_writer: None, - notification_committer: None, - }; - worker.apply_permissions_from_manifest(); - worker.apply_prune_from_manifest(); - worker.write_worker_metadata_pending()?; - Ok(worker) - } - /// Restore a Worker by resolving its name-keyed metadata to an active /// `(SessionId, SegmentId)` and then using the normal session-log restore /// path. The metadata stores only the active pointer; lineage and origin @@ -9215,14 +9058,10 @@ where // the lookup + install inside a single `LockFileGuard` is what // makes "no two live Workers write to the same session log" // actually structural rather than a hopeful pre-check. - let socket_path = dir::default_base() - .map_err(ScopeLockError::from)? - .join(&manifest.worker.name) - .join("sock"); let scope_allocation = worker_allocation::install_top_level_with_deny( manifest.worker.name.clone(), std::process::id(), - socket_path, + PathBuf::new(), // Reserved legacy allocation field; no Worker endpoint exists. common.scope.allow_rules(), common.scope.deny_rules(), segment_id, @@ -9320,7 +9159,6 @@ where pending_committed_history: Arc::new(Mutex::new(std::collections::VecDeque::new())), failed_activation_authority: Mutex::new(None), scope_allocation: Some(scope_allocation), - callback_socket: None, runtime_ticket_role: None, prompts: common.prompts, inject_resident_summary: true, @@ -10416,6 +10254,8 @@ struct WorkerCommon { feature_instructions: Vec, } +/// Migration-only safety probe for retained process-child metadata. +/// New Internal SubWorkers never publish socket metadata or use this path. async fn restored_child_reachable(child: &WorkerSpawnedChild) -> bool { tokio::time::timeout( RESTORE_RECONCILIATION_REACHABILITY_TIMEOUT, @@ -17962,7 +17802,6 @@ permission = "write" WorkerWorkspaceContext::no_workspace(), WorkerFilesystemAuthority::None, layout, - crate::WorkerControllerTransport::InProcess, ) .with_model_client(NoopClient) .with_subjektiv_host(host) @@ -18282,7 +18121,6 @@ permission = "write" .join("runtime/runs") .join(uuid::Uuid::now_v7().to_string()), }, - crate::WorkerControllerTransport::InProcess, ) .with_model_client(NoopClient) } diff --git a/crates/worker/tests/controller_test.rs b/crates/worker/tests/controller_test.rs index 52ebdc62c..afc2450d3 100644 --- a/crates/worker/tests/controller_test.rs +++ b/crates/worker/tests/controller_test.rs @@ -1738,42 +1738,24 @@ async fn snapshot_includes_user_input_for_in_flight_turn() { // The Running event is the in-flight visibility fence: the committed // annotated input must already be available to an immediately attaching // subscriber rather than racing behind this status transition. - let stream = tokio::net::UnixStream::connect(handle.runtime_dir.socket_path()) - .await - .unwrap(); - let (reader, _writer) = stream.into_split(); - let mut reader = protocol::stream::JsonLineReader::new(reader); - - loop { - let event = reader.next::().await.unwrap().unwrap(); - match event { - Event::Snapshot { session, .. } => { - let found = session.entries.iter().any(|entry| match &entry.data { - protocol::SessionSnapshotEntryData::UserInput { segments } => { - protocol::Segment::flatten_to_text(segments) == "hello in-flight" - } - protocol::SessionSnapshotEntryData::Message { - role: protocol::SessionMessageRole::User, - content, - } => content.iter().any(|part| { - matches!( - part, - protocol::SessionContentPart::Text { text } - if text == "hello in-flight" - ) - }), - _ => false, - }); - assert!( - found, - "snapshot must carry the in-flight UserInput entry: {session:?}" - ); - return; + let Event::Snapshot { session, .. } = handle.snapshot_event() else { + panic!("expected snapshot"); + }; + assert!( + session.entries.iter().any(|entry| match &entry.data { + protocol::SessionSnapshotEntryData::UserInput { segments } => { + protocol::Segment::flatten_to_text(segments) == "hello in-flight" } - Event::Alert(_) => continue, - other => panic!("expected Snapshot first, got {other:?}"), - } - } + protocol::SessionSnapshotEntryData::Message { + role: protocol::SessionMessageRole::User, + content, + } => content.iter().any(|part| matches!(part, + protocol::SessionContentPart::Text { text } if text == "hello in-flight" + )), + _ => false, + }), + "snapshot must carry the in-flight UserInput entry: {session:?}" + ); } #[tokio::test] @@ -1855,24 +1837,10 @@ async fn attach_snapshot_includes_current_status() { .unwrap(); wait_for_status(&handle, WorkerStatus::Running).await; - let stream = tokio::net::UnixStream::connect(handle.runtime_dir.socket_path()) - .await - .unwrap(); - let (reader, _writer) = stream.into_split(); - let mut reader = protocol::stream::JsonLineReader::new(reader); - - // First event after connect is the snapshot — it carries the current status. - loop { - let event = reader.next::().await.unwrap().unwrap(); - match event { - Event::Snapshot { state, .. } => { - assert_eq!(state.catalog_status(), WorkerStatus::Running); - return; - } - Event::Alert(_) => continue, - other => panic!("expected Snapshot, got {other:?}"), - } - } + let Event::Snapshot { state, .. } = handle.snapshot_event() else { + panic!("expected snapshot"); + }; + assert_eq!(state.catalog_status(), WorkerStatus::Running); } #[tokio::test] @@ -2928,7 +2896,7 @@ async fn status_json_reflects_worker_name() { } // --------------------------------------------------------------------------- -// Socket transport tests +// Controller lifecycle tests // --------------------------------------------------------------------------- #[tokio::test] @@ -2966,283 +2934,148 @@ async fn shutdown_closes_method_admission_before_terminal_confirmation() { shutdown_rx.await.unwrap(); } +/// Uses the real Controller and metadata store; only the model client is replaced. #[tokio::test] -async fn shutdown_joins_socket_server_with_active_connection() { - use tokio::net::UnixStream; - +async fn register_peer_returns_existing_response_and_persists_reciprocal_metadata() { + use session_store::{WorkerMetadata, WorkerMetadataStore}; let worker = make_worker(MockClient::new(simple_text_events())).await; - let runtime_base = tempfile::tempdir().unwrap(); - let (handle, shutdown_rx) = WorkerController::spawn(worker, runtime_base.path()) - .await + let store = worker.store().clone(); + store + .write(&WorkerMetadata::new("test-worker", None)) .unwrap(); - let socket_path = handle.runtime_dir.socket_path(); - let _connection = UnixStream::connect(&socket_path).await.unwrap(); - + store + .write(&WorkerMetadata::new("peer-worker", None)) + .unwrap(); + let handle = spawn_controller(worker).await; + let mut events = handle.subscribe(); handle - .send(Method::Shutdown { - command: worker_command(&handle), + .send(Method::RegisterPeer { + name: "peer-worker".into(), }) .await .unwrap(); - tokio::time::timeout(std::time::Duration::from_secs(5), shutdown_rx) + assert!( + drain_until( + &mut events, + std::time::Duration::from_secs(2), + |event| matches!( + event, + Event::PeerRegistered { result } if result == &serde_json::json!({ + "source": "test-worker", "peer": "peer-worker", + }) + ) + ) .await - .expect("controller should join its socket tasks") - .expect("controller shutdown signal should remain open"); - assert!(!socket_path.exists()); + ); + assert_eq!( + store.read_by_name("test-worker").unwrap().unwrap().peers[0].worker_name, + "peer-worker" + ); + assert_eq!( + store.read_by_name("peer-worker").unwrap().unwrap().peers[0].worker_name, + "test-worker" + ); + assert_eq!(handle.shared_state.catalog_status(), WorkerStatus::Idle); } #[tokio::test] -async fn socket_run_receives_events() { - use protocol::stream::{JsonLineReader, JsonLineWriter}; - use tokio::net::UnixStream; - - let client = MockClient::new(simple_text_events()); - let worker = make_worker(client).await; +async fn register_peer_missing_target_is_rejected_without_starting_a_worker() { + use session_store::{WorkerMetadata, WorkerMetadataStore}; + let worker = make_worker(MockClient::new(simple_text_events())).await; + let store = worker.store().clone(); + store + .write(&WorkerMetadata::new("test-worker", None)) + .unwrap(); let handle = spawn_controller(worker).await; - - // Give the socket server a moment to bind - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - - let sock_path = handle.runtime_dir.socket_path(); - let stream = UnixStream::connect(&sock_path).await.unwrap(); - let (reader, writer) = stream.into_split(); - let mut reader = JsonLineReader::new(reader); - let mut writer = JsonLineWriter::new(writer); - - // Send run method via socket - writer - .write(&Method::submit_text( - protocol::new_submission_request_id(), - "Hello", - )) + let mut events = handle.subscribe(); + handle + .send(Method::RegisterPeer { + name: "missing-peer".into(), + }) .await .unwrap(); - - // Collect events - let mut saw_turn_start = false; - let mut saw_text_delta = false; - let mut saw_turn_end = false; - - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); - loop { - tokio::select! { - event = reader.next::() => { - match event { - Ok(Some(Event::TurnStart { .. })) => saw_turn_start = true, - Ok(Some(Event::TextDelta { .. })) => saw_text_delta = true, - Ok(Some(Event::TurnEnd { .. })) => { - saw_turn_end = true; - break; - } - Ok(None) | Err(_) => break, - _ => {} - } - } - _ = tokio::time::sleep_until(deadline) => break, - } - } - - assert!(saw_turn_start, "should see turn_start via socket"); - assert!(saw_text_delta, "should see text_delta via socket"); - assert!(saw_turn_end, "should see turn_end via socket"); -} - -#[tokio::test] -async fn socket_worker_event_turn_ended_while_idle_auto_starts_turn() { - use protocol::stream::{JsonLineReader, JsonLineWriter}; - use tokio::net::UnixStream; - - let client = MockClient::new(simple_text_events()); - let worker = make_worker(client).await; - let handle = spawn_controller(worker).await; - - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - - let sock_path = handle.runtime_dir.socket_path(); - let stream = UnixStream::connect(&sock_path).await.unwrap(); - let (reader, writer) = stream.into_split(); - let mut reader = JsonLineReader::new(reader); - let mut writer = JsonLineWriter::new(writer); - - writer - .write(&Method::WorkerEvent(protocol::WorkerEvent::TurnEnded { - worker_name: "child".into(), - })) + assert!( + drain_until( + &mut events, + std::time::Duration::from_secs(2), + |event| matches!( + event, + Event::Error { code: worker::ErrorCode::InvalidRequest, message } + if message.contains("missing-peer") && message.contains("does not exist") + ) + ) .await + ); + assert!(store.read_by_name("missing-peer").unwrap().is_none()); + assert!( + store + .read_by_name("test-worker") + .unwrap() + .unwrap() + .peers + .is_empty() + ); +} + +#[tokio::test] +async fn legacy_process_discovery_methods_require_host_or_runtime_authority() { + let worker = make_worker(MockClient::new(simple_text_events())).await; + let handle = spawn_controller(worker).await; + let mut events = handle.subscribe(); + for method in [ + Method::ListWorkers, + Method::RestoreWorker { + name: "old-worker".into(), + }, + ] { + handle.send(method).await.unwrap(); + assert!( + drain_until( + &mut events, + std::time::Duration::from_secs(2), + |event| matches!( + event, + Event::Error { code: worker::ErrorCode::InvalidRequest, message } + if message.contains("Host or Runtime") + ) + ) + .await + ); + } + assert_eq!(handle.shared_state.catalog_status(), WorkerStatus::Idle); +} + +#[tokio::test] +async fn all_controller_launch_modes_leave_external_transport_to_the_host() { + let root = tempfile::tempdir().unwrap(); + for mode in ["direct", "runtime", "run"] { + let worker = make_worker(MockClient::new(simple_text_events())).await; + let base = root.path().join(mode); + let (handle, shutdown) = match mode { + "direct" => WorkerController::spawn(worker, &base).await, + "runtime" => WorkerController::spawn_runtime_managed(worker, &base).await, + "run" => { + WorkerController::spawn_runtime_managed_run(worker, &base.join("runs/1")).await + } + _ => unreachable!(), + } .unwrap(); - - let mut saw_worker_event_echo = false; - let mut saw_turn_start = false; - let mut saw_turn_end = false; - - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); - // The SystemItem and TurnEnd events arrive through independent - // broadcast lanes (sink fan-out vs `working_event_tx`), so their relative - // order on the wire is non-deterministic. Keep reading until both - // are observed (or the deadline trips), rather than breaking on - // the first TurnEnd. - loop { - if saw_worker_event_echo && saw_turn_end { - break; - } - tokio::select! { - event = reader.next::() => { - match event { - Ok(Some(Event::SystemItem { ref item, .. })) - if item.get("kind").and_then(|k| k.as_str()) == Some("worker_event") - && item - .pointer("/presentation/event/worker_name") - .and_then(|v| v.as_str()) == Some("child") => - { - saw_worker_event_echo = true; - } - Ok(Some(Event::TurnStart { .. })) => saw_turn_start = true, - Ok(Some(Event::TurnEnd { .. })) => { - saw_turn_end = true; - } - Ok(None) | Err(_) => break, - _ => {} - } - } - _ = tokio::time::sleep_until(deadline) => break, - } - } - - assert!( - saw_worker_event_echo, - "WorkerEvent::TurnEnded via socket should be echoed as Event::SystemItem(WorkerEvent)" - ); - assert!( - saw_turn_start, - "WorkerEvent::TurnEnded via socket should auto-start a turn" - ); - assert!( - saw_turn_end, - "auto-triggered turn should reach turn_end via socket" - ); -} - -async fn socket_error_after_method_line( - handle: &WorkerHandle, - line: &[u8], -) -> (worker::ErrorCode, String) { - use protocol::stream::JsonLineReader; - use tokio::io::AsyncWriteExt; - use tokio::net::UnixStream; - - let sock_path = handle.runtime_dir.socket_path(); - let stream = UnixStream::connect(&sock_path).await.unwrap(); - let (reader, mut writer) = stream.into_split(); - let mut reader = JsonLineReader::new(reader); - - writer.write_all(line).await.unwrap(); - - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1); - loop { - tokio::select! { - event = reader.next::() => { - match event { - Ok(Some(Event::Error { code, message })) => return (code, message), - Ok(Some(_)) => {} - Ok(None) => panic!("socket closed before invalid-method error"), - Err(e) => panic!("socket read failed before invalid-method error: {e}"), - } - } - _ = tokio::time::sleep_until(deadline) => { - panic!("timed out waiting for invalid-method error") - } - } - } -} - -#[tokio::test] -async fn socket_schema_invalid_method_returns_error() { - let client = MockClient::new(simple_text_events()); - let worker = make_worker(client).await; - let handle = spawn_controller(worker).await; - - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - - let (code, message) = socket_error_after_method_line(&handle, b"{\"bad\":\"json\"}\n").await; - - assert_eq!(code, worker::ErrorCode::InvalidRequest); - assert!( - message.contains("invalid method"), - "expected invalid-method diagnostic, got: {message}" - ); -} - -#[tokio::test] -async fn socket_malformed_method_returns_error() { - let client = MockClient::new(simple_text_events()); - let worker = make_worker(client).await; - let handle = spawn_controller(worker).await; - - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - - let (code, message) = socket_error_after_method_line(&handle, b"{not-json}\n").await; - - assert_eq!(code, worker::ErrorCode::InvalidRequest); - assert!( - message.contains("invalid method"), - "expected invalid-method diagnostic, got: {message}" - ); -} - -#[tokio::test] -async fn socket_peer_close_without_method_does_not_broadcast_error() { - use protocol::stream::JsonLineReader; - use tokio::net::UnixStream; - - let client = MockClient::new(simple_text_events()); - let worker = make_worker(client).await; - let handle = spawn_controller(worker).await; - - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - - let mut broadcast_rx = handle.subscribe(); - let sock_path = handle.runtime_dir.socket_path(); - let stream = UnixStream::connect(&sock_path).await.unwrap(); - let (reader, writer) = stream.into_split(); - let mut reader = JsonLineReader::new(reader); - - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1); - loop { - tokio::select! { - event = reader.next::() => { - match event { - Ok(Some(Event::Snapshot { .. })) => break, - Ok(Some(_)) => {} - Ok(None) => panic!("socket closed before connect-time snapshot"), - Err(e) => panic!("socket read failed before connect-time snapshot: {e}"), - } - } - _ = tokio::time::sleep_until(deadline) => { - panic!("timed out waiting for connect-time snapshot") - } - } - } - - drop(writer); - drop(reader); - - let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(200); - loop { - tokio::select! { - event = broadcast_rx.recv() => { - match event { - Ok(Event::Error { code, message }) => { - panic!("peer close without Method broadcast error {code:?}: {message}") - } - Ok(_) => {} - Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - panic!("broadcast receiver lagged while checking peer close: {n}") - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, - } - } - _ = tokio::time::sleep_until(deadline) => break, + for legacy in ["pid", "status.json", "manifest.toml", "sock", "worker.sock"] { + assert!( + !handle.runtime_dir.path().join(legacy).exists(), + "{mode} created {legacy}" + ); } + handle + .send(Method::Shutdown { + command: worker_command(&handle), + }) + .await + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(5), shutdown) + .await + .unwrap() + .unwrap(); } } @@ -4006,27 +3839,15 @@ fn item_text_contains(item: &Item, needle: &str) -> bool { } async fn snapshot_contains_user_input(handle: &WorkerHandle, needle: &str) -> bool { - let stream = tokio::net::UnixStream::connect(handle.runtime_dir.socket_path()) - .await - .unwrap(); - let (reader, _writer) = stream.into_split(); - let mut reader = protocol::stream::JsonLineReader::new(reader); - - loop { - let event = reader.next::().await.unwrap().unwrap(); - match event { - Event::Snapshot { session, .. } => { - return session.entries.into_iter().any(|entry| match entry.data { - protocol::SessionSnapshotEntryData::UserInput { segments } => { - protocol::Segment::flatten_to_text(&segments).contains(needle) - } - _ => false, - }); - } - Event::Alert(_) => continue, - other => panic!("expected Snapshot first, got {other:?}"), + let Event::Snapshot { session, .. } = handle.snapshot_event() else { + panic!("expected snapshot"); + }; + session.entries.into_iter().any(|entry| match entry.data { + protocol::SessionSnapshotEntryData::UserInput { segments } => { + protocol::Segment::flatten_to_text(&segments).contains(needle) } - } + _ => false, + }) } #[tokio::test] diff --git a/crates/worker/tests/worker_events_test.rs b/crates/worker/tests/worker_events_test.rs index 8e6e7c734..cce0893f2 100644 --- a/crates/worker/tests/worker_events_test.rs +++ b/crates/worker/tests/worker_events_test.rs @@ -1,15 +1,10 @@ -//! Legacy process callback events are diagnostics only after Internal SubWorker migration. +//! Agent-visible Internal SubWorker event summaries. -use std::sync::Arc; - -use protocol::{Permission, ScopeRule, WorkerEvent}; -use tempfile::TempDir; -use worker::ipc::event::{apply_event_side_effects, render_event}; -use worker::runtime::dir::RuntimeDir; -use worker::spawn::registry::SpawnedWorkerRegistry; +use protocol::WorkerEvent; +use worker::ipc::event::render_event; #[test] -fn render_event_keeps_bounded_legacy_diagnostics() { +fn render_event_identifies_child_and_failure() { let rendered = render_event(&WorkerEvent::Errored { worker_name: "legacy-child".into(), message: "boom".into(), @@ -17,30 +12,3 @@ fn render_event_keeps_bounded_legacy_diagnostics() { assert!(rendered.contains("legacy-child")); assert!(rendered.contains("boom")); } - -#[tokio::test] -async fn legacy_callback_cannot_register_process_subworker_authority() { - let runtime_base = TempDir::new().unwrap(); - let runtime_dir = Arc::new( - RuntimeDir::create(runtime_base.path(), "parent") - .await - .unwrap(), - ); - let registry = SpawnedWorkerRegistry::new(runtime_dir.clone()); - let scope_root = TempDir::new().unwrap(); - let event = WorkerEvent::ScopeSubDelegated { - parent_worker: "legacy-parent".into(), - sub_worker: "legacy-child".into(), - sub_socket: "/tmp/legacy-child.sock".into(), - scope: vec![ScopeRule { - target: scope_root.path().to_path_buf(), - permission: Permission::Write, - recursive: true, - symlink_policy: Default::default(), - }], - }; - - apply_event_side_effects(&event, ®istry, "parent", &None).await; - - assert!(!runtime_dir.path().join("spawned_workers.json").exists()); -} diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index f9377801c..9461b46ea 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -4169,7 +4169,6 @@ impl WorkspaceApi { .with_runtime_request_source(&embedded_identity, embedded_request_audience), ) .with_runtime_store_dir(config.embedded_runtime_store_root.clone()) - .with_controller_transport(worker::WorkerControllerTransport::InProcess) .with_resource_client(Arc::new(resource_broker.clone())), ) .map_err(|err| { diff --git a/crates/workspace-server/src/server_assignment_attention_tests.rs b/crates/workspace-server/src/server_assignment_attention_tests.rs index ae622fe8b..101afdd5e 100644 --- a/crates/workspace-server/src/server_assignment_attention_tests.rs +++ b/crates/workspace-server/src/server_assignment_attention_tests.rs @@ -467,7 +467,6 @@ impl AttentionWorkerFactory { worker::WorkerBootstrapLayout::RuntimeManagedRun { run_dir: root.join(uuid::Uuid::now_v7().to_string()), }, - worker::WorkerControllerTransport::InProcess, ) .start() .await diff --git a/crates/workspace-server/src/server_assignment_delivery_tests.rs b/crates/workspace-server/src/server_assignment_delivery_tests.rs index af1948942..4cdf7c351 100644 --- a/crates/workspace-server/src/server_assignment_delivery_tests.rs +++ b/crates/workspace-server/src/server_assignment_delivery_tests.rs @@ -800,7 +800,6 @@ impl DeliveryWorkerFactory { worker::WorkerBootstrapLayout::RuntimeManagedRun { run_dir: root.join(uuid::Uuid::now_v7().to_string()), }, - worker::WorkerControllerTransport::InProcess, ) .with_model_client(self.client.clone()); let prepared = if restore { diff --git a/crates/yoi/Cargo.toml b/crates/yoi/Cargo.toml index b951864da..37349b4af 100644 --- a/crates/yoi/Cargo.toml +++ b/crates/yoi/Cargo.toml @@ -15,7 +15,7 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] } client = { workspace = true } futures = { workspace = true } manifest = { workspace = true } -worker = { workspace = true } +protocol = { workspace = true } server-api = { workspace = true } workdir = { workspace = true } tokio-tungstenite = { workspace = true } diff --git a/crates/yoi/src/cli_connection.rs b/crates/yoi/src/cli_connection.rs index 573eeafd9..109dd8aa2 100644 --- a/crates/yoi/src/cli_connection.rs +++ b/crates/yoi/src/cli_connection.rs @@ -25,7 +25,7 @@ pub(crate) enum CliCommand { Panel, Keys, SetupModel, - WorkerRuntime, + Serve, WorkerCleanup, Ticket, Objective, @@ -45,7 +45,7 @@ impl CliCommand { CliCommand::Panel => "yoi panel", CliCommand::Keys => "yoi keys", CliCommand::SetupModel => "yoi setup-model", - CliCommand::WorkerRuntime => "yoi worker", + CliCommand::Serve => "yoi --local serve", CliCommand::WorkerCleanup => "yoi worker management", CliCommand::Ticket => "yoi ticket", CliCommand::Objective => "yoi objective", @@ -68,7 +68,7 @@ impl CliCommand { CliCommand::Login => CliConnectionRequirement::BackendOnly, CliCommand::Keys | CliCommand::SetupModel - | CliCommand::WorkerRuntime + | CliCommand::Serve | CliCommand::WorkerCleanup | CliCommand::Plugin | CliCommand::Mcp @@ -325,7 +325,7 @@ mod tests { for command in [ CliCommand::Keys, CliCommand::SetupModel, - CliCommand::WorkerRuntime, + CliCommand::Serve, CliCommand::WorkerCleanup, CliCommand::Plugin, CliCommand::Mcp, diff --git a/crates/yoi/src/local_cli.rs b/crates/yoi/src/local_cli.rs new file mode 100644 index 000000000..b4af7a53a --- /dev/null +++ b/crates/yoi/src/local_cli.rs @@ -0,0 +1,273 @@ +//! Explicit Standalone socket commands; launch and transport remain Host-owned. + +use std::path::PathBuf; + +use super::{ParseError, required_option_value}; + +pub(crate) const SERVE_HELP: &str = r#"yoi --local serve + +Usage: + yoi --local serve --socket [--profile ] [--workspace ] + yoi --local serve --socket --worker-id + +Run a foreground Standalone Host without a TUI. New Workers use the same Profile +resolution and saved Worker store as `yoi --local`. --worker-id restores a saved +Standalone Worker with its saved Profile and working directory; it is not a Runtime id. + +Options: + --socket Host-owned Unix endpoint (required) + --profile Profile for a new Worker; conflicts with --worker-id + --workspace Working directory for a new Worker (defaults to cwd) + --worker-id Restore a saved Standalone Worker + -h, --help Print help + +Connect with `yoi --local attach --socket `. The endpoint parent must already +be a private directory owned by this Unix user (mode 0700). Existing endpoints are +never replaced or auto-removed as stale; only same-uid clients are admitted. +Client EOF/disconnect leaves this Host running. Send protocol Method::Shutdown to +explicitly stop the Host, or use SIGINT/SIGTERM for confirmed Host shutdown. +"#; + +pub(crate) const ATTACH_HELP: &str = r#"yoi --local attach + +Usage: + yoi --local attach --socket + +Connect stdin/stdout to an existing Standalone Host: one protocol Method JSON +object per stdin line, one protocol Event JSON object per stdout line (JSONL). +EOF disconnects this client without stopping the Host. Method::Shutdown explicitly +stops the Host. No TUI, Worker spawn, or Backend/Runtime selection is performed. + +Options: + --socket Host-owned Unix endpoint (required) + -h, --help Print help +"#; + +#[derive(Debug)] +pub(crate) enum LocalCli { + Help(&'static str), + Serve(ServeOptions), + Attach(PathBuf), +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct ServeOptions { + pub endpoint: PathBuf, + pub workspace: Option, + pub profile: Option, + pub restore: Option, +} + +pub(crate) fn parse(command: &str, args: &[String]) -> Result { + let serve = command == "serve"; + let mut endpoint = None; + let mut workspace = None; + let mut profile = None; + let mut restore = None; + let mut i = 0; + while i < args.len() { + let arg = &args[i]; + if matches!(arg.as_str(), "--help" | "-h") { + return Ok(LocalCli::Help(if serve { SERVE_HELP } else { ATTACH_HELP })); + } + let (option, inline) = arg + .split_once('=') + .map_or((arg.as_str(), None), |(key, value)| (key, Some(value))); + if !matches!(option, "--socket") + && !(serve && matches!(option, "--workspace" | "--profile" | "--worker-id")) + { + return Err(ParseError(format!("unknown yoi {command} option `{arg}`"))); + } + let value = if let Some(value) = inline { + if value.is_empty() { + return Err(ParseError(format!("{option} requires a value"))); + } + i += 1; + value + } else { + let value = required_option_value(args, i, option)?; + i += 2; + value + }; + match option { + "--socket" => set_once(&mut endpoint, PathBuf::from(value), option)?, + "--workspace" => set_once(&mut workspace, PathBuf::from(value), option)?, + "--profile" => set_once(&mut profile, value.to_owned(), option)?, + "--worker-id" => { + let id = value.parse().map_err(|_| { + ParseError("--worker-id requires a Standalone Worker UUID".to_owned()) + })?; + set_once(&mut restore, id, option)?; + } + _ => unreachable!("validated option"), + } + } + let endpoint = endpoint + .ok_or_else(|| ParseError(format!("yoi --local {command} requires --socket ")))?; + if !serve { + return Ok(LocalCli::Attach(endpoint)); + } + if restore.is_some() && (profile.is_some() || workspace.is_some()) { + return Err(ParseError( + "--worker-id restores the saved Profile and cwd; it cannot be combined with --profile or --workspace" + .to_owned(), + )); + } + Ok(LocalCli::Serve(ServeOptions { + endpoint, + workspace, + profile, + restore, + })) +} + +fn set_once(slot: &mut Option, value: T, option: &str) -> Result<(), ParseError> { + if slot.is_some() { + return Err(ParseError(format!("{option} may only be provided once"))); + } + *slot = Some(value); + Ok(()) +} + +pub(crate) async fn run_serve( + options: ServeOptions, + state_dir: PathBuf, +) -> Result<(), Box> { + let launch = if let Some(worker_id) = options.restore { + standalone::HeadlessLaunch::Restore { + state_dir, + worker_id, + } + } else { + let workspace = options.workspace.map_or_else(std::env::current_dir, Ok)?; + let profile = options + .profile + .map_or(manifest::ProfileSelector::Default, |value| { + manifest::ProfileSelector::parse_cli(&value) + }); + let launch = + standalone::StandaloneLaunchConfig::new(workspace, state_dir, profile, "local") + .resolve()?; + standalone::HeadlessLaunch::Fresh(launch) + }; + standalone::run_headless(launch, options.endpoint).await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_strings(command: &str, args: &[&str]) -> Result { + parse( + command, + &args.iter().map(|arg| (*arg).to_owned()).collect::>(), + ) + } + + #[test] + fn serve_parses_fresh_launch_and_saved_worker_restore() { + let LocalCli::Serve(options) = parse_strings( + "serve", + &[ + "--socket", + "/tmp/host.sock", + "--profile=builtin:standalone", + "--workspace", + "/work", + ], + ) + .unwrap() else { + panic!("expected serve") + }; + assert_eq!(options.endpoint, PathBuf::from("/tmp/host.sock")); + assert_eq!(options.workspace, Some(PathBuf::from("/work"))); + assert_eq!(options.profile.as_deref(), Some("builtin:standalone")); + assert_eq!(options.restore, None); + + let id = "01900000-0000-7000-8000-000000000001"; + let LocalCli::Serve(options) = + parse_strings("serve", &["--socket=/tmp/host.sock", "--worker-id", id]).unwrap() + else { + panic!("expected serve") + }; + assert_eq!(options.restore.unwrap().to_string(), id); + } + + #[test] + fn serve_rejects_invalid_restore_or_launch_overrides() { + for args in [ + vec!["--worker-id", "not-a-uuid"], + vec![ + "--worker-id", + "01900000-0000-7000-8000-000000000001", + "--profile", + "standalone", + ], + vec![ + "--worker-id", + "01900000-0000-7000-8000-000000000001", + "--workspace", + "/work", + ], + vec!["--runtime-id", "runtime"], + vec!["--backend=http://backend.example"], + vec!["--subject", "subject"], + ] { + let input = [vec!["--socket", "/tmp/host.sock"], args].concat(); + assert!(parse_strings("serve", &input).is_err(), "{input:?}"); + } + } + + #[test] + fn attach_accepts_only_endpoint_options() { + for args in [ + vec!["--socket", "/tmp/host.sock"], + vec!["--socket=/tmp/host.sock"], + ] { + let LocalCli::Attach(endpoint) = parse_strings("attach", &args).unwrap() else { + panic!("expected attach") + }; + assert_eq!(endpoint, PathBuf::from("/tmp/host.sock")); + } + for args in [ + vec!["--profile", "standalone"], + vec!["--workspace", "/work"], + vec!["--worker-id", "01900000-0000-7000-8000-000000000001"], + vec!["--backend", "http://backend.example"], + ] { + let input = [vec!["--socket", "/tmp/host.sock"], args].concat(); + assert!(parse_strings("attach", &input).is_err(), "{input:?}"); + } + } + + #[test] + fn socket_is_required_nonempty_and_unique() { + for command in ["serve", "attach"] { + for args in [ + vec![], + vec!["--socket"], + vec!["--socket="], + vec!["--socket", ""], + vec!["--socket", "--profile"], + vec!["--socket=a", "--socket", "b"], + ] { + assert!(parse_strings(command, &args).is_err(), "{command} {args:?}"); + } + } + } + + #[test] + fn help_explains_protocol_disconnect_and_explicit_shutdown() { + for (command, expected) in [("serve", SERVE_HELP), ("attach", ATTACH_HELP)] { + let LocalCli::Help(help) = parse_strings(command, &["--help"]).unwrap() else { + panic!("expected help") + }; + assert_eq!(help, expected); + assert!(help.contains("Method::Shutdown")); + assert!(help.contains("--socket ")); + } + assert!(ATTACH_HELP.contains("JSONL")); + assert!(ATTACH_HELP.contains("EOF disconnects")); + } +} diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 5ec7513ea..12871d0ec 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -4,6 +4,7 @@ //! execution in lower-level crates. mod cli_connection; +mod local_cli; mod mcp_cli; mod objective_cli; mod plugin_cli; @@ -66,7 +67,12 @@ enum Mode { WorkspaceCreate(WorkspaceCreateOptions), WorkdirHelp, WorkdirShare(workdir_share::WorkdirShareOptions), - WorkerRuntime(Vec), + LocalHelp(&'static str), + LocalServe { + options: local_cli::ServeOptions, + state_dir: PathBuf, + }, + LocalAttach(PathBuf), Keys, SetupModel, Tui { @@ -260,7 +266,26 @@ async fn run(mode: Mode) -> ExitCode { } } } - Mode::WorkerRuntime(args) => worker::entrypoint::run_cli_from("yoi worker", args).await, + Mode::LocalHelp(help) => { + print!("{help}"); + ExitCode::SUCCESS + } + Mode::LocalServe { options, state_dir } => { + match local_cli::run_serve(options, state_dir).await { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("yoi --local serve: {error}"); + ExitCode::FAILURE + } + } + } + Mode::LocalAttach(endpoint) => match standalone::run_unix_client(endpoint).await { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("yoi --local attach: {error}"); + ExitCode::FAILURE + } + }, Mode::Keys => tui::keys::launch().await, Mode::SetupModel => tui::setup_model::launch().await, Mode::Tui { @@ -467,6 +492,33 @@ fn parse_args_slice_with_connection_resolver( "workdir" => { return parse_workdir_args(&args[1..], &target_selection); } + "serve" | "attach" => { + if target_selection.backend_url.is_some() || target_selection.workspace_id.is_some() { + return Err(ParseError(format!( + "yoi {} is local-only and does not accept Backend selectors", + args[0] + ))); + } + if !target_selection.explicit_local { + return Err(ParseError(format!( + "yoi {} requires explicit --local selection", + args[0] + ))); + } + return match local_cli::parse(&args[0], &args[1..])? { + local_cli::LocalCli::Help(help) => Ok(Mode::LocalHelp(help)), + local_cli::LocalCli::Attach(endpoint) => Ok(Mode::LocalAttach(endpoint)), + local_cli::LocalCli::Serve(options) => { + let target = + resolve_local_cli_connection(connection_resolver, CliCommand::Serve)?; + let state_dir = target + .spawn_worker() + .map_err(|error| ParseError(error.to_string()))? + .state_dir; + Ok(Mode::LocalServe { options, state_dir }) + } + }; + } "worker" => { if let Some(cli) = worker_cleanup_cli::parse_worker_management_args(&args[1..]) .map_err(|e| ParseError(e.to_string()))? @@ -475,9 +527,9 @@ fn parse_args_slice_with_connection_resolver( resolve_local_cli_connection(connection_resolver, CliCommand::WorkerCleanup)?; return Ok(Mode::WorkerCleanup(cli)); } - let _target = - resolve_local_cli_connection(connection_resolver, CliCommand::WorkerRuntime)?; - return Ok(Mode::WorkerRuntime(args[1..].to_vec())); + return Err(ParseError( + "direct Worker execution was removed; use `yoi --local serve --socket ` (legacy metadata cleanup: `yoi worker --help`)".to_owned(), + )); } "objective" => { let cli = objective_cli::parse_objective_args(&args[1..]) @@ -973,10 +1025,8 @@ fn parse_console_options( ) -> Result { let mut workspace_root = current_dir()?; let mut worker_name = None; - let mut session = None; let mut profile = None; let mut subject_id = None; - let mut socket_override = None; let mut runtime_id = None; let mut worker_id = None; let mut i = 0; @@ -1003,26 +1053,6 @@ fn parse_console_options( workspace_root = PathBuf::from(value); i += 2; } - "--session" => { - let value = args - .get(i + 1) - .ok_or_else(|| ParseError("--session requires a path".to_string()))?; - if value.starts_with('-') || value.is_empty() { - return Err(ParseError("--session requires a path".to_string())); - } - session = Some(PathBuf::from(value)); - i += 2; - } - "--socket" => { - let value = args - .get(i + 1) - .ok_or_else(|| ParseError("--socket requires a path".to_string()))?; - if value.starts_with('-') || value.is_empty() { - return Err(ParseError("--socket requires a path".to_string())); - } - socket_override = Some(PathBuf::from(value)); - i += 2; - } "--subject" => { let value = required_option_value(args, i, "--subject")?; set_subject_selector(&mut subject_id, value)?; @@ -1078,22 +1108,6 @@ fn parse_console_options( workspace_root = PathBuf::from(value); i += 1; } - arg if arg.starts_with("--session=") => { - let value = arg.trim_start_matches("--session="); - if value.is_empty() { - return Err(ParseError("--session requires a path".to_string())); - } - session = Some(PathBuf::from(value)); - i += 1; - } - arg if arg.starts_with("--socket=") => { - let value = arg.trim_start_matches("--socket="); - if value.is_empty() { - return Err(ParseError("--socket requires a path".to_string())); - } - socket_override = Some(PathBuf::from(value)); - i += 1; - } arg if arg.starts_with("--profile=") => { let value = arg.trim_start_matches("--profile="); if value.is_empty() { @@ -1138,11 +1152,7 @@ fn parse_console_options( } if subject_id.is_some() { - if session.is_some() - || socket_override.is_some() - || runtime_id.is_some() - || worker_id.is_some() - { + if runtime_id.is_some() || worker_id.is_some() { return Err(ParseError( "--subject can only be used for fresh local spawn, not attach or restore" .to_owned(), @@ -1160,29 +1170,12 @@ fn parse_console_options( "--worker-id requires --runtime-id for Runtime API attach".to_string(), )); } - if (runtime_id.is_some() || worker_id.is_some()) - && (session.is_some() - || worker_name.is_some() - || socket_override.is_some() - || profile.is_some()) + if (runtime_id.is_some() || worker_id.is_some()) && (worker_name.is_some() || profile.is_some()) { return Err(ParseError( - "Runtime API target cannot be combined with --worker, --socket, --session, or --profile".to_string(), + "Runtime API target cannot be combined with --worker or --profile".to_string(), )); } - if profile.is_some() && (session.is_some() || socket_override.is_some()) { - return Err(ParseError( - "--profile can only be used for fresh spawn".to_string(), - )); - } - if session.is_some() && socket_override.is_some() { - return Err(ParseError( - "--session cannot be combined with --socket".to_string(), - )); - } - if socket_override.is_some() && worker_name.is_none() { - return Err(ParseError("--socket requires --worker".to_string())); - } let target = if subject_id.is_some() { // A local Subject selector must never trigger Backend Workspace discovery. @@ -1215,18 +1208,6 @@ fn parse_console_options( .to_string(), )); } - if session.is_some() { - return Err(ParseError( - "--local does not accept legacy --session; use `yoi --local resume` for Standalone Worker restore" - .to_string(), - )); - } - if socket_override.is_some() { - return Err(ParseError( - "--local uses an in-process Standalone connection and does not accept --socket" - .to_string(), - )); - } } if let (Some(runtime_id), Some(worker_id)) = (runtime_id.clone(), worker_id) { @@ -1253,13 +1234,6 @@ fn parse_console_options( }); } - if target.kind() == TargetKind::Standalone && (session.is_some() || socket_override.is_some()) { - return Err(ParseError( - "Standalone does not accept legacy Worker session or socket selectors; use `yoi --local resume` for the standalone Worker store" - .to_string(), - )); - } - let mode = if target.kind() == TargetKind::Standalone { LaunchMode::Spawn { worker_name, @@ -1267,14 +1241,9 @@ fn parse_console_options( subject_id, } } else { - if worker_name.is_some() - || profile.is_some() - || session.is_some() - || socket_override.is_some() - { + if worker_name.is_some() || profile.is_some() { return Err(ParseError( - "Backend target does not accept host-local Worker, profile, session, or socket selectors" - .to_string(), + "Backend target does not accept host-local Worker or profile selectors".to_string(), )); } LaunchMode::BackendSpawn @@ -2109,7 +2078,9 @@ Host commands: yoi workdir share Interactively share a client-hosted External Workdir. keys Manage local model/API keys setup-model Configure a local model provider - worker [WORKER_OPTIONS] Run the direct Worker process entrypoint + --local serve --socket Run a headless Standalone Host (serve --help for restore) + --local attach --socket Connect a JSONL protocol client; EOF leaves Host running + worker delete/prune Safely clean up legacy Worker metadata only ticket Manage Tickets through a Backend target objective Manage Objectives through a Backend target plugin Author/check/pack explicit Plugin packages @@ -2939,39 +2910,23 @@ backend = "shared" } #[test] - fn parser_standalone_rejects_legacy_session_and_socket_inputs() { - let resolver = DefaultBackendCliConnectionResolver { - backend_url: "http://default-backend.example", - }; - let session_args = ["--local", "--session", "session-1"] - .into_iter() - .map(String::from) - .collect::>(); - let err = parse_args_slice_with_connection_resolver(&session_args, &resolver).unwrap_err(); - assert_eq!( - err.0, - "--local does not accept legacy --session; use `yoi --local resume` for Standalone Worker restore" - ); - - let socket_args = [ - "--local", - "--worker", - "worker-a", - "--socket", - "/tmp/worker.sock", - ] - .into_iter() - .map(String::from) - .collect::>(); - let err = parse_args_slice_with_connection_resolver(&socket_args, &resolver).unwrap_err(); - assert_eq!( - err.0, - "--local uses an in-process Standalone connection and does not accept --socket" - ); + fn console_rejects_removed_session_and_socket_options() { + for prefix in [vec!["--local"], vec!["--backend", "http://backend.example"]] { + for option in [ + "--session", + "--session=log", + "--socket", + "--socket=endpoint", + ] { + let args = [prefix.clone(), vec![option]].concat(); + let error = parse_args_from(args).unwrap_err(); + assert_eq!(error.to_string(), format!("unknown argument: {option}")); + } + } } #[test] - fn parse_backend_runtime_target_rejects_legacy_socket_mix() { + fn backend_runtime_target_rejects_local_launch_options() { let err = parse_args_from([ "--backend", "http://127.0.0.1:8787", @@ -2985,7 +2940,7 @@ backend = "shared" .unwrap_err(); assert_eq!( err.to_string(), - "Runtime API target cannot be combined with --worker, --socket, --session, or --profile" + "Runtime API target cannot be combined with --worker or --profile" ); } @@ -3124,15 +3079,121 @@ backend = "shared" } #[test] - fn parse_worker_subcommand_uses_runtime_mode() { - match parse_args_from(["worker", "--worker", "agent", "--profile", "default"]).unwrap() { - Mode::WorkerRuntime(args) => { - assert_eq!(args, ["--worker", "agent", "--profile", "default"]) - } - _ => panic!("expected WorkerRuntime mode"), + fn direct_worker_execution_is_rejected_without_compatibility_fallback() { + for args in [ + vec!["worker"], + vec!["worker", "--worker", "agent", "--profile", "default"], + ] { + let error = parse_args_from(args).unwrap_err(); + assert!( + error + .to_string() + .contains("direct Worker execution was removed") + ); + assert!(error.to_string().contains("yoi --local serve --socket")); } } + #[test] + fn socket_commands_require_explicit_local_and_reject_backend_selection() { + let resolver = DefaultBackendCliConnectionResolver { + backend_url: "http://backend.example", + }; + for command in ["serve", "attach"] { + for args in [ + vec![command, "--socket", "/tmp/host.sock"], + vec![ + "--backend=http://backend.example", + command, + "--socket=/tmp/host.sock", + ], + vec![ + "--local", + "--workspace-id=workspace", + command, + "--socket=/tmp/host.sock", + ], + ] { + let input = args.iter().map(|arg| (*arg).to_owned()).collect::>(); + assert!( + parse_args_slice_with_connection_resolver(&input, &resolver).is_err(), + "{input:?}" + ); + } + } + } + + #[test] + fn socket_commands_dispatch_without_backend_default_or_workspace_discovery() { + let resolver = DefaultBackendCliConnectionResolver { + backend_url: "http://backend.example", + }; + let args = [ + "--local", + "serve", + "--socket=/tmp/host.sock", + "--profile=builtin:standalone", + ] + .into_iter() + .map(String::from) + .collect::>(); + let Mode::LocalServe { options, state_dir } = + parse_args_slice_with_connection_resolver(&args, &resolver).unwrap() + else { + panic!("expected headless serve") + }; + assert_eq!(options.profile.as_deref(), Some("builtin:standalone")); + assert!(state_dir.is_absolute()); + + let args = ["--local", "attach", "--socket=/tmp/host.sock"] + .into_iter() + .map(String::from) + .collect::>(); + let Mode::LocalAttach(endpoint) = + parse_args_slice_with_connection_resolver(&args, &resolver).unwrap() + else { + panic!("expected attach") + }; + assert_eq!(endpoint, PathBuf::from("/tmp/host.sock")); + } + + #[test] + fn socket_attach_and_help_do_not_require_worker_state_resolution() { + struct UnavailableWorkerState; + impl CliConnectionResolver for UnavailableWorkerState { + fn resolve_connection( + &self, + _command: CliCommand, + _input: CliConnectionInput<'_>, + ) -> Result, ParseError> { + Err(ParseError( + "Standalone state directory is unavailable".to_owned(), + )) + } + } + let resolver = UnavailableWorkerState; + for command in ["serve", "attach"] { + let args = ["--local", command, "--help"] + .into_iter() + .map(String::from) + .collect::>(); + assert!(matches!( + parse_args_slice_with_connection_resolver(&args, &resolver).unwrap(), + Mode::LocalHelp(_) + )); + } + let args = ["--local", "attach", "--socket=/tmp/host.sock"] + .into_iter() + .map(String::from) + .collect::>(); + assert!(matches!( + parse_args_slice_with_connection_resolver(&args, &resolver).unwrap(), + Mode::LocalAttach(_) + )); + assert!(TOP_LEVEL_HELP.contains("--local serve --socket ")); + assert!(TOP_LEVEL_HELP.contains("--local attach --socket ")); + } + #[test] fn parse_worker_delete_uses_cleanup_mode() { match parse_args_from(["worker", "delete", "agent", "--dry-run"]).unwrap() { @@ -3880,36 +3941,6 @@ backend = "shared" } } - #[test] - fn parse_profile_rejects_resume_attach_modes() { - let segment_id = session_store::new_segment_id().to_string(); - let cases = [ - ( - vec![ - "--profile".to_string(), - "p.toml".to_string(), - "--session".to_string(), - segment_id, - ], - "--profile can only be used for fresh spawn", - ), - ( - vec![ - "--profile".to_string(), - "p.toml".to_string(), - "--socket".to_string(), - "/tmp/yoi/sock".to_string(), - ], - "--profile can only be used for fresh spawn", - ), - ]; - - for (args, message) in cases { - let err = parse_args_from(args).unwrap_err(); - assert_eq!(err.to_string(), message); - } - } - #[test] fn parse_dashboard_word_is_not_an_alias_or_worker_name() { let err = parse_args_from(["dashboard"]).unwrap_err(); diff --git a/crates/yoi/src/worker_cleanup_cli.rs b/crates/yoi/src/worker_cleanup_cli.rs index 54d1eddbb..69bcd3766 100644 --- a/crates/yoi/src/worker_cleanup_cli.rs +++ b/crates/yoi/src/worker_cleanup_cli.rs @@ -1,3 +1,8 @@ +//! Cleanup of legacy name-keyed Worker metadata, not Standalone execution state. +//! +//! Historical socket probes only refuse unsafe deletion; they never discover or +//! attach Workers for execution. Session logs and shared history remain untouched. + use std::fmt; use std::io; use std::path::{Path, PathBuf}; @@ -484,7 +489,7 @@ fn to_error(error: E) -> WorkerCleanupCliError { } pub fn help_text() -> &'static str { - "yoi worker\n\nUsage:\n yoi worker delete [--force] [--dry-run]\n yoi worker prune --older-than [--force] [--dry-run]\n yoi worker [WORKER_OPTIONS]\n\nDescription:\n delete/prune are safe Worker metadata cleanup commands. `worker delete` removes only name-keyed Worker metadata and never removes session logs/history. Live or uncertain Worker liveness is refused. Without --force the command reports only.\n\nDuration units: s, m, h, d, w\n\nOptions:\n --force Perform deletion after safety checks\n --dry-run Report only, even with --force\n --older-than Required explicit age threshold for prune\n -h, --help Print help\n" + "yoi worker\n\nUsage:\n yoi worker delete [--force] [--dry-run]\n yoi worker prune --older-than [--force] [--dry-run]\n\nDescription:\n delete/prune clean up legacy name-keyed Worker metadata only; they do not start or restore Workers. Use `yoi --local serve --socket ` for a headless Standalone Host. `worker delete` removes only name-keyed Worker metadata and never removes session logs/history. Live or uncertain Worker liveness is refused. Without --force the command reports only.\n\nDuration units: s, m, h, d, w\n\nOptions:\n --force Perform deletion after safety checks\n --dry-run Report only, even with --force\n --older-than Required explicit age threshold for prune\n -h, --help Print help\n" } fn delete_help_text() -> &'static str {