fix: route standalone protocol through client transports

This commit is contained in:
2026-08-31 12:11:35 +09:00
parent 10eaf4a5fb
commit a7f09fad98
15 changed files with 825 additions and 471 deletions
+1
View File
@@ -7,6 +7,7 @@ license.workspace = true
[dependencies]
agen.workspace = true
client.workspace = true
fs4.workspace = true
manifest.workspace = true
protocol.workspace = true
+121 -20
View File
@@ -2,14 +2,20 @@ use std::path::PathBuf;
use std::time::Duration;
use agen::llm_client::client::LlmClient;
use client::Client;
use client::transport::in_process::{Peer as InProcessPeer, Socket as InProcessSocket};
use protocol::stream::{decode_method, encode_event};
use protocol::{Event, Method};
use session_store::{
CombinedStore, FsStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerMetadataStore,
};
use thiserror::Error;
use tokio::sync::broadcast;
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,
};
use worker::{BootstrappedWorker, WorkerError, WorkerFilesystemAuthority, WorkerWorkspaceContext};
use crate::launch::ResolvedStandaloneLaunch;
@@ -55,12 +61,6 @@ pub enum StandaloneStartupError {
Controller,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum StandaloneRequestError {
#[error("the standalone Worker is no longer accepting requests")]
WorkerUnavailable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum StandaloneShutdownError {
#[error("the standalone Worker did not stop before the shutdown deadline")]
@@ -277,19 +277,15 @@ impl StandaloneHost {
&self.record
}
pub async fn send(&self, method: Method) -> Result<(), StandaloneRequestError> {
self.handle
.send(method)
.await
.map_err(|_| StandaloneRequestError::WorkerUnavailable)
}
pub fn subscribe(&self) -> broadcast::Receiver<Event> {
self.handle.subscribe()
}
pub fn snapshot(&self) -> Event {
self.handle.snapshot_event()
/// Open one complete client-side Worker protocol session.
///
/// 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)
}
pub fn with_shutdown_timeout(mut self, shutdown_timeout: Duration) -> Self {
@@ -349,6 +345,111 @@ impl StandaloneHost {
}
}
async fn run_protocol_session(
handle: worker::WorkerHandle,
streams: WorkerProtocolSessionStreams,
mut peer: InProcessPeer,
) {
let WorkerProtocolSessionStreams {
snapshot_event,
mut log_entries,
alert_snapshot,
mut events,
} = streams;
if !send_protocol_snapshot(&peer, alert_snapshot, snapshot_event).await {
return;
}
loop {
tokio::select! {
message = peer.next() => {
let Some(message) = message else {
return;
};
let Ok(method) = decode_method(&message) else {
return;
};
if let Some(event) = dispatch_worker_protocol_method(&handle, method).await
&& !send_protocol_event(&peer, event).await
{
return;
}
}
event = events.recv() => {
match event {
Ok(event) => {
if !send_protocol_event(&peer, event).await {
return;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
let replacement = subscribe_worker_protocol_session(&handle);
let WorkerProtocolSessionStreams {
snapshot_event,
log_entries: replacement_log_entries,
alert_snapshot,
events: replacement_events,
} = replacement;
log_entries = replacement_log_entries;
events = replacement_events;
if !send_protocol_snapshot(&peer, alert_snapshot, snapshot_event).await {
return;
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
}
}
entry = log_entries.recv() => {
match entry {
Ok(entry) => {
if let Some(event) = live_log_entry_event(entry)
&& !send_protocol_event(&peer, event).await
{
return;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
let replacement = subscribe_worker_protocol_session(&handle);
let WorkerProtocolSessionStreams {
snapshot_event,
log_entries: replacement_log_entries,
alert_snapshot,
events: replacement_events,
} = replacement;
log_entries = replacement_log_entries;
events = replacement_events;
if !send_protocol_snapshot(&peer, alert_snapshot, snapshot_event).await {
return;
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
}
}
}
}
}
async fn send_protocol_snapshot(
peer: &InProcessPeer,
alert_snapshot: Vec<protocol::Alert>,
snapshot_event: Event,
) -> bool {
for alert in alert_snapshot {
if !send_protocol_event(peer, Event::Alert(alert)).await {
return false;
}
}
send_protocol_event(peer, snapshot_event).await
}
async fn send_protocol_event(peer: &InProcessPeer, event: Event) -> bool {
let Ok(message) = encode_event(&event) else {
return false;
};
peer.send(message).await.is_ok()
}
fn backing_store(
store: &StandaloneSessionStore,
id: StandaloneSessionId,
+1 -3
View File
@@ -8,9 +8,7 @@ pub mod host;
pub mod launch;
pub mod store;
pub use host::{
StandaloneHost, StandaloneRequestError, StandaloneShutdownError, StandaloneStartupError,
};
pub use host::{StandaloneHost, StandaloneShutdownError, StandaloneStartupError};
pub use launch::{ResolvedStandaloneLaunch, StandaloneLaunchConfig, StandaloneLaunchError};
pub use store::{
StaleLeasePolicy, StandaloneCwdIdentity, StandaloneListScope, StandaloneSessionId,
+47 -18
View File
@@ -8,6 +8,8 @@ use agen::llm_client::error::ClientError;
use agen::llm_client::event::{Event as LlmEvent, StopReason};
use agen::llm_client::types::Request;
use async_trait::async_trait;
use client::Client;
use client::transport::in_process::Socket as InProcessSocket;
use futures::{Stream, stream};
use protocol::{Event, Method};
use standalone::{
@@ -88,17 +90,29 @@ async fn in_process_host_runs_text_and_read_tool_then_shuts_down() {
let host = StandaloneHost::start_with_model_client(launch, client)
.await
.expect("start in-process host");
let mut events = host.subscribe();
let mut protocol_client = host.connect();
host.send(Method::run_text("read the probe"))
protocol_client
.send(&Method::run_text("read the probe"))
.await
.expect("submit input");
tokio::time::timeout(Duration::from_secs(30), async {
let mut saw_user_message = false;
let mut saw_text = false;
let mut saw_tool_result = false;
loop {
match events.recv().await.expect("worker event") {
match protocol_client
.next_event()
.await
.expect("protocol event")
.expect("worker event")
{
Event::UserMessage { segments }
if format!("{segments:?}").contains("read the probe") =>
{
saw_user_message = true;
}
Event::TextDelta { text } if text.contains("standalone response") => {
saw_text = true;
}
@@ -106,6 +120,10 @@ async fn in_process_host_runs_text_and_read_tool_then_shuts_down() {
saw_tool_result = true;
}
Event::RunEnd { .. } => {
assert!(
saw_user_message,
"stream must expose the committed user message"
);
assert!(saw_text, "stream must expose the model text delta");
assert!(saw_tool_result, "stream must expose the tool result");
break;
@@ -251,15 +269,18 @@ async fn standalone_restore_preserves_history_tasks_notifications_and_cwd_scope(
]);
let host = StandaloneHost::start_with_model_client(launch, first_client).await?;
let session_id = host.session_id();
let mut events = host.subscribe();
host.send(Method::run_text("first request")).await?;
wait_for_run_end(&mut events).await?;
host.send(Method::Notify {
message: "persisted notification".to_string(),
auto_run: true,
})
.await?;
wait_for_run_end(&mut events).await?;
let mut protocol_client = host.connect();
protocol_client
.send(&Method::run_text("first request"))
.await?;
wait_for_run_end(&mut protocol_client).await?;
protocol_client
.send(&Method::Notify {
message: "persisted notification".to_string(),
auto_run: true,
})
.await?;
wait_for_run_end(&mut protocol_client).await?;
host.shutdown().await?;
let store = StandaloneSessionStore::open(&state_dir)?;
@@ -288,16 +309,24 @@ async fn standalone_restore_preserves_history_tasks_notifications_and_cwd_scope(
let host =
StandaloneHost::restore_with_model_client(state_dir.clone(), session_id, second_client)
.await?;
let snapshot = format!("{:?}", host.snapshot());
let mut protocol_client = host.connect();
let snapshot = format!(
"{:?}",
protocol_client
.next_event()
.await
.expect("restored protocol stream")
.expect("restored snapshot")
);
assert!(snapshot.contains("first request"), "{snapshot}");
assert!(snapshot.contains("first answer"), "{snapshot}");
assert!(snapshot.contains("persisted task"), "{snapshot}");
assert!(snapshot.contains("persisted notification"), "{snapshot}");
let mut events = host.subscribe();
host.send(Method::run_text("continue after restore"))
protocol_client
.send(&Method::run_text("continue after restore"))
.await?;
wait_for_run_end(&mut events).await?;
wait_for_run_end(&mut protocol_client).await?;
let request = second_inspection
.requests()
.into_iter()
@@ -503,10 +532,10 @@ async fn standalone_metadata_fails_closed_on_incomplete_or_newer_records() -> Te
Ok(())
}
async fn wait_for_run_end(events: &mut tokio::sync::broadcast::Receiver<Event>) -> TestResult {
async fn wait_for_run_end(client: &mut Client<InProcessSocket>) -> TestResult {
tokio::time::timeout(Duration::from_secs(10), async {
loop {
if matches!(events.recv().await, Ok(Event::RunEnd { .. })) {
if matches!(client.next_event().await, Ok(Some(Event::RunEnd { .. }))) {
break;
}
}