1use 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
27pub 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#[derive(Clone, Debug)]
42pub struct HistoryCommitBarrier {
43 revision: watch::Receiver<u64>,
44}
45
46#[derive(Clone, Debug)]
48pub struct HistoryCommitAcknowledger {
49 revision: watch::Sender<u64>,
50}
51
52pub 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 pub fn acknowledge(&self) {
69 self.revision
70 .send_modify(|revision| *revision = revision.saturating_add(1));
71 }
72}
73
74impl HistoryCommitBarrier {
75 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
102fn 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 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
507pub 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
533pub 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 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
642pub 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
683pub 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 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 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}