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("session store failure: {0}")]
115 Storage(String),
116}
117
118pub 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 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 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 _ => 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 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 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}