Skip to main content

bamboo_engine/
session_messaging.rs

1//! Internal logical-session messaging service.
2//!
3//! This module deliberately has no HTTP concepts. User transports, tools,
4//! actor adapters, the SDK, and runtime coordinators all submit the same typed
5//! envelope addressed by stable [`Session::id`](bamboo_domain::Session).
6
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::Arc;
9use std::time::Instant;
10
11use bamboo_agent_core::storage::Storage;
12use bamboo_domain::{
13    Session, SessionActivationDisposition, SessionActivationError, SessionActivationPolicy,
14    SessionActivationPort, SessionInboxError, SessionInboxPort, SessionInboxReceipt,
15    SessionMessageBody, SessionMessageContent, SessionMessageEnvelope, SessionMessageId,
16    SessionMessageKind, SessionMessageSource, SupervisorReference,
17};
18
19/// A followup is one bounded peer instruction, not a context export or file transfer.
20pub const MAX_SUPERVISOR_FOLLOWUP_BYTES: usize = 16 * 1024;
21
22/// Observable counters for the internal delivery plane.
23#[derive(Debug, Default)]
24pub struct SessionMessagingMetrics {
25    delivered: AtomicU64,
26    rejected: AtomicU64,
27    invalid_envelope: AtomicU64,
28    unauthorized: AtomicU64,
29    payload_too_large: AtomicU64,
30    backlog_full: AtomicU64,
31    storage_failed: AtomicU64,
32    activation_failed: AtomicU64,
33    active_notified: AtomicU64,
34    activation_reserved: AtomicU64,
35    activation_coalesced: AtomicU64,
36    delivery_latency_micros: AtomicU64,
37}
38
39#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
40pub struct SessionMessagingMetricsSnapshot {
41    pub delivered: u64,
42    pub rejected: u64,
43    pub invalid_envelope: u64,
44    pub unauthorized: u64,
45    pub payload_too_large: u64,
46    pub backlog_full: u64,
47    pub storage_failed: u64,
48    pub activation_failed: u64,
49    pub active_notified: u64,
50    pub activation_reserved: u64,
51    pub activation_coalesced: u64,
52    pub delivery_latency_micros: u64,
53}
54
55impl SessionMessagingMetrics {
56    pub fn snapshot(&self) -> SessionMessagingMetricsSnapshot {
57        SessionMessagingMetricsSnapshot {
58            delivered: self.delivered.load(Ordering::Relaxed),
59            rejected: self.rejected.load(Ordering::Relaxed),
60            invalid_envelope: self.invalid_envelope.load(Ordering::Relaxed),
61            unauthorized: self.unauthorized.load(Ordering::Relaxed),
62            payload_too_large: self.payload_too_large.load(Ordering::Relaxed),
63            backlog_full: self.backlog_full.load(Ordering::Relaxed),
64            storage_failed: self.storage_failed.load(Ordering::Relaxed),
65            activation_failed: self.activation_failed.load(Ordering::Relaxed),
66            active_notified: self.active_notified.load(Ordering::Relaxed),
67            activation_reserved: self.activation_reserved.load(Ordering::Relaxed),
68            activation_coalesced: self.activation_coalesced.load(Ordering::Relaxed),
69            delivery_latency_micros: self.delivery_latency_micros.load(Ordering::Relaxed),
70        }
71    }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct SessionMessengerReceipt {
76    pub delivery: SessionInboxReceipt,
77    pub activation: SessionActivationDisposition,
78}
79
80/// Durable admission separated from activation so coordinators can commit
81/// related control-plane state between those phases without opening a race.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct SessionMessengerAdmission {
84    pub envelope_id: String,
85    pub target_session_id: String,
86    pub delivery: SessionInboxReceipt,
87}
88
89#[derive(Debug, thiserror::Error)]
90pub enum SessionMessengerError {
91    #[error("invalid session message: {0}")]
92    InvalidEnvelope(String),
93    #[error("session message source not found: {0}")]
94    SourceNotFound(String),
95    #[error("session message target not found: {0}")]
96    TargetNotFound(String),
97    #[error("session {source_session_id} is not authorized to message session {target}")]
98    Unauthorized {
99        source_session_id: String,
100        target: String,
101    },
102    #[error(transparent)]
103    Inbox(#[from] SessionInboxError),
104    /// The enqueue is already durable when this error is returned. The receipt
105    /// lets the caller distinguish it from a delivery failure and retry
106    /// activation without inventing a second message id.
107    #[error("message {receipt_id} was durably delivered but activation failed: {source}")]
108    Activation {
109        receipt_id: String,
110        receipt: SessionInboxReceipt,
111        #[source]
112        source: SessionActivationError,
113    },
114    #[error("session store failure: {0}")]
115    Storage(String),
116}
117
118/// Validates logical identity, atomically enqueues, then requests one runtime
119/// activation. The service owns no process-global registry.
120pub struct SessionMessenger {
121    sessions: Arc<dyn Storage>,
122    inbox: Arc<dyn SessionInboxPort>,
123    activation: Arc<dyn SessionActivationPort>,
124    metrics: Arc<SessionMessagingMetrics>,
125}
126
127impl SessionMessenger {
128    pub fn new(
129        sessions: Arc<dyn Storage>,
130        inbox: Arc<dyn SessionInboxPort>,
131        activation: Arc<dyn SessionActivationPort>,
132    ) -> Self {
133        Self {
134            sessions,
135            inbox,
136            activation,
137            metrics: Arc::new(SessionMessagingMetrics::default()),
138        }
139    }
140
141    pub fn inbox(&self) -> &Arc<dyn SessionInboxPort> {
142        &self.inbox
143    }
144
145    pub fn activation(&self) -> &Arc<dyn SessionActivationPort> {
146        &self.activation
147    }
148
149    pub fn metrics(&self) -> &Arc<SessionMessagingMetrics> {
150        &self.metrics
151    }
152
153    /// Follow an already-attached Ordinary Root from the original executing
154    /// Supervisor. The concrete inbox revalidates and retains all authority
155    /// locks through durable admission; a prior inspect_link is never a grant.
156    pub async fn supervisor_followup(
157        &self,
158        supervisor: &SupervisorReference,
159        target_session_id: &str,
160        operation_id: &str,
161        message: &str,
162    ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
163        bamboo_domain::validate_supervisor_target_id(target_session_id)
164            .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
165        SessionMessageId::parse(operation_id)
166            .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
167        if message.trim().is_empty() || message.len() > MAX_SUPERVISOR_FOLLOWUP_BYTES {
168            return Err(SessionMessengerError::InvalidEnvelope(format!(
169                "followup message must contain 1..={MAX_SUPERVISOR_FOLLOWUP_BYTES} UTF-8 bytes"
170            )));
171        }
172        let envelope = SessionMessageEnvelope {
173            id: SessionMessageId::stable(
174                "supervisor_followup",
175                &serde_json::json!({
176                    "supervisor": supervisor,
177                    "target_session_id": target_session_id,
178                    "operation_id": operation_id,
179                }),
180            ),
181            source: SessionMessageSource::Session {
182                session_id: supervisor.session_id.clone(),
183            },
184            target_session_id: target_session_id.to_string(),
185            kind: SessionMessageKind::PeerMessage,
186            body: SessionMessageBody::Content(SessionMessageContent::text(message)),
187            created_at: chrono::Utc::now(),
188            thread_id: None,
189            in_reply_to: None,
190            attempt: None,
191            correlation_id: Some(operation_id.to_string()),
192        };
193        let started = Instant::now();
194        let delivery = self
195            .inbox
196            .deliver_supervisor_followup(supervisor, &envelope)
197            .await
198            .map_err(|error| {
199                let error = SessionMessengerError::Inbox(error);
200                self.record_rejection(&error);
201                error
202            })?;
203        let admission = self.record_admission(envelope, delivery, started);
204        // Followup never answers a permission/clarification or interrupts a
205        // specific child/Bash wait; the existing spawner owns the final check.
206        self.activate_with_policy(&admission, SessionActivationPolicy::RespectSpecificWait)
207            .await
208    }
209
210    async fn load_session(&self, id: &str) -> Result<Option<Session>, SessionMessengerError> {
211        self.sessions
212            .load_session(id)
213            .await
214            .map_err(|error| SessionMessengerError::Storage(error.to_string()))
215    }
216
217    fn logical_root(session: &Session) -> &str {
218        if session.root_session_id.trim().is_empty() {
219            &session.id
220        } else {
221            &session.root_session_id
222        }
223    }
224
225    async fn validate_relationship(
226        &self,
227        envelope: &SessionMessageEnvelope,
228    ) -> Result<(), SessionMessengerError> {
229        envelope
230            .validate()
231            .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
232        let target = self
233            .load_session(&envelope.target_session_id)
234            .await?
235            .ok_or_else(|| {
236                SessionMessengerError::TargetNotFound(envelope.target_session_id.clone())
237            })?;
238
239        let SessionMessageSource::Session { session_id } = &envelope.source else {
240            return Ok(());
241        };
242        let source = self
243            .load_session(session_id)
244            .await?
245            .ok_or_else(|| SessionMessengerError::SourceNotFound(session_id.clone()))?;
246
247        let same_root = Self::logical_root(&source) == Self::logical_root(&target);
248        let source_project = source.project_id_meta();
249        let target_project = target.project_id_meta();
250        let project_compatible = match (source_project.as_deref(), target_project.as_deref()) {
251            (Some(left), Some(right)) => left == right,
252            // Older sessions did not persist project_id. Same-root ancestry is
253            // the compatibility authority until both sides have migrated.
254            _ => true,
255        };
256        if !same_root || !project_compatible {
257            return Err(SessionMessengerError::Unauthorized {
258                source_session_id: source.id,
259                target: target.id,
260            });
261        }
262        Ok(())
263    }
264
265    fn record_rejection(&self, error: &SessionMessengerError) {
266        self.metrics.rejected.fetch_add(1, Ordering::Relaxed);
267        match error {
268            SessionMessengerError::InvalidEnvelope(_) => {
269                self.metrics
270                    .invalid_envelope
271                    .fetch_add(1, Ordering::Relaxed);
272            }
273            SessionMessengerError::Unauthorized { .. }
274            | SessionMessengerError::SourceNotFound(_)
275            | SessionMessengerError::TargetNotFound(_) => {
276                self.metrics.unauthorized.fetch_add(1, Ordering::Relaxed);
277            }
278            SessionMessengerError::Inbox(SessionInboxError::PayloadTooLarge { .. }) => {
279                self.metrics
280                    .payload_too_large
281                    .fetch_add(1, Ordering::Relaxed);
282            }
283            SessionMessengerError::Inbox(SessionInboxError::BacklogFull { .. }) => {
284                self.metrics.backlog_full.fetch_add(1, Ordering::Relaxed);
285            }
286            SessionMessengerError::Inbox(_) | SessionMessengerError::Storage(_) => {
287                self.metrics.storage_failed.fetch_add(1, Ordering::Relaxed);
288            }
289            SessionMessengerError::Activation { .. } => {}
290        }
291    }
292
293    pub async fn admit(
294        &self,
295        envelope: SessionMessageEnvelope,
296    ) -> Result<SessionMessengerAdmission, SessionMessengerError> {
297        let started = Instant::now();
298        if let Err(error) = self.validate_relationship(&envelope).await {
299            self.record_rejection(&error);
300            tracing::warn!(
301                message_id = %envelope.id,
302                target_session_id = %envelope.target_session_id,
303                error = %error,
304                "session message rejected"
305            );
306            return Err(error);
307        }
308
309        let delivery = match self.inbox.deliver(&envelope).await {
310            Ok(receipt) => receipt,
311            Err(error) => {
312                let error = SessionMessengerError::Inbox(error);
313                self.record_rejection(&error);
314                return Err(error);
315            }
316        };
317        Ok(self.record_admission(envelope, delivery, started))
318    }
319
320    fn record_admission(
321        &self,
322        envelope: SessionMessageEnvelope,
323        delivery: SessionInboxReceipt,
324        started: Instant,
325    ) -> SessionMessengerAdmission {
326        self.metrics.delivered.fetch_add(1, Ordering::Relaxed);
327        self.metrics.delivery_latency_micros.fetch_add(
328            started.elapsed().as_micros().min(u128::from(u64::MAX)) as u64,
329            Ordering::Relaxed,
330        );
331        SessionMessengerAdmission {
332            envelope_id: envelope.id.as_str().to_string(),
333            target_session_id: envelope.target_session_id,
334            delivery,
335        }
336    }
337
338    pub async fn activate(
339        &self,
340        admission: &SessionMessengerAdmission,
341    ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
342        self.activate_with_policy(admission, SessionActivationPolicy::InterruptSpecificWait)
343            .await
344    }
345
346    pub async fn activate_with_policy(
347        &self,
348        admission: &SessionMessengerAdmission,
349        policy: SessionActivationPolicy,
350    ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
351        if let Err(error) = self
352            .inbox
353            .mark_activation_eligible(
354                &admission.target_session_id,
355                admission.delivery.generation,
356                policy,
357            )
358            .await
359        {
360            self.metrics
361                .activation_failed
362                .fetch_add(1, Ordering::Relaxed);
363            return Err(SessionMessengerError::Activation {
364                receipt_id: admission.delivery.id.to_string(),
365                receipt: admission.delivery.clone(),
366                source: SessionActivationError::Internal(format!(
367                    "persist activation watermark: {error}"
368                )),
369            });
370        }
371        self.activate_prepared(admission).await
372    }
373
374    /// Request execution for an admission whose activation policy was already
375    /// durably recorded by a coordinator.
376    pub async fn activate_prepared(
377        &self,
378        admission: &SessionMessengerAdmission,
379    ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
380        let activation = match self
381            .activation
382            .request_activation(&admission.target_session_id, admission.delivery.generation)
383            .await
384        {
385            Ok(disposition) => disposition,
386            Err(source) => {
387                self.metrics
388                    .activation_failed
389                    .fetch_add(1, Ordering::Relaxed);
390                tracing::error!(
391                    message_id = %admission.envelope_id,
392                    target_session_id = %admission.target_session_id,
393                    generation = admission.delivery.generation,
394                    error = %source,
395                    "session message durable but activation failed"
396                );
397                return Err(SessionMessengerError::Activation {
398                    receipt_id: admission.delivery.id.to_string(),
399                    receipt: admission.delivery.clone(),
400                    source,
401                });
402            }
403        };
404        match activation {
405            SessionActivationDisposition::ActiveNotified => {
406                self.metrics.active_notified.fetch_add(1, Ordering::Relaxed);
407            }
408            SessionActivationDisposition::ActivationReserved => {
409                self.metrics
410                    .activation_reserved
411                    .fetch_add(1, Ordering::Relaxed);
412            }
413            SessionActivationDisposition::ActivationCoalesced => {
414                self.metrics
415                    .activation_coalesced
416                    .fetch_add(1, Ordering::Relaxed);
417            }
418        }
419        tracing::info!(
420            message_id = %admission.envelope_id,
421            target_session_id = %admission.target_session_id,
422            generation = admission.delivery.generation,
423            ?activation,
424            "session message durably delivered"
425        );
426        Ok(SessionMessengerReceipt {
427            delivery: admission.delivery.clone(),
428            activation,
429        })
430    }
431
432    /// Durably authorize the admitted queue prefix before a related
433    /// control-plane transition is committed.
434    ///
435    /// Coordinators call this while their specific wait is still armed, then
436    /// checkpoint the wait clear, and finally call [`activate`](Self::activate).
437    /// A crash in either gap is recoverable: startup sees the watermark, while
438    /// the real reservation adapter refuses to run until the specific wait has
439    /// been durably cleared.
440    pub async fn prepare_activation(
441        &self,
442        admission: &SessionMessengerAdmission,
443    ) -> Result<(), SessionMessengerError> {
444        self.inbox
445            .mark_activation_eligible(
446                &admission.target_session_id,
447                admission.delivery.generation,
448                SessionActivationPolicy::RespectSpecificWait,
449            )
450            .await
451            .map_err(SessionMessengerError::Inbox)
452    }
453
454    pub async fn send(
455        &self,
456        envelope: SessionMessageEnvelope,
457    ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
458        let admission = self.admit(envelope).await?;
459        self.activate(&admission).await
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use async_trait::async_trait;
467    use bamboo_domain::{
468        SessionActivationDisposition, SessionInboxLimits, SessionMessageBody,
469        SessionMessageContent, SessionMessageId, SessionMessageKind,
470    };
471    use bamboo_storage::{FileSessionInbox, SessionStoreV2};
472    use tempfile::TempDir;
473
474    struct RecordingActivation {
475        calls: tokio::sync::Mutex<Vec<(String, u64)>>,
476    }
477
478    #[async_trait]
479    impl SessionActivationPort for RecordingActivation {
480        async fn request_activation(
481            &self,
482            target_session_id: &str,
483            inbox_generation: u64,
484        ) -> Result<SessionActivationDisposition, SessionActivationError> {
485            self.calls
486                .lock()
487                .await
488                .push((target_session_id.to_string(), inbox_generation));
489            Ok(SessionActivationDisposition::ActivationReserved)
490        }
491    }
492
493    async fn fixture() -> (
494        TempDir,
495        Arc<SessionStoreV2>,
496        Arc<RecordingActivation>,
497        SessionMessenger,
498    ) {
499        let temp = TempDir::new().unwrap();
500        let store = Arc::new(
501            SessionStoreV2::new(temp.path().to_path_buf())
502                .await
503                .unwrap(),
504        );
505        let activation = Arc::new(RecordingActivation {
506            calls: tokio::sync::Mutex::new(Vec::new()),
507        });
508        let inbox = Arc::new(FileSessionInbox::new(
509            store.clone(),
510            SessionInboxLimits::default(),
511        ));
512        let messenger = SessionMessenger::new(store.clone(), inbox, activation.clone());
513        (temp, store, activation, messenger)
514    }
515
516    fn peer(source: &str, target: &str, id: &str) -> SessionMessageEnvelope {
517        SessionMessageEnvelope {
518            id: SessionMessageId::parse(id).unwrap(),
519            source: SessionMessageSource::Session {
520                session_id: source.to_string(),
521            },
522            target_session_id: target.to_string(),
523            kind: SessionMessageKind::PeerMessage,
524            body: SessionMessageBody::Content(SessionMessageContent::text("hello")),
525            created_at: chrono::Utc::now(),
526            thread_id: None,
527            in_reply_to: None,
528            attempt: None,
529            correlation_id: None,
530        }
531    }
532
533    #[tokio::test]
534    async fn same_root_delivery_enqueues_then_requests_activation() {
535        let (_temp, store, activation, messenger) = fixture().await;
536        let root = Session::new("root", "model");
537        let mut child = Session::new("child", "model");
538        child.kind = bamboo_domain::SessionKind::Child;
539        child.parent_session_id = Some("root".to_string());
540        child.root_session_id = "root".to_string();
541        store.save_session(&root).await.unwrap();
542        store.save_session(&child).await.unwrap();
543
544        let receipt = messenger
545            .send(peer("root", "child", "msg-1"))
546            .await
547            .unwrap();
548        assert_eq!(receipt.delivery.generation, 1);
549        assert_eq!(
550            activation.calls.lock().await.as_slice(),
551            &[("child".to_string(), 1)]
552        );
553        assert_eq!(messenger.metrics().snapshot().delivered, 1);
554    }
555
556    #[tokio::test]
557    async fn cross_root_peer_is_rejected_before_enqueue() {
558        let (_temp, store, activation, messenger) = fixture().await;
559        store
560            .save_session(&Session::new("root-a", "model"))
561            .await
562            .unwrap();
563        store
564            .save_session(&Session::new("root-b", "model"))
565            .await
566            .unwrap();
567
568        let error = messenger
569            .send(peer("root-a", "root-b", "msg-2"))
570            .await
571            .unwrap_err();
572        assert!(matches!(error, SessionMessengerError::Unauthorized { .. }));
573        assert!(activation.calls.lock().await.is_empty());
574        let metrics = messenger.metrics().snapshot();
575        assert_eq!(metrics.rejected, 1);
576        assert_eq!(metrics.unauthorized, 1);
577        assert_eq!(metrics.payload_too_large, 0);
578        assert_eq!(metrics.backlog_full, 0);
579    }
580
581    #[tokio::test]
582    async fn same_root_different_project_peer_is_rejected_before_enqueue() {
583        let (_temp, store, activation, messenger) = fixture().await;
584        let mut source = Session::new("project-root", "model");
585        source.set_project_id_meta("project-a");
586        let mut target = Session::new("project-child", "model");
587        target.kind = bamboo_domain::SessionKind::Child;
588        target.parent_session_id = Some(source.id.clone());
589        target.root_session_id = source.id.clone();
590        target.set_project_id_meta("project-b");
591        store.save_session(&source).await.unwrap();
592        store.save_session(&target).await.unwrap();
593
594        let error = messenger
595            .send(peer(&source.id, &target.id, "different-project"))
596            .await
597            .unwrap_err();
598        assert!(matches!(error, SessionMessengerError::Unauthorized { .. }));
599        assert!(activation.calls.lock().await.is_empty());
600        let metrics = messenger.metrics().snapshot();
601        assert_eq!(metrics.delivered, 0);
602        assert_eq!(metrics.rejected, 1);
603        assert_eq!(metrics.unauthorized, 1);
604    }
605
606    #[tokio::test]
607    async fn limit_rejections_have_distinct_metrics() {
608        let temp = TempDir::new().unwrap();
609        let store = Arc::new(
610            SessionStoreV2::new(temp.path().to_path_buf())
611                .await
612                .unwrap(),
613        );
614        store
615            .save_session(&Session::new("target", "model"))
616            .await
617            .unwrap();
618        let activation = Arc::new(RecordingActivation {
619            calls: tokio::sync::Mutex::new(Vec::new()),
620        });
621        let inbox = Arc::new(FileSessionInbox::new(
622            store.clone(),
623            SessionInboxLimits {
624                max_payload_bytes: 512,
625                max_backlog: 1,
626                max_claim_batch: 1,
627            },
628        ));
629        let messenger = SessionMessenger::new(store, inbox, activation.clone());
630
631        let oversized = SessionMessageEnvelope::user_input("target", "x".repeat(2048));
632        assert!(matches!(
633            messenger.send(oversized).await,
634            Err(SessionMessengerError::Inbox(
635                SessionInboxError::PayloadTooLarge { .. }
636            ))
637        ));
638        messenger
639            .send(SessionMessageEnvelope::user_input("target", "first"))
640            .await
641            .unwrap();
642        assert!(matches!(
643            messenger
644                .send(SessionMessageEnvelope::user_input("target", "second"))
645                .await,
646            Err(SessionMessengerError::Inbox(
647                SessionInboxError::BacklogFull { .. }
648            ))
649        ));
650
651        let metrics = messenger.metrics().snapshot();
652        assert_eq!(metrics.rejected, 2);
653        assert_eq!(metrics.payload_too_large, 1);
654        assert_eq!(metrics.backlog_full, 1);
655        assert_eq!(activation.calls.lock().await.len(), 1);
656    }
657}