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.
This commit is contained in:
2026-10-22 01:29:39 +09:00
parent 5aa2dc579a
commit 533477c2e7
40 changed files with 1830 additions and 5363 deletions
Generated
+1 -2
View File
@@ -6521,7 +6521,6 @@ dependencies = [
"axum", "axum",
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",
"clap",
"client", "client",
"config-source", "config-source",
"dotenv", "dotenv",
@@ -6687,6 +6686,7 @@ dependencies = [
"futures", "futures",
"manifest", "manifest",
"project-record", "project-record",
"protocol",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
@@ -6702,7 +6702,6 @@ dependencies = [
"tui", "tui",
"uuid", "uuid",
"workdir", "workdir",
"worker",
] ]
[[package]] [[package]]
+1 -38
View File
@@ -100,21 +100,6 @@ pub fn worker_allocation_path() -> Option<PathBuf> {
worker_allocation_path_from_runtime_dir(runtime_dir()) worker_allocation_path_from_runtime_dir(runtime_dir())
} }
/// `<runtime_dir>/<worker_name>/` — per-Worker runtime directory.
pub fn worker_runtime_dir(worker_name: &str) -> Option<PathBuf> {
worker_runtime_dir_from_runtime_dir(runtime_dir(), worker_name)
}
/// `<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<PathBuf> {
worker_socket_path_from_runtime_dir(runtime_dir(), worker_name)
}
// ---- internals -------------------------------------------------------------- // ---- internals --------------------------------------------------------------
fn resolve_config_dir_from_parts( fn resolve_config_dir_from_parts(
@@ -204,20 +189,6 @@ fn worker_allocation_path_from_runtime_dir(runtime_dir: Option<PathBuf>) -> Opti
Some(runtime_dir?.join("workers.json")) Some(runtime_dir?.join("workers.json"))
} }
fn worker_runtime_dir_from_runtime_dir(
runtime_dir: Option<PathBuf>,
worker_name: &str,
) -> Option<PathBuf> {
Some(runtime_dir?.join(worker_name))
}
fn worker_socket_path_from_runtime_dir(
runtime_dir: Option<PathBuf>,
worker_name: &str,
) -> Option<PathBuf> {
Some(worker_runtime_dir_from_runtime_dir(runtime_dir, worker_name)?.join("sock"))
}
/// Treats empty environment values as unset. `std::env::var` distinguishes /// Treats empty environment values as unset. `std::env::var` distinguishes
/// `Ok("")` from `Err(NotPresent)`, but path resolution treats both as unset /// `Ok("")` from `Err(NotPresent)`, but path resolution treats both as unset
/// for intuitive behavior. /// for intuitive behavior.
@@ -455,16 +426,8 @@ mod tests {
PathBuf::from("/sand/sessions") PathBuf::from("/sand/sessions")
); );
assert_eq!( 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") 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")
);
} }
} }
+8 -19
View File
@@ -52,7 +52,7 @@ pub enum AuthenticatedInputSource {
/// Assigned whenever a serialized tracked method crosses an untrusted /// Assigned whenever a serialized tracked method crosses an untrusted
/// protocol boundary. Receivers must handle it exactly like public input. /// protocol boundary. Receivers must handle it exactly like public input.
UntrustedWire, 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. /// This is not a Backend Account or authority accepted from wire fields.
LocalOperator, LocalOperator,
Account { Account {
@@ -399,15 +399,12 @@ pub enum Method {
/// Typed lifecycle events sent from a child Worker to its parent. /// Typed lifecycle events sent from a child Worker to its parent.
/// ///
/// Delivered as `Method::WorkerEvent` over the parent's Unix socket. The /// Delivered in-process to the parent Controller's protocol intake. Agent-visible variants
/// parent Controller always applies variant-specific side effects /// are queued into the notification buffer. Reports are advisory: receivers must check the
/// (registry / worker-allocation updates). Agent-visible variants are also /// actual child session before treating its work as complete.
/// queued into the notification buffer; control-plane-only variants are
/// not injected into the parent's LLM context.
/// ///
/// Transport is fire-and-forget; receivers must tolerate out-of-order /// Retained legacy control-only variants can be decoded from saved history, but do not
/// delivery (e.g. `TurnEnded` arriving after `ShutDown` for the same /// register live children, delegate scope, or establish a socket callback.
/// child Worker).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
@@ -429,16 +426,8 @@ pub enum WorkerEvent {
/// Child has stopped (controller loop is exiting). /// Child has stopped (controller loop is exiting).
ShutDown { worker_name: String }, ShutDown { worker_name: String },
/// Child SubWorker sub-delegated scope to a grandchild SubWorker via `SubWorkerSpawn`. /// Legacy process-child scope report retained for saved Session decoding only.
/// /// Live Controllers ignore it; only the Host-owned Internal registry grants child scope.
/// 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.
ScopeSubDelegated { ScopeSubDelegated {
/// Sub-delegating Worker (= the sender itself). /// Sub-delegating Worker (= the sender itself).
parent_worker: String, parent_worker: String,
+1 -1
View File
@@ -23,7 +23,7 @@ serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
session-store.workspace = true session-store.workspace = true
thiserror.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"] } uuid = { workspace = true, features = ["v7"] }
worker.workspace = true worker.workspace = true
+2 -1
View File
@@ -4,7 +4,8 @@ A Host that starts existing Workers in the same process. Assembles interactive W
## Reference index ## 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. - [`launch`] — Profile, cwd, and Subject validation before launch.
- [`store`] — Worker records and leases. - [`store`] — Worker records and leases.
- [`jobs`] — Job execution and independent result and cleanup lifetimes. - [`jobs`] — Job execution and independent result and cleanup lifetimes.
+39 -10
View File
@@ -24,7 +24,6 @@ use session_store::{
}; };
use thiserror::Error; use thiserror::Error;
use worker::bootstrap::{WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout}; use worker::bootstrap::{WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout};
use worker::controller::WorkerControllerTransport;
use worker::ipc::protocol_session::{ use worker::ipc::protocol_session::{
WorkerProtocolSessionStreams, dispatch_worker_protocol_method, live_log_entry_event, WorkerProtocolSessionStreams, dispatch_worker_protocol_method, live_log_entry_event,
subscribe_worker_protocol_session, subscribe_worker_protocol_session,
@@ -222,7 +221,6 @@ impl StandaloneHost {
workspace_context, workspace_context,
filesystem_authority, filesystem_authority,
WorkerBootstrapLayout::Direct { runtime_base }, WorkerBootstrapLayout::Direct { runtime_base },
WorkerControllerTransport::InProcess,
); );
if let Some(subject) = &subject { if let Some(subject) = &subject {
bootstrap = bootstrap.with_subjektiv_host(subject.host.clone()); bootstrap = bootstrap.with_subjektiv_host(subject.host.clone());
@@ -430,7 +428,6 @@ impl StandaloneHost {
workspace_context, workspace_context,
filesystem_authority, filesystem_authority,
WorkerBootstrapLayout::Direct { runtime_base }, WorkerBootstrapLayout::Direct { runtime_base },
WorkerControllerTransport::InProcess,
); );
if let Some(subject) = &subject { if let Some(subject) = &subject {
bootstrap = bootstrap.with_subjektiv_host(subject.host.clone()); bootstrap = bootstrap.with_subjektiv_host(subject.host.clone());
@@ -520,10 +517,7 @@ impl StandaloneHost {
/// Working events, committed session entries, alert snapshots, and the /// Working events, committed session entries, alert snapshots, and the
/// initial history snapshot are merged behind the client boundary. /// initial history snapshot are merged behind the client boundary.
pub fn connect(&self) -> Client<InProcessSocket> { pub fn connect(&self) -> Client<InProcessSocket> {
let streams = subscribe_worker_protocol_session(&self.handle); Client::new(connect_protocol(self.handle.clone()))
let (socket, peer) = InProcessSocket::pair();
tokio::spawn(run_protocol_session(self.handle.clone(), streams, peer));
Client::new(socket)
} }
/// Logical Subject disconnection, not Worker shutdown or completion of its represented work. /// 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 { pub fn with_shutdown_timeout(mut self, shutdown_timeout: Duration) -> Self {
self.shutdown_timeout = shutdown_timeout; self.shutdown_timeout = shutdown_timeout;
self self
@@ -647,9 +670,8 @@ impl Drop for StandaloneHost {
} }
} }
// The in-process channel is owned by the local operator's Host, not an // Both in-process views and credential-checked Unix clients are admitted by this operator's
// arbitrary socket or Backend account. decode_method discards wire provenance; // Host. decode_method discards wire provenance; only this boundary stamps local human input.
// only this boundary can stamp accepted local human input.
fn authorize_local_input(method: protocol::Method) -> protocol::Method { fn authorize_local_input(method: protocol::Method) -> protocol::Method {
use protocol::{AuthenticatedInputSource, Method}; use protocol::{AuthenticatedInputSource, Method};
match 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( async fn run_protocol_session(
handle: worker::WorkerHandle, handle: worker::WorkerHandle,
streams: WorkerProtocolSessionStreams, streams: WorkerProtocolSessionStreams,
+38
View File
@@ -7,6 +7,44 @@ pub mod launch;
pub mod store; pub mod store;
pub mod subjektiv; pub mod subjektiv;
mod subjektiv_sessions; 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::{ pub use host::{
StandaloneHost, StandaloneShutdownError, StandaloneStartupError, StandaloneHost, StandaloneShutdownError, StandaloneStartupError,
@@ -127,7 +127,6 @@ async fn local_host_compaction_freezes_pending_journal_through_request_and_durab
worker::WorkerWorkspaceContext::no_workspace(), worker::WorkerWorkspaceContext::no_workspace(),
worker::WorkerFilesystemAuthority::None, worker::WorkerFilesystemAuthority::None,
worker::WorkerBootstrapLayout::Direct { runtime_base: fixture._temp.path().join("runtime") }, 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(); ).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); assert_eq!(prepared.worker().session_id().to_string(), fixture.context.session_id);
let source_segment = prepared.worker().segment_id(); let source_segment = prepared.worker().segment_id();
+296
View File
@@ -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<Path>) -> io::Result<Self> {
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<JoinHandle<io::Result<()>>>,
stop: Option<oneshot::Sender<()>>,
}
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<R, W>(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());
}
}
+229
View File
@@ -608,6 +608,235 @@ async fn standalone_metadata_fails_closed_on_incomplete_or_newer_records() -> Te
Ok(()) 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::<Event>())
.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::<Event>().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<dyn std::error::Error>>(())
})
.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<dyn std::error::Error>>(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<dyn std::error::Error>>(())
})
.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::<Vec<_>>();
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<dyn std::error::Error>>(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<InProcessSocket>) -> TestResult { async fn wait_for_run_end(client: &mut Client<InProcessSocket>) -> TestResult {
tokio::time::timeout(Duration::from_secs(10), async { tokio::time::timeout(Duration::from_secs(10), async {
loop { loop {
+6 -31
View File
@@ -146,9 +146,9 @@ use worker::feature::builtin::{
use worker::ipc::protocol_session::{WorkerProtocolSessionStreams, live_log_entry_event}; use worker::ipc::protocol_session::{WorkerProtocolSessionStreams, live_log_entry_event};
use worker::{ use worker::{
PreparedWorker, PromptCatalogSource, SegmentLogSink, SubjektivSessionAttributionLifecycle, PreparedWorker, PromptCatalogSource, SegmentLogSink, SubjektivSessionAttributionLifecycle,
Worker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout, Worker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout, WorkerError,
WorkerControllerTransport, WorkerError, WorkerFilesystemAuthority, WorkerHandle, WorkerFilesystemAuthority, WorkerHandle, WorkerSharedState, WorkerWorkspaceContext,
WorkerSharedState, WorkerWorkspaceContext, WorkspaceClient, WorkspaceId, WorkspaceClient, WorkspaceId,
}; };
#[cfg(feature = "ws-server")] #[cfg(feature = "ws-server")]
@@ -508,7 +508,6 @@ pub struct ProfileRuntimeWorkerFactory {
worker_mutation_identity: Option<RuntimeIdentityMaterial>, worker_mutation_identity: Option<RuntimeIdentityMaterial>,
workspace_request_clients: Arc<HashMap<String, RuntimeWorkspaceRequestClient>>, workspace_request_clients: Arc<HashMap<String, RuntimeWorkspaceRequestClient>>,
embedded_worker_mutation_dispatcher: Option<Arc<dyn EmbeddedWorkerMutationDispatcher>>, embedded_worker_mutation_dispatcher: Option<Arc<dyn EmbeddedWorkerMutationDispatcher>>,
controller_transport: WorkerControllerTransport,
failed_restore_sessions: Arc< failed_restore_sessions: Arc<
Mutex< Mutex<
HashMap< HashMap<
@@ -536,7 +535,6 @@ impl ProfileRuntimeWorkerFactory {
worker_mutation_identity: None, worker_mutation_identity: None,
workspace_request_clients: Arc::new(HashMap::new()), workspace_request_clients: Arc::new(HashMap::new()),
embedded_worker_mutation_dispatcher: None, embedded_worker_mutation_dispatcher: None,
controller_transport: WorkerControllerTransport::InProcess,
failed_restore_sessions: Arc::new(Mutex::new(HashMap::new())), failed_restore_sessions: Arc::new(Mutex::new(HashMap::new())),
} }
} }
@@ -585,14 +583,6 @@ impl ProfileRuntimeWorkerFactory {
self 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<PathBuf>) -> Self { pub fn with_runtime_store_dir(mut self, runtime_store_dir: impl Into<PathBuf>) -> Self {
self.worker_aggregate_root = Some(runtime_store_dir.into().join("workers")); self.worker_aggregate_root = Some(runtime_store_dir.into().join("workers"));
self self
@@ -1598,13 +1588,13 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
.resolve_profile(selector, &worker_root, &worker_name) .resolve_profile(selector, &worker_root, &worker_name)
.map_err(|err| format!("failed to resolve profile source archive: {err}"))?; .map_err(|err| format!("failed to resolve profile source archive: {err}"))?;
if has_local_filesystem { if has_local_filesystem {
worker::entrypoint::resolve_runtime_profile_manifest_from_manifest( worker::profile_manifest::resolve_runtime_profile_manifest_from_manifest(
manifest, manifest,
&worker_root, &worker_root,
&worker_name, &worker_name,
)? )?
} else { } else {
worker::entrypoint::resolve_runtime_profile_manifest_from_manifest_without_filesystem( worker::profile_manifest::resolve_runtime_profile_manifest_from_manifest_without_filesystem(
manifest, manifest,
&worker_root, &worker_root,
&worker_name, &worker_name,
@@ -1651,7 +1641,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
WorkerBootstrapLayout::RuntimeManagedRun { WorkerBootstrapLayout::RuntimeManagedRun {
run_dir: run_dir.clone(), run_dir: run_dir.clone(),
}, },
self.controller_transport,
); );
let bootstrap = match &self.spawn_model_client_factory { let bootstrap = match &self.spawn_model_client_factory {
Some(factory) => bootstrap.with_model_client(factory()), Some(factory) => bootstrap.with_model_client(factory()),
@@ -2004,7 +1993,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
WorkerBootstrapLayout::RuntimeManagedRun { WorkerBootstrapLayout::RuntimeManagedRun {
run_dir: run_dir.clone(), run_dir: run_dir.clone(),
}, },
self.controller_transport,
) )
.start() .start()
.await .await
@@ -6515,8 +6503,7 @@ mod tests {
let backend = WorkerRuntimeExecutionBackend::new( let backend = WorkerRuntimeExecutionBackend::new(
ProfileRuntimeWorkerFactory::new(root.path().join("missing-profiles")) ProfileRuntimeWorkerFactory::new(root.path().join("missing-profiles"))
.with_runtime_store_dir(&store) .with_runtime_store_dir(&store)
.with_runtime_id("runtime-subjektiv-test") .with_runtime_id("runtime-subjektiv-test"),
.with_controller_transport(WorkerControllerTransport::InProcess),
) )
.unwrap(); .unwrap();
backend.preflight_restore(&request).unwrap(); backend.preflight_restore(&request).unwrap();
@@ -9132,17 +9119,6 @@ mod tests {
assert!(error.contains("replacement Worker is required"), "{error}"); 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] #[tokio::test]
#[serial_test::serial(worker_allocation)] #[serial_test::serial(worker_allocation)]
async fn in_process_restore_does_not_bind_unix_socket_under_overlong_store_path() { 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()) let controller = ProfileRuntimeWorkerFactory::new(root.path())
.with_runtime_store_dir(&runtime_store_dir) .with_runtime_store_dir(&runtime_store_dir)
.with_controller_transport(WorkerControllerTransport::InProcess)
.restore_controller(WorkerExecutionRestoreRequest { .restore_controller(WorkerExecutionRestoreRequest {
operation_id: crate::execution::WorkerLifecycleOperationId::new(), operation_id: crate::execution::WorkerLifecycleOperationId::new(),
worker_ref: worker_ref.clone(), worker_ref: worker_ref.clone(),
-1
View File
@@ -11,7 +11,6 @@ default = []
[dependencies] [dependencies]
async-trait = { workspace = true } async-trait = { workspace = true }
clap = { version = "4.6.0", features = ["derive"] }
agen = { workspace = true, features = ["codex"] } agen = { workspace = true, features = ["codex"] }
session-store = { workspace = true } session-store = { workspace = true }
secrets = { workspace = true } secrets = { workspace = true }
+8 -27
View File
@@ -6,9 +6,7 @@ use thiserror::Error;
use workdir::WorkdirSessionHandle; use workdir::WorkdirSessionHandle;
use crate::PromptCatalogSource; use crate::PromptCatalogSource;
use crate::controller::{ use crate::controller::{ShutdownReceiver, WorkerController, WorkerHandle};
ShutdownReceiver, WorkerController, WorkerControllerTransport, WorkerHandle,
};
use crate::worker::{Worker, WorkerError, WorkerFilesystemAuthority, WorkerWorkspaceContext}; use crate::worker::{Worker, WorkerError, WorkerFilesystemAuthority, WorkerWorkspaceContext};
use manifest::WorkerManifest; use manifest::WorkerManifest;
@@ -30,7 +28,6 @@ pub struct WorkerBootstrap<St> {
workspace_context: WorkerWorkspaceContext, workspace_context: WorkerWorkspaceContext,
filesystem_authority: WorkerFilesystemAuthority, filesystem_authority: WorkerFilesystemAuthority,
layout: WorkerBootstrapLayout, layout: WorkerBootstrapLayout,
transport: WorkerControllerTransport,
model_client: Option<Box<dyn LlmClient>>, model_client: Option<Box<dyn LlmClient>>,
workdir_session: Option<WorkdirSessionHandle>, workdir_session: Option<WorkdirSessionHandle>,
subjektiv_host: Option<std::sync::Arc<dyn crate::subjektiv::SubjektivHost>>, subjektiv_host: Option<std::sync::Arc<dyn crate::subjektiv::SubjektivHost>>,
@@ -41,7 +38,6 @@ pub struct WorkerBootstrap<St> {
pub struct PreparedWorker<C: LlmClient, St: Store> { pub struct PreparedWorker<C: LlmClient, St: Store> {
worker: Worker<C, St>, worker: Worker<C, St>,
layout: WorkerBootstrapLayout, layout: WorkerBootstrapLayout,
transport: WorkerControllerTransport,
} }
/// Live controller returned only after Worker construction and feature /// Live controller returned only after Worker construction and feature
@@ -68,7 +64,6 @@ impl<St> WorkerBootstrap<St>
where where
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
{ {
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
manifest: WorkerManifest, manifest: WorkerManifest,
store: St, store: St,
@@ -76,7 +71,6 @@ where
workspace_context: WorkerWorkspaceContext, workspace_context: WorkerWorkspaceContext,
filesystem_authority: WorkerFilesystemAuthority, filesystem_authority: WorkerFilesystemAuthority,
layout: WorkerBootstrapLayout, layout: WorkerBootstrapLayout,
transport: WorkerControllerTransport,
) -> Self { ) -> Self {
Self { Self {
manifest, manifest,
@@ -85,7 +79,6 @@ where
workspace_context, workspace_context,
filesystem_authority, filesystem_authority,
layout, layout,
transport,
model_client: None, model_client: None,
workdir_session: None, workdir_session: None,
subjektiv_host: None, subjektiv_host: None,
@@ -165,7 +158,7 @@ where
) )
.map_err(WorkerBootstrapError::Worker)?; .map_err(WorkerBootstrapError::Worker)?;
} }
Ok(PreparedWorker::new(worker, self.layout, self.transport)) Ok(PreparedWorker::new(worker, self.layout))
} }
pub async fn prepare_restored( pub async fn prepare_restored(
@@ -206,7 +199,7 @@ where
) )
.map_err(WorkerBootstrapError::Worker)?; .map_err(WorkerBootstrapError::Worker)?;
} }
Ok(PreparedWorker::new(worker, self.layout, self.transport)) Ok(PreparedWorker::new(worker, self.layout))
} }
pub async fn start(self) -> Result<BootstrappedWorker, WorkerBootstrapError> { pub async fn start(self) -> Result<BootstrappedWorker, WorkerBootstrapError> {
@@ -221,16 +214,8 @@ where
{ {
/// Wrap a restored Worker in the same pre-exposure lifecycle used by fresh /// Wrap a restored Worker in the same pre-exposure lifecycle used by fresh
/// bootstraps. /// bootstraps.
pub fn new( pub fn new(worker: Worker<C, St>, layout: WorkerBootstrapLayout) -> Self {
worker: Worker<C, St>, Self { worker, layout }
layout: WorkerBootstrapLayout,
transport: WorkerControllerTransport,
) -> Self {
Self {
worker,
layout,
transport,
}
} }
pub fn worker(&self) -> &Worker<C, St> { pub fn worker(&self) -> &Worker<C, St> {
@@ -242,7 +227,7 @@ where
} }
pub async fn start(self) -> Result<BootstrappedWorker, WorkerBootstrapError> { pub async fn start(self) -> Result<BootstrappedWorker, WorkerBootstrapError> {
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<C, St>( pub async fn start_worker_controller<C, St>(
worker: Worker<C, St>, worker: Worker<C, St>,
layout: WorkerBootstrapLayout, layout: WorkerBootstrapLayout,
transport: WorkerControllerTransport,
) -> Result<BootstrappedWorker, WorkerBootstrapError> ) -> Result<BootstrappedWorker, WorkerBootstrapError>
where where
C: LlmClient + Clone + 'static, C: LlmClient + Clone + 'static,
@@ -261,13 +245,10 @@ where
let cleanup_sessions = worker.workdir_sessions(); let cleanup_sessions = worker.workdir_sessions();
let controller = match layout { let controller = match layout {
WorkerBootstrapLayout::Direct { runtime_base } => { 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 } => { WorkerBootstrapLayout::RuntimeManagedRun { run_dir } => {
WorkerController::spawn_runtime_managed_run_with_transport_owned( WorkerController::spawn_runtime_managed_run_owned(worker, &run_dir).await
worker, &run_dir, transport,
)
.await
} }
}; };
File diff suppressed because it is too large Load Diff
@@ -65,7 +65,6 @@ async fn prepared(
WorkerBootstrapLayout::Direct { WorkerBootstrapLayout::Direct {
runtime_base: temp.path().join("runtime"), runtime_base: temp.path().join("runtime"),
}, },
WorkerControllerTransport::InProcess,
) )
.with_model_client(client) .with_model_client(client)
.prepare() .prepare()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -66
View File
@@ -1,57 +1,6 @@
//! `WorkerEvent` send / receive helpers. //! Agent-visible rendering of typed Internal SubWorker lifecycle events.
//!
//! 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.
use std::path::{Path, PathBuf}; use protocol::WorkerEvent;
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<PathBuf>, 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");
}
});
}
/// Render a variant into a one-line human-readable string. /// 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<SpawnedWorkerRegistry>,
_self_name: &str,
_self_parent_socket: &Option<PathBuf>,
) {
}
-1
View File
@@ -1,7 +1,6 @@
pub mod alerter; pub mod alerter;
pub mod event; pub mod event;
pub mod protocol_session; pub mod protocol_session;
pub mod server;
pub(crate) mod interceptor; pub(crate) mod interceptor;
pub(crate) mod notify_buffer; pub(crate) mod notify_buffer;
-224
View File
@@ -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<JoinHandle<()>>,
shutdown: Option<oneshot::Sender<()>>,
path: PathBuf,
}
impl SocketServer {
/// Start listening on the WorkerHandle's socket path.
pub async fn start(handle: &WorkerHandle) -> Result<Self, io::Error> {
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));
}
}
+3 -5
View File
@@ -7,8 +7,6 @@ pub mod bootstrap;
pub mod checkout; pub mod checkout;
pub mod compact; pub mod compact;
pub mod controller; pub mod controller;
pub mod discovery;
pub mod entrypoint;
pub mod feature; pub mod feature;
mod file_operation; mod file_operation;
pub mod fs_view; pub mod fs_view;
@@ -19,9 +17,9 @@ pub mod job;
mod metadata_lifecycle; mod metadata_lifecycle;
pub mod model_client; pub mod model_client;
mod paste_artifact_tool; mod paste_artifact_tool;
pub mod profile_manifest;
pub mod prompt; pub mod prompt;
pub mod runtime; pub mod runtime;
pub mod runtime_command;
pub mod segment_log_sink; pub mod segment_log_sink;
mod session_capture; mod session_capture;
mod session_history; mod session_history;
@@ -37,6 +35,7 @@ pub mod wip;
mod internal_worker; mod internal_worker;
mod interrupt_prep; mod interrupt_prep;
mod peer;
mod permission; mod permission;
mod worker; mod worker;
@@ -45,10 +44,9 @@ pub use bootstrap::{
WorkerBootstrapLayout, start_worker_controller, WorkerBootstrapLayout, start_worker_controller,
}; };
pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate}; 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 hook::{Hook, HookEventKind, HookRegistryBuilder};
pub use ipc::alerter::Alerter; pub use ipc::alerter::Alerter;
pub use ipc::server::SocketServer;
pub use manifest::{ pub use manifest::{
AuthRef, ModelManifest, SchemeKind, Scope, WorkerManifest, WorkerManifestConfig, AuthRef, ModelManifest, SchemeKind, Scope, WorkerManifest, WorkerManifestConfig,
WorkerMetaConfig, WorkerMetaConfig,
+186
View File
@@ -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<PeerRegistrationResult, PeerRegistrationError> {
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<Option<PeerRegistrationResult>, 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<Option<WorkerMetadata>, WorkerStoreError> {
self.0.read_by_name(name)
}
fn list_names(&self) -> Result<Vec<String>, 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()
);
}
}
}
+183
View File
@@ -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::<Vec<_>>();
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<ScopeRule>, defaults: Vec<ScopeRule>) {
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());
}
}
+77 -233
View File
@@ -1,17 +1,12 @@
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use manifest::{ScopeRule, paths}; use manifest::ScopeRule;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::fs; use tokio::fs;
use crate::shared_state::WorkerSharedState; /// Legacy process-child projection used only by persisted scope reclamation.
/// Socket fields retain the stored format; they are never connected to.
/// 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.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpawnedWorkerRecord { pub struct SpawnedWorkerRecord {
/// Spawned Worker's identity. /// Spawned Worker's identity.
@@ -25,56 +20,33 @@ pub struct SpawnedWorkerRecord {
pub callback_address: PathBuf, pub callback_address: PathBuf,
} }
/// Manages the Worker's runtime directory on tmpfs. /// Artifact directory retained by an in-process Controller.
/// ///
/// ```text /// Worker liveness and configuration belong to the Host, not filesystem projections.
/// <runtime_dir>/{worker_name}/ /// Persistent run directories and pre-existing data are never removed on drop.
/// ├── pid
/// ├── status.json
/// ├── manifest.toml
/// ├── history.json
/// └── sock (created by socket listener, not by RuntimeDir)
/// ```
///
/// `<runtime_dir>` is resolved via [`manifest::paths::runtime_dir`].
/// Files are written atomically (write tmp → rename).
/// The directory is removed on drop.
pub struct RuntimeDir { pub struct RuntimeDir {
path: PathBuf, path: PathBuf,
write_legacy_snapshots: bool,
preserve_on_drop: bool, preserve_on_drop: bool,
socket_file_name: &'static str,
} }
impl RuntimeDir { 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<Self, io::Error> { pub async fn create(base: &Path, worker_name: &str) -> Result<Self, io::Error> {
let path = base.join(worker_name); Self::create_transient(base, worker_name).await
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",
})
} }
/// Create an ephemeral Runtime-owned artifact directory. /// Preserve pre-existing directories rather than claiming their contents for cleanup.
///
/// Runtime-managed Workers keep their status in the owning Runtime and do
/// not materialize legacy pid/status/manifest projections.
pub async fn create_transient(base: &Path, worker_name: &str) -> Result<Self, io::Error> { pub async fn create_transient(base: &Path, worker_name: &str) -> Result<Self, io::Error> {
fs::create_dir_all(base).await?;
let path = base.join(worker_name); 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 { Ok(Self {
path, path,
write_legacy_snapshots: false, preserve_on_drop,
preserve_on_drop: false,
socket_file_name: "sock",
}) })
} }
@@ -100,39 +72,11 @@ impl RuntimeDir {
std::fs::File::open(parent)?.sync_all()?; std::fs::File::open(parent)?.sync_all()?;
Ok(Self { Ok(Self {
path: path.to_path_buf(), path: path.to_path_buf(),
write_legacy_snapshots: false,
preserve_on_drop: true, preserve_on_drop: true,
socket_file_name: "worker.sock",
}) })
} }
/// Create in the default base directory resolved via /// Rewrite the legacy projection during persisted process-child reclamation.
/// [`manifest::paths::runtime_dir`].
pub async fn create_default(worker_name: &str) -> Result<Self, io::Error> {
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.
pub async fn write_spawned_workers( pub async fn write_spawned_workers(
&self, &self,
records: &[SpawnedWorkerRecord], records: &[SpawnedWorkerRecord],
@@ -145,26 +89,10 @@ impl RuntimeDir {
pub fn path(&self) -> &Path { pub fn path(&self) -> &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 { impl Drop for RuntimeDir {
fn drop(&mut self) { fn drop(&mut self) {
let _ = std::fs::remove_file(self.socket_path());
if !self.preserve_on_drop { if !self.preserve_on_drop {
let _ = std::fs::remove_dir_all(&self.path); let _ = std::fs::remove_dir_all(&self.path);
} }
@@ -179,156 +107,72 @@ async fn atomic_write(target: &Path, content: &[u8]) -> Result<(), io::Error> {
Ok(()) 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<PathBuf, io::Error> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[tokio::test]
async fn creates_directory_and_pid() { async fn transient_controller_directory_has_no_liveness_projections_and_is_released() {
let tmp = tempfile::tempdir().unwrap(); let root = tempfile::tempdir().unwrap();
let rt = RuntimeDir::create(tmp.path(), "my-worker").await.unwrap(); let path;
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<SpawnedWorkerRecord> = 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;
{ {
let rt = RuntimeDir::create(tmp.path(), "my-worker").await.unwrap(); let runtime = RuntimeDir::create(root.path(), "worker").await.unwrap();
dir_path = rt.path().to_owned(); path = runtime.path().to_owned();
assert!(dir_path.exists()); 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(),
"[]"
);
} }
} }
@@ -72,7 +72,8 @@ pub struct Allocation {
pub worker_name: String, pub worker_name: String,
/// Owning process. Checked with `kill(pid, 0)` for stale detection. /// Owning process. Checked with `kill(pid, 0)` for stale detection.
pub pid: u32, 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, pub socket: PathBuf,
/// Allow rules granted to this Worker (write + read). /// Allow rules granted to this Worker (write + read).
pub scope_allow: Vec<ScopeRule>, pub scope_allow: Vec<ScopeRule>,
@@ -338,6 +339,51 @@ mod tests {
assert_eq!(guard.data().allocations[0].worker_name, "a"); 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] #[test]
fn atomic_save_reopens_complete_table_at_both_rename_boundaries() { fn atomic_save_reopens_complete_table_at_both_rename_boundaries() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
-165
View File
@@ -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<OsString>,
}
impl WorkerRuntimeCommand {
pub fn new(program: impl Into<PathBuf>, prefix_args: Vec<OsString>) -> Self {
Self {
program: program.into(),
prefix_args,
}
}
pub fn for_current_exe() -> io::Result<Self> {
Ok(Self::for_executable(std::env::current_exe()?))
}
pub fn for_executable(program: impl Into<PathBuf>) -> 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> {
Self::resolve_from_env_value(
std::env::var_os(POD_RUNTIME_COMMAND_ENV),
std::env::current_exe,
)
}
fn resolve_from_env_value<F>(
override_program: Option<OsString>,
current_exe: F,
) -> io::Result<Self>
where
F: FnOnce() -> io::Result<PathBuf>,
{
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<I, S>(&self, args: I) -> Vec<OsString>
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
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::<Vec<_>>()
);
}
#[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::<Vec<_>>()
);
}
#[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::<Vec<_>>()
);
}
}
-181
View File
@@ -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<R>(reader: &mut JsonLineReader<R>) -> std::io::Result<()>
where
R: tokio::io::AsyncBufRead + Unpin,
{
loop {
let event = tokio::time::timeout(SOCKET_OP_TIMEOUT, reader.next::<Event>())
.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<serde_json::Value>) -> 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<Event>,
) -> JoinHandle<Option<Method>> {
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::<Method>().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:?}"),
}
}
}
-1
View File
@@ -1,4 +1,3 @@
pub mod comm_tools;
mod published; mod published;
pub mod registry; pub mod registry;
pub mod tool; pub mod tool;
+2 -6
View File
@@ -678,8 +678,8 @@ pub(crate) fn test_connection(client: Arc<dyn WorkspaceClient>) -> SubjektivHost
mod tests { mod tests {
use super::*; use super::*;
use crate::{ use crate::{
Method, WorkerBootstrap, WorkerBootstrapLayout, WorkerControllerTransport, Method, WorkerBootstrap, WorkerBootstrapLayout, WorkerFilesystemAuthority, WorkerManifest,
WorkerFilesystemAuthority, WorkerManifest, WorkerWorkspaceContext, WorkerWorkspaceContext,
}; };
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent}; use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use agen::llm_client::{ClientError, LlmClient, Request}; use agen::llm_client::{ClientError, LlmClient, Request};
@@ -944,7 +944,6 @@ enabled = true
WorkerBootstrapLayout::Direct { WorkerBootstrapLayout::Direct {
runtime_base: temp.path().join("runtime"), runtime_base: temp.path().join("runtime"),
}, },
WorkerControllerTransport::InProcess,
) )
.with_model_client(TaskScriptClient::default()) .with_model_client(TaskScriptClient::default())
.with_subjektiv_host(host) .with_subjektiv_host(host)
@@ -1137,7 +1136,6 @@ enabled = true
WorkerBootstrapLayout::Direct { WorkerBootstrapLayout::Direct {
runtime_base: temp.path().join("runtime"), runtime_base: temp.path().join("runtime"),
}, },
WorkerControllerTransport::InProcess,
) )
.with_model_client(ScriptedClient(tx)) .with_model_client(ScriptedClient(tx))
.with_subjektiv_host(host.clone()) .with_subjektiv_host(host.clone())
@@ -1318,7 +1316,6 @@ enabled = true
WorkerBootstrapLayout::Direct { WorkerBootstrapLayout::Direct {
runtime_base: temp.path().join("runtime"), runtime_base: temp.path().join("runtime"),
}, },
WorkerControllerTransport::InProcess,
) )
.with_model_client(ScriptedClient(tx)) .with_model_client(ScriptedClient(tx))
.with_subjektiv_host(host.clone()) .with_subjektiv_host(host.clone())
@@ -1377,7 +1374,6 @@ enabled = true
WorkerBootstrapLayout::Direct { WorkerBootstrapLayout::Direct {
runtime_base: temp.path().join("runtime"), runtime_base: temp.path().join("runtime"),
}, },
WorkerControllerTransport::InProcess,
) )
.with_model_client(ScriptedClient(tx)); .with_model_client(ScriptedClient(tx));
let bootstrap = if attach { let bootstrap = if attach {
+7 -169
View File
@@ -531,7 +531,6 @@ use crate::prompt::agents_md::read_agents_md;
use crate::prompt::catalog::{CatalogError, PromptCatalog, WorkspacePromptProjection}; use crate::prompt::catalog::{CatalogError, PromptCatalog, WorkspacePromptProjection};
use crate::prompt::source::PromptCatalogSource; use crate::prompt::source::PromptCatalogSource;
use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate}; use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
use crate::runtime::dir;
use crate::runtime::worker_allocation::{ use crate::runtime::worker_allocation::{
self, ScopeAllocationGuard, ScopeLockError, SegmentActivationGuard, self, ScopeAllocationGuard, ScopeLockError, SegmentActivationGuard,
}; };
@@ -3456,18 +3455,13 @@ pub struct Worker<C: LlmClient, St: Store> {
/// authority before the outer allocation guard reacquires the same lock. /// authority before the outer allocation guard reacquires the same lock.
failed_activation_authority: Mutex<Option<SegmentActivationGuard>>, failed_activation_authority: Mutex<Option<SegmentActivationGuard>>,
/// Scope allocation in the machine-wide lock file. `Some` for /// 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 /// `restore_from_manifest` (production paths); `None` for the
/// low-level `Worker::new` constructor used in tests, which bypasses /// low-level `Worker::new` constructor used in tests, which bypasses
/// the registry. Kept purely for its `Drop` impl, which releases /// the registry. Kept purely for its `Drop` impl, which releases
/// the allocation when the Worker is dropped. /// the allocation when the Worker is dropped.
#[allow(dead_code)] #[allow(dead_code)]
scope_allocation: Option<ScopeAllocationGuard>, scope_allocation: Option<ScopeAllocationGuard>,
/// 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<PathBuf>,
/// Transient launch role for Ticket role sessions. This is process-local /// Transient launch role for Ticket role sessions. This is process-local
/// runtime identity used by controller policy; it is not model-visible and /// runtime identity used by controller policy; it is not model-visible and
/// is not persisted into Ticket claim/session records. /// is not persisted into Ticket claim/session records.
@@ -3751,7 +3745,6 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
pending_committed_history: Arc::new(Mutex::new(std::collections::VecDeque::new())), pending_committed_history: Arc::new(Mutex::new(std::collections::VecDeque::new())),
failed_activation_authority: Mutex::new(None), failed_activation_authority: Mutex::new(None),
scope_allocation: None, scope_allocation: None,
callback_socket: None,
runtime_ticket_role: None, runtime_ticket_role: None,
prompts, prompts,
inject_resident_summary: true, inject_resident_summary: true,
@@ -3916,8 +3909,8 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
self.runtime_ticket_role.as_deref() self.runtime_ticket_role.as_deref()
} }
/// Set the process-local Ticket role marker. Intended for entrypoint /// Set the process-local Ticket role marker supplied by the Host at launch,
/// launch metadata, not for model-visible prompts or durable claims. /// not by model-visible prompts or durable claims.
pub fn set_runtime_ticket_role(&mut self, role: Option<String>) { pub fn set_runtime_ticket_role(&mut self, role: Option<String>) {
self.runtime_ticket_role = role; self.runtime_ticket_role = role;
} }
@@ -4788,15 +4781,6 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
self.pending_notifies.clone() 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( pub(crate) fn install_compaction_budget(
&mut self, &mut self,
budget: Option<crate::compact::worker::CompactWorkerInterceptor>, budget: Option<crate::compact::worker::CompactWorkerInterceptor>,
@@ -8304,14 +8288,10 @@ where
// Register this Worker in the machine-wide worker-allocation // Register this Worker in the machine-wide worker-allocation
// before building anything else, so a spawn that conflicts on // before building anything else, so a spawn that conflicts on
// scope fails fast. // 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( let scope_allocation = worker_allocation::install_top_level(
manifest.worker.name.clone(), manifest.worker.name.clone(),
std::process::id(), std::process::id(),
socket_path, PathBuf::new(), // Reserved legacy allocation field; no Worker endpoint exists.
common.scope.allow_rules(), common.scope.allow_rules(),
segment_id, segment_id,
)?; )?;
@@ -8372,7 +8352,6 @@ where
pending_committed_history: Arc::new(Mutex::new(std::collections::VecDeque::new())), pending_committed_history: Arc::new(Mutex::new(std::collections::VecDeque::new())),
failed_activation_authority: Mutex::new(None), failed_activation_authority: Mutex::new(None),
scope_allocation: Some(scope_allocation), scope_allocation: Some(scope_allocation),
callback_socket: None,
runtime_ticket_role: None, runtime_ticket_role: None,
prompts: common.prompts, prompts: common.prompts,
inject_resident_summary: true, inject_resident_summary: true,
@@ -8773,7 +8752,6 @@ where
pending_committed_history: Arc::new(Mutex::new(std::collections::VecDeque::new())), pending_committed_history: Arc::new(Mutex::new(std::collections::VecDeque::new())),
failed_activation_authority: Mutex::new(None), failed_activation_authority: Mutex::new(None),
scope_allocation: None, scope_allocation: None,
callback_socket: None,
runtime_ticket_role: None, runtime_ticket_role: None,
prompts: common.prompts, prompts: common.prompts,
inject_resident_summary: true, inject_resident_summary: true,
@@ -8796,141 +8774,6 @@ where
Ok(worker) 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<Self, WorkerError> {
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<Self, WorkerError> {
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::<Box<dyn LlmClient>, 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::<SystemItem>::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 /// Restore a Worker by resolving its name-keyed metadata to an active
/// `(SessionId, SegmentId)` and then using the normal session-log restore /// `(SessionId, SegmentId)` and then using the normal session-log restore
/// path. The metadata stores only the active pointer; lineage and origin /// 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 // the lookup + install inside a single `LockFileGuard` is what
// makes "no two live Workers write to the same session log" // makes "no two live Workers write to the same session log"
// actually structural rather than a hopeful pre-check. // 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( let scope_allocation = worker_allocation::install_top_level_with_deny(
manifest.worker.name.clone(), manifest.worker.name.clone(),
std::process::id(), std::process::id(),
socket_path, PathBuf::new(), // Reserved legacy allocation field; no Worker endpoint exists.
common.scope.allow_rules(), common.scope.allow_rules(),
common.scope.deny_rules(), common.scope.deny_rules(),
segment_id, segment_id,
@@ -9320,7 +9159,6 @@ where
pending_committed_history: Arc::new(Mutex::new(std::collections::VecDeque::new())), pending_committed_history: Arc::new(Mutex::new(std::collections::VecDeque::new())),
failed_activation_authority: Mutex::new(None), failed_activation_authority: Mutex::new(None),
scope_allocation: Some(scope_allocation), scope_allocation: Some(scope_allocation),
callback_socket: None,
runtime_ticket_role: None, runtime_ticket_role: None,
prompts: common.prompts, prompts: common.prompts,
inject_resident_summary: true, inject_resident_summary: true,
@@ -10416,6 +10254,8 @@ struct WorkerCommon {
feature_instructions: Vec<FeatureInstructionDeclaration>, feature_instructions: Vec<FeatureInstructionDeclaration>,
} }
/// 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 { async fn restored_child_reachable(child: &WorkerSpawnedChild) -> bool {
tokio::time::timeout( tokio::time::timeout(
RESTORE_RECONCILIATION_REACHABILITY_TIMEOUT, RESTORE_RECONCILIATION_REACHABILITY_TIMEOUT,
@@ -17962,7 +17802,6 @@ permission = "write"
WorkerWorkspaceContext::no_workspace(), WorkerWorkspaceContext::no_workspace(),
WorkerFilesystemAuthority::None, WorkerFilesystemAuthority::None,
layout, layout,
crate::WorkerControllerTransport::InProcess,
) )
.with_model_client(NoopClient) .with_model_client(NoopClient)
.with_subjektiv_host(host) .with_subjektiv_host(host)
@@ -18282,7 +18121,6 @@ permission = "write"
.join("runtime/runs") .join("runtime/runs")
.join(uuid::Uuid::now_v7().to_string()), .join(uuid::Uuid::now_v7().to_string()),
}, },
crate::WorkerControllerTransport::InProcess,
) )
.with_model_client(NoopClient) .with_model_client(NoopClient)
} }
+147 -326
View File
@@ -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 // The Running event is the in-flight visibility fence: the committed
// annotated input must already be available to an immediately attaching // annotated input must already be available to an immediately attaching
// subscriber rather than racing behind this status transition. // subscriber rather than racing behind this status transition.
let stream = tokio::net::UnixStream::connect(handle.runtime_dir.socket_path()) let Event::Snapshot { session, .. } = handle.snapshot_event() else {
.await panic!("expected snapshot");
.unwrap(); };
let (reader, _writer) = stream.into_split(); assert!(
let mut reader = protocol::stream::JsonLineReader::new(reader); session.entries.iter().any(|entry| match &entry.data {
loop {
let event = reader.next::<Event>().await.unwrap().unwrap();
match event {
Event::Snapshot { session, .. } => {
let found = session.entries.iter().any(|entry| match &entry.data {
protocol::SessionSnapshotEntryData::UserInput { segments } => { protocol::SessionSnapshotEntryData::UserInput { segments } => {
protocol::Segment::flatten_to_text(segments) == "hello in-flight" protocol::Segment::flatten_to_text(segments) == "hello in-flight"
} }
protocol::SessionSnapshotEntryData::Message { protocol::SessionSnapshotEntryData::Message {
role: protocol::SessionMessageRole::User, role: protocol::SessionMessageRole::User,
content, content,
} => content.iter().any(|part| { } => content.iter().any(|part| matches!(part,
matches!( protocol::SessionContentPart::Text { text } if text == "hello in-flight"
part, )),
protocol::SessionContentPart::Text { text }
if text == "hello in-flight"
)
}),
_ => false, _ => false,
}); }),
assert!(
found,
"snapshot must carry the in-flight UserInput entry: {session:?}" "snapshot must carry the in-flight UserInput entry: {session:?}"
); );
return;
}
Event::Alert(_) => continue,
other => panic!("expected Snapshot first, got {other:?}"),
}
}
} }
#[tokio::test] #[tokio::test]
@@ -1855,24 +1837,10 @@ async fn attach_snapshot_includes_current_status() {
.unwrap(); .unwrap();
wait_for_status(&handle, WorkerStatus::Running).await; wait_for_status(&handle, WorkerStatus::Running).await;
let stream = tokio::net::UnixStream::connect(handle.runtime_dir.socket_path()) let Event::Snapshot { state, .. } = handle.snapshot_event() else {
.await panic!("expected snapshot");
.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::<Event>().await.unwrap().unwrap();
match event {
Event::Snapshot { state, .. } => {
assert_eq!(state.catalog_status(), WorkerStatus::Running); assert_eq!(state.catalog_status(), WorkerStatus::Running);
return;
}
Event::Alert(_) => continue,
other => panic!("expected Snapshot, got {other:?}"),
}
}
} }
#[tokio::test] #[tokio::test]
@@ -2928,7 +2896,7 @@ async fn status_json_reflects_worker_name() {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Socket transport tests // Controller lifecycle tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[tokio::test] #[tokio::test]
@@ -2966,283 +2934,148 @@ async fn shutdown_closes_method_admission_before_terminal_confirmation() {
shutdown_rx.await.unwrap(); shutdown_rx.await.unwrap();
} }
/// Uses the real Controller and metadata store; only the model client is replaced.
#[tokio::test] #[tokio::test]
async fn shutdown_joins_socket_server_with_active_connection() { async fn register_peer_returns_existing_response_and_persists_reciprocal_metadata() {
use tokio::net::UnixStream; use session_store::{WorkerMetadata, WorkerMetadataStore};
let worker = make_worker(MockClient::new(simple_text_events())).await; let worker = make_worker(MockClient::new(simple_text_events())).await;
let runtime_base = tempfile::tempdir().unwrap(); let store = worker.store().clone();
let (handle, shutdown_rx) = WorkerController::spawn(worker, runtime_base.path()) store
.write(&WorkerMetadata::new("test-worker", None))
.unwrap();
store
.write(&WorkerMetadata::new("peer-worker", None))
.unwrap();
let handle = spawn_controller(worker).await;
let mut events = handle.subscribe();
handle
.send(Method::RegisterPeer {
name: "peer-worker".into(),
})
.await .await
.unwrap(); .unwrap();
let socket_path = handle.runtime_dir.socket_path(); assert!(
let _connection = UnixStream::connect(&socket_path).await.unwrap(); 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
);
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 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;
let mut events = handle.subscribe();
handle
.send(Method::RegisterPeer {
name: "missing-peer".into(),
})
.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("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();
for legacy in ["pid", "status.json", "manifest.toml", "sock", "worker.sock"] {
assert!(
!handle.runtime_dir.path().join(legacy).exists(),
"{mode} created {legacy}"
);
}
handle handle
.send(Method::Shutdown { .send(Method::Shutdown {
command: worker_command(&handle), command: worker_command(&handle),
}) })
.await .await
.unwrap(); .unwrap();
tokio::time::timeout(std::time::Duration::from_secs(5), shutdown_rx) tokio::time::timeout(std::time::Duration::from_secs(5), shutdown)
.await
.expect("controller should join its socket tasks")
.expect("controller shutdown signal should remain open");
assert!(!socket_path.exists());
}
#[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;
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",
))
.await .await
.unwrap()
.unwrap(); .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::<Event>() => {
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(),
}))
.await
.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::<Event>() => {
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::<Event>() => {
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::<Event>() => {
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,
}
} }
} }
@@ -4006,27 +3839,15 @@ fn item_text_contains(item: &Item, needle: &str) -> bool {
} }
async fn snapshot_contains_user_input(handle: &WorkerHandle, 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()) let Event::Snapshot { session, .. } = handle.snapshot_event() else {
.await panic!("expected snapshot");
.unwrap(); };
let (reader, _writer) = stream.into_split(); session.entries.into_iter().any(|entry| match entry.data {
let mut reader = protocol::stream::JsonLineReader::new(reader);
loop {
let event = reader.next::<Event>().await.unwrap().unwrap();
match event {
Event::Snapshot { session, .. } => {
return session.entries.into_iter().any(|entry| match entry.data {
protocol::SessionSnapshotEntryData::UserInput { segments } => { protocol::SessionSnapshotEntryData::UserInput { segments } => {
protocol::Segment::flatten_to_text(&segments).contains(needle) protocol::Segment::flatten_to_text(&segments).contains(needle)
} }
_ => false, _ => false,
}); })
}
Event::Alert(_) => continue,
other => panic!("expected Snapshot first, got {other:?}"),
}
}
} }
#[tokio::test] #[tokio::test]
+4 -36
View File
@@ -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::WorkerEvent;
use worker::ipc::event::render_event;
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;
#[test] #[test]
fn render_event_keeps_bounded_legacy_diagnostics() { fn render_event_identifies_child_and_failure() {
let rendered = render_event(&WorkerEvent::Errored { let rendered = render_event(&WorkerEvent::Errored {
worker_name: "legacy-child".into(), worker_name: "legacy-child".into(),
message: "boom".into(), message: "boom".into(),
@@ -17,30 +12,3 @@ fn render_event_keeps_bounded_legacy_diagnostics() {
assert!(rendered.contains("legacy-child")); assert!(rendered.contains("legacy-child"));
assert!(rendered.contains("boom")); 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, &registry, "parent", &None).await;
assert!(!runtime_dir.path().join("spawned_workers.json").exists());
}
-1
View File
@@ -4169,7 +4169,6 @@ impl WorkspaceApi {
.with_runtime_request_source(&embedded_identity, embedded_request_audience), .with_runtime_request_source(&embedded_identity, embedded_request_audience),
) )
.with_runtime_store_dir(config.embedded_runtime_store_root.clone()) .with_runtime_store_dir(config.embedded_runtime_store_root.clone())
.with_controller_transport(worker::WorkerControllerTransport::InProcess)
.with_resource_client(Arc::new(resource_broker.clone())), .with_resource_client(Arc::new(resource_broker.clone())),
) )
.map_err(|err| { .map_err(|err| {
@@ -467,7 +467,6 @@ impl AttentionWorkerFactory {
worker::WorkerBootstrapLayout::RuntimeManagedRun { worker::WorkerBootstrapLayout::RuntimeManagedRun {
run_dir: root.join(uuid::Uuid::now_v7().to_string()), run_dir: root.join(uuid::Uuid::now_v7().to_string()),
}, },
worker::WorkerControllerTransport::InProcess,
) )
.start() .start()
.await .await
@@ -800,7 +800,6 @@ impl DeliveryWorkerFactory {
worker::WorkerBootstrapLayout::RuntimeManagedRun { worker::WorkerBootstrapLayout::RuntimeManagedRun {
run_dir: root.join(uuid::Uuid::now_v7().to_string()), run_dir: root.join(uuid::Uuid::now_v7().to_string()),
}, },
worker::WorkerControllerTransport::InProcess,
) )
.with_model_client(self.client.clone()); .with_model_client(self.client.clone());
let prepared = if restore { let prepared = if restore {
+1 -1
View File
@@ -15,7 +15,7 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] }
client = { workspace = true } client = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
manifest = { workspace = true } manifest = { workspace = true }
worker = { workspace = true } protocol = { workspace = true }
server-api = { workspace = true } server-api = { workspace = true }
workdir = { workspace = true } workdir = { workspace = true }
tokio-tungstenite = { workspace = true } tokio-tungstenite = { workspace = true }
+4 -4
View File
@@ -25,7 +25,7 @@ pub(crate) enum CliCommand {
Panel, Panel,
Keys, Keys,
SetupModel, SetupModel,
WorkerRuntime, Serve,
WorkerCleanup, WorkerCleanup,
Ticket, Ticket,
Objective, Objective,
@@ -45,7 +45,7 @@ impl CliCommand {
CliCommand::Panel => "yoi panel", CliCommand::Panel => "yoi panel",
CliCommand::Keys => "yoi keys", CliCommand::Keys => "yoi keys",
CliCommand::SetupModel => "yoi setup-model", CliCommand::SetupModel => "yoi setup-model",
CliCommand::WorkerRuntime => "yoi worker", CliCommand::Serve => "yoi --local serve",
CliCommand::WorkerCleanup => "yoi worker management", CliCommand::WorkerCleanup => "yoi worker management",
CliCommand::Ticket => "yoi ticket", CliCommand::Ticket => "yoi ticket",
CliCommand::Objective => "yoi objective", CliCommand::Objective => "yoi objective",
@@ -68,7 +68,7 @@ impl CliCommand {
CliCommand::Login => CliConnectionRequirement::BackendOnly, CliCommand::Login => CliConnectionRequirement::BackendOnly,
CliCommand::Keys CliCommand::Keys
| CliCommand::SetupModel | CliCommand::SetupModel
| CliCommand::WorkerRuntime | CliCommand::Serve
| CliCommand::WorkerCleanup | CliCommand::WorkerCleanup
| CliCommand::Plugin | CliCommand::Plugin
| CliCommand::Mcp | CliCommand::Mcp
@@ -325,7 +325,7 @@ mod tests {
for command in [ for command in [
CliCommand::Keys, CliCommand::Keys,
CliCommand::SetupModel, CliCommand::SetupModel,
CliCommand::WorkerRuntime, CliCommand::Serve,
CliCommand::WorkerCleanup, CliCommand::WorkerCleanup,
CliCommand::Plugin, CliCommand::Plugin,
CliCommand::Mcp, CliCommand::Mcp,
+273
View File
@@ -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 <PATH> [--profile <REF>] [--workspace <PATH>]
yoi --local serve --socket <PATH> --worker-id <UUID>
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 <PATH> Host-owned Unix endpoint (required)
--profile <REF> Profile for a new Worker; conflicts with --worker-id
--workspace <PATH> Working directory for a new Worker (defaults to cwd)
--worker-id <UUID> Restore a saved Standalone Worker
-h, --help Print help
Connect with `yoi --local attach --socket <PATH>`. 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 <PATH>
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 <PATH> 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<PathBuf>,
pub profile: Option<String>,
pub restore: Option<protocol::WorkerId>,
}
pub(crate) fn parse(command: &str, args: &[String]) -> Result<LocalCli, ParseError> {
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 <PATH>")))?;
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<T>(slot: &mut Option<T>, 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<dyn std::error::Error>> {
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<LocalCli, ParseError> {
parse(
command,
&args.iter().map(|arg| (*arg).to_owned()).collect::<Vec<_>>(),
)
}
#[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 <PATH>"));
}
assert!(ATTACH_HELP.contains("JSONL"));
assert!(ATTACH_HELP.contains("EOF disconnects"));
}
}
+190 -159
View File
@@ -4,6 +4,7 @@
//! execution in lower-level crates. //! execution in lower-level crates.
mod cli_connection; mod cli_connection;
mod local_cli;
mod mcp_cli; mod mcp_cli;
mod objective_cli; mod objective_cli;
mod plugin_cli; mod plugin_cli;
@@ -66,7 +67,12 @@ enum Mode {
WorkspaceCreate(WorkspaceCreateOptions), WorkspaceCreate(WorkspaceCreateOptions),
WorkdirHelp, WorkdirHelp,
WorkdirShare(workdir_share::WorkdirShareOptions), WorkdirShare(workdir_share::WorkdirShareOptions),
WorkerRuntime(Vec<String>), LocalHelp(&'static str),
LocalServe {
options: local_cli::ServeOptions,
state_dir: PathBuf,
},
LocalAttach(PathBuf),
Keys, Keys,
SetupModel, SetupModel,
Tui { 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::Keys => tui::keys::launch().await,
Mode::SetupModel => tui::setup_model::launch().await, Mode::SetupModel => tui::setup_model::launch().await,
Mode::Tui { Mode::Tui {
@@ -467,6 +492,33 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
"workdir" => { "workdir" => {
return parse_workdir_args(&args[1..], &target_selection); 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" => { "worker" => {
if let Some(cli) = worker_cleanup_cli::parse_worker_management_args(&args[1..]) if let Some(cli) = worker_cleanup_cli::parse_worker_management_args(&args[1..])
.map_err(|e| ParseError(e.to_string()))? .map_err(|e| ParseError(e.to_string()))?
@@ -475,9 +527,9 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
resolve_local_cli_connection(connection_resolver, CliCommand::WorkerCleanup)?; resolve_local_cli_connection(connection_resolver, CliCommand::WorkerCleanup)?;
return Ok(Mode::WorkerCleanup(cli)); return Ok(Mode::WorkerCleanup(cli));
} }
let _target = return Err(ParseError(
resolve_local_cli_connection(connection_resolver, CliCommand::WorkerRuntime)?; "direct Worker execution was removed; use `yoi --local serve --socket <PATH>` (legacy metadata cleanup: `yoi worker --help`)".to_owned(),
return Ok(Mode::WorkerRuntime(args[1..].to_vec())); ));
} }
"objective" => { "objective" => {
let cli = objective_cli::parse_objective_args(&args[1..]) let cli = objective_cli::parse_objective_args(&args[1..])
@@ -973,10 +1025,8 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
) -> Result<Mode, ParseError> { ) -> Result<Mode, ParseError> {
let mut workspace_root = current_dir()?; let mut workspace_root = current_dir()?;
let mut worker_name = None; let mut worker_name = None;
let mut session = None;
let mut profile = None; let mut profile = None;
let mut subject_id = None; let mut subject_id = None;
let mut socket_override = None;
let mut runtime_id = None; let mut runtime_id = None;
let mut worker_id = None; let mut worker_id = None;
let mut i = 0; let mut i = 0;
@@ -1003,26 +1053,6 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
workspace_root = PathBuf::from(value); workspace_root = PathBuf::from(value);
i += 2; 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" => { "--subject" => {
let value = required_option_value(args, i, "--subject")?; let value = required_option_value(args, i, "--subject")?;
set_subject_selector(&mut subject_id, value)?; set_subject_selector(&mut subject_id, value)?;
@@ -1078,22 +1108,6 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
workspace_root = PathBuf::from(value); workspace_root = PathBuf::from(value);
i += 1; 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=") => { arg if arg.starts_with("--profile=") => {
let value = arg.trim_start_matches("--profile="); let value = arg.trim_start_matches("--profile=");
if value.is_empty() { if value.is_empty() {
@@ -1138,11 +1152,7 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
} }
if subject_id.is_some() { if subject_id.is_some() {
if session.is_some() if runtime_id.is_some() || worker_id.is_some() {
|| socket_override.is_some()
|| runtime_id.is_some()
|| worker_id.is_some()
{
return Err(ParseError( return Err(ParseError(
"--subject can only be used for fresh local spawn, not attach or restore" "--subject can only be used for fresh local spawn, not attach or restore"
.to_owned(), .to_owned(),
@@ -1160,29 +1170,12 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
"--worker-id requires --runtime-id for Runtime API attach".to_string(), "--worker-id requires --runtime-id for Runtime API attach".to_string(),
)); ));
} }
if (runtime_id.is_some() || worker_id.is_some()) if (runtime_id.is_some() || worker_id.is_some()) && (worker_name.is_some() || profile.is_some())
&& (session.is_some()
|| worker_name.is_some()
|| socket_override.is_some()
|| profile.is_some())
{ {
return Err(ParseError( 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() { let target = if subject_id.is_some() {
// A local Subject selector must never trigger Backend Workspace discovery. // A local Subject selector must never trigger Backend Workspace discovery.
@@ -1215,18 +1208,6 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
.to_string(), .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) { if let (Some(runtime_id), Some(worker_id)) = (runtime_id.clone(), worker_id) {
@@ -1253,13 +1234,6 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
}); });
} }
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 { let mode = if target.kind() == TargetKind::Standalone {
LaunchMode::Spawn { LaunchMode::Spawn {
worker_name, worker_name,
@@ -1267,14 +1241,9 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
subject_id, subject_id,
} }
} else { } else {
if worker_name.is_some() if worker_name.is_some() || profile.is_some() {
|| profile.is_some()
|| session.is_some()
|| socket_override.is_some()
{
return Err(ParseError( return Err(ParseError(
"Backend target does not accept host-local Worker, profile, session, or socket selectors" "Backend target does not accept host-local Worker or profile selectors".to_string(),
.to_string(),
)); ));
} }
LaunchMode::BackendSpawn LaunchMode::BackendSpawn
@@ -2109,7 +2078,9 @@ Host commands:
yoi workdir share Interactively share a client-hosted External Workdir. yoi workdir share Interactively share a client-hosted External Workdir.
keys Manage local model/API keys keys Manage local model/API keys
setup-model Configure a local model provider setup-model Configure a local model provider
worker [WORKER_OPTIONS] Run the direct Worker process entrypoint --local serve --socket <PATH> Run a headless Standalone Host (serve --help for restore)
--local attach --socket <PATH> Connect a JSONL protocol client; EOF leaves Host running
worker delete/prune Safely clean up legacy Worker metadata only
ticket <COMMAND> Manage Tickets through a Backend target ticket <COMMAND> Manage Tickets through a Backend target
objective <COMMAND> Manage Objectives through a Backend target objective <COMMAND> Manage Objectives through a Backend target
plugin <COMMAND> Author/check/pack explicit Plugin packages plugin <COMMAND> Author/check/pack explicit Plugin packages
@@ -2939,39 +2910,23 @@ backend = "shared"
} }
#[test] #[test]
fn parser_standalone_rejects_legacy_session_and_socket_inputs() { fn console_rejects_removed_session_and_socket_options() {
let resolver = DefaultBackendCliConnectionResolver { for prefix in [vec!["--local"], vec!["--backend", "http://backend.example"]] {
backend_url: "http://default-backend.example", for option in [
}; "--session",
let session_args = ["--local", "--session", "session-1"] "--session=log",
.into_iter()
.map(String::from)
.collect::<Vec<_>>();
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", "--socket",
"/tmp/worker.sock", "--socket=endpoint",
] ] {
.into_iter() let args = [prefix.clone(), vec![option]].concat();
.map(String::from) let error = parse_args_from(args).unwrap_err();
.collect::<Vec<_>>(); assert_eq!(error.to_string(), format!("unknown argument: {option}"));
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"
);
} }
#[test] #[test]
fn parse_backend_runtime_target_rejects_legacy_socket_mix() { fn backend_runtime_target_rejects_local_launch_options() {
let err = parse_args_from([ let err = parse_args_from([
"--backend", "--backend",
"http://127.0.0.1:8787", "http://127.0.0.1:8787",
@@ -2985,7 +2940,7 @@ backend = "shared"
.unwrap_err(); .unwrap_err();
assert_eq!( assert_eq!(
err.to_string(), 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,13 +3079,119 @@ backend = "shared"
} }
#[test] #[test]
fn parse_worker_subcommand_uses_runtime_mode() { fn direct_worker_execution_is_rejected_without_compatibility_fallback() {
match parse_args_from(["worker", "--worker", "agent", "--profile", "default"]).unwrap() { for args in [
Mode::WorkerRuntime(args) => { vec!["worker"],
assert_eq!(args, ["--worker", "agent", "--profile", "default"]) 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"));
} }
_ => panic!("expected WorkerRuntime mode"),
} }
#[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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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<Box<dyn Target>, 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::<Vec<_>>();
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::<Vec<_>>();
assert!(matches!(
parse_args_slice_with_connection_resolver(&args, &resolver).unwrap(),
Mode::LocalAttach(_)
));
assert!(TOP_LEVEL_HELP.contains("--local serve --socket <PATH>"));
assert!(TOP_LEVEL_HELP.contains("--local attach --socket <PATH>"));
} }
#[test] #[test]
@@ -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] #[test]
fn parse_dashboard_word_is_not_an_alias_or_worker_name() { fn parse_dashboard_word_is_not_an_alias_or_worker_name() {
let err = parse_args_from(["dashboard"]).unwrap_err(); let err = parse_args_from(["dashboard"]).unwrap_err();
+6 -1
View File
@@ -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::fmt;
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -484,7 +489,7 @@ fn to_error<E: fmt::Display>(error: E) -> WorkerCleanupCliError {
} }
pub fn help_text() -> &'static str { pub fn help_text() -> &'static str {
"yoi worker\n\nUsage:\n yoi worker delete <NAME> [--force] [--dry-run]\n yoi worker prune --older-than <DURATION> [--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 <NAME> [--force] [--dry-run]\n yoi worker prune --older-than <DURATION> [--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 <PATH>` 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 { fn delete_help_text() -> &'static str {