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