From 4d9b211d69cdab67ae9c95ffef15f9bd28f8f11a Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 30 Aug 2026 11:29:59 +0900 Subject: [PATCH] feat: add in-process standalone worker host --- Cargo.lock | 17 +++ Cargo.toml | 3 + crates/standalone/Cargo.toml | 22 +++ crates/standalone/src/host.rs | 154 +++++++++++++++++++ crates/standalone/src/launch.rs | 86 +++++++++++ crates/standalone/src/lib.rs | 13 ++ crates/standalone/tests/host.rs | 212 +++++++++++++++++++++++++++ crates/worker/src/bootstrap.rs | 157 ++++++++++++++++++++ crates/worker/src/controller.rs | 14 ++ crates/worker/src/entrypoint.rs | 17 ++- crates/worker/src/lib.rs | 5 + crates/worker/src/worker.rs | 49 ++++++- docs/README.md | 1 + docs/design/standalone-agent-host.md | 38 +++++ 14 files changed, 782 insertions(+), 6 deletions(-) create mode 100644 crates/standalone/Cargo.toml create mode 100644 crates/standalone/src/host.rs create mode 100644 crates/standalone/src/launch.rs create mode 100644 crates/standalone/src/lib.rs create mode 100644 crates/standalone/tests/host.rs create mode 100644 crates/worker/src/bootstrap.rs create mode 100644 docs/design/standalone-agent-host.md diff --git a/Cargo.lock b/Cargo.lock index 7a313ade..7b6397bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4616,6 +4616,23 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "standalone" +version = "0.1.0" +dependencies = [ + "agen", + "async-trait", + "futures", + "manifest", + "protocol", + "session-store", + "tempfile", + "thiserror 2.0.18", + "tokio", + "uuid", + "worker", +] + [[package]] name = "static_assertions" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index f6e03a24..36789a2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/agen", "crates/agen-macros", "crates/session-store", + "crates/standalone", "crates/secrets", "crates/manifest", "crates/mcp", @@ -36,6 +37,7 @@ default-members = [ "crates/agen", "crates/agen-macros", "crates/session-store", + "crates/standalone", "crates/secrets", "crates/manifest", "crates/mcp", @@ -87,6 +89,7 @@ protocol = { path = "crates/protocol" } session-metrics = { path = "crates/session-metrics" } session-analytics = { path = "crates/session-analytics" } session-store = { path = "crates/session-store" } +standalone = { path = "crates/standalone" } secrets = { path = "crates/secrets" } tools = { path = "crates/tools" } config-source = { path = "crates/config-source" } diff --git a/crates/standalone/Cargo.toml b/crates/standalone/Cargo.toml new file mode 100644 index 00000000..d23af099 --- /dev/null +++ b/crates/standalone/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "standalone" +description = "In-process standalone Worker host" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +agen.workspace = true +manifest.workspace = true +protocol.workspace = true +session-store.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["rt", "sync", "time"] } +worker.workspace = true + +[dev-dependencies] +async-trait.workspace = true +futures.workspace = true +tempfile.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } +uuid = { workspace = true, features = ["v7"] } diff --git a/crates/standalone/src/host.rs b/crates/standalone/src/host.rs new file mode 100644 index 00000000..d0bbdd35 --- /dev/null +++ b/crates/standalone/src/host.rs @@ -0,0 +1,154 @@ +use std::time::Duration; + +use agen::llm_client::client::LlmClient; +use protocol::{Event, Method}; +use session_store::{CombinedStore, FsStore, FsWorkerStore}; +use thiserror::Error; +use tokio::sync::broadcast; +use worker::bootstrap::{WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout}; +use worker::controller::WorkerControllerTransport; +use worker::{WorkerError, WorkerFilesystemAuthority, WorkerWorkspaceContext}; + +use crate::launch::ResolvedStandaloneLaunch; + +const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10); + +/// One top-level Worker plus its process-owned local Workdir session. +/// +/// The host deliberately exposes the existing typed Worker protocol rather +/// than owning an HTTP/WebSocket server or a second execution model. +pub struct StandaloneHost { + handle: worker::WorkerHandle, + shutdown: worker::controller::ShutdownReceiver, + shutdown_timeout: Duration, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum StandaloneStartupError { + #[error("the standalone state store could not be opened")] + StateStore, + #[error("the resolved Worker configuration is invalid")] + WorkerConfiguration, + #[error("the configured model provider is unavailable")] + ModelProvider, + #[error("the fixed standalone feature composition could not be installed")] + FeatureComposition, + #[error("the in-process Worker controller could not start")] + Controller, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum StandaloneRequestError { + #[error("the standalone Worker is no longer accepting requests")] + WorkerUnavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum StandaloneShutdownError { + #[error("the standalone Worker did not stop before the shutdown deadline")] + DeadlineExceeded, + #[error("the standalone Worker shutdown confirmation was lost")] + ConfirmationLost, +} + +impl StandaloneHost { + pub async fn start(launch: ResolvedStandaloneLaunch) -> Result { + Self::start_with_optional_model_client(launch, None).await + } + + pub async fn start_with_model_client( + launch: ResolvedStandaloneLaunch, + model_client: C, + ) -> Result + where + C: LlmClient + 'static, + { + Self::start_with_optional_model_client(launch, Some(Box::new(model_client))).await + } + + async fn start_with_optional_model_client( + launch: ResolvedStandaloneLaunch, + model_client: Option>, + ) -> Result { + std::fs::create_dir_all(&launch.state_dir) + .map_err(|_| StandaloneStartupError::StateStore)?; + let session_store = FsStore::new(launch.state_dir.join("sessions")) + .map_err(|_| StandaloneStartupError::StateStore)?; + let worker_store = FsWorkerStore::new(launch.state_dir.join("workers")) + .map_err(|_| StandaloneStartupError::StateStore)?; + let store = CombinedStore::new(session_store, worker_store); + let filesystem_authority = + WorkerFilesystemAuthority::local(launch.cwd.clone(), launch.cwd.clone()); + let workspace_context = WorkerWorkspaceContext::local_filesystem(None); + let runtime_base = launch.state_dir.join("runtime"); + + let mut bootstrap = WorkerBootstrap::new( + launch.profile.manifest, + store, + launch.prompt_catalog, + workspace_context, + filesystem_authority, + WorkerBootstrapLayout::Direct { runtime_base }, + WorkerControllerTransport::InProcess, + ); + if let Some(model_client) = model_client { + bootstrap = bootstrap.with_model_client(model_client); + } + let started = bootstrap.start().await.map_err(classify_startup_error)?; + + Ok(Self { + handle: started.handle, + shutdown: started.shutdown, + shutdown_timeout: DEFAULT_SHUTDOWN_TIMEOUT, + }) + } + + pub async fn send(&self, method: Method) -> Result<(), StandaloneRequestError> { + self.handle + .send(method) + .await + .map_err(|_| StandaloneRequestError::WorkerUnavailable) + } + + pub fn subscribe(&self) -> broadcast::Receiver { + self.handle.subscribe() + } + + pub fn snapshot(&self) -> Event { + self.handle.snapshot_event() + } + + pub fn with_shutdown_timeout(mut self, shutdown_timeout: Duration) -> Self { + self.shutdown_timeout = shutdown_timeout; + self + } + + pub async fn shutdown(self) -> Result<(), StandaloneShutdownError> { + let StandaloneHost { + handle, + shutdown, + shutdown_timeout, + } = self; + let _ = handle.send(Method::Shutdown).await; + match tokio::time::timeout(shutdown_timeout, shutdown).await { + Ok(Ok(())) => Ok(()), + Ok(Err(_)) => Err(StandaloneShutdownError::ConfirmationLost), + Err(_) => Err(StandaloneShutdownError::DeadlineExceeded), + } + } +} + +fn classify_startup_error(error: WorkerBootstrapError) -> StandaloneStartupError { + match error { + WorkerBootstrapError::Worker(WorkerError::Provider(_)) => { + StandaloneStartupError::ModelProvider + } + WorkerBootstrapError::Worker(_) => StandaloneStartupError::WorkerConfiguration, + WorkerBootstrapError::Controller { source, .. } + if source.kind() == std::io::ErrorKind::Other => + { + StandaloneStartupError::FeatureComposition + } + WorkerBootstrapError::Controller { .. } => StandaloneStartupError::Controller, + } +} diff --git a/crates/standalone/src/launch.rs b/crates/standalone/src/launch.rs new file mode 100644 index 00000000..621d3f80 --- /dev/null +++ b/crates/standalone/src/launch.rs @@ -0,0 +1,86 @@ +use std::path::{Path, PathBuf}; + +use manifest::{ + ProfileExecutionTarget, ProfileResolveOptions, ProfileResolver, ProfileSelector, + ResolvedProfile, +}; +use thiserror::Error; +use worker::PromptCatalogSource; + +/// Process launch input resolved before any Worker/session side effect occurs. +#[derive(Debug, Clone)] +pub struct StandaloneLaunchConfig { + pub cwd: PathBuf, + pub state_dir: PathBuf, + pub profile: ProfileSelector, + pub worker_name: String, +} + +pub struct ResolvedStandaloneLaunch { + pub cwd: PathBuf, + pub state_dir: PathBuf, + pub profile: ResolvedProfile, + pub prompt_catalog: PromptCatalogSource, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum StandaloneLaunchError { + #[error("the standalone working directory is unavailable")] + WorkingDirectoryUnavailable, + #[error("path-based profiles are not standalone launch authority")] + PathProfileUnsupported, + #[error("the standalone profile could not be resolved")] + ProfileResolutionFailed, +} + +impl StandaloneLaunchConfig { + pub fn new( + cwd: impl Into, + state_dir: impl Into, + profile: ProfileSelector, + worker_name: impl Into, + ) -> Self { + Self { + cwd: cwd.into(), + state_dir: state_dir.into(), + profile, + worker_name: worker_name.into(), + } + } + + /// Resolve only built-in/XDG profile authority and bind standalone scope + /// to the canonical process cwd. Repository-local profile discovery is + /// deliberately not part of this path. + pub fn resolve(self) -> Result { + if matches!(self.profile, ProfileSelector::Path { .. }) { + return Err(StandaloneLaunchError::PathProfileUnsupported); + } + let cwd = canonical_directory(&self.cwd)?; + let profile = ProfileResolver::new() + .with_workspace_base(&cwd) + .resolve_for_target( + &self.profile, + ProfileResolveOptions { + worker_name: Some(self.worker_name), + }, + ProfileExecutionTarget::Standalone, + ) + .map_err(|_| StandaloneLaunchError::ProfileResolutionFailed)?; + + Ok(ResolvedStandaloneLaunch { + cwd, + state_dir: self.state_dir, + profile, + prompt_catalog: PromptCatalogSource::builtins_only(), + }) + } +} + +fn canonical_directory(path: &Path) -> Result { + let path = std::fs::canonicalize(path) + .map_err(|_| StandaloneLaunchError::WorkingDirectoryUnavailable)?; + if !path.is_dir() { + return Err(StandaloneLaunchError::WorkingDirectoryUnavailable); + } + Ok(path) +} diff --git a/crates/standalone/src/lib.rs b/crates/standalone/src/lib.rs new file mode 100644 index 00000000..b0c65331 --- /dev/null +++ b/crates/standalone/src/lib.rs @@ -0,0 +1,13 @@ +//! In-process standalone host for one top-level Yoi Worker. +//! +//! The crate composes existing `worker`, `manifest`, `session-store`, and +//! `workdir` contracts. It intentionally owns no TUI, Runtime, Workspace +//! Server, HTTP, WebSocket, subprocess Worker, or alternative execution path. + +pub mod host; +pub mod launch; + +pub use host::{ + StandaloneHost, StandaloneRequestError, StandaloneShutdownError, StandaloneStartupError, +}; +pub use launch::{ResolvedStandaloneLaunch, StandaloneLaunchConfig, StandaloneLaunchError}; diff --git a/crates/standalone/tests/host.rs b/crates/standalone/tests/host.rs new file mode 100644 index 00000000..157f15e6 --- /dev/null +++ b/crates/standalone/tests/host.rs @@ -0,0 +1,212 @@ +use std::collections::VecDeque; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use agen::llm_client::client::LlmClient; +use agen::llm_client::error::ClientError; +use agen::llm_client::event::{Event as LlmEvent, StopReason}; +use agen::llm_client::types::Request; +use async_trait::async_trait; +use futures::{Stream, stream}; +use protocol::{Event, Method}; +use standalone::{StandaloneHost, StandaloneLaunchConfig}; +use uuid::Uuid; + +#[derive(Clone)] +struct ScriptedClient { + responses: Arc>>>, + requests: Arc>>, +} + +impl ScriptedClient { + fn new(responses: Vec>) -> Self { + Self { + responses: Arc::new(Mutex::new(responses.into())), + requests: Arc::new(Mutex::new(Vec::new())), + } + } + + fn requests(&self) -> Vec { + self.requests.lock().expect("requests lock").clone() + } +} + +#[async_trait] +impl LlmClient for ScriptedClient { + async fn stream( + &self, + request: Request, + ) -> Result> + Send>>, ClientError> + { + self.requests.lock().expect("requests lock").push(request); + let response = self + .responses + .lock() + .expect("responses lock") + .pop_front() + .expect("scripted response"); + Ok(Box::pin(stream::iter(response.into_iter().map(Ok)))) + } + + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } +} + +#[tokio::test] +async fn in_process_host_runs_text_and_read_tool_then_shuts_down() { + let temp = tempfile::tempdir().expect("tempdir"); + std::fs::write(temp.path().join("probe.txt"), "standalone tool evidence\n") + .expect("write probe"); + let worker_name = format!("standalone-{}", Uuid::now_v7()); + let launch = StandaloneLaunchConfig::new( + temp.path(), + temp.path().join("state"), + manifest::ProfileSelector::Default, + &worker_name, + ) + .resolve() + .expect("resolve standalone profile"); + + let client = ScriptedClient::new(vec![ + vec![ + LlmEvent::tool_use_start(0, "read-1", "Read"), + LlmEvent::tool_input_delta(0, r#"{"file_path":"probe.txt"}"#), + LlmEvent::tool_use_stop(0), + ], + vec![ + LlmEvent::text_block_start(0), + LlmEvent::text_delta(0, "standalone response"), + LlmEvent::text_block_stop(0, Some(StopReason::EndTurn)), + ], + ]); + let inspection = client.clone(); + let host = StandaloneHost::start_with_model_client(launch, client) + .await + .expect("start in-process host"); + let mut events = host.subscribe(); + + host.send(Method::run_text("read the probe")) + .await + .expect("submit input"); + + tokio::time::timeout(Duration::from_secs(30), async { + let mut saw_text = false; + let mut saw_tool_result = false; + loop { + match events.recv().await.expect("worker event") { + Event::TextDelta { text } if text.contains("standalone response") => { + saw_text = true; + } + Event::ToolResult { .. } => { + saw_tool_result = true; + } + Event::RunEnd { .. } => { + assert!(saw_text, "stream must expose the model text delta"); + assert!(saw_tool_result, "stream must expose the tool result"); + break; + } + _ => {} + } + } + }) + .await + .expect("run completed"); + + let requests = inspection.requests(); + assert_eq!(requests.len(), 2); + let tool_names = requests[0] + .tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(); + assert!(tool_names.contains(&"Read")); + assert!(tool_names.contains(&"TaskCreate")); + assert!(tool_names.contains(&"SubWorkerSpawn")); + assert!(format!("{:?}", requests[1].items).contains("standalone tool evidence")); + assert!( + !temp + .path() + .join("state/runtime") + .join(&worker_name) + .join("worker.sock") + .exists() + ); + + host.shutdown().await.expect("graceful shutdown"); +} + +#[tokio::test] +async fn state_store_failure_is_redacted_and_starts_no_controller() { + let temp = tempfile::tempdir().expect("tempdir"); + let state_path = temp.path().join("state-file-with-secret-name"); + std::fs::write(&state_path, "not a directory").expect("write blocking file"); + let launch = StandaloneLaunchConfig::new( + temp.path(), + &state_path, + manifest::ProfileSelector::Default, + format!("standalone-failure-{}", Uuid::now_v7()), + ) + .resolve() + .expect("resolve launch"); + let client = ScriptedClient::new(Vec::new()); + + let error = StandaloneHost::start_with_model_client(launch, client) + .await + .err() + .expect("state store startup rejected"); + assert_eq!(error, standalone::StandaloneStartupError::StateStore); + assert_eq!( + error.to_string(), + "the standalone state store could not be opened" + ); + assert!(!error.to_string().contains("secret-name")); + assert!( + !temp + .path() + .join("state-file-with-secret-name/runtime") + .exists() + ); +} + +#[test] +fn standalone_crate_has_no_tui_runtime_or_workspace_server_dependency() { + let manifest = include_str!("../Cargo.toml"); + let dependencies = manifest + .split("[dependencies]") + .nth(1) + .expect("dependencies section") + .split("[dev-dependencies]") + .next() + .expect("dependency body"); + for forbidden in ["tui", "worker-runtime", "yoi-workspace-server"] { + assert!( + !dependencies.lines().any(|line| { + line.split_once('=') + .is_some_and(|(name, _)| name.trim() == forbidden) + }), + "standalone must not depend on {forbidden}" + ); + } +} + +#[test] +fn launch_rejects_path_profile_before_worker_startup() { + let temp = tempfile::tempdir().expect("tempdir"); + let error = StandaloneLaunchConfig::new( + temp.path(), + temp.path().join("state"), + manifest::ProfileSelector::Path { + path: temp.path().join("profile.dcdl"), + }, + "standalone-path-profile", + ) + .resolve() + .err() + .expect("path profile rejected"); + assert_eq!( + error, + standalone::StandaloneLaunchError::PathProfileUnsupported + ); +} diff --git a/crates/worker/src/bootstrap.rs b/crates/worker/src/bootstrap.rs new file mode 100644 index 00000000..fe474b1b --- /dev/null +++ b/crates/worker/src/bootstrap.rs @@ -0,0 +1,157 @@ +use std::path::PathBuf; + +use agen::llm_client::client::LlmClient; +use session_store::{Store, WorkerMetadataStore}; +use thiserror::Error; +use workdir::WorkdirSessionHandle; + +use crate::PromptCatalogSource; +use crate::controller::{ + ShutdownReceiver, WorkerController, WorkerControllerTransport, WorkerHandle, +}; +use crate::worker::{Worker, WorkerError, WorkerFilesystemAuthority, WorkerWorkspaceContext}; +use manifest::WorkerManifest; + +/// Filesystem layout used by a Worker controller started through the reusable +/// bootstrap boundary. +#[derive(Debug, Clone)] +pub enum WorkerBootstrapLayout { + /// A direct Worker rooted below the supplied runtime base directory. + Direct { runtime_base: PathBuf }, + /// A runtime-managed Worker with an exact persisted run directory. + RuntimeManagedRun { run_dir: PathBuf }, +} + +/// Construction and controller inputs that are stable for one Worker launch. +pub struct WorkerBootstrap { + manifest: WorkerManifest, + store: St, + prompt_catalog: PromptCatalogSource, + workspace_context: WorkerWorkspaceContext, + filesystem_authority: WorkerFilesystemAuthority, + layout: WorkerBootstrapLayout, + transport: WorkerControllerTransport, + model_client: Option>, + workdir_session: Option, +} + +/// Live controller returned only after Worker construction and feature +/// installation have completed successfully. +pub struct BootstrappedWorker { + pub handle: WorkerHandle, + pub shutdown: ShutdownReceiver, +} + +#[derive(Debug, Error)] +pub enum WorkerBootstrapError { + #[error("worker construction failed")] + Worker(#[source] WorkerError), + #[error("worker controller startup failed")] + Controller { + #[source] + source: std::io::Error, + cleanup_failed: bool, + }, +} + +impl WorkerBootstrap +where + St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, +{ + #[allow(clippy::too_many_arguments)] + pub fn new( + manifest: WorkerManifest, + store: St, + prompt_catalog: PromptCatalogSource, + workspace_context: WorkerWorkspaceContext, + filesystem_authority: WorkerFilesystemAuthority, + layout: WorkerBootstrapLayout, + transport: WorkerControllerTransport, + ) -> Self { + Self { + manifest, + store, + prompt_catalog, + workspace_context, + filesystem_authority, + layout, + transport, + model_client: None, + workdir_session: None, + } + } + + /// Inject a process-owned model client. This is primarily useful for + /// embedded hosts and deterministic tests that must not start an external + /// model server. + pub fn with_model_client(mut self, model_client: C) -> Self + where + C: LlmClient + 'static, + { + self.model_client = Some(Box::new(model_client)); + self + } + + /// Bind an already materialized Workdir session instead of asking the + /// Worker to derive one from filesystem authority. + pub fn with_workdir_session(mut self, workdir_session: WorkdirSessionHandle) -> Self { + self.workdir_session = Some(workdir_session); + self + } + + pub async fn start(self) -> Result { + let mut worker = Worker::from_manifest_with_context_and_model_client( + self.manifest, + self.store, + self.prompt_catalog, + self.workspace_context, + self.filesystem_authority, + self.model_client, + ) + .await + .map_err(WorkerBootstrapError::Worker)?; + + if let Some(workdir_session) = self.workdir_session { + worker.bind_workdir_session(Some(workdir_session)); + } + start_worker_controller(worker, self.layout, self.transport).await + } +} + +/// Start the shared direct/runtime-managed controller lifecycle for an already +/// constructed Worker. Restore paths use this after replaying durable state; +/// fresh hosts normally use [`WorkerBootstrap::start`]. +pub async fn start_worker_controller( + worker: Worker, + layout: WorkerBootstrapLayout, + transport: WorkerControllerTransport, +) -> Result +where + C: LlmClient + Clone + 'static, + St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, +{ + let cleanup_session = worker.workdir_session().cloned(); + let controller = match layout { + WorkerBootstrapLayout::Direct { runtime_base } => { + WorkerController::spawn_with_transport(worker, &runtime_base, transport).await + } + WorkerBootstrapLayout::RuntimeManagedRun { run_dir } => { + WorkerController::spawn_runtime_managed_run_with_transport(worker, &run_dir, transport) + .await + } + }; + + match controller { + Ok((handle, shutdown)) => Ok(BootstrappedWorker { handle, shutdown }), + Err(source) => { + let cleanup_failed = match cleanup_session { + Some(session) => session.close().await.is_err(), + None => false, + }; + Err(WorkerBootstrapError::Controller { + source, + cleanup_failed, + }) + } + } +} diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index ff943c60..516486d6 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -240,6 +240,20 @@ impl WorkerController { .await } + /// Spawn a direct Worker while letting an in-process host select the + /// controller transport explicitly. + pub async fn spawn_with_transport( + 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, + { + Self::spawn_inner(worker, runtime_base, false, None, transport).await + } + /// Spawn a Worker owned by `worker-runtime`. /// /// The controller still uses an ephemeral directory for Unix sockets and diff --git a/crates/worker/src/entrypoint.rs b/crates/worker/src/entrypoint.rs index 957cedd3..2b258df6 100644 --- a/crates/worker/src/entrypoint.rs +++ b/crates/worker/src/entrypoint.rs @@ -3,8 +3,8 @@ use std::path::{Path, PathBuf}; use std::process::ExitCode; use crate::{ - PromptCatalogSource, Worker, WorkerController, WorkerFilesystemAuthority, - WorkerWorkspaceContext, + PromptCatalogSource, Worker, WorkerBootstrapLayout, WorkerControllerTransport, + WorkerFilesystemAuthority, WorkerWorkspaceContext, start_worker_controller, }; use clap::{CommandFactory, FromArgMatches, Parser}; use manifest::{Permission, ScopeConfig, ScopeRule, WorkerManifest, WorkerManifestConfig, paths}; @@ -634,13 +634,22 @@ async fn run_cli_inner(cli: Cli) -> ExitCode { return ExitCode::FAILURE; } }; - let (handle, shutdown_rx) = match WorkerController::spawn(worker, &runtime_base).await { - Ok(pair) => pair, + 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 diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index 8de12fbd..a631b47d 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -1,3 +1,4 @@ +pub mod bootstrap; pub mod compact; pub mod controller; pub mod discovery; @@ -23,6 +24,10 @@ mod interrupt_prep; mod permission; mod worker; +pub use bootstrap::{ + BootstrappedWorker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout, + start_worker_controller, +}; pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate}; pub use controller::{ShutdownReceiver, WorkerController, WorkerControllerTransport, WorkerHandle}; pub use hook::{Hook, HookEventKind, HookRegistryBuilder}; diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 28f69623..6aa1d871 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -5107,15 +5107,35 @@ where loader: PromptCatalogSource, workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, + ) -> Result { + Self::from_manifest_with_context_and_model_client( + manifest, + store, + loader, + workspace_context, + filesystem_authority, + None, + ) + .await + } + + pub(crate) async fn from_manifest_with_context_and_model_client( + manifest: WorkerManifest, + store: St, + loader: PromptCatalogSource, + workspace_context: WorkerWorkspaceContext, + filesystem_authority: WorkerFilesystemAuthority, + model_client: Option>, ) -> Result { validate_workspace_memory_snapshot(&manifest.worker.name, &manifest, &workspace_context)?; - let common = prepare_worker_common_with_context( + let common = prepare_worker_common_with_context_and_model_client( &manifest, &loader, /* parse_template */ true, workspace_context, filesystem_authority, manifest.scope.clone(), + model_client, )?; // Segment creation is deferred to the first run (see @@ -6592,6 +6612,26 @@ fn prepare_worker_common_with_context( workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, scope_config: ScopeConfig, +) -> Result { + prepare_worker_common_with_context_and_model_client( + manifest, + loader, + parse_template, + workspace_context, + filesystem_authority, + scope_config, + None, + ) +} + +fn prepare_worker_common_with_context_and_model_client( + manifest: &WorkerManifest, + loader: &PromptCatalogSource, + parse_template: bool, + workspace_context: WorkerWorkspaceContext, + filesystem_authority: WorkerFilesystemAuthority, + scope_config: ScopeConfig, + model_client: Option>, ) -> Result { let filesystem_authority = match filesystem_authority { WorkerFilesystemAuthority::None => WorkerFilesystemAuthority::None, @@ -6627,6 +6667,7 @@ fn prepare_worker_common_with_context( workspace_context, filesystem_authority, scope, + model_client, ) } @@ -6637,6 +6678,7 @@ fn prepare_worker_common_from_scope( workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, scope: Scope, + model_client: Option>, ) -> Result { if let Some(local) = filesystem_authority.as_local() { if !scope.is_readable(&local.root) { @@ -6653,7 +6695,10 @@ fn prepare_worker_common_from_scope( let delegation_scope = DelegationScope::from_config(&manifest.delegation_scope).map_err(WorkerError::Scope)?; - let client = crate::model_client::build_client(&manifest.model)?; + let client = match model_client { + Some(client) => client, + None => crate::model_client::build_client(&manifest.model)?, + }; let prompts = Arc::new(ArcSwap::from(PromptCatalog::load(loader)?)); let system_prompt_template = if parse_template { Some( diff --git a/docs/README.md b/docs/README.md index 4cd18397..d02c1065 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,6 +23,7 @@ It is not a dumping ground for external research, old plans, API inventories, or 15. [`development/rust-testing-strategy.md`](development/rust-testing-strategy.md) — what Yoi Rust tests should prove, where they belong, and how to name them. 16. [`development/validation.md`](development/validation.md) — how to check changes. 17. [`development/workspace-schema-migrations.md`](development/workspace-schema-migrations.md) — how to preflight, apply, verify, and roll back control-plane SQLite schema changes. +18. [`design/standalone-agent-host.md`](design/standalone-agent-host.md) — in-process standalone Worker host の依存方向、authority、lifecycle、非目標。 ## What belongs here diff --git a/docs/design/standalone-agent-host.md b/docs/design/standalone-agent-host.md new file mode 100644 index 00000000..d5ff72da --- /dev/null +++ b/docs/design/standalone-agent-host.md @@ -0,0 +1,38 @@ +# Standalone Agent Host + +`crates/standalone` は、既存の Yoi Worker を同一プロセス内で起動する最小の host 境界である。 +新しい Agent 実行系ではなく、`manifest`、`worker`、`session-store`、`workdir` の既存契約を固定構成で組み立てる。 + +## 依存方向 + +```text +standalone + ├─ manifest + ├─ worker + ├─ session-store + └─ protocol + +worker + └─ WorkerBootstrap / start_worker_controller +``` + +`standalone` は `tui`、`worker-runtime`、`yoi-workspace-server` に依存しない。 +`worker` の direct entrypoint と `standalone` は同じ `start_worker_controller` lifecycle を使い、後者は `WorkerBootstrap` で fresh Worker construction も共有する。 + +## authority と lifecycle + +- launch は `ProfileExecutionTarget::Standalone` で built-in/XDG Profile を resolve する。repository-local Profile と path selector は authority にしない。 +- canonical cwd を `WorkerFilesystemAuthority::local` の root/cwd とし、host ごとに top-level Worker と process-owned `WorkdirSession` を一つ作る。 +- Controller transport は `InProcess` に固定する。通常起動で Worker subprocess、HTTP/WS server、Unix socket を作らない。 +- model provider は通常の resolved Manifest から構築する。埋め込み host と deterministic test は `start_with_model_client` で同じ bootstrap に process-owned client を注入できる。 +- feature plan/install は既存 `WorkerController` が行う。Task や optional direct SubWorker を standalone 側で再実装しない。 +- `shutdown()` は既存 `Method::Shutdown` を送り、controller が active run、SubWorker registry、Workdir session、MachineScope allocation を順に片付けた後の confirmation を待つ。 +- startup error は category のみを公開し、credential、prompt 本文、session metadata、内部 path を error text に含めない。 + +## 非目標 + +- TUI/CLI routing や画面実装 +- Runtime / Workspace Server / Orchestrator / Ticket authority の内包 +- standalone 独自の HTTP/WS API +- Worker/Tool/Task/SubWorker protocol の fork +- subprocess Worker launcher や runtime-owned Worker catalog