Skip to main content

bamboo_engine/runtime/execution/
event_forwarder.rs

1//! Event forwarding from MPSC to broadcast channels.
2//!
3//! Creates an MPSC channel for agent loop events and spawns a background task
4//! that relays events to the session's broadcast sender while tracking runner
5//! diagnostic state (budget events, tool execution, round progress).
6
7use std::collections::HashMap;
8use std::sync::Arc;
9
10use chrono::Utc;
11use tokio::sync::{broadcast, mpsc, RwLock};
12
13use bamboo_agent_core::AgentEvent;
14
15use super::runner_state::AgentRunner;
16
17/// Inbox to the account-wide change feed: `(session_id, event)` before the
18/// writer assigns a seq. Threaded as `Option` so engine-internal callers that
19/// have no feed (tests, standalone embeddings) can pass `None`. Defined here so
20/// the engine stays free of any `bamboo-server` dependency.
21pub type AccountFeedInbox = mpsc::Sender<(Option<String>, AgentEvent)>;
22
23/// Forward a durable change event onto the account feed, if an inbox is wired.
24///
25/// Ephemeral events (tokens, heartbeats, …) are filtered out before any clone,
26/// so this is near-free on the hot path. `session_id` is supplied explicitly so
27/// terminal events (which carry no id) still route to the right session.
28fn mirror_to_account_feed(inbox: &Option<AccountFeedInbox>, session_id: &str, event: &AgentEvent) {
29    if let Some(inbox) = inbox {
30        if event.is_durable_change() {
31            let route_session_id = event.session_id().unwrap_or(session_id);
32            let _ = inbox.try_send((Some(route_session_id.to_string()), event.clone()));
33        }
34    }
35}
36
37#[cfg(test)]
38#[allow(clippy::items_after_test_module)]
39mod tests {
40    use super::*;
41
42    #[tokio::test]
43    async fn child_approval_change_routes_to_parent_account_envelope() {
44        let (tx, mut rx) = mpsc::channel(4);
45        let event = AgentEvent::ChildApprovalChanged {
46            parent_session_id: "parent-1".into(),
47            child_session_id: "child-1".into(),
48            child_attempt: 1,
49            request_id: "req-1".into(),
50            version: 2,
51            status: "approved".into(),
52            reason: None,
53            tool_name: "Bash".into(),
54            permission: "execute".into(),
55            resource: "/tmp/x".into(),
56            created_at: "2026-01-01T00:00:00Z".into(),
57            resolved_at: Some("2026-01-01T00:00:01Z".into()),
58        };
59
60        mirror_to_account_feed(&Some(tx), "child-1", &event);
61        let (session_id, mirrored) = rx.recv().await.unwrap();
62        assert_eq!(session_id.as_deref(), Some("parent-1"));
63        assert!(matches!(mirrored, AgentEvent::ChildApprovalChanged { .. }));
64    }
65}
66
67/// Create an MPSC channel for agent events and spawn a forwarding task
68/// that relays events to the broadcast sender while tracking runner
69/// diagnostic fields for live visibility.
70///
71/// `account_feed_inbox`, when present, also mirrors durable change events onto
72/// the account-wide feed for resumable multi-client sync.
73///
74/// Returns `(mpsc_tx, forwarder_handle)`.
75pub fn create_event_forwarder(
76    session_id: String,
77    broadcast_tx: broadcast::Sender<AgentEvent>,
78    runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
79    account_feed_inbox: Option<AccountFeedInbox>,
80) -> (mpsc::Sender<AgentEvent>, tokio::task::JoinHandle<()>) {
81    let (mpsc_tx, mut mpsc_rx) = mpsc::channel::<AgentEvent>(100);
82
83    let forwarder = tokio::spawn(async move {
84        // Emit ExecutionStarted as the first event so the frontend can correlate
85        // all subsequent events to this run via run_id.
86        {
87            let runners = runners.read().await;
88            if let Some(runner) = runners.get(&session_id) {
89                let started_event = AgentEvent::ExecutionStarted {
90                    run_id: runner.run_id.clone(),
91                    session_id: session_id.clone(),
92                    started_at: Utc::now().to_rfc3339(),
93                };
94                mirror_to_account_feed(&account_feed_inbox, &session_id, &started_event);
95                let _ = broadcast_tx.send(started_event);
96            }
97        }
98
99        while let Some(event) = mpsc_rx.recv().await {
100            {
101                let mut runners = runners.write().await;
102                if let Some(runner) = runners.get_mut(&session_id) {
103                    runner.last_event_at = Some(Utc::now());
104
105                    match &event {
106                        AgentEvent::TokenBudgetUpdated { .. } => {
107                            runner.last_budget_event = Some(event.clone());
108                        }
109                        AgentEvent::ToolStart { tool_name, .. } => {
110                            runner.last_tool_name = Some(tool_name.clone());
111                            runner.last_tool_phase = Some("begin".to_string());
112                        }
113                        AgentEvent::ToolLifecycle {
114                            tool_name, phase, ..
115                        } => {
116                            runner.last_tool_name = Some(tool_name.clone());
117                            runner.last_tool_phase = Some(phase.clone());
118                        }
119                        AgentEvent::RunnerProgress { round_count, .. } => {
120                            runner.round_count = *round_count;
121                        }
122                        _ => {}
123                    }
124                }
125            }
126            mirror_to_account_feed(&account_feed_inbox, &session_id, &event);
127            let _ = broadcast_tx.send(event);
128        }
129    });
130
131    (mpsc_tx, forwarder)
132}