use std::collections::HashMap;
use std::future::Future;
use std::sync::Arc;
use bamboo_subagent::{AgentRef, InboxKind, InboxMessage, MsgId, ReplyBody};
use chrono::Utc;
use tokio_util::sync::CancellationToken;
use crate::client::BrokerClient;
use crate::error::BrokerResult;
pub enum Handled {
Reply(String),
Ack,
Leave,
}
pub async fn serve_mailbox<H, Fut>(
endpoint: &str,
me: AgentRef,
token: &str,
handler: H,
) -> BrokerResult<()>
where
H: Fn(InboxMessage, CancellationToken) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Handled> + Send + 'static,
{
serve_mailbox_with_shutdown(endpoint, me, token, handler, CancellationToken::new()).await
}
pub async fn serve_mailbox_with_shutdown<H, Fut>(
endpoint: &str,
me: AgentRef,
token: &str,
handler: H,
shutdown: CancellationToken,
) -> BrokerResult<()>
where
H: Fn(InboxMessage, CancellationToken) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Handled> + Send + 'static,
{
serve_mailbox_full(endpoint, me, token, handler, shutdown, None).await
}
pub async fn serve_mailbox_full<H, Fut>(
endpoint: &str,
me: AgentRef,
token: &str,
handler: H,
shutdown: CancellationToken,
tls_config: Option<Arc<rustls::ClientConfig>>,
) -> BrokerResult<()>
where
H: Fn(InboxMessage, CancellationToken) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Handled> + Send + 'static,
{
let mut client =
BrokerClient::connect_with_tls(endpoint, me.clone(), token, clone_tls_config(&tls_config))
.await?;
client.subscribe().await?;
serve_loop(&mut client, &me, handler, shutdown).await
}
fn clone_tls_config(cfg: &Option<Arc<rustls::ClientConfig>>) -> Option<rustls::ClientConfig> {
cfg.as_deref().cloned()
}
struct Completion {
id: MsgId,
reply_to: String,
handled: Handled,
}
pub async fn serve_loop<H, Fut>(
client: &mut BrokerClient,
me: &AgentRef,
handler: H,
shutdown: CancellationToken,
) -> BrokerResult<()>
where
H: Fn(InboxMessage, CancellationToken) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Handled> + Send + 'static,
{
let handler = Arc::new(handler);
let mut inflight: HashMap<MsgId, CancellationToken> = HashMap::new();
let (done_tx, mut done_rx) = tokio::sync::mpsc::unbounded_channel::<Completion>();
let mut messages_open = true;
loop {
tokio::select! {
biased;
Some(done) = done_rx.recv() => {
inflight.remove(&done.id);
let Completion { id, reply_to, handled } = done;
match handled {
Handled::Reply(answer) => {
let reply = InboxMessage {
id: MsgId::new(),
from: me.clone(),
kind: InboxKind::Reply,
body: serde_json::to_value(ReplyBody { answer })
.unwrap_or_else(|_| serde_json::json!({})),
created_at: Utc::now(),
correlation_id: Some(id.clone()),
};
client.deliver(&reply_to, reply).await?;
client.ack(id).await?;
}
Handled::Ack => client.ack(id).await?,
Handled::Leave => {}
}
}
_ = shutdown.cancelled(), if messages_open => {
tracing::info!("broker worker: graceful shutdown requested — draining in-flight work");
messages_open = false;
}
event = client.next_message_or_cancel(), if messages_open => match event {
crate::client::ServeEvent::Cancel(Some(cid)) => {
if let Some(tok) = inflight.get(&cid) {
tok.cancel();
}
}
crate::client::ServeEvent::Cancel(None) => messages_open = false,
crate::client::ServeEvent::Message(Some(msg)) => {
let id = msg.id.clone();
let reply_to = msg.from.session_id.clone();
let token = CancellationToken::new();
inflight.insert(id.clone(), token.clone());
let handler = Arc::clone(&handler);
let done_tx = done_tx.clone();
tokio::spawn(async move {
let handled = handler(msg, token).await;
let _ = done_tx.send(Completion { id, reply_to, handled });
});
}
crate::client::ServeEvent::Message(None) => messages_open = false,
},
}
if !messages_open && inflight.is_empty() {
break;
}
}
Ok(())
}
pub async fn serve_with<F, Fut>(
endpoint: &str,
me: AgentRef,
token: &str,
answer: Arc<F>,
) -> BrokerResult<()>
where
F: Fn(InboxMessage) -> Fut + Send + Sync + 'static,
Fut: Future<Output = String> + Send,
{
serve_mailbox(endpoint, me, token, move |msg, _cancel| {
let answer = Arc::clone(&answer);
async move { Handled::Reply(answer(msg).await) }
})
.await
}
pub async fn serve_executor<E>(
endpoint: &str,
me: AgentRef,
token: &str,
executor: Arc<E>,
) -> BrokerResult<()>
where
E: bamboo_subagent::ChildExecutor + ?Sized,
{
serve_executor_with_shutdown(endpoint, me, token, executor, CancellationToken::new()).await
}
pub async fn serve_executor_with_shutdown<E>(
endpoint: &str,
me: AgentRef,
token: &str,
executor: Arc<E>,
shutdown: CancellationToken,
) -> BrokerResult<()>
where
E: bamboo_subagent::ChildExecutor + ?Sized,
{
serve_executor_full(endpoint, me, token, executor, shutdown, None).await
}
pub async fn serve_executor_full<E>(
endpoint: &str,
me: AgentRef,
token: &str,
executor: Arc<E>,
shutdown: CancellationToken,
tls_config: Option<Arc<rustls::ClientConfig>>,
) -> BrokerResult<()>
where
E: bamboo_subagent::ChildExecutor + ?Sized,
{
let context: Arc<tokio::sync::Mutex<Vec<serde_json::Value>>> =
Arc::new(tokio::sync::Mutex::new(Vec::new()));
let coords: RunCoords = Arc::new(tokio::sync::Mutex::new(HashMap::new()));
let waiters: ApprovalWaiters = Arc::new(tokio::sync::Mutex::new(HashMap::new()));
let endpoint_owned = endpoint.to_string();
let token_owned = token.to_string();
let me_owned = me.clone();
let tls_owned = tls_config.clone();
serve_mailbox_full(
endpoint,
me,
token,
move |msg, cancel| {
let executor = Arc::clone(&executor);
let context = Arc::clone(&context);
let coords = Arc::clone(&coords);
let waiters = Arc::clone(&waiters);
let endpoint = endpoint_owned.clone();
let token = token_owned.clone();
let me = me_owned.clone();
let tls_config = tls_owned.clone();
async move {
match msg.kind {
InboxKind::Run => {
handle_run(
executor.as_ref(),
&endpoint,
&token,
&me,
msg,
cancel,
&coords,
&waiters,
tls_config,
)
.await
}
InboxKind::Steer => {
if let Some(run_id) = &msg.correlation_id {
let text = msg
.body
.get("text")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
if let Some(coord) = coords.lock().await.get(run_id) {
let _ = coord.steer_tx.send(text);
}
}
Handled::Ack
}
InboxKind::ApprovalReply => {
let id = msg
.body
.get("id")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let approved = msg
.body
.get("approved")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if let Some(tx) = waiters.lock().await.remove(&id) {
let _ = tx.send(approved);
}
Handled::Ack
}
_ => handle_with_executor(executor.as_ref(), &context, msg, cancel).await,
}
}
},
shutdown,
tls_config,
)
.await
}
struct RunCoord {
steer_tx: tokio::sync::mpsc::UnboundedSender<String>,
}
type RunCoords = Arc<tokio::sync::Mutex<HashMap<MsgId, RunCoord>>>;
type ApprovalWaiters = Arc<tokio::sync::Mutex<HashMap<String, tokio::sync::oneshot::Sender<bool>>>>;
#[allow(clippy::too_many_arguments)]
async fn handle_run<E>(
executor: &E,
endpoint: &str,
token: &str,
me: &AgentRef,
msg: InboxMessage,
cancel: CancellationToken,
coords: &RunCoords,
waiters: &ApprovalWaiters,
tls_config: Option<Arc<rustls::ClientConfig>>,
) -> Handled
where
E: bamboo_subagent::ChildExecutor + ?Sized,
{
use bamboo_subagent::{EventSink, HostBridge, RunSpec, SteerInbox};
let spec: RunSpec = match serde_json::from_value(msg.body) {
Ok(s) => s,
Err(e) => {
tracing::warn!("run {:?}: malformed RunSpec, dropping: {e}", msg.id);
return Handled::Ack;
}
};
let run_id = msg.id.clone();
let parent = msg.from.session_id.clone();
let (sink, mut events) = EventSink::channel();
let (steer_tx, steer_inbox) = SteerInbox::channel();
coords
.lock()
.await
.insert(run_id.clone(), RunCoord { steer_tx });
let (host_bridge, mut host_rx) = HostBridge::channel();
let sink = sink.with_host_bridge(host_bridge);
let (outcome_tx, outcome_rx) = tokio::sync::oneshot::channel();
let endpoint = endpoint.to_string();
let token = token.to_string();
let me = me.clone();
let run_id_fwd = run_id.clone();
let me_fwd = me.clone();
let parent_fwd = parent.clone();
let (ep_fwd, tok_fwd) = (endpoint.clone(), token.clone());
let tls_fwd = tls_config.clone();
let forward = tokio::spawn(async move {
let mut deliver = match BrokerClient::connect_with_tls(
&ep_fwd,
me_fwd.clone(),
&tok_fwd,
tls_fwd.as_deref().cloned(),
)
.await
{
Ok(c) => c,
Err(e) => {
tracing::warn!("run {run_id_fwd:?}: event deliver connect failed: {e}");
return;
}
};
let emit = |kind, body| InboxMessage {
id: MsgId::new(),
from: me_fwd.clone(),
kind,
body,
created_at: Utc::now(),
correlation_id: Some(run_id_fwd.clone()),
};
while let Some(event) = events.recv().await {
if deliver
.deliver(&parent_fwd, emit(InboxKind::Event, event))
.await
.is_err()
{
return; }
}
if let Ok(outcome) = outcome_rx.await {
let body = serde_json::to_value(&outcome).unwrap_or_else(|_| serde_json::json!({}));
let _ = deliver
.deliver(&parent_fwd, emit(InboxKind::Outcome, body))
.await;
}
});
let waiters_drain = Arc::clone(waiters);
let run_id_appr = run_id.clone();
let approval = tokio::spawn(async move {
let mut deliver = match BrokerClient::connect_with_tls(
&endpoint,
me.clone(),
&token,
tls_config.as_deref().cloned(),
)
.await
{
Ok(c) => c,
Err(e) => {
tracing::warn!("run {run_id_appr:?}: approval deliver connect failed: {e}");
while let Some(req) = host_rx.recv().await {
let _ = req.reply.send(serde_json::json!({ "approved": false }));
}
return;
}
};
while let Some(req) = host_rx.recv().await {
let approval_id = MsgId::new();
let approval_id_str = format!("{approval_id:?}");
let (atx, arx) = tokio::sync::oneshot::channel::<bool>();
waiters_drain
.lock()
.await
.insert(approval_id_str.clone(), atx);
let m = InboxMessage {
id: MsgId::new(),
from: me.clone(),
kind: InboxKind::ApprovalRequest,
body: serde_json::json!({ "id": approval_id_str, "request": req.body }),
created_at: Utc::now(),
correlation_id: Some(run_id_appr.clone()),
};
if deliver.deliver(&parent, m).await.is_err() {
let _ = req.reply.send(serde_json::json!({ "approved": false }));
continue;
}
let approved = arx.await.unwrap_or(false);
let _ = req.reply.send(serde_json::json!({ "approved": approved }));
}
});
let outcome = executor.run(spec, sink, steer_inbox, cancel).await;
coords.lock().await.remove(&run_id);
let _ = outcome_tx.send(outcome);
let _ = forward.await;
let _ = approval.await;
Handled::Ack
}
async fn handle_with_executor<E>(
executor: &E,
context: &tokio::sync::Mutex<Vec<serde_json::Value>>,
msg: InboxMessage,
cancel: CancellationToken,
) -> Handled
where
E: bamboo_subagent::ChildExecutor + ?Sized,
{
use bamboo_subagent::{AskBody, AskMode, EventSink, RunSpec, SteerInbox};
let (question, persist) = match msg.kind {
InboxKind::Ask => match serde_json::from_value::<AskBody>(msg.body) {
Ok(b) => (b.question, matches!(b.mode, AskMode::Steer)),
Err(_) => return Handled::Ack, },
InboxKind::Task => (
msg.body
.get("assignment")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
true,
),
_ => return Handled::Ack,
};
let prior = context.lock().await.clone();
let (sink, _discard) = EventSink::channel();
let outcome = executor
.run(
RunSpec {
assignment: question.clone(),
reasoning_effort: None,
messages: prior,
},
sink,
SteerInbox::disconnected(),
cancel,
)
.await;
let result = outcome.result;
let answer = result
.clone()
.or(outcome.error)
.unwrap_or_else(|| "(no result)".to_string());
if persist {
if let Some(result) = result {
let mut ctx = context.lock().await;
ctx.push(serde_json::json!({ "role": "user", "content": question }));
ctx.push(serde_json::json!({ "role": "assistant", "content": result }));
}
}
Handled::Reply(answer)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::BrokerCore;
use crate::server::BrokerServer;
use bamboo_subagent::{AskBody, AskMode};
use std::time::Duration;
use tokio::net::TcpListener;
const TOKEN: &str = "t";
async fn start() -> (String, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let core = Arc::new(BrokerCore::new(dir.path()));
let server = Arc::new(BrokerServer::new(core, TOKEN));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let _ = server.serve(listener).await;
});
(format!("ws://{addr}"), dir)
}
fn ask(from: &str, q: &str) -> InboxMessage {
InboxMessage {
id: MsgId::new(),
from: AgentRef {
session_id: from.into(),
role: None,
},
kind: InboxKind::Ask,
body: serde_json::to_value(AskBody {
question: q.into(),
mode: AskMode::Query,
})
.unwrap(),
created_at: Utc::now(),
correlation_id: None,
}
}
#[tokio::test]
async fn serve_mailbox_answers_and_correlates() {
let (endpoint, _dir) = start().await;
let worker_ep = endpoint.clone();
tokio::spawn(async move {
let _ = serve_with(
&worker_ep,
AgentRef {
session_id: "worker".into(),
role: Some("echo".into()),
},
TOKEN,
Arc::new(|msg: InboxMessage| async move {
let body: AskBody = serde_json::from_value(msg.body).unwrap();
format!("echo: {}", body.question)
}),
)
.await;
});
let mut orch = BrokerClient::connect(
&endpoint,
AgentRef {
session_id: "orch".into(),
role: None,
},
TOKEN,
)
.await
.unwrap();
orch.subscribe().await.unwrap();
let q = ask("orch", "are you up?");
let qid = q.id.clone();
orch.deliver("worker", q).await.unwrap();
let reply = tokio::time::timeout(Duration::from_secs(5), orch.next_message())
.await
.expect("reply within timeout")
.expect("reply present");
assert_eq!(reply.kind, InboxKind::Reply);
assert_eq!(reply.correlation_id, Some(qid));
let body: ReplyBody = serde_json::from_value(reply.body).unwrap();
assert_eq!(body.answer, "echo: are you up?");
}
struct ContextReporter;
#[async_trait::async_trait]
impl bamboo_subagent::ChildExecutor for ContextReporter {
async fn run(
&self,
spec: bamboo_subagent::RunSpec,
_events: bamboo_subagent::EventSink,
_steer: bamboo_subagent::SteerInbox,
_cancel: tokio_util::sync::CancellationToken,
) -> bamboo_subagent::ChildOutcome {
bamboo_subagent::ChildOutcome::completed(format!("ctx={}", spec.messages.len()))
}
}
async fn ask_mode(orch: &mut BrokerClient, to: &str, q: &str, mode: AskMode) -> String {
let msg = InboxMessage {
id: MsgId::new(),
from: AgentRef {
session_id: "orch2".into(),
role: None,
},
kind: InboxKind::Ask,
body: serde_json::to_value(AskBody {
question: q.into(),
mode,
})
.unwrap(),
created_at: Utc::now(),
correlation_id: None,
};
let qid = msg.id.clone();
orch.deliver(to, msg).await.unwrap();
loop {
let r = tokio::time::timeout(Duration::from_secs(5), orch.next_message())
.await
.expect("reply within timeout")
.expect("reply present");
if r.correlation_id == Some(qid.clone()) {
return serde_json::from_value::<ReplyBody>(r.body).unwrap().answer;
}
}
}
#[tokio::test]
async fn query_is_read_only_steer_persists_context() {
let (endpoint, _dir) = start().await;
let worker_ep = endpoint.clone();
tokio::spawn(async move {
let _ = serve_executor(
&worker_ep,
AgentRef {
session_id: "agent".into(),
role: None,
},
TOKEN,
Arc::new(ContextReporter),
)
.await;
});
let mut orch = BrokerClient::connect(
&endpoint,
AgentRef {
session_id: "orch2".into(),
role: None,
},
TOKEN,
)
.await
.unwrap();
orch.subscribe().await.unwrap();
assert_eq!(
ask_mode(&mut orch, "agent", "q1", AskMode::Query).await,
"ctx=0"
);
assert_eq!(
ask_mode(&mut orch, "agent", "q2", AskMode::Query).await,
"ctx=0"
);
assert_eq!(
ask_mode(&mut orch, "agent", "s1", AskMode::Steer).await,
"ctx=0"
);
assert_eq!(
ask_mode(&mut orch, "agent", "q3", AskMode::Query).await,
"ctx=2"
);
assert_eq!(
ask_mode(&mut orch, "agent", "s2", AskMode::Steer).await,
"ctx=2"
);
assert_eq!(
ask_mode(&mut orch, "agent", "q4", AskMode::Query).await,
"ctx=4"
);
}
#[tokio::test]
async fn cancel_aborts_in_flight_run_and_loop_keeps_serving() {
use bamboo_subagent::{ChildExecutor, ChildOutcome, EventSink, RunSpec, SteerInbox};
struct ParkOrEcho;
#[async_trait::async_trait]
impl ChildExecutor for ParkOrEcho {
async fn run(
&self,
spec: RunSpec,
_events: EventSink,
_steer: SteerInbox,
cancel: CancellationToken,
) -> ChildOutcome {
if spec.assignment.contains("park") {
cancel.cancelled().await;
ChildOutcome::cancelled()
} else {
ChildOutcome::completed(format!("echo: {}", spec.assignment))
}
}
}
let (endpoint, _dir) = start().await;
let worker_ep = endpoint.clone();
tokio::spawn(async move {
let _ = serve_executor(
&worker_ep,
AgentRef {
session_id: "worker".into(),
role: None,
},
TOKEN,
Arc::new(ParkOrEcho),
)
.await;
});
let mut orch = BrokerClient::connect(
&endpoint,
AgentRef {
session_id: "orch".into(),
role: None,
},
TOKEN,
)
.await
.unwrap();
orch.subscribe().await.unwrap();
let probe = ask("orch", "ping");
let probe_id = probe.id.clone();
orch.deliver("worker", probe).await.unwrap();
let r0 = tokio::time::timeout(Duration::from_secs(5), orch.next_message())
.await
.expect("probe reply")
.expect("present");
assert_eq!(r0.correlation_id, Some(probe_id));
let q1 = ask("orch", "please park");
let qid1 = q1.id.clone();
orch.deliver("worker", q1).await.unwrap();
orch.cancel("worker", &qid1).await.unwrap();
let reply1 = tokio::time::timeout(Duration::from_secs(5), orch.next_message())
.await
.expect("cancelled run still replies — loop not wedged")
.expect("present");
assert_eq!(reply1.correlation_id, Some(qid1));
let q2 = ask("orch", "hello");
let qid2 = q2.id.clone();
orch.deliver("worker", q2).await.unwrap();
let reply2 = tokio::time::timeout(Duration::from_secs(5), orch.next_message())
.await
.expect("loop keeps serving after a cancel")
.expect("present");
assert_eq!(reply2.correlation_id, Some(qid2));
let body: ReplyBody = serde_json::from_value(reply2.body).unwrap();
assert_eq!(body.answer, "echo: hello");
}
#[tokio::test]
async fn concurrent_asks_to_one_worker_overlap() {
use bamboo_subagent::{ChildExecutor, ChildOutcome, EventSink, RunSpec, SteerInbox};
use std::sync::atomic::{AtomicU32, Ordering};
const N: usize = 4;
struct SlowEcho {
in_flight: AtomicU32,
max_in_flight: AtomicU32,
rendezvous: tokio::sync::Barrier,
}
#[async_trait::async_trait]
impl ChildExecutor for SlowEcho {
async fn run(
&self,
spec: RunSpec,
_events: EventSink,
_steer: SteerInbox,
_cancel: CancellationToken,
) -> ChildOutcome {
if spec.assignment == "ping" {
return ChildOutcome::completed(format!("done: {}", spec.assignment));
}
let now_in_flight = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
self.max_in_flight
.fetch_max(now_in_flight, Ordering::SeqCst);
self.rendezvous.wait().await;
tokio::time::sleep(Duration::from_millis(50)).await;
self.in_flight.fetch_sub(1, Ordering::SeqCst);
ChildOutcome::completed(format!("done: {}", spec.assignment))
}
}
let slow_echo = Arc::new(SlowEcho {
in_flight: AtomicU32::new(0),
max_in_flight: AtomicU32::new(0),
rendezvous: tokio::sync::Barrier::new(N),
});
let (endpoint, _dir) = start().await;
let worker_ep = endpoint.clone();
let slow_echo_for_worker = slow_echo.clone();
tokio::spawn(async move {
let _ = serve_executor(
&worker_ep,
AgentRef {
session_id: "worker".into(),
role: None,
},
TOKEN,
slow_echo_for_worker,
)
.await;
});
let mut orch = BrokerClient::connect(
&endpoint,
AgentRef {
session_id: "orch".into(),
role: None,
},
TOKEN,
)
.await
.unwrap();
orch.subscribe().await.unwrap();
let probe = ask("orch", "ping");
let probe_id = probe.id.clone();
orch.deliver("worker", probe).await.unwrap();
loop {
let r = tokio::time::timeout(Duration::from_secs(5), orch.next_message())
.await
.expect("probe reply")
.expect("present");
if r.correlation_id == Some(probe_id.clone()) {
break;
}
}
let mut want: std::collections::HashSet<MsgId> = std::collections::HashSet::new();
for i in 0..N {
let q = ask("orch", &format!("q{i}"));
want.insert(q.id.clone());
orch.deliver("worker", q).await.unwrap();
}
tokio::time::timeout(Duration::from_secs(20), async {
while !want.is_empty() {
let r = tokio::time::timeout(Duration::from_secs(5), orch.next_message())
.await
.expect("a reply arrives")
.expect("present");
if let Some(cid) = &r.correlation_id {
want.remove(cid);
}
}
})
.await
.expect(
"timed out waiting for the N concurrent Asks to complete — this means the \
per-ask spawn is serializing them (only some of the N ever reached the \
rendezvous barrier), which is the regression this test guards against",
);
let max_in_flight = slow_echo.max_in_flight.load(Ordering::SeqCst);
assert_eq!(
max_in_flight, N as u32,
"{N} concurrent Asks to ONE worker must OVERLAP (serial handling could never \
observe more than 1 in flight at once); observed max_in_flight = {max_in_flight}"
);
}
#[tokio::test]
async fn cancelled_steer_does_not_pollute_context() {
use bamboo_subagent::{ChildExecutor, ChildOutcome, EventSink, RunSpec, SteerInbox};
struct ParkOrReportCtx;
#[async_trait::async_trait]
impl ChildExecutor for ParkOrReportCtx {
async fn run(
&self,
spec: RunSpec,
_events: EventSink,
_steer: SteerInbox,
cancel: CancellationToken,
) -> ChildOutcome {
if spec.assignment.contains("park") {
cancel.cancelled().await;
ChildOutcome::cancelled()
} else {
ChildOutcome::completed(format!("ctx={}", spec.messages.len()))
}
}
}
let (endpoint, _dir) = start().await;
let worker_ep = endpoint.clone();
tokio::spawn(async move {
let _ = serve_executor(
&worker_ep,
AgentRef {
session_id: "w".into(),
role: None,
},
TOKEN,
Arc::new(ParkOrReportCtx),
)
.await;
});
let mut orch = BrokerClient::connect(
&endpoint,
AgentRef {
session_id: "orch2".into(),
role: None,
},
TOKEN,
)
.await
.unwrap();
orch.subscribe().await.unwrap();
assert_eq!(
ask_mode(&mut orch, "w", "ping", AskMode::Query).await,
"ctx=0"
);
let steer = InboxMessage {
id: MsgId::new(),
from: AgentRef {
session_id: "orch2".into(),
role: None,
},
kind: InboxKind::Ask,
body: serde_json::to_value(AskBody {
question: "park this steer".into(),
mode: AskMode::Steer,
})
.unwrap(),
created_at: Utc::now(),
correlation_id: None,
};
let sid = steer.id.clone();
orch.deliver("w", steer).await.unwrap();
orch.cancel("w", &sid).await.unwrap();
loop {
let m = tokio::time::timeout(Duration::from_secs(5), orch.next_message())
.await
.expect("cancelled steer replies")
.expect("present");
if m.correlation_id == Some(sid.clone()) {
break;
}
}
assert_eq!(
ask_mode(&mut orch, "w", "again", AskMode::Query).await,
"ctx=0"
);
}
#[tokio::test]
async fn run_streams_events_then_outcome_to_parent() {
use bamboo_subagent::{EchoExecutor, RunSpec};
let (endpoint, _dir) = start().await;
let worker_ep = endpoint.clone();
tokio::spawn(async move {
let _ = serve_executor(
&worker_ep,
AgentRef {
session_id: "w".into(),
role: None,
},
TOKEN,
Arc::new(EchoExecutor),
)
.await;
});
let mut parent = BrokerClient::connect(
&endpoint,
AgentRef {
session_id: "orch".into(),
role: None,
},
TOKEN,
)
.await
.unwrap();
parent.subscribe().await.unwrap();
let spec = RunSpec {
assignment: "ping pong".into(),
reasoning_effort: None,
messages: vec![],
};
let run = InboxMessage {
id: MsgId::new(),
from: AgentRef {
session_id: "orch".into(),
role: None,
},
kind: InboxKind::Run,
body: serde_json::to_value(&spec).unwrap(),
created_at: Utc::now(),
correlation_id: None,
};
let run_id = run.id.clone();
parent.deliver("w", run).await.unwrap();
let mut events = 0usize;
let outcome = loop {
let msg = tokio::time::timeout(Duration::from_secs(5), parent.next_message())
.await
.expect("a run message arrives")
.expect("stream open");
assert_eq!(
msg.correlation_id.as_ref(),
Some(&run_id),
"run messages must correlate to the run id"
);
match msg.kind {
InboxKind::Event => {
events += 1;
parent.ack(msg.id).await.ok();
}
InboxKind::Outcome => break msg,
other => panic!("unexpected kind during run: {other:?}"),
}
};
assert!(events >= 1, "expected streamed events, got {events}");
let oc: bamboo_subagent::ChildOutcome = serde_json::from_value(outcome.body).unwrap();
assert_eq!(oc.result.as_deref(), Some("echo: ping pong"));
}
#[tokio::test]
async fn graceful_shutdown_drains_in_flight_ask_then_exits() {
use bamboo_subagent::{ChildExecutor, ChildOutcome, EventSink, RunSpec, SteerInbox};
struct SlowEcho;
#[async_trait::async_trait]
impl ChildExecutor for SlowEcho {
async fn run(
&self,
spec: RunSpec,
_events: EventSink,
_steer: SteerInbox,
_cancel: CancellationToken,
) -> ChildOutcome {
tokio::time::sleep(Duration::from_millis(300)).await;
ChildOutcome::completed(format!("echo: {}", spec.assignment))
}
}
let (endpoint, _dir) = start().await;
let shutdown = CancellationToken::new();
let worker_ep = endpoint.clone();
let worker_shutdown = shutdown.clone();
let worker = tokio::spawn(async move {
serve_executor_with_shutdown(
&worker_ep,
AgentRef {
session_id: "worker".into(),
role: None,
},
TOKEN,
Arc::new(SlowEcho),
worker_shutdown,
)
.await
});
let mut orch = BrokerClient::connect(
&endpoint,
AgentRef {
session_id: "orch".into(),
role: None,
},
TOKEN,
)
.await
.unwrap();
orch.subscribe().await.unwrap();
let probe = ask("orch", "ping");
let probe_id = probe.id.clone();
orch.deliver("worker", probe).await.unwrap();
let r0 = tokio::time::timeout(Duration::from_secs(5), orch.next_message())
.await
.expect("probe reply")
.expect("present");
assert_eq!(r0.correlation_id, Some(probe_id));
let q = ask("orch", "slow one");
let qid = q.id.clone();
orch.deliver("worker", q).await.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await; shutdown.cancel();
let reply = tokio::time::timeout(Duration::from_secs(5), orch.next_message())
.await
.expect("in-flight ask must be drained, not lost")
.expect("present");
assert_eq!(reply.correlation_id, Some(qid));
let body: ReplyBody = serde_json::from_value(reply.body).unwrap();
assert_eq!(body.answer, "echo: slow one");
let served = tokio::time::timeout(Duration::from_secs(5), worker)
.await
.expect("serve_executor_with_shutdown returns after the drain")
.expect("worker task not panicked");
assert!(served.is_ok(), "graceful shutdown exits Ok: {served:?}");
}
#[tokio::test]
async fn graceful_shutdown_idle_worker_exits_promptly() {
let (endpoint, _dir) = start().await;
let shutdown = CancellationToken::new();
let worker_shutdown = shutdown.clone();
let worker_ep = endpoint.clone();
let worker = tokio::spawn(async move {
serve_executor_with_shutdown(
&worker_ep,
AgentRef {
session_id: "idle".into(),
role: None,
},
TOKEN,
Arc::new(bamboo_subagent::EchoExecutor),
worker_shutdown,
)
.await
});
let mut orch = BrokerClient::connect(
&endpoint,
AgentRef {
session_id: "orch".into(),
role: None,
},
TOKEN,
)
.await
.unwrap();
orch.subscribe().await.unwrap();
let probe = ask("orch", "ping");
orch.deliver("idle", probe).await.unwrap();
let _ = tokio::time::timeout(Duration::from_secs(5), orch.next_message())
.await
.expect("probe reply")
.expect("present");
shutdown.cancel();
let served = tokio::time::timeout(Duration::from_secs(5), worker)
.await
.expect("idle worker exits promptly on graceful shutdown")
.expect("worker task not panicked");
assert!(served.is_ok(), "graceful shutdown exits Ok: {served:?}");
}
#[tokio::test]
async fn list_connected_finds_subscribed_actors_by_role() {
let (endpoint, _dir) = start().await;
async fn join(endpoint: &str, id: &str, role: &str) -> BrokerClient {
let mut c = BrokerClient::connect(
endpoint,
AgentRef {
session_id: id.into(),
role: Some(role.into()),
},
TOKEN,
)
.await
.unwrap();
c.subscribe().await.unwrap();
c
}
let _w1 = join(&endpoint, "w1", "gpu-pool").await;
let _w2 = join(&endpoint, "w2", "gpu-pool").await;
let _w3 = join(&endpoint, "w3", "cpu-pool").await;
let mut q = BrokerClient::connect(
&endpoint,
AgentRef {
session_id: "orch".into(),
role: None,
},
TOKEN,
)
.await
.unwrap();
let mut gpu = q.list_connected("gpu-pool").await.unwrap();
gpu.sort();
assert_eq!(gpu, vec!["w1".to_string(), "w2".to_string()]);
assert_eq!(
q.list_connected("cpu-pool").await.unwrap(),
vec!["w3".to_string()]
);
assert!(q.list_connected("none").await.unwrap().is_empty());
}
}