feat: unify headless execution under the Standalone Host

Expose an optional private same-uid Unix operator adapter and JSONL serve/attach CLI using the production Standalone Profile, store, restore lease and shutdown lifecycle. Remove direct Worker subprocess/discovery/readiness/controller sockets and parent socket callbacks; retain metadata-only peer registration and legacy safety readers.

Validated real Host/client input, events, provenance, disconnect, admitted shutdown and shared production restore/duplicate exclusion; normal standalone/worker/manifest/protocol/client/yoi/tui/server tests, root check, rustdoc, fmt and state-contract audit. Runtime lib: 476 passed, damaged_checkout_does_not_block_restore_but_is_rejected_when_used fails at missing_git diagnostic; unchanged restore logic, not relaxed or retried. Runtime bins/docs pass.
This commit is contained in:
2026-10-22 01:29:39 +09:00
parent 5aa2dc579a
commit 533477c2e7
40 changed files with 1830 additions and 5363 deletions
+155 -334
View File
@@ -1738,42 +1738,24 @@ async fn snapshot_includes_user_input_for_in_flight_turn() {
// The Running event is the in-flight visibility fence: the committed
// annotated input must already be available to an immediately attaching
// subscriber rather than racing behind this status transition.
let stream = tokio::net::UnixStream::connect(handle.runtime_dir.socket_path())
.await
.unwrap();
let (reader, _writer) = stream.into_split();
let mut reader = protocol::stream::JsonLineReader::new(reader);
loop {
let event = reader.next::<Event>().await.unwrap().unwrap();
match event {
Event::Snapshot { session, .. } => {
let found = session.entries.iter().any(|entry| match &entry.data {
protocol::SessionSnapshotEntryData::UserInput { segments } => {
protocol::Segment::flatten_to_text(segments) == "hello in-flight"
}
protocol::SessionSnapshotEntryData::Message {
role: protocol::SessionMessageRole::User,
content,
} => content.iter().any(|part| {
matches!(
part,
protocol::SessionContentPart::Text { text }
if text == "hello in-flight"
)
}),
_ => false,
});
assert!(
found,
"snapshot must carry the in-flight UserInput entry: {session:?}"
);
return;
let Event::Snapshot { session, .. } = handle.snapshot_event() else {
panic!("expected snapshot");
};
assert!(
session.entries.iter().any(|entry| match &entry.data {
protocol::SessionSnapshotEntryData::UserInput { segments } => {
protocol::Segment::flatten_to_text(segments) == "hello in-flight"
}
Event::Alert(_) => continue,
other => panic!("expected Snapshot first, got {other:?}"),
}
}
protocol::SessionSnapshotEntryData::Message {
role: protocol::SessionMessageRole::User,
content,
} => content.iter().any(|part| matches!(part,
protocol::SessionContentPart::Text { text } if text == "hello in-flight"
)),
_ => false,
}),
"snapshot must carry the in-flight UserInput entry: {session:?}"
);
}
#[tokio::test]
@@ -1855,24 +1837,10 @@ async fn attach_snapshot_includes_current_status() {
.unwrap();
wait_for_status(&handle, WorkerStatus::Running).await;
let stream = tokio::net::UnixStream::connect(handle.runtime_dir.socket_path())
.await
.unwrap();
let (reader, _writer) = stream.into_split();
let mut reader = protocol::stream::JsonLineReader::new(reader);
// First event after connect is the snapshot — it carries the current status.
loop {
let event = reader.next::<Event>().await.unwrap().unwrap();
match event {
Event::Snapshot { state, .. } => {
assert_eq!(state.catalog_status(), WorkerStatus::Running);
return;
}
Event::Alert(_) => continue,
other => panic!("expected Snapshot, got {other:?}"),
}
}
let Event::Snapshot { state, .. } = handle.snapshot_event() else {
panic!("expected snapshot");
};
assert_eq!(state.catalog_status(), WorkerStatus::Running);
}
#[tokio::test]
@@ -2928,7 +2896,7 @@ async fn status_json_reflects_worker_name() {
}
// ---------------------------------------------------------------------------
// Socket transport tests
// Controller lifecycle tests
// ---------------------------------------------------------------------------
#[tokio::test]
@@ -2966,283 +2934,148 @@ async fn shutdown_closes_method_admission_before_terminal_confirmation() {
shutdown_rx.await.unwrap();
}
/// Uses the real Controller and metadata store; only the model client is replaced.
#[tokio::test]
async fn shutdown_joins_socket_server_with_active_connection() {
use tokio::net::UnixStream;
async fn register_peer_returns_existing_response_and_persists_reciprocal_metadata() {
use session_store::{WorkerMetadata, WorkerMetadataStore};
let worker = make_worker(MockClient::new(simple_text_events())).await;
let runtime_base = tempfile::tempdir().unwrap();
let (handle, shutdown_rx) = WorkerController::spawn(worker, runtime_base.path())
.await
let store = worker.store().clone();
store
.write(&WorkerMetadata::new("test-worker", None))
.unwrap();
let socket_path = handle.runtime_dir.socket_path();
let _connection = UnixStream::connect(&socket_path).await.unwrap();
store
.write(&WorkerMetadata::new("peer-worker", None))
.unwrap();
let handle = spawn_controller(worker).await;
let mut events = handle.subscribe();
handle
.send(Method::Shutdown {
command: worker_command(&handle),
.send(Method::RegisterPeer {
name: "peer-worker".into(),
})
.await
.unwrap();
tokio::time::timeout(std::time::Duration::from_secs(5), shutdown_rx)
assert!(
drain_until(
&mut events,
std::time::Duration::from_secs(2),
|event| matches!(
event,
Event::PeerRegistered { result } if result == &serde_json::json!({
"source": "test-worker", "peer": "peer-worker",
})
)
)
.await
.expect("controller should join its socket tasks")
.expect("controller shutdown signal should remain open");
assert!(!socket_path.exists());
);
assert_eq!(
store.read_by_name("test-worker").unwrap().unwrap().peers[0].worker_name,
"peer-worker"
);
assert_eq!(
store.read_by_name("peer-worker").unwrap().unwrap().peers[0].worker_name,
"test-worker"
);
assert_eq!(handle.shared_state.catalog_status(), WorkerStatus::Idle);
}
#[tokio::test]
async fn socket_run_receives_events() {
use protocol::stream::{JsonLineReader, JsonLineWriter};
use tokio::net::UnixStream;
let client = MockClient::new(simple_text_events());
let worker = make_worker(client).await;
async fn register_peer_missing_target_is_rejected_without_starting_a_worker() {
use session_store::{WorkerMetadata, WorkerMetadataStore};
let worker = make_worker(MockClient::new(simple_text_events())).await;
let store = worker.store().clone();
store
.write(&WorkerMetadata::new("test-worker", None))
.unwrap();
let handle = spawn_controller(worker).await;
// Give the socket server a moment to bind
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let sock_path = handle.runtime_dir.socket_path();
let stream = UnixStream::connect(&sock_path).await.unwrap();
let (reader, writer) = stream.into_split();
let mut reader = JsonLineReader::new(reader);
let mut writer = JsonLineWriter::new(writer);
// Send run method via socket
writer
.write(&Method::submit_text(
protocol::new_submission_request_id(),
"Hello",
))
let mut events = handle.subscribe();
handle
.send(Method::RegisterPeer {
name: "missing-peer".into(),
})
.await
.unwrap();
// Collect events
let mut saw_turn_start = false;
let mut saw_text_delta = false;
let mut saw_turn_end = false;
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
loop {
tokio::select! {
event = reader.next::<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(),
}))
assert!(
drain_until(
&mut events,
std::time::Duration::from_secs(2),
|event| matches!(
event,
Event::Error { code: worker::ErrorCode::InvalidRequest, message }
if message.contains("missing-peer") && message.contains("does not exist")
)
)
.await
);
assert!(store.read_by_name("missing-peer").unwrap().is_none());
assert!(
store
.read_by_name("test-worker")
.unwrap()
.unwrap()
.peers
.is_empty()
);
}
#[tokio::test]
async fn legacy_process_discovery_methods_require_host_or_runtime_authority() {
let worker = make_worker(MockClient::new(simple_text_events())).await;
let handle = spawn_controller(worker).await;
let mut events = handle.subscribe();
for method in [
Method::ListWorkers,
Method::RestoreWorker {
name: "old-worker".into(),
},
] {
handle.send(method).await.unwrap();
assert!(
drain_until(
&mut events,
std::time::Duration::from_secs(2),
|event| matches!(
event,
Event::Error { code: worker::ErrorCode::InvalidRequest, message }
if message.contains("Host or Runtime")
)
)
.await
);
}
assert_eq!(handle.shared_state.catalog_status(), WorkerStatus::Idle);
}
#[tokio::test]
async fn all_controller_launch_modes_leave_external_transport_to_the_host() {
let root = tempfile::tempdir().unwrap();
for mode in ["direct", "runtime", "run"] {
let worker = make_worker(MockClient::new(simple_text_events())).await;
let base = root.path().join(mode);
let (handle, shutdown) = match mode {
"direct" => WorkerController::spawn(worker, &base).await,
"runtime" => WorkerController::spawn_runtime_managed(worker, &base).await,
"run" => {
WorkerController::spawn_runtime_managed_run(worker, &base.join("runs/1")).await
}
_ => unreachable!(),
}
.unwrap();
let mut saw_worker_event_echo = false;
let mut saw_turn_start = false;
let mut saw_turn_end = false;
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
// The SystemItem and TurnEnd events arrive through independent
// broadcast lanes (sink fan-out vs `working_event_tx`), so their relative
// order on the wire is non-deterministic. Keep reading until both
// are observed (or the deadline trips), rather than breaking on
// the first TurnEnd.
loop {
if saw_worker_event_echo && saw_turn_end {
break;
}
tokio::select! {
event = reader.next::<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,
for legacy in ["pid", "status.json", "manifest.toml", "sock", "worker.sock"] {
assert!(
!handle.runtime_dir.path().join(legacy).exists(),
"{mode} created {legacy}"
);
}
handle
.send(Method::Shutdown {
command: worker_command(&handle),
})
.await
.unwrap();
tokio::time::timeout(std::time::Duration::from_secs(5), shutdown)
.await
.unwrap()
.unwrap();
}
}
@@ -4006,27 +3839,15 @@ fn item_text_contains(item: &Item, needle: &str) -> bool {
}
async fn snapshot_contains_user_input(handle: &WorkerHandle, needle: &str) -> bool {
let stream = tokio::net::UnixStream::connect(handle.runtime_dir.socket_path())
.await
.unwrap();
let (reader, _writer) = stream.into_split();
let mut reader = protocol::stream::JsonLineReader::new(reader);
loop {
let event = reader.next::<Event>().await.unwrap().unwrap();
match event {
Event::Snapshot { session, .. } => {
return session.entries.into_iter().any(|entry| match entry.data {
protocol::SessionSnapshotEntryData::UserInput { segments } => {
protocol::Segment::flatten_to_text(&segments).contains(needle)
}
_ => false,
});
}
Event::Alert(_) => continue,
other => panic!("expected Snapshot first, got {other:?}"),
let Event::Snapshot { session, .. } = handle.snapshot_event() else {
panic!("expected snapshot");
};
session.entries.into_iter().any(|entry| match entry.data {
protocol::SessionSnapshotEntryData::UserInput { segments } => {
protocol::Segment::flatten_to_text(&segments).contains(needle)
}
}
_ => false,
})
}
#[tokio::test]
+4 -36
View File
@@ -1,15 +1,10 @@
//! Legacy process callback events are diagnostics only after Internal SubWorker migration.
//! Agent-visible Internal SubWorker event summaries.
use std::sync::Arc;
use protocol::{Permission, ScopeRule, WorkerEvent};
use tempfile::TempDir;
use worker::ipc::event::{apply_event_side_effects, render_event};
use worker::runtime::dir::RuntimeDir;
use worker::spawn::registry::SpawnedWorkerRegistry;
use protocol::WorkerEvent;
use worker::ipc::event::render_event;
#[test]
fn render_event_keeps_bounded_legacy_diagnostics() {
fn render_event_identifies_child_and_failure() {
let rendered = render_event(&WorkerEvent::Errored {
worker_name: "legacy-child".into(),
message: "boom".into(),
@@ -17,30 +12,3 @@ fn render_event_keeps_bounded_legacy_diagnostics() {
assert!(rendered.contains("legacy-child"));
assert!(rendered.contains("boom"));
}
#[tokio::test]
async fn legacy_callback_cannot_register_process_subworker_authority() {
let runtime_base = TempDir::new().unwrap();
let runtime_dir = Arc::new(
RuntimeDir::create(runtime_base.path(), "parent")
.await
.unwrap(),
);
let registry = SpawnedWorkerRegistry::new(runtime_dir.clone());
let scope_root = TempDir::new().unwrap();
let event = WorkerEvent::ScopeSubDelegated {
parent_worker: "legacy-parent".into(),
sub_worker: "legacy-child".into(),
sub_socket: "/tmp/legacy-child.sock".into(),
scope: vec![ScopeRule {
target: scope_root.path().to_path_buf(),
permission: Permission::Write,
recursive: true,
symlink_policy: Default::default(),
}],
};
apply_event_side_effects(&event, &registry, "parent", &None).await;
assert!(!runtime_dir.path().join("spawned_workers.json").exists());
}