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:
Generated
+1
-2
@@ -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]]
|
||||||
|
|||||||
@@ -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")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+40
-660
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
@@ -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,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;
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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<_>>()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,4 +1,3 @@
|
|||||||
pub mod comm_tools;
|
|
||||||
mod published;
|
mod published;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
pub mod tool;
|
pub mod tool;
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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, ®istry, "parent", &None).await;
|
|
||||||
|
|
||||||
assert!(!runtime_dir.path().join("spawned_workers.json").exists());
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user