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, watch, RwLock};
12use tokio_util::sync::CancellationToken;
13
14use bamboo_agent_core::AgentEvent;
15
16use super::runner_state::AgentRunner;
17
18fn visible_terminal_reason(event: &AgentEvent) -> Option<&'static str> {
19    match event {
20        AgentEvent::Complete { .. } => Some("complete"),
21        AgentEvent::Cancelled { .. } => Some("cancelled"),
22        AgentEvent::Error { .. } => Some("error"),
23        _ => None,
24    }
25}
26
27/// Inbox to the account-wide change feed: `(session_id, event)` before the
28/// writer assigns a seq. Threaded as `Option` so engine-internal callers that
29/// have no feed (tests, standalone embeddings) can pass `None`. Defined here so
30/// the engine stays free of any `bamboo-server` dependency.
31pub type AccountFeedInbox = mpsc::Sender<(Option<String>, AgentEvent)>;
32
33#[cfg(test)]
34#[path = "root_actor_event_forwarder_tests.rs"]
35mod root_actor_tests;
36
37/// Completion signal for the durable history barrier emitted at the end of a
38/// run. The runner must not become replaceable until the forwarder has actually
39/// published `SessionHistoryCommitted`; merely enqueueing the event leaves a
40/// race where a successor can retire this generation first.
41#[derive(Clone, Debug)]
42pub struct HistoryCommitBarrier {
43    revision: watch::Receiver<u64>,
44}
45
46/// Forwarder-owned half of [`HistoryCommitBarrier`].
47#[derive(Clone, Debug)]
48pub struct HistoryCommitAcknowledger {
49    revision: watch::Sender<u64>,
50}
51
52/// Create the acknowledgment pair shared by an event producer and forwarder.
53pub fn history_commit_barrier() -> (HistoryCommitAcknowledger, HistoryCommitBarrier) {
54    let (revision_tx, revision_rx) = watch::channel(0);
55    (
56        HistoryCommitAcknowledger {
57            revision: revision_tx,
58        },
59        HistoryCommitBarrier {
60            revision: revision_rx,
61        },
62    )
63}
64
65impl HistoryCommitAcknowledger {
66    /// Mark one history barrier as synchronously published to every configured
67    /// sink. Call this only after the publication fence accepts the event.
68    pub fn acknowledge(&self) {
69        self.revision
70            .send_modify(|revision| *revision = revision.saturating_add(1));
71    }
72}
73
74impl HistoryCommitBarrier {
75    /// Enqueue a history barrier and wait until the matching forwarder has
76    /// published it. Returns `false` if the forwarder disappeared first.
77    pub async fn send_and_wait(
78        &mut self,
79        event_tx: &mpsc::Sender<AgentEvent>,
80        session_id: String,
81    ) -> bool {
82        let observed = *self.revision.borrow_and_update();
83        if event_tx
84            .send(AgentEvent::SessionHistoryCommitted { session_id })
85            .await
86            .is_err()
87        {
88            return false;
89        }
90
91        loop {
92            if *self.revision.borrow() > observed {
93                return true;
94            }
95            if self.revision.changed().await.is_err() {
96                return false;
97            }
98        }
99    }
100}
101
102/// Forward a durable change event onto the account feed, if an inbox is wired.
103///
104/// Ephemeral events (tokens, heartbeats, …) are filtered out before any clone,
105/// so this is near-free on the hot path. `session_id` is supplied explicitly so
106/// terminal events (which carry no id) still route to the right session.
107fn mirror_to_account_feed(inbox: &Option<AccountFeedInbox>, session_id: &str, event: &AgentEvent) {
108    if let Some(inbox) = inbox {
109        if event.is_durable_change() {
110            let route_session_id = event.session_id().unwrap_or(session_id);
111            let _ = inbox.try_send((Some(route_session_id.to_string()), event.clone()));
112        }
113    }
114}
115
116#[cfg(test)]
117#[allow(clippy::items_after_test_module)]
118mod tests {
119    use super::*;
120
121    use bamboo_agent_core::ToolResult;
122    use serde_json::json;
123
124    #[tokio::test]
125    async fn generic_forwarder_projects_native_download_without_changing_ordinary_results() {
126        let session_id = "generic-download-display";
127        let (broadcast_tx, mut broadcast_rx) = broadcast::channel(16);
128        let mut runner = AgentRunner::new();
129        runner.status = super::super::runner_state::AgentStatus::Running;
130        runner.event_sender = broadcast_tx.clone();
131        let run_id = runner.run_id.clone();
132        let runners = Arc::new(RwLock::new(HashMap::from([(
133            session_id.to_string(),
134            runner,
135        )])));
136        let (input, forwarder) =
137            create_event_forwarder(session_id.to_string(), run_id, broadcast_tx, runners, None);
138        assert!(matches!(
139            broadcast_rx.recv().await.unwrap(),
140            AgentEvent::ExecutionStarted { .. }
141        ));
142
143        let private = "private-file-url-and-bytes";
144        input
145            .send(AgentEvent::ToolStart {
146                tool_call_id: "download".into(),
147                tool_name: "browser".into(),
148                arguments: json!({"action":"download","selector":private,"expected_epoch":9}),
149            })
150            .await
151            .unwrap();
152        input
153            .send(AgentEvent::ToolError {
154                tool_call_id: "download".into(),
155                error: private.into(),
156            })
157            .await
158            .unwrap();
159        input
160            .send(AgentEvent::ToolComplete {
161                tool_call_id: "download".into(),
162                result: ToolResult::text(false, private),
163            })
164            .await
165            .unwrap();
166        input
167            .send(AgentEvent::ToolStart {
168                tool_call_id: "read".into(),
169                tool_name: "Read".into(),
170                arguments: json!({"path":"readme.md"}),
171            })
172            .await
173            .unwrap();
174        input
175            .send(AgentEvent::ToolComplete {
176                tool_call_id: "read".into(),
177                result: ToolResult::text(true, "ordinary Read result"),
178            })
179            .await
180            .unwrap();
181        input
182            .send(AgentEvent::HookLifecycle {
183                hook_name: private.into(),
184                point: bamboo_domain::AgentHookPoint::AfterToolExecution,
185                phase: private.into(),
186                duration_ms: 7,
187                decision: bamboo_domain::HookResult::WithContext {
188                    result: Box::new(bamboo_domain::HookResult::Deny {
189                        reason: private.into(),
190                    }),
191                    text: private.into(),
192                },
193            })
194            .await
195            .unwrap();
196        drop(input);
197
198        let mut visible = Vec::new();
199        for _ in 0..6 {
200            visible.push(
201                tokio::time::timeout(std::time::Duration::from_secs(5), broadcast_rx.recv())
202                    .await
203                    .unwrap()
204                    .unwrap(),
205            );
206        }
207        for event in &visible[..3] {
208            assert!(!serde_json::to_string(event).unwrap().contains(private));
209        }
210        assert!(
211            matches!(&visible[2], AgentEvent::ToolComplete { result, .. }
212            if result.result == "Tool result hidden" && result.images.is_empty())
213        );
214        assert!(
215            matches!(&visible[4], AgentEvent::ToolComplete { result, .. }
216            if result.result == "ordinary Read result")
217        );
218        assert!(!serde_json::to_string(&visible[5])
219            .unwrap()
220            .contains(private));
221        assert!(matches!(
222            &visible[5],
223            AgentEvent::HookLifecycle {
224                point: bamboo_domain::AgentHookPoint::AfterToolExecution,
225                duration_ms: 7,
226                decision: bamboo_domain::HookResult::WithContext { .. },
227                ..
228            }
229        ));
230        forwarder.await.unwrap();
231    }
232
233    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
234    async fn hundreds_of_child_streams_progress_while_global_registry_is_locked() {
235        let runners = Arc::new(RwLock::new(HashMap::new()));
236        let mut streams = Vec::new();
237        for index in 0..512 {
238            let id = format!("child-{index}");
239            let mut runner = AgentRunner::new();
240            runner.status = super::super::runner_state::AgentStatus::Running;
241            let mut receiver = runner.event_sender.subscribe();
242            let sender = runner.event_sender.clone();
243            let run_id = runner.run_id.clone();
244            runners.write().await.insert(id.clone(), runner);
245            let (input, task) = create_event_forwarder(id, run_id, sender, runners.clone(), None);
246            assert!(matches!(
247                receiver.recv().await.unwrap(),
248                AgentEvent::ExecutionStarted { .. }
249            ));
250            streams.push((input, receiver, task));
251        }
252        let held_registry = runners.write().await;
253        let started = std::time::Instant::now();
254        tokio::time::timeout(std::time::Duration::from_secs(10), async {
255            futures::future::join_all(streams.iter_mut().map(|(input, receiver, _)| async move {
256                for _ in 0..64 {
257                    input
258                        .send(AgentEvent::Token {
259                            content: "delta".into(),
260                        })
261                        .await
262                        .unwrap();
263                }
264                for _ in 0..64 {
265                    assert!(matches!(
266                        receiver.recv().await.unwrap(),
267                        AgentEvent::Token { .. }
268                    ));
269                }
270            }))
271            .await;
272        })
273        .await
274        .expect("independent token streams must not wait for registry ownership");
275        eprintln!(
276            "512 child streams / 32768 tokens with held registry: {:?}",
277            started.elapsed()
278        );
279        assert!(held_registry
280            .values()
281            .all(|runner| runner.last_activity_at().is_some()));
282        drop(held_registry);
283        for (input, _, task) in streams {
284            drop(input);
285            task.await.unwrap();
286        }
287    }
288
289    #[tokio::test]
290    async fn child_approval_change_routes_to_parent_account_envelope() {
291        let (tx, mut rx) = mpsc::channel(4);
292        let event = AgentEvent::ChildApprovalChanged {
293            parent_session_id: "parent-1".into(),
294            child_session_id: "child-1".into(),
295            child_attempt: 1,
296            request_id: "req-1".into(),
297            version: 2,
298            status: "approved".into(),
299            reason: None,
300            tool_name: "Bash".into(),
301            permission: "execute".into(),
302            resource: "/tmp/x".into(),
303            created_at: "2026-01-01T00:00:00Z".into(),
304            resolved_at: Some("2026-01-01T00:00:01Z".into()),
305        };
306
307        mirror_to_account_feed(&Some(tx), "child-1", &event);
308        let (session_id, mirrored) = rx.recv().await.unwrap();
309        assert_eq!(session_id.as_deref(), Some("parent-1"));
310        assert!(matches!(mirrored, AgentEvent::ChildApprovalChanged { .. }));
311    }
312
313    #[tokio::test]
314    async fn child_browser_approval_is_redacted_before_broadcast_replay_and_account_feed() {
315        let session_id = "child-browser-private";
316        let private = "private-download-selector";
317        let (broadcast_tx, mut broadcast_rx) = broadcast::channel(8);
318        let mut runner = AgentRunner::new();
319        runner.status = super::super::runner_state::AgentStatus::Running;
320        runner.event_sender = broadcast_tx.clone();
321        let run_id = runner.run_id.clone();
322        let runners = Arc::new(RwLock::new(HashMap::from([(
323            session_id.to_string(),
324            runner,
325        )])));
326        let (account_tx, mut account_rx) = mpsc::channel(8);
327        let (event_tx, forwarder) = create_event_forwarder(
328            session_id.into(),
329            run_id,
330            broadcast_tx,
331            runners.clone(),
332            Some(account_tx),
333        );
334        assert!(matches!(
335            broadcast_rx.recv().await.unwrap(),
336            AgentEvent::ExecutionStarted { .. }
337        ));
338        event_tx
339            .send(AgentEvent::ChildApprovalChanged {
340                parent_session_id: "parent-private".into(),
341                child_session_id: session_id.into(),
342                child_attempt: 4,
343                request_id: "request-private".into(),
344                version: 7,
345                status: "denied".into(),
346                reason: Some(private.into()),
347                tool_name: "browser".into(),
348                permission: private.into(),
349                resource: format!("browser:17:download:css:{private}"),
350                created_at: "2026-09-24T00:00:00Z".into(),
351                resolved_at: Some("2026-09-24T00:00:01Z".into()),
352            })
353            .await
354            .unwrap();
355        let live = broadcast_rx.recv().await.unwrap();
356        assert!(!serde_json::to_string(&live).unwrap().contains(private));
357        let _started = account_rx.recv().await.unwrap();
358        let (route, feed) = account_rx.recv().await.unwrap();
359        assert_eq!(route.as_deref(), Some("parent-private"));
360        assert!(!serde_json::to_string(&feed).unwrap().contains(private));
361        assert!(
362            !serde_json::to_string(&runners.read().await[session_id].last_critical_events)
363                .unwrap()
364                .contains(private)
365        );
366        drop(event_tx);
367        forwarder.await.unwrap();
368    }
369
370    #[tokio::test]
371    async fn history_commit_barrier_acknowledges_actual_publication() {
372        let session_id = "session-history-barrier";
373        let (broadcast_tx, mut broadcast_rx) = broadcast::channel(16);
374        let mut runner = AgentRunner::new();
375        runner.status = super::super::runner_state::AgentStatus::Running;
376        runner.event_sender = broadcast_tx.clone();
377        let run_id = runner.run_id.clone();
378        let runners = Arc::new(RwLock::new(HashMap::from([(
379            session_id.to_string(),
380            runner,
381        )])));
382        let (event_tx, forwarder, mut barrier) = create_event_forwarder_with_history_commit_barrier(
383            session_id.to_string(),
384            run_id,
385            broadcast_tx,
386            runners,
387            None,
388        );
389
390        assert!(matches!(
391            broadcast_rx.recv().await.unwrap(),
392            AgentEvent::ExecutionStarted { .. }
393        ));
394        assert!(
395            barrier
396                .send_and_wait(&event_tx, session_id.to_string())
397                .await,
398            "producer must not continue until the forwarder publishes the barrier"
399        );
400        assert!(matches!(
401            broadcast_rx.recv().await.unwrap(),
402            AgentEvent::SessionHistoryCommitted { session_id: id } if id == session_id
403        ));
404
405        drop(event_tx);
406        forwarder.await.unwrap();
407    }
408
409    #[tokio::test]
410    async fn delayed_old_forwarder_cannot_publish_after_successor_reservation() {
411        let session_id = "session-generation";
412        let (broadcast_tx, mut broadcast_rx) = broadcast::channel(16);
413        let mut successor = AgentRunner::new();
414        successor.run_id = "run-new".to_string();
415        successor.status = super::super::runner_state::AgentStatus::Running;
416        successor.event_sender = broadcast_tx.clone();
417        let runners = Arc::new(RwLock::new(HashMap::from([(
418            session_id.to_string(),
419            successor,
420        )])));
421
422        // The successor is already visible on the shared transport before an
423        // old forwarder task finally gets CPU time.
424        broadcast_tx
425            .send(AgentEvent::ExecutionStarted {
426                run_id: "run-new".to_string(),
427                session_id: session_id.to_string(),
428                started_at: Utc::now().to_rfc3339(),
429            })
430            .unwrap();
431        let (old_tx, old_forwarder) = create_event_forwarder(
432            session_id.to_string(),
433            "run-old".to_string(),
434            broadcast_tx.clone(),
435            runners,
436            None,
437        );
438        let _ = old_tx
439            .send(AgentEvent::NeedClarification {
440                question: "stale".to_string(),
441                options: Some(vec!["A".to_string()]),
442                tool_call_id: Some("old-tool".to_string()),
443                tool_name: Some("ConclusionWithOptions".to_string()),
444                allow_custom: false,
445                source: Some(bamboo_agent_core::PendingQuestionSource::PauseTool),
446            })
447            .await;
448        drop(old_tx);
449        old_forwarder.await.unwrap();
450
451        assert!(matches!(
452            broadcast_rx.recv().await.unwrap(),
453            AgentEvent::ExecutionStarted { ref run_id, .. } if run_id == "run-new"
454        ));
455        assert!(
456            tokio::time::timeout(std::time::Duration::from_millis(50), broadcast_rx.recv())
457                .await
458                .is_err(),
459            "old Started/Need/Complete must all be suppressed"
460        );
461    }
462
463    #[tokio::test]
464    async fn wrapped_child_lifecycle_reaches_bounded_legacy_replay_consumer() {
465        let session_id = "root";
466        let (broadcast_tx, mut broadcast_rx) = broadcast::channel(8);
467        let mut runner = AgentRunner::new();
468        runner.status = super::super::runner_state::AgentStatus::Running;
469        runner.event_sender = broadcast_tx.clone();
470        let run_id = runner.run_id.clone();
471        let runners = Arc::new(RwLock::new(HashMap::from([(session_id.into(), runner)])));
472        let (input, forwarder) = create_event_forwarder(
473            session_id.into(),
474            run_id,
475            broadcast_tx,
476            runners.clone(),
477            None,
478        );
479        assert!(matches!(
480            broadcast_rx.recv().await.unwrap(),
481            AgentEvent::ExecutionStarted { .. }
482        ));
483        let legacy = AgentEvent::SubAgentEvent {
484            parent_session_id: "root".into(),
485            child_session_id: "parent".into(),
486            event: Box::new(AgentEvent::SubAgentStarted {
487                parent_session_id: "parent".into(),
488                child_session_id: "grandchild".into(),
489                title: Some("work".into()),
490            }),
491        };
492        input.send(legacy).await.unwrap();
493        assert!(matches!(
494            broadcast_rx.recv().await.unwrap(),
495            AgentEvent::SubAgentEvent { .. }
496        ));
497        let guard = runners.read().await;
498        let cached = &guard[session_id].last_critical_events;
499        assert_eq!(cached.len(), 1);
500        assert!(matches!(&cached[0], AgentEvent::SubAgentEvent { .. }));
501        drop(guard);
502        drop(input);
503        forwarder.await.unwrap();
504    }
505}
506
507/// Create an MPSC channel for agent events and spawn a forwarding task
508/// that relays events to the broadcast sender while tracking runner
509/// diagnostic fields for live visibility.
510///
511/// `account_feed_inbox`, when present, also mirrors durable change events onto
512/// the account-wide feed for resumable multi-client sync.
513///
514/// Returns `(mpsc_tx, forwarder_handle)`.
515pub fn create_event_forwarder(
516    session_id: String,
517    run_id: String,
518    broadcast_tx: broadcast::Sender<AgentEvent>,
519    runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
520    account_feed_inbox: Option<AccountFeedInbox>,
521) -> (mpsc::Sender<AgentEvent>, tokio::task::JoinHandle<()>) {
522    let (event_tx, forwarder, _history_commit_barrier) =
523        create_event_forwarder_with_history_commit_barrier(
524            session_id,
525            run_id,
526            broadcast_tx,
527            runners,
528            account_feed_inbox,
529        );
530    (event_tx, forwarder)
531}
532
533/// Create an event forwarder plus a producer-side durable-history barrier.
534/// Runtime paths that finalize and replace runner generations must use this
535/// variant so they can await actual barrier publication before finalization.
536pub fn create_event_forwarder_with_history_commit_barrier(
537    session_id: String,
538    run_id: String,
539    broadcast_tx: broadcast::Sender<AgentEvent>,
540    runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
541    account_feed_inbox: Option<AccountFeedInbox>,
542) -> (
543    mpsc::Sender<AgentEvent>,
544    tokio::task::JoinHandle<()>,
545    HistoryCommitBarrier,
546) {
547    create_event_forwarder_with_root_actor(
548        session_id,
549        run_id,
550        broadcast_tx,
551        runners,
552        account_feed_inbox,
553        None,
554    )
555}
556
557async fn publish_forwarded_event(
558    root_actor: Option<&crate::events::RootActorEventPublication>,
559    session_id: &str,
560    event: &AgentEvent,
561    publish: Box<dyn FnOnce() -> bool + Send>,
562) -> std::io::Result<()> {
563    if let Some(owner) = root_actor {
564        owner
565            .publish(event.session_id().unwrap_or(session_id), event, publish)
566            .await
567    } else if publish() {
568        Ok(())
569    } else {
570        Err(std::io::Error::other("event publication was retired"))
571    }
572}
573
574const ROOT_AUTHORITY_LOST: &str =
575    "This run was interrupted because its execution ownership was lost. Reload the conversation before retrying.";
576
577fn record_publication_rejection(
578    error: &std::io::Error,
579    session_id: &str,
580    run_id: &str,
581    event: Option<&AgentEvent>,
582    root_actor: Option<&crate::events::RootActorEventPublication>,
583    phase: &'static str,
584) -> String {
585    use super::workspace_diagnostics::{record_failure, WorkspaceLogContext};
586    let diagnostic_id = format!("RPA-{}", uuid::Uuid::new_v4().simple());
587    let request_id = event.and_then(|event| match event {
588        AgentEvent::ToolStart { tool_call_id, .. }
589        | AgentEvent::ToolComplete { tool_call_id, .. }
590        | AgentEvent::ToolError { tool_call_id, .. }
591        | AgentEvent::ToolLifecycle { tool_call_id, .. } => Some(tool_call_id.as_str()),
592        _ => None,
593    });
594    if let Some(owner) = root_actor {
595        let (lease_epoch, attempt, fence_id) = owner.diagnostic_identity();
596        tracing::warn!(target: "bamboo.execution_publication", %diagnostic_id,
597            lease_epoch, attempt, %fence_id, phase,
598            "Root publication rejected under captured execution fence");
599    }
600    record_failure(
601        WorkspaceLogContext {
602            session_id,
603            run_id: Some(run_id),
604            request_id,
605            diagnostic_id: Some(&diagnostic_id),
606        },
607        phase,
608        error,
609    )
610}
611
612struct RootInterruption<'a> {
613    session_id: &'a str,
614    run_id: &'a str,
615    runners: &'a Arc<RwLock<HashMap<String, AgentRunner>>>,
616    publication: &'a super::event_publication::EventPublication,
617    visible: &'a super::visible_messages::VisibleMessageStream,
618    cancel_token: &'a CancellationToken,
619}
620
621impl RootInterruption<'_> {
622    async fn close(self, diagnostic_id: &str) {
623        // Retain the original exact-owner cancellation/publication behavior.
624        // A diagnostic number never permits mutation or retries a tool.
625        self.cancel_token.cancel();
626        self.publication.retire().await;
627        self.visible.mark_terminal("error");
628        let guard = self.runners.read().await;
629        if let Some(runner) = guard
630            .get(self.session_id)
631            .filter(|runner| runner.run_id == self.run_id)
632        {
633            let _ = runner.event_sender.send(AgentEvent::Error {
634                message: format!(
635                    "{ROOT_AUTHORITY_LOST} [actor_authority_conflict; {diagnostic_id}]"
636                ),
637            });
638        }
639    }
640}
641
642/// Close only the obsolete run's live transport. This is not a runtime-state
643/// publication: it never enters the account journal, replay cache, or storage.
644/// The exact local runner guard keeps this fixed error ahead of any successor's
645/// Started frame on the shared session channel. A successor already installed
646/// in the registry must not receive the obsolete run's error or cancellation.
647/// Server adapters share this boundary with the generic engine forwarder.
648pub async fn interrupt_root_on_authority_loss(
649    error: &std::io::Error,
650    session_id: &str,
651    run_id: &str,
652    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
653    publication: &super::event_publication::EventPublication,
654    visible: &super::visible_messages::VisibleMessageStream,
655    cancel_token: &CancellationToken,
656) {
657    if !error
658        .get_ref()
659        .is_some_and(|cause| cause.is::<bamboo_domain::SessionAuthorityConflict>())
660    {
661        return;
662    }
663    let diagnostic_id = record_publication_rejection(
664        error,
665        session_id,
666        run_id,
667        None,
668        None,
669        "root_authority_interrupt",
670    );
671    RootInterruption {
672        session_id,
673        run_id,
674        runners,
675        publication,
676        visible,
677        cancel_token,
678    }
679    .close(&diagnostic_id)
680    .await;
681}
682
683/// The Root adapter passes its immutable capability before any Started frame.
684/// Legacy Child/Supervisor/standalone callers keep the existing queue behavior.
685pub fn create_event_forwarder_with_root_actor(
686    session_id: String,
687    run_id: String,
688    broadcast_tx: broadcast::Sender<AgentEvent>,
689    runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
690    account_feed_inbox: Option<AccountFeedInbox>,
691    root_actor: Option<crate::events::RootActorEventPublication>,
692) -> (
693    mpsc::Sender<AgentEvent>,
694    tokio::task::JoinHandle<()>,
695    HistoryCommitBarrier,
696) {
697    let (mpsc_tx, mut mpsc_rx) = mpsc::channel::<AgentEvent>(100);
698    let (history_commit_acknowledger, history_commit_barrier) = history_commit_barrier();
699    let forward = async move {
700        if root_actor
701            .as_ref()
702            .is_some_and(|owner| !owner.matches_execution(&session_id, &run_id))
703        {
704            tracing::warn!(%session_id, %run_id, "Root forwarder handoff does not match its execution");
705            return;
706        }
707        // A bound Root never enters the legacy account queue.
708        let legacy_feed = if root_actor.is_some() {
709            None
710        } else {
711            account_feed_inbox
712        };
713        let started_event = AgentEvent::ExecutionStarted {
714            run_id: run_id.clone(),
715            session_id: session_id.clone(),
716            started_at: Utc::now().to_rfc3339(),
717        };
718        let (publication, visible_messages, cancel_token) = {
719            let guard = runners.clone().read_owned().await;
720            let Some(runner) = guard
721                .get(&session_id)
722                .filter(|runner| runner.run_id == run_id)
723            else {
724                return;
725            };
726            let publication = runner.event_publication.clone();
727            let visible_messages = runner.visible_messages.clone();
728            let cancel_token = runner.cancel_token.clone();
729            let feed = legacy_feed.clone();
730            let sid = session_id.clone();
731            let tx = broadcast_tx.clone();
732            let frame = started_event.clone();
733            if let Err(error) = publish_forwarded_event(
734                root_actor.as_ref(),
735                &session_id,
736                &frame,
737                Box::new(move || {
738                    let _guard = guard;
739                    mirror_to_account_feed(&feed, &sid, &started_event);
740                    let _ = tx.send(started_event);
741                    true
742                }),
743            )
744            .await
745            {
746                let diagnostic_id = record_publication_rejection(
747                    &error,
748                    &session_id,
749                    &run_id,
750                    Some(&frame),
751                    root_actor.as_ref(),
752                    "root_started_publication",
753                );
754                if error
755                    .get_ref()
756                    .is_some_and(|cause| cause.is::<bamboo_domain::SessionAuthorityConflict>())
757                {
758                    RootInterruption {
759                        session_id: &session_id,
760                        run_id: &run_id,
761                        runners: &runners,
762                        publication: &publication,
763                        visible: &visible_messages,
764                        cancel_token: &cancel_token,
765                    }
766                    .close(&diagnostic_id)
767                    .await;
768                }
769                return;
770            }
771            (publication, visible_messages, cancel_token)
772        };
773        let mut tool_event_display = bamboo_agent_core::NativeToolEventDisplay::default();
774        while let Some(event) = mpsc_rx.recv().await {
775            match &event {
776                AgentEvent::VisibleMessageStart {
777                    message_id,
778                    created_at,
779                } => {
780                    if !publication.publish(|| {
781                        visible_messages.start(message_id.clone(), created_at.to_owned())
782                    }) {
783                        return;
784                    }
785                    continue;
786                }
787                AgentEvent::VisibleMessageDiscard { message_id } => {
788                    if !publication.publish(|| visible_messages.discard(message_id)) {
789                        return;
790                    }
791                    continue;
792                }
793                _ => {}
794            }
795            let event = tool_event_display.project(event);
796            let event = crate::external_agents::live::approval_event_for_display(event);
797            let needs_runner_update = event.is_replayable_session_state()
798                || matches!(
799                    &event,
800                    AgentEvent::TokenBudgetUpdated { .. }
801                        | AgentEvent::ToolStart { .. }
802                        | AgentEvent::ToolLifecycle { .. }
803                        | AgentEvent::RunnerProgress { .. }
804                );
805            let is_history_commit = matches!(&event, AgentEvent::SessionHistoryCommitted { .. });
806            let frame = event.clone();
807            let feed = legacy_feed.clone();
808            let sid = session_id.clone();
809            let tx = broadcast_tx.clone();
810            let result = if !needs_runner_update {
811                let visible_token = match &event {
812                    AgentEvent::Token { content } => Some(content.clone()),
813                    _ => None,
814                };
815                let terminal_reason = visible_terminal_reason(&event);
816                // Ephemeral token traffic retains its independent hot path.
817                let owner = root_actor
818                    .as_ref()
819                    .filter(|_| event.is_durable_change() || terminal_reason.is_some());
820                let publication = publication.clone();
821                let visible = visible_messages.clone();
822                publish_forwarded_event(
823                    owner,
824                    &session_id,
825                    &frame,
826                    Box::new(move || {
827                        publication.publish(|| {
828                            if let Some(content) = visible_token {
829                                visible.append(content);
830                            }
831                            mirror_to_account_feed(&feed, &sid, &event);
832                            let _ = tx.send(event);
833                            if let Some(reason) = terminal_reason {
834                                visible.mark_terminal(reason);
835                            }
836                            if is_history_commit {
837                                visible.history_committed();
838                            }
839                        })
840                    }),
841                )
842                .await
843            } else {
844                let mut guard = runners.clone().write_owned().await;
845                if !guard
846                    .get(&session_id)
847                    .is_some_and(|runner| runner.run_id == run_id)
848                {
849                    return;
850                }
851                let publication = publication.clone();
852                publish_forwarded_event(
853                    root_actor.as_ref(),
854                    &session_id,
855                    &frame,
856                    Box::new(move || {
857                        let runner = guard.get_mut(&sid).expect("retained exact runner guard");
858                        runner.last_event_at = Some(Utc::now());
859                        publication.touch();
860                        if event.is_replayable_session_state() {
861                            runner.push_critical_event(event.clone());
862                        }
863                        match &event {
864                            AgentEvent::TokenBudgetUpdated { .. } => {
865                                runner.last_budget_event = Some(event.clone())
866                            }
867                            AgentEvent::ToolStart { tool_name, .. } => {
868                                runner.last_tool_name = Some(tool_name.clone());
869                                runner.last_tool_phase = Some("begin".into());
870                            }
871                            AgentEvent::ToolLifecycle {
872                                tool_name, phase, ..
873                            } => {
874                                runner.last_tool_name = Some(tool_name.clone());
875                                runner.last_tool_phase = Some(phase.clone());
876                            }
877                            AgentEvent::RunnerProgress { round_count, .. } => {
878                                runner.round_count = *round_count;
879                                runner.visible_messages.begin_round(*round_count);
880                            }
881                            _ => {}
882                        }
883                        mirror_to_account_feed(&feed, &sid, &event);
884                        let _ = tx.send(event);
885                        true
886                    }),
887                )
888                .await
889            };
890            if let Err(error) = result {
891                let diagnostic_id = record_publication_rejection(
892                    &error,
893                    &session_id,
894                    &run_id,
895                    Some(&frame),
896                    root_actor.as_ref(),
897                    "root_runtime_publication",
898                );
899                if error
900                    .get_ref()
901                    .is_some_and(|cause| cause.is::<bamboo_domain::SessionAuthorityConflict>())
902                {
903                    RootInterruption {
904                        session_id: &session_id,
905                        run_id: &run_id,
906                        runners: &runners,
907                        publication: &publication,
908                        visible: &visible_messages,
909                        cancel_token: &cancel_token,
910                    }
911                    .close(&diagnostic_id)
912                    .await;
913                }
914                return;
915            }
916            if is_history_commit {
917                history_commit_acknowledger.acknowledge();
918            }
919        }
920    };
921    (mpsc_tx, tokio::spawn(forward), history_commit_barrier)
922}