1use 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
19pub const MAX_SUPERVISOR_FOLLOWUP_BYTES: usize = 16 * 1024;
21
22#[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#[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 #[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(
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
131pub 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 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 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 _ => 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 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 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 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 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}