use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{broadcast, mpsc};
use bamboo_agent_core::AgentEvent;
use bamboo_engine::events::change_feed::ChangeEvent;
use bamboo_engine::events::journal;
use actix_web::web;
use super::envelope::{
feed_reset_control, gap_control, terminal_control, Encoding, OutFrame, ServerEnvelope,
};
use crate::app_state::{AgentStatus, AppState};
use crate::handlers::agent::events::{has_running_child, terminal_event_if_ready, Coalescer};
use crate::handlers::agent::stream::{plan_replay, ReplayPlan};
pub(crate) type OutboundTx = mpsc::Sender<OutFrame>;
async fn send_env(out: &OutboundTx, encoding: Encoding, env: ServerEnvelope) -> bool {
match env.encode(encoding) {
Some(frame) => out.send(frame).await.is_ok(),
None => true,
}
}
pub(crate) fn spawn_feed_forwarder(
out: OutboundTx,
encoding: Encoding,
mut receiver: broadcast::Receiver<Arc<ChangeEvent>>,
events_dir: PathBuf,
since: u64,
latest_at_start: u64,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let ReplayPlan {
reset_from,
events,
last_replayed,
} = plan_replay(&events_dir, since, latest_at_start);
let mut last_replayed = last_replayed;
if let Some(from) = reset_from {
if !send_env(
&out,
encoding,
ServerEnvelope::control("feed", 0, feed_reset_control(from)),
)
.await
{
return;
}
}
for ce in events {
if !send_env(&out, encoding, feed_envelope(&ce)).await {
return;
}
}
loop {
match receiver.recv().await {
Ok(ce) => {
if ce.seq <= last_replayed {
continue; }
if !send_env(&out, encoding, feed_envelope(&ce)).await {
return;
}
last_replayed = ce.seq;
}
Err(broadcast::error::RecvError::Lagged(_)) => {
if let Ok(events) = journal::read_since(&events_dir, last_replayed) {
for ce in events {
if ce.seq <= last_replayed {
continue;
}
if !send_env(&out, encoding, feed_envelope(&ce)).await {
return;
}
last_replayed = ce.seq;
}
}
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
})
}
fn feed_envelope(ce: &ChangeEvent) -> ServerEnvelope {
let event = serde_json::to_value(ce).unwrap_or(serde_json::Value::Null);
ServerEnvelope::event("feed", ce.seq, event)
}
#[derive(Default)]
pub(crate) struct AgentSeq(AtomicU64);
impl AgentSeq {
pub(crate) fn next(&self) -> u64 {
self.0.fetch_add(1, Ordering::Relaxed) + 1
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn spawn_agent_forwarder(
state: web::Data<AppState>,
session_id: String,
out: OutboundTx,
encoding: Encoding,
ch: String,
mut receiver: broadcast::Receiver<AgentEvent>,
budget_event_to_replay: Option<AgentEvent>,
critical_events_to_replay: Vec<AgentEvent>,
batch_ms: u64,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let seq = AgentSeq::default();
let _watcher_guard = crate::app_state::watchers::WatcherGuard::new(
state.session_watchers.clone(),
&session_id,
);
for event in critical_events_to_replay {
if !emit_agent_event(&out, encoding, &ch, &seq, event).await {
return;
}
}
if let Some(event) = budget_event_to_replay {
if !emit_agent_event(&out, encoding, &ch, &seq, event).await {
return;
}
}
if batch_ms == 0 {
let mut awaiting_children = false;
loop {
match receiver.recv().await {
Ok(event) => {
let is_terminal = is_terminal_event(&event);
let is_child_completed =
matches!(event, AgentEvent::SubAgentCompleted { .. });
if !emit_agent_event(&out, encoding, &ch, &seq, event).await {
return;
}
if is_terminal {
if has_running_child(&state, &session_id).await {
awaiting_children = true;
continue;
}
let _ = send_env(
&out,
encoding,
ServerEnvelope::control(
&ch,
seq.next(),
terminal_control("complete"),
),
)
.await;
return;
}
if awaiting_children
&& is_child_completed
&& !has_running_child(&state, &session_id).await
{
let _ = send_env(
&out,
encoding,
ServerEnvelope::control(
&ch,
seq.next(),
terminal_control("complete"),
),
)
.await;
return;
}
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
match handle_agent_lag(
&state,
&session_id,
&out,
encoding,
&ch,
&seq,
skipped,
awaiting_children,
)
.await
{
LagOutcome::Continue => continue,
LagOutcome::Stop | LagOutcome::Disconnected => return,
}
}
Err(broadcast::error::RecvError::Closed) => return,
}
}
}
let mut coalescer = Coalescer::default();
let flush_window = Duration::from_millis(batch_ms);
let mut flush_deadline: Option<tokio::time::Instant> = None;
let mut awaiting_children = false;
loop {
let sleep_until = flush_deadline
.unwrap_or_else(|| tokio::time::Instant::now() + Duration::from_secs(86_400));
tokio::select! {
_ = tokio::time::sleep_until(sleep_until), if flush_deadline.is_some() => {
if let Some(pending) = coalescer.take_pending() {
if !emit_agent_event(&out, encoding, &ch, &seq, pending).await {
return;
}
}
flush_deadline = None;
}
recv = receiver.recv() => {
match recv {
Ok(event) => {
let is_terminal = is_terminal_event(&event);
let is_child_completed =
matches!(event, AgentEvent::SubAgentCompleted { .. });
for out_event in coalescer.push(event) {
if !emit_agent_event(&out, encoding, &ch, &seq, out_event).await {
return;
}
}
if coalescer.has_pending() {
if flush_deadline.is_none() {
flush_deadline =
Some(tokio::time::Instant::now() + flush_window);
}
} else {
flush_deadline = None;
}
if is_terminal {
if has_running_child(&state, &session_id).await {
awaiting_children = true;
continue;
}
let _ = send_env(
&out,
encoding,
ServerEnvelope::control(&ch, seq.next(), terminal_control("complete")),
)
.await;
return;
}
if awaiting_children
&& is_child_completed
&& !has_running_child(&state, &session_id).await
{
let _ = send_env(
&out,
encoding,
ServerEnvelope::control(&ch, seq.next(), terminal_control("complete")),
)
.await;
return;
}
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
if let Some(pending) = coalescer.take_pending() {
if !emit_agent_event(&out, encoding, &ch, &seq, pending).await {
return;
}
flush_deadline = None;
}
match handle_agent_lag(
&state,
&session_id,
&out,
encoding,
&ch,
&seq,
skipped,
awaiting_children,
)
.await
{
LagOutcome::Continue => {}
LagOutcome::Stop | LagOutcome::Disconnected => return,
}
}
Err(broadcast::error::RecvError::Closed) => {
if let Some(pending) = coalescer.take_pending() {
let _ = emit_agent_event(&out, encoding, &ch, &seq, pending).await;
}
return;
}
}
}
}
}
})
}
#[derive(Debug, PartialEq, Eq)]
enum LagOutcome {
Continue,
Stop,
Disconnected,
}
async fn handle_agent_lag(
state: &web::Data<AppState>,
session_id: &str,
out: &OutboundTx,
encoding: Encoding,
ch: &str,
seq: &AgentSeq,
skipped: u64,
own_terminal_already_emitted: bool,
) -> LagOutcome {
tracing::warn!(
"[{}] ws_v2 agent channel lagged: {} events lost to broadcast-ring overrun; \
emitting gap control (client must reconcile via REST)",
session_id,
skipped
);
if !send_env(
out,
encoding,
ServerEnvelope::control(ch, seq.next(), gap_control(skipped)),
)
.await
{
return LagOutcome::Disconnected;
}
let runner_status = {
let runners = state.agent_runners.read().await;
runners.get(session_id).map(|runner| runner.status.clone())
};
if matches!(runner_status, Some(AgentStatus::Running)) {
return LagOutcome::Continue;
}
let Some(terminal_event) = terminal_event_if_ready(state, session_id, runner_status).await
else {
return LagOutcome::Continue;
};
tracing::warn!(
"[{}] ws_v2 agent channel: lag gap swallowed the run's terminal \
(own_terminal_already_emitted={}); closing the channel",
session_id,
own_terminal_already_emitted
);
if !own_terminal_already_emitted
&& !emit_agent_event(out, encoding, ch, seq, terminal_event).await
{
return LagOutcome::Disconnected;
}
let _ = send_env(
out,
encoding,
ServerEnvelope::control(ch, seq.next(), terminal_control("complete")),
)
.await;
LagOutcome::Stop
}
async fn emit_agent_event(
out: &OutboundTx,
encoding: Encoding,
ch: &str,
seq: &AgentSeq,
event: AgentEvent,
) -> bool {
let value = match serde_json::to_value(&event) {
Ok(v) => v,
Err(_) => return true,
};
send_env(out, encoding, ServerEnvelope::event(ch, seq.next(), value)).await
}
fn is_terminal_event(event: &AgentEvent) -> bool {
matches!(
event,
AgentEvent::Complete { .. } | AgentEvent::Cancelled { .. } | AgentEvent::Error { .. }
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_state::AgentRunner;
#[test]
fn agent_seq_is_monotonic_and_one_based() {
let seq = AgentSeq::default();
assert_eq!(seq.next(), 1);
assert_eq!(seq.next(), 2);
assert_eq!(seq.next(), 3);
}
#[test]
fn feed_live_skip_predicate() {
let last_replayed = 1006u64;
let skip = |seq: u64| seq <= last_replayed;
assert!(skip(1005));
assert!(skip(1006)); assert!(!skip(1007)); assert!(!skip(2000));
}
#[test]
fn coalescer_reuse_smoke() {
let mut c = Coalescer::default();
assert!(c
.push(AgentEvent::Token {
content: "Hel".into()
})
.is_empty());
assert!(c
.push(AgentEvent::Token {
content: "lo".into()
})
.is_empty());
let out = c.push(AgentEvent::Complete {
usage: Default::default(),
});
assert_eq!(out.len(), 2);
match &out[0] {
AgentEvent::Token { content } => assert_eq!(content, "Hello"),
other => panic!("expected merged Token, got {other:?}"),
}
assert!(matches!(out[1], AgentEvent::Complete { .. }));
}
#[test]
fn terminal_event_predicate() {
assert!(is_terminal_event(&AgentEvent::Complete {
usage: Default::default()
}));
assert!(is_terminal_event(&AgentEvent::Cancelled { message: None }));
assert!(is_terminal_event(&AgentEvent::Error {
message: "x".into()
}));
assert!(!is_terminal_event(&AgentEvent::Token {
content: "x".into()
}));
}
#[test]
fn feed_envelope_uses_change_event_seq_and_full_payload() {
let ce = ChangeEvent {
seq: 99,
ts: chrono::Utc::now(),
session_id: Some("s1".into()),
event: AgentEvent::Token {
content: "hi".into(),
},
};
let env = feed_envelope(&ce);
assert_eq!(env.seq, 99);
let v = serde_json::to_value(&env).unwrap();
assert_eq!(v["ch"], "feed");
assert_eq!(v["seq"], 99);
assert_eq!(v["event"]["seq"], 99);
assert_eq!(v["event"]["session_id"], "s1");
assert_eq!(v["event"]["event"]["type"], "token");
}
async fn next_json(rx: &mut mpsc::Receiver<OutFrame>) -> serde_json::Value {
match tokio::time::timeout(Duration::from_secs(5), rx.recv())
.await
.expect("frame arrives before timeout")
.expect("outbound queue still open")
{
OutFrame::Text(text) => serde_json::from_str(&text).expect("frame is JSON"),
OutFrame::Binary(_) => panic!("JSON mode must not emit binary frames"),
}
}
async fn test_state(session_id: &str) -> (web::Data<AppState>, tempfile::TempDir) {
let tmp = tempfile::tempdir().expect("tempdir");
let state = web::Data::new(AppState::new(tmp.path().to_path_buf()).await.unwrap());
let mut session = bamboo_agent_core::Session::new(session_id, "test-model");
session.add_message(bamboo_agent_core::Message::assistant("done", None));
state.save_session(&mut session).await;
(state, tmp)
}
fn lagged_channel() -> (
broadcast::Sender<AgentEvent>,
broadcast::Receiver<AgentEvent>,
) {
let (tx, rx) = broadcast::channel::<AgentEvent>(4);
for i in 0..10 {
let _ = tx.send(AgentEvent::Token {
content: format!("t{i}"),
});
}
(tx, rx)
}
#[tokio::test]
async fn lag_emits_gap_control_and_synthesized_terminal_when_run_finished() {
let (state, _tmp) = test_state("lag-done").await;
let (_tx, rx) = lagged_channel();
let (out_tx, mut out_rx) = mpsc::channel::<OutFrame>(64);
let _handle = spawn_agent_forwarder(
state.clone(),
"lag-done".to_string(),
out_tx,
Encoding::Json,
"agent.lag-done".to_string(),
rx,
None,
Vec::new(),
0,
);
let gap = next_json(&mut out_rx).await;
assert_eq!(gap["ch"], "agent.lag-done");
assert_eq!(gap["control"]["type"], "gap");
assert_eq!(gap["control"]["skipped"], 6);
let terminal_event = next_json(&mut out_rx).await;
assert_eq!(terminal_event["event"]["type"], "complete");
let terminal = next_json(&mut out_rx).await;
assert_eq!(terminal["control"]["type"], "terminal");
assert!(
tokio::time::timeout(Duration::from_secs(5), out_rx.recv())
.await
.expect("close arrives before timeout")
.is_none(),
"channel must close after the synthesized terminal"
);
}
#[tokio::test]
async fn lag_emits_gap_control_and_keeps_tailing_when_runner_running() {
let (state, _tmp) = test_state("lag-live").await;
{
let mut runners = state.agent_runners.write().await;
let runner = runners
.entry("lag-live".to_string())
.or_insert_with(AgentRunner::new);
runner.status = AgentStatus::Running;
}
let (tx, rx) = lagged_channel();
let (out_tx, mut out_rx) = mpsc::channel::<OutFrame>(64);
let _handle = spawn_agent_forwarder(
state.clone(),
"lag-live".to_string(),
out_tx,
Encoding::Json,
"agent.lag-live".to_string(),
rx,
None,
Vec::new(),
0,
);
let gap = next_json(&mut out_rx).await;
assert_eq!(gap["control"]["type"], "gap");
assert_eq!(gap["control"]["skipped"], 6);
for i in 6..10 {
let frame = next_json(&mut out_rx).await;
assert_eq!(frame["event"]["type"], "token");
assert_eq!(frame["event"]["content"], format!("t{i}"));
}
let _ = tx.send(AgentEvent::Token {
content: "after-gap".to_string(),
});
let frame = next_json(&mut out_rx).await;
assert_eq!(frame["event"]["content"], "after-gap");
}
#[tokio::test]
async fn lag_during_awaiting_children_closes_without_duplicate_terminal_event() {
let (state, _tmp) = test_state("lag-parent").await;
let mut child =
bamboo_agent_core::Session::new_child("lag-child", "lag-parent", "test-model", "child");
state.save_session(&mut child).await;
{
let mut runners = state.agent_runners.write().await;
let runner = runners
.entry("lag-child".to_string())
.or_insert_with(AgentRunner::new);
runner.status = AgentStatus::Running;
}
let (tx, rx) = broadcast::channel::<AgentEvent>(4);
let (out_tx, mut out_rx) = mpsc::channel::<OutFrame>(64);
let _handle = spawn_agent_forwarder(
state.clone(),
"lag-parent".to_string(),
out_tx,
Encoding::Json,
"agent.lag-parent".to_string(),
rx,
None,
Vec::new(),
0,
);
let _ = tx.send(AgentEvent::Complete {
usage: Default::default(),
});
let real_terminal = next_json(&mut out_rx).await;
assert_eq!(real_terminal["event"]["type"], "complete");
{
let mut runners = state.agent_runners.write().await;
runners.remove("lag-child");
}
let _ = tx.send(AgentEvent::SubAgentCompleted {
parent_session_id: "lag-parent".to_string(),
child_session_id: "lag-child".to_string(),
status: "completed".to_string(),
error: None,
});
for i in 0..9 {
let _ = tx.send(AgentEvent::Token {
content: format!("child-tail-{i}"),
});
}
let gap = next_json(&mut out_rx).await;
assert_eq!(gap["control"]["type"], "gap");
let terminal = next_json(&mut out_rx).await;
assert_eq!(
terminal["control"]["type"], "terminal",
"self-heal must close with the control only — a synthesized terminal \
event here would be a duplicate completion: {terminal}"
);
assert!(
tokio::time::timeout(Duration::from_secs(5), out_rx.recv())
.await
.expect("close arrives before timeout")
.is_none(),
"channel must close after the terminal control"
);
}
}