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:
@@ -23,7 +23,7 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
session-store.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio = { workspace = true, features = ["rt", "sync", "time"] }
|
||||
tokio = { workspace = true, features = ["rt", "sync", "time", "net", "io-util", "io-std", "signal"] }
|
||||
uuid = { workspace = true, features = ["v7"] }
|
||||
worker.workspace = true
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ A Host that starts existing Workers in the same process. Assembles interactive W
|
||||
|
||||
## Reference index
|
||||
|
||||
- [`host`] — bootstrap and confirmed shutdown.
|
||||
- [`host`] — bootstrap and confirmed shutdown, with or without a TUI.
|
||||
- [`unix`] — optional operator transport; disconnect does not release execution ownership.
|
||||
- [`launch`] — Profile, cwd, and Subject validation before launch.
|
||||
- [`store`] — Worker records and leases.
|
||||
- [`jobs`] — Job execution and independent result and cleanup lifetimes.
|
||||
|
||||
@@ -24,7 +24,6 @@ use session_store::{
|
||||
};
|
||||
use thiserror::Error;
|
||||
use worker::bootstrap::{WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout};
|
||||
use worker::controller::WorkerControllerTransport;
|
||||
use worker::ipc::protocol_session::{
|
||||
WorkerProtocolSessionStreams, dispatch_worker_protocol_method, live_log_entry_event,
|
||||
subscribe_worker_protocol_session,
|
||||
@@ -222,7 +221,6 @@ impl StandaloneHost {
|
||||
workspace_context,
|
||||
filesystem_authority,
|
||||
WorkerBootstrapLayout::Direct { runtime_base },
|
||||
WorkerControllerTransport::InProcess,
|
||||
);
|
||||
if let Some(subject) = &subject {
|
||||
bootstrap = bootstrap.with_subjektiv_host(subject.host.clone());
|
||||
@@ -430,7 +428,6 @@ impl StandaloneHost {
|
||||
workspace_context,
|
||||
filesystem_authority,
|
||||
WorkerBootstrapLayout::Direct { runtime_base },
|
||||
WorkerControllerTransport::InProcess,
|
||||
);
|
||||
if let Some(subject) = &subject {
|
||||
bootstrap = bootstrap.with_subjektiv_host(subject.host.clone());
|
||||
@@ -520,10 +517,7 @@ impl StandaloneHost {
|
||||
/// Working events, committed session entries, alert snapshots, and the
|
||||
/// initial history snapshot are merged behind the client boundary.
|
||||
pub fn connect(&self) -> Client<InProcessSocket> {
|
||||
let streams = subscribe_worker_protocol_session(&self.handle);
|
||||
let (socket, peer) = InProcessSocket::pair();
|
||||
tokio::spawn(run_protocol_session(self.handle.clone(), streams, peer));
|
||||
Client::new(socket)
|
||||
Client::new(connect_protocol(self.handle.clone()))
|
||||
}
|
||||
|
||||
/// Logical Subject disconnection, not Worker shutdown or completion of its represented work.
|
||||
@@ -565,6 +559,35 @@ impl StandaloneHost {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run without a TUI until an admitted protocol Shutdown, controller exit, or process signal.
|
||||
/// Client EOF only disconnects that client. All exits confirm the same production shutdown
|
||||
/// and lease release as the in-process UI; an unconfirmed shutdown retains the lease.
|
||||
pub async fn serve_unix(
|
||||
self,
|
||||
endpoint: crate::unix::UnixEndpoint,
|
||||
) -> Result<(), crate::HeadlessError> {
|
||||
let mut adapter = crate::unix::UnixAdapter::start(endpoint, self.handle.clone());
|
||||
let signal = async {
|
||||
let mut terminate =
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
|
||||
tokio::select! {
|
||||
result = tokio::signal::ctrl_c() => result,
|
||||
_ = terminate.recv() => Ok(()),
|
||||
}
|
||||
};
|
||||
let result = tokio::select! {
|
||||
_ = self.handle.protocol_closed() => Ok(()),
|
||||
result = signal => result,
|
||||
result = adapter.ended() => result,
|
||||
};
|
||||
// Reclaim the public entry and every connection before releasing execution ownership.
|
||||
let transport = adapter.shutdown().await;
|
||||
self.shutdown().await?;
|
||||
result?;
|
||||
transport?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn with_shutdown_timeout(mut self, shutdown_timeout: Duration) -> Self {
|
||||
self.shutdown_timeout = shutdown_timeout;
|
||||
self
|
||||
@@ -647,9 +670,8 @@ impl Drop for StandaloneHost {
|
||||
}
|
||||
}
|
||||
|
||||
// The in-process channel is owned by the local operator's Host, not an
|
||||
// arbitrary socket or Backend account. decode_method discards wire provenance;
|
||||
// only this boundary can stamp accepted local human input.
|
||||
// Both in-process views and credential-checked Unix clients are admitted by this operator's
|
||||
// Host. decode_method discards wire provenance; only this boundary stamps local human input.
|
||||
fn authorize_local_input(method: protocol::Method) -> protocol::Method {
|
||||
use protocol::{AuthenticatedInputSource, Method};
|
||||
match method {
|
||||
@@ -692,6 +714,13 @@ fn authorize_local_input(method: protocol::Method) -> protocol::Method {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn connect_protocol(handle: worker::WorkerHandle) -> InProcessSocket {
|
||||
let streams = subscribe_worker_protocol_session(&handle);
|
||||
let (socket, peer) = InProcessSocket::pair();
|
||||
tokio::spawn(run_protocol_session(handle, streams, peer));
|
||||
socket
|
||||
}
|
||||
|
||||
async fn run_protocol_session(
|
||||
handle: worker::WorkerHandle,
|
||||
streams: WorkerProtocolSessionStreams,
|
||||
|
||||
@@ -7,6 +7,44 @@ pub mod launch;
|
||||
pub mod store;
|
||||
pub mod subjektiv;
|
||||
mod subjektiv_sessions;
|
||||
pub mod unix;
|
||||
|
||||
pub use unix::{UnixEndpoint, relay_unix_client, run_unix_client};
|
||||
|
||||
/// Restore reads the saved cwd and Profile, not a newly resolved launch recipe.
|
||||
pub enum HeadlessLaunch {
|
||||
Fresh(ResolvedStandaloneLaunch),
|
||||
Restore {
|
||||
state_dir: std::path::PathBuf,
|
||||
worker_id: WorkerId,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum HeadlessError {
|
||||
#[error(transparent)]
|
||||
Transport(#[from] std::io::Error),
|
||||
#[error(transparent)]
|
||||
Startup(#[from] StandaloneStartupError),
|
||||
#[error(transparent)]
|
||||
Shutdown(#[from] StandaloneShutdownError),
|
||||
}
|
||||
|
||||
/// Reserve the optional public transport before creating or restoring execution state.
|
||||
pub async fn run_headless(
|
||||
launch: HeadlessLaunch,
|
||||
endpoint: std::path::PathBuf,
|
||||
) -> Result<(), HeadlessError> {
|
||||
let endpoint = UnixEndpoint::bind(endpoint)?;
|
||||
let host = match launch {
|
||||
HeadlessLaunch::Fresh(launch) => StandaloneHost::start(launch).await?,
|
||||
HeadlessLaunch::Restore {
|
||||
state_dir,
|
||||
worker_id,
|
||||
} => StandaloneHost::restore(state_dir, worker_id).await?,
|
||||
};
|
||||
host.serve_unix(endpoint).await
|
||||
}
|
||||
|
||||
pub use host::{
|
||||
StandaloneHost, StandaloneShutdownError, StandaloneStartupError,
|
||||
|
||||
@@ -127,7 +127,6 @@ async fn local_host_compaction_freezes_pending_journal_through_request_and_durab
|
||||
worker::WorkerWorkspaceContext::no_workspace(),
|
||||
worker::WorkerFilesystemAuthority::None,
|
||||
worker::WorkerBootstrapLayout::Direct { runtime_base: fixture._temp.path().join("runtime") },
|
||||
worker::WorkerControllerTransport::InProcess,
|
||||
).with_model_client(model.clone()).with_subjektiv_host(fixture.host().clone()).prepare_restored("journal-test").await.unwrap();
|
||||
assert_eq!(prepared.worker().session_id().to_string(), fixture.context.session_id);
|
||||
let source_segment = prepared.worker().segment_id();
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
#[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 {
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
|
||||
Reference in New Issue
Block a user