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    /// Staged delivery is durable, but its explicit activation watermark did
115    /// not persist. Immediate delivery instead binds intent at publication.
116    /// Startup cannot infer permission to run from admission alone; a caller
117    /// must retry this exact envelope until the watermark is committed.
118    #[error(
119        "message {receipt_id} was delivered but activation eligibility was not persisted: {source}"
120    )]
121    ActivationEligibility {
122        receipt_id: String,
123        receipt: SessionInboxReceipt,
124        #[source]
125        source: SessionInboxError,
126    },
127    #[error("session store failure: {0}")]
128    Storage(String),
129}
130
131/// Validates logical identity, atomically enqueues, then requests one runtime
132/// activation. The service owns no process-global registry.
133pub struct SessionMessenger {
134    sessions: Arc<dyn Storage>,
135    inbox: Arc<dyn SessionInboxPort>,
136    activation: Arc<dyn SessionActivationPort>,
137    metrics: Arc<SessionMessagingMetrics>,
138}
139
140impl SessionMessenger {
141    pub fn new(
142        sessions: Arc<dyn Storage>,
143        inbox: Arc<dyn SessionInboxPort>,
144        activation: Arc<dyn SessionActivationPort>,
145    ) -> Self {
146        Self {
147            sessions,
148            inbox,
149            activation,
150            metrics: Arc::new(SessionMessagingMetrics::default()),
151        }
152    }
153
154    pub fn inbox(&self) -> &Arc<dyn SessionInboxPort> {
155        &self.inbox
156    }
157
158    pub fn activation(&self) -> &Arc<dyn SessionActivationPort> {
159        &self.activation
160    }
161
162    pub fn metrics(&self) -> &Arc<SessionMessagingMetrics> {
163        &self.metrics
164    }
165
166    /// Follow an already-attached Ordinary Root from the original executing
167    /// Supervisor. The concrete inbox revalidates and retains all authority
168    /// locks through durable admission; a prior inspect_link is never a grant.
169    pub async fn supervisor_followup(
170        &self,
171        supervisor: &SupervisorReference,
172        target_session_id: &str,
173        operation_id: &str,
174        message: &str,
175    ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
176        bamboo_domain::validate_supervisor_target_id(target_session_id)
177            .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
178        SessionMessageId::parse(operation_id)
179            .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
180        if message.trim().is_empty() || message.len() > MAX_SUPERVISOR_FOLLOWUP_BYTES {
181            return Err(SessionMessengerError::InvalidEnvelope(format!(
182                "followup message must contain 1..={MAX_SUPERVISOR_FOLLOWUP_BYTES} UTF-8 bytes"
183            )));
184        }
185        let envelope = SessionMessageEnvelope {
186            id: SessionMessageId::stable(
187                "supervisor_followup",
188                &serde_json::json!({
189                    "supervisor": supervisor,
190                    "target_session_id": target_session_id,
191                    "operation_id": operation_id,
192                }),
193            ),
194            source: SessionMessageSource::Session {
195                session_id: supervisor.session_id.clone(),
196            },
197            target_session_id: target_session_id.to_string(),
198            kind: SessionMessageKind::PeerMessage,
199            body: SessionMessageBody::Content(SessionMessageContent::text(message)),
200            created_at: chrono::Utc::now(),
201            thread_id: None,
202            in_reply_to: None,
203            attempt: None,
204            correlation_id: Some(operation_id.to_string()),
205        };
206        let started = Instant::now();
207        let delivery = self
208            .inbox
209            .deliver_supervisor_followup(supervisor, &envelope)
210            .await
211            .map_err(|error| {
212                let error = SessionMessengerError::Inbox(error);
213                self.record_rejection(&error);
214                error
215            })?;
216        let admission = self.record_admission(envelope, delivery, started);
217        // Its specific-message RespectSpecificWait intent was published while
218        // holding Supervisor authority. This grants no staged sibling prefix.
219        self.activate_prepared(&admission).await
220    }
221
222    async fn load_session(&self, id: &str) -> Result<Option<Session>, SessionMessengerError> {
223        self.sessions
224            .load_session(id)
225            .await
226            .map_err(|error| SessionMessengerError::Storage(error.to_string()))
227    }
228
229    fn logical_root(session: &Session) -> &str {
230        if session.root_session_id.trim().is_empty() {
231            &session.id
232        } else {
233            &session.root_session_id
234        }
235    }
236
237    async fn validate_relationship(
238        &self,
239        envelope: &SessionMessageEnvelope,
240    ) -> Result<(), SessionMessengerError> {
241        envelope
242            .validate()
243            .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
244        let target = self
245            .load_session(&envelope.target_session_id)
246            .await?
247            .ok_or_else(|| {
248                SessionMessengerError::TargetNotFound(envelope.target_session_id.clone())
249            })?;
250
251        let SessionMessageSource::Session { session_id } = &envelope.source else {
252            return Ok(());
253        };
254        let source = self
255            .load_session(session_id)
256            .await?
257            .ok_or_else(|| SessionMessengerError::SourceNotFound(session_id.clone()))?;
258
259        let same_root = Self::logical_root(&source) == Self::logical_root(&target);
260        let source_project = source.project_id_meta();
261        let target_project = target.project_id_meta();
262        let project_compatible = match (source_project.as_deref(), target_project.as_deref()) {
263            (Some(left), Some(right)) => left == right,
264            // Older sessions did not persist project_id. Same-root ancestry is
265            // the compatibility authority until both sides have migrated.
266            _ => true,
267        };
268        if !same_root || !project_compatible {
269            return Err(SessionMessengerError::Unauthorized {
270                source_session_id: source.id,
271                target: target.id,
272            });
273        }
274        Ok(())
275    }
276
277    fn record_rejection(&self, error: &SessionMessengerError) {
278        self.metrics.rejected.fetch_add(1, Ordering::Relaxed);
279        match error {
280            SessionMessengerError::InvalidEnvelope(_) => {
281                self.metrics
282                    .invalid_envelope
283                    .fetch_add(1, Ordering::Relaxed);
284            }
285            SessionMessengerError::Unauthorized { .. }
286            | SessionMessengerError::SourceNotFound(_)
287            | SessionMessengerError::TargetNotFound(_) => {
288                self.metrics.unauthorized.fetch_add(1, Ordering::Relaxed);
289            }
290            SessionMessengerError::Inbox(SessionInboxError::AdmissionCancelled) => {}
291            SessionMessengerError::Inbox(SessionInboxError::PayloadTooLarge { .. }) => {
292                self.metrics
293                    .payload_too_large
294                    .fetch_add(1, Ordering::Relaxed);
295            }
296            SessionMessengerError::Inbox(SessionInboxError::BacklogFull { .. }) => {
297                self.metrics.backlog_full.fetch_add(1, Ordering::Relaxed);
298            }
299            SessionMessengerError::Inbox(_) | SessionMessengerError::Storage(_) => {
300                self.metrics.storage_failed.fetch_add(1, Ordering::Relaxed);
301            }
302            SessionMessengerError::Activation { .. }
303            | SessionMessengerError::ActivationEligibility { .. } => {}
304        }
305    }
306
307    pub async fn admit(
308        &self,
309        envelope: SessionMessageEnvelope,
310    ) -> Result<SessionMessengerAdmission, SessionMessengerError> {
311        self.admit_with_gate(envelope, None).await
312    }
313
314    pub async fn admit_with_gate(
315        &self,
316        envelope: SessionMessageEnvelope,
317        gate: Option<&bamboo_domain::AdmissionGate>,
318    ) -> Result<SessionMessengerAdmission, SessionMessengerError> {
319        self.admit_with_delivery_intent(envelope, gate, None).await
320    }
321
322    /// Commit this specific message's immediate permission together with the
323    /// envelope. Coordinators continue to use staged [`admit`](Self::admit).
324    pub async fn admit_with_activation_intent(
325        &self,
326        envelope: SessionMessageEnvelope,
327        policy: SessionActivationPolicy,
328        gate: Option<&bamboo_domain::AdmissionGate>,
329    ) -> Result<SessionMessengerAdmission, SessionMessengerError> {
330        self.admit_with_delivery_intent(envelope, gate, Some(policy))
331            .await
332    }
333
334    async fn admit_with_delivery_intent(
335        &self,
336        envelope: SessionMessageEnvelope,
337        gate: Option<&bamboo_domain::AdmissionGate>,
338        policy: Option<SessionActivationPolicy>,
339    ) -> Result<SessionMessengerAdmission, SessionMessengerError> {
340        let started = Instant::now();
341        if let Err(error) = self.validate_relationship(&envelope).await {
342            self.record_rejection(&error);
343            tracing::warn!(
344                message_id = %envelope.id,
345                target_session_id = %envelope.target_session_id,
346                error = %error,
347                "session message rejected"
348            );
349            return Err(error);
350        }
351
352        let delivery = match (policy, gate) {
353            (Some(policy), gate) => {
354                self.inbox
355                    .deliver_with_activation_intent(&envelope, policy, gate)
356                    .await
357            }
358            (None, Some(gate)) => self.inbox.deliver_with_gate(&envelope, gate).await,
359            (None, None) => self.inbox.deliver(&envelope).await,
360        };
361        let delivery = match delivery {
362            Ok(receipt) => receipt,
363            Err(error) => {
364                let error = SessionMessengerError::Inbox(error);
365                self.record_rejection(&error);
366                return Err(error);
367            }
368        };
369        Ok(self.record_admission(envelope, delivery, started))
370    }
371
372    fn record_admission(
373        &self,
374        envelope: SessionMessageEnvelope,
375        delivery: SessionInboxReceipt,
376        started: Instant,
377    ) -> SessionMessengerAdmission {
378        self.metrics.delivered.fetch_add(1, Ordering::Relaxed);
379        self.metrics.delivery_latency_micros.fetch_add(
380            started.elapsed().as_micros().min(u128::from(u64::MAX)) as u64,
381            Ordering::Relaxed,
382        );
383        SessionMessengerAdmission {
384            envelope_id: envelope.id.as_str().to_string(),
385            target_session_id: envelope.target_session_id,
386            delivery,
387        }
388    }
389
390    pub async fn activate(
391        &self,
392        admission: &SessionMessengerAdmission,
393    ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
394        self.activate_with_policy(admission, SessionActivationPolicy::InterruptSpecificWait)
395            .await
396    }
397
398    pub async fn activate_with_policy(
399        &self,
400        admission: &SessionMessengerAdmission,
401        policy: SessionActivationPolicy,
402    ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
403        if let Err(error) = self
404            .inbox
405            .mark_activation_eligible(
406                &admission.target_session_id,
407                admission.delivery.generation,
408                policy,
409            )
410            .await
411        {
412            self.metrics
413                .activation_failed
414                .fetch_add(1, Ordering::Relaxed);
415            return Err(SessionMessengerError::ActivationEligibility {
416                receipt_id: admission.delivery.id.to_string(),
417                receipt: admission.delivery.clone(),
418                source: error,
419            });
420        }
421        self.activate_prepared(admission).await
422    }
423
424    /// Request execution for an admission whose activation policy was already
425    /// durably recorded by a coordinator.
426    pub async fn activate_prepared(
427        &self,
428        admission: &SessionMessengerAdmission,
429    ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
430        let activation = match self
431            .activation
432            .request_activation(&admission.target_session_id, admission.delivery.generation)
433            .await
434        {
435            Ok(disposition) => disposition,
436            Err(source) => {
437                self.metrics
438                    .activation_failed
439                    .fetch_add(1, Ordering::Relaxed);
440                tracing::error!(
441                    message_id = %admission.envelope_id,
442                    target_session_id = %admission.target_session_id,
443                    generation = admission.delivery.generation,
444                    error = %source,
445                    "session message durable but activation failed"
446                );
447                return Err(SessionMessengerError::Activation {
448                    receipt_id: admission.delivery.id.to_string(),
449                    receipt: admission.delivery.clone(),
450                    source,
451                });
452            }
453        };
454        match activation {
455            SessionActivationDisposition::ActiveNotified => {
456                self.metrics.active_notified.fetch_add(1, Ordering::Relaxed);
457            }
458            SessionActivationDisposition::ActivationReserved => {
459                self.metrics
460                    .activation_reserved
461                    .fetch_add(1, Ordering::Relaxed);
462            }
463            SessionActivationDisposition::ActivationCoalesced => {
464                self.metrics
465                    .activation_coalesced
466                    .fetch_add(1, Ordering::Relaxed);
467            }
468        }
469        tracing::info!(
470            message_id = %admission.envelope_id,
471            target_session_id = %admission.target_session_id,
472            generation = admission.delivery.generation,
473            ?activation,
474            "session message durably delivered"
475        );
476        Ok(SessionMessengerReceipt {
477            delivery: admission.delivery.clone(),
478            activation,
479        })
480    }
481
482    /// Durably authorize the admitted queue prefix before a related
483    /// control-plane transition is committed.
484    ///
485    /// Coordinators call this while their specific wait is still armed, then
486    /// checkpoint the wait clear, and finally call [`activate`](Self::activate).
487    /// A crash in either gap is recoverable: startup sees the watermark, while
488    /// the real reservation adapter refuses to run until the specific wait has
489    /// been durably cleared.
490    pub async fn prepare_activation(
491        &self,
492        admission: &SessionMessengerAdmission,
493    ) -> Result<(), SessionMessengerError> {
494        self.inbox
495            .mark_activation_eligible(
496                &admission.target_session_id,
497                admission.delivery.generation,
498                SessionActivationPolicy::RespectSpecificWait,
499            )
500            .await
501            .map_err(SessionMessengerError::Inbox)
502    }
503
504    pub async fn send(
505        &self,
506        envelope: SessionMessageEnvelope,
507    ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
508        self.send_with_gate(envelope, None).await
509    }
510
511    pub async fn send_with_gate(
512        &self,
513        envelope: SessionMessageEnvelope,
514        gate: Option<&bamboo_domain::AdmissionGate>,
515    ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
516        let admission = self
517            .admit_with_activation_intent(
518                envelope,
519                SessionActivationPolicy::InterruptSpecificWait,
520                gate,
521            )
522            .await?;
523        self.activate_prepared(&admission).await
524    }
525
526    pub async fn send_with_policy(
527        &self,
528        envelope: SessionMessageEnvelope,
529        policy: SessionActivationPolicy,
530    ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
531        let admission = self
532            .admit_with_activation_intent(envelope, policy, None)
533            .await?;
534        self.activate_prepared(&admission).await
535    }
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541    use async_trait::async_trait;
542    use bamboo_domain::{
543        SessionActivationDisposition, SessionInboxLimits, SessionMessageBody,
544        SessionMessageContent, SessionMessageId, SessionMessageKind,
545    };
546    use bamboo_storage::{FileSessionInbox, SessionStoreV2};
547    use tempfile::TempDir;
548
549    struct RecordingActivation {
550        calls: tokio::sync::Mutex<Vec<(String, u64)>>,
551    }
552
553    struct FailingActivation;
554
555    #[async_trait]
556    impl SessionActivationPort for FailingActivation {
557        async fn request_activation(
558            &self,
559            _target_session_id: &str,
560            _inbox_generation: u64,
561        ) -> Result<SessionActivationDisposition, SessionActivationError> {
562            Err(SessionActivationError::Internal(
563                "injected wakeup failure".into(),
564            ))
565        }
566    }
567
568    #[tokio::test]
569    async fn immediate_activation_failure_preserves_receipt_and_restart_permission() {
570        let (temp, store, recording, _messenger) = fixture().await;
571        store
572            .save_session(&Session::new("root", "model"))
573            .await
574            .unwrap();
575        let inbox = Arc::new(FileSessionInbox::new(
576            store.clone(),
577            SessionInboxLimits::default(),
578        ));
579        let messenger = SessionMessenger::new(store, inbox, Arc::new(FailingActivation));
580        let envelope = SessionMessageEnvelope::user_input("root", "steer");
581        let gate = bamboo_domain::AdmissionGate::default();
582        let receipt = match messenger
583            .send_with_gate(envelope.clone(), Some(&gate))
584            .await
585            .unwrap_err()
586        {
587            SessionMessengerError::Activation { receipt, .. } => receipt,
588            error => panic!("expected durable delivery with failed wakeup: {error}"),
589        };
590        assert!(gate.is_committed());
591        gate.cancel_if_pending();
592        let restarted_store = Arc::new(SessionStoreV2::new(temp.path().into()).await.unwrap());
593        let restarted = Arc::new(FileSessionInbox::new(
594            restarted_store.clone(),
595            SessionInboxLimits::default(),
596        ));
597        let backlog = restarted.inspect("root").await.unwrap();
598        assert!(backlog.activation_pending());
599        assert_eq!(backlog.coordinator_generation, 0);
600        // This is the startup reconciliation request: the durable generation
601        // comes from this message's intent while the coordinator prefix is 0.
602        recording
603            .request_activation("root", backlog.activation_generation)
604            .await
605            .unwrap();
606        let retry = SessionMessenger::new(restarted_store, restarted.clone(), recording.clone());
607        assert_eq!(
608            retry
609                .send_with_gate(envelope, Some(&gate))
610                .await
611                .unwrap()
612                .delivery,
613            receipt
614        );
615        assert_eq!(restarted.inspect("root").await.unwrap().pending, 1);
616        assert_eq!(messenger.metrics().snapshot().activation_failed, 1);
617        let claims = restarted.claim("root", 128).await.unwrap();
618        assert_eq!(claims.len(), 1);
619        assert_eq!(claims[0].envelope.id, receipt.id);
620    }
621
622    struct FailEligibilityOnce {
623        inner: Arc<FileSessionInbox>,
624        fail_next: std::sync::atomic::AtomicBool,
625    }
626
627    #[async_trait]
628    impl SessionInboxPort for FailEligibilityOnce {
629        async fn deliver(
630            &self,
631            envelope: &SessionMessageEnvelope,
632        ) -> Result<SessionInboxReceipt, SessionInboxError> {
633            self.inner.deliver(envelope).await
634        }
635
636        async fn deliver_with_gate(
637            &self,
638            envelope: &SessionMessageEnvelope,
639            gate: &bamboo_domain::AdmissionGate,
640        ) -> Result<SessionInboxReceipt, SessionInboxError> {
641            self.inner.deliver_with_gate(envelope, gate).await
642        }
643
644        async fn mark_activation_eligible(
645            &self,
646            target_session_id: &str,
647            generation: u64,
648            policy: SessionActivationPolicy,
649        ) -> Result<(), SessionInboxError> {
650            if self.fail_next.swap(false, Ordering::SeqCst) {
651                return Err(SessionInboxError::Storage(
652                    "injected activation watermark failure".into(),
653                ));
654            }
655            self.inner
656                .mark_activation_eligible(target_session_id, generation, policy)
657                .await
658        }
659
660        async fn claim(
661            &self,
662            target_session_id: &str,
663            limit: usize,
664        ) -> Result<Vec<bamboo_domain::SessionInboxClaim>, SessionInboxError> {
665            self.inner.claim(target_session_id, limit).await
666        }
667
668        async fn was_admitted(
669            &self,
670            target_session_id: &str,
671            id: &SessionMessageId,
672        ) -> Result<bool, SessionInboxError> {
673            self.inner.was_admitted(target_session_id, id).await
674        }
675
676        async fn ack(
677            &self,
678            target_session_id: &str,
679            claim: &bamboo_domain::SessionInboxClaim,
680        ) -> Result<(), SessionInboxError> {
681            self.inner.ack(target_session_id, claim).await
682        }
683
684        async fn inspect(
685            &self,
686            target_session_id: &str,
687        ) -> Result<bamboo_domain::SessionInboxBacklog, SessionInboxError> {
688            self.inner.inspect(target_session_id).await
689        }
690    }
691
692    #[async_trait]
693    impl SessionActivationPort for RecordingActivation {
694        async fn request_activation(
695            &self,
696            target_session_id: &str,
697            inbox_generation: u64,
698        ) -> Result<SessionActivationDisposition, SessionActivationError> {
699            self.calls
700                .lock()
701                .await
702                .push((target_session_id.to_string(), inbox_generation));
703            Ok(SessionActivationDisposition::ActivationReserved)
704        }
705    }
706
707    async fn fixture() -> (
708        TempDir,
709        Arc<SessionStoreV2>,
710        Arc<RecordingActivation>,
711        SessionMessenger,
712    ) {
713        let temp = TempDir::new().unwrap();
714        let store = Arc::new(
715            SessionStoreV2::new(temp.path().to_path_buf())
716                .await
717                .unwrap(),
718        );
719        let activation = Arc::new(RecordingActivation {
720            calls: tokio::sync::Mutex::new(Vec::new()),
721        });
722        let inbox = Arc::new(FileSessionInbox::new(
723            store.clone(),
724            SessionInboxLimits::default(),
725        ));
726        let messenger = SessionMessenger::new(store.clone(), inbox, activation.clone());
727        (temp, store, activation, messenger)
728    }
729
730    fn peer(source: &str, target: &str, id: &str) -> SessionMessageEnvelope {
731        SessionMessageEnvelope {
732            id: SessionMessageId::parse(id).unwrap(),
733            source: SessionMessageSource::Session {
734                session_id: source.to_string(),
735            },
736            target_session_id: target.to_string(),
737            kind: SessionMessageKind::PeerMessage,
738            body: SessionMessageBody::Content(SessionMessageContent::text("hello")),
739            created_at: chrono::Utc::now(),
740            thread_id: None,
741            in_reply_to: None,
742            attempt: None,
743            correlation_id: None,
744        }
745    }
746
747    #[tokio::test]
748    async fn same_root_delivery_enqueues_then_requests_activation() {
749        let (_temp, store, activation, messenger) = fixture().await;
750        let root = Session::new("root", "model");
751        let mut child = Session::new("child", "model");
752        child.kind = bamboo_domain::SessionKind::Child;
753        child.parent_session_id = Some("root".to_string());
754        child.root_session_id = "root".to_string();
755        store.save_session(&root).await.unwrap();
756        store.save_session(&child).await.unwrap();
757
758        let receipt = messenger
759            .send(peer("root", "child", "msg-1"))
760            .await
761            .unwrap();
762        assert_eq!(receipt.delivery.generation, 1);
763        assert_eq!(
764            activation.calls.lock().await.as_slice(),
765            &[("child".to_string(), 1)]
766        );
767        assert_eq!(messenger.metrics().snapshot().delivered, 1);
768    }
769
770    #[tokio::test]
771    async fn staged_delivery_without_watermark_reports_exact_retry_required() {
772        let temp = TempDir::new().unwrap();
773        let store = Arc::new(
774            SessionStoreV2::new(temp.path().to_path_buf())
775                .await
776                .unwrap(),
777        );
778        let root = Session::new("root", "model");
779        let mut child = Session::new("child", "model");
780        child.kind = bamboo_domain::SessionKind::Child;
781        child.parent_session_id = Some("root".to_string());
782        child.root_session_id = "root".to_string();
783        store.save_session(&root).await.unwrap();
784        store.save_session(&child).await.unwrap();
785        let inbox = Arc::new(FailEligibilityOnce {
786            inner: Arc::new(FileSessionInbox::new(
787                store.clone(),
788                SessionInboxLimits::default(),
789            )),
790            fail_next: std::sync::atomic::AtomicBool::new(true),
791        });
792        let activation = Arc::new(RecordingActivation {
793            calls: tokio::sync::Mutex::new(Vec::new()),
794        });
795        let messenger = SessionMessenger::new(store.clone(), inbox, activation.clone());
796        let envelope = peer("root", "child", "watermark-failure");
797        let gate = bamboo_domain::AdmissionGate::default();
798
799        let admission = messenger
800            .admit_with_gate(envelope.clone(), Some(&gate))
801            .await
802            .unwrap();
803        let receipt = match messenger.activate(&admission).await.unwrap_err() {
804            SessionMessengerError::ActivationEligibility { receipt, .. } => receipt,
805            error => panic!("expected retry-required classification: {error}"),
806        };
807        assert!(gate.is_committed());
808        assert!(activation.calls.lock().await.is_empty());
809        let reopened = FileSessionInbox::new(store, SessionInboxLimits::default());
810        let backlog = reopened.inspect("child").await.unwrap();
811        assert_eq!(backlog.pending + backlog.claimed, 1);
812        assert_eq!(backlog.activation_generation, 0);
813        assert_eq!(backlog.generation, receipt.generation);
814
815        let retry = messenger
816            .admit_with_gate(envelope, Some(&gate))
817            .await
818            .unwrap();
819        let retried = messenger.activate(&retry).await.unwrap();
820        assert_eq!(retried.delivery, receipt);
821        assert_eq!(activation.calls.lock().await.len(), 1);
822        let reopened = reopened.inspect("child").await.unwrap();
823        assert_eq!(reopened.activation_generation, receipt.generation);
824        assert_eq!(reopened.pending + reopened.claimed, 1);
825    }
826
827    #[tokio::test]
828    async fn cross_root_peer_is_rejected_before_enqueue() {
829        let (_temp, store, activation, messenger) = fixture().await;
830        store
831            .save_session(&Session::new("root-a", "model"))
832            .await
833            .unwrap();
834        store
835            .save_session(&Session::new("root-b", "model"))
836            .await
837            .unwrap();
838
839        let error = messenger
840            .send(peer("root-a", "root-b", "msg-2"))
841            .await
842            .unwrap_err();
843        assert!(matches!(error, SessionMessengerError::Unauthorized { .. }));
844        assert!(activation.calls.lock().await.is_empty());
845        let metrics = messenger.metrics().snapshot();
846        assert_eq!(metrics.rejected, 1);
847        assert_eq!(metrics.unauthorized, 1);
848        assert_eq!(metrics.payload_too_large, 0);
849        assert_eq!(metrics.backlog_full, 0);
850    }
851
852    #[tokio::test]
853    async fn same_root_different_project_peer_is_rejected_before_enqueue() {
854        let (_temp, store, activation, messenger) = fixture().await;
855        let mut source = Session::new("project-root", "model");
856        source.set_project_id_meta("project-a");
857        let mut target = Session::new("project-child", "model");
858        target.kind = bamboo_domain::SessionKind::Child;
859        target.parent_session_id = Some(source.id.clone());
860        target.root_session_id = source.id.clone();
861        target.set_project_id_meta("project-b");
862        store.save_session(&source).await.unwrap();
863        store.save_session(&target).await.unwrap();
864
865        let error = messenger
866            .send(peer(&source.id, &target.id, "different-project"))
867            .await
868            .unwrap_err();
869        assert!(matches!(error, SessionMessengerError::Unauthorized { .. }));
870        assert!(activation.calls.lock().await.is_empty());
871        let metrics = messenger.metrics().snapshot();
872        assert_eq!(metrics.delivered, 0);
873        assert_eq!(metrics.rejected, 1);
874        assert_eq!(metrics.unauthorized, 1);
875    }
876
877    #[tokio::test]
878    async fn limit_rejections_have_distinct_metrics() {
879        let temp = TempDir::new().unwrap();
880        let store = Arc::new(
881            SessionStoreV2::new(temp.path().to_path_buf())
882                .await
883                .unwrap(),
884        );
885        store
886            .save_session(&Session::new("target", "model"))
887            .await
888            .unwrap();
889        let activation = Arc::new(RecordingActivation {
890            calls: tokio::sync::Mutex::new(Vec::new()),
891        });
892        let inbox = Arc::new(FileSessionInbox::new(
893            store.clone(),
894            SessionInboxLimits {
895                max_payload_bytes: 512,
896                max_backlog: 1,
897                max_claim_batch: 1,
898            },
899        ));
900        let messenger = SessionMessenger::new(store, inbox, activation.clone());
901
902        let oversized = SessionMessageEnvelope::user_input("target", "x".repeat(2048));
903        assert!(matches!(
904            messenger.send(oversized).await,
905            Err(SessionMessengerError::Inbox(
906                SessionInboxError::PayloadTooLarge { .. }
907            ))
908        ));
909        messenger
910            .send(SessionMessageEnvelope::user_input("target", "first"))
911            .await
912            .unwrap();
913        assert!(matches!(
914            messenger
915                .send(SessionMessageEnvelope::user_input("target", "second"))
916                .await,
917            Err(SessionMessengerError::Inbox(
918                SessionInboxError::BacklogFull { .. }
919            ))
920        ));
921
922        let metrics = messenger.metrics().snapshot();
923        assert_eq!(metrics.rejected, 2);
924        assert_eq!(metrics.payload_too_large, 1);
925        assert_eq!(metrics.backlog_full, 1);
926        assert_eq!(activation.calls.lock().await.len(), 1);
927    }
928}