1use std::collections::{HashSet, VecDeque};
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::Arc;
10use std::time::Instant;
11
12use bamboo_agent_core::storage::Storage;
13use bamboo_domain::{
14 ActorSession, Session, SessionActivationDisposition, SessionActivationError,
15 SessionActivationPolicy, SessionActivationPort, SessionCallerReference, SessionInboxError,
16 SessionInboxPort, SessionInboxReceipt, SessionMessageBody, SessionMessageContent,
17 SessionMessageDeliveryIntent, SessionMessageEnvelope, SessionMessageId, SessionMessageKind,
18 SessionMessageSource, SupervisorReference,
19};
20
21pub const MAX_SUPERVISOR_FOLLOWUP_BYTES: usize = 16 * 1024;
23
24pub const MAX_PEER_MESSAGE_BYTES: usize = bamboo_domain::MAX_SESSION_PEER_MESSAGE_BYTES;
25pub const MAX_PEER_TARGETS: usize = 128;
26pub const MAX_PEER_TARGET_RESULT_BYTES: usize = 32 * 1024;
27const MAX_PEER_TARGET_TITLE_BYTES: usize = 256;
28const MAX_PEER_TARGET_STATUS_BYTES: usize = 64;
29const MAX_PEER_TARGET_SCAN: usize = 1024;
30
31#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
33pub struct SessionPeerTarget {
34 pub session_id: String,
35 pub parent_session_id: Option<String>,
36 pub kind: bamboo_domain::SessionKind,
37 pub title: String,
38 pub status: Option<String>,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
42pub struct SessionPeerTargets {
43 pub targets: Vec<SessionPeerTarget>,
44 pub truncated: bool,
45}
46
47struct PeerTreePort<'a>(&'a dyn Storage);
48
49#[async_trait::async_trait]
50impl crate::session_app::child_session::owned_tree::OwnedTreePort for PeerTreePort<'_> {
51 async fn load(
52 &self,
53 id: &str,
54 ) -> Result<Session, crate::session_app::child_session::owned_tree::OwnedTreeError> {
55 use crate::session_app::child_session::owned_tree::OwnedTreeError;
56 bamboo_domain::validate_supervisor_target_id(id)
57 .map_err(|_| OwnedTreeError::InvalidLineage)?;
58 self.0
59 .load_runtime_control_plane(id)
60 .await
61 .map_err(|_| OwnedTreeError::InvalidLineage)?
62 .ok_or(OwnedTreeError::InvalidLineage)
63 }
64
65 async fn child_ids(
66 &self,
67 parent_id: &str,
68 ) -> Result<Vec<String>, crate::session_app::child_session::owned_tree::OwnedTreeError> {
69 use crate::session_app::child_session::owned_tree::OwnedTreeError;
70 self.0
71 .list_child_run_statuses(parent_id)
72 .await
73 .map(|children| children.into_iter().map(|(id, _)| id).collect())
74 .map_err(|_| OwnedTreeError::InvalidLineage)
75 }
76}
77
78#[derive(Debug, Default)]
80pub struct SessionMessagingMetrics {
81 delivered: AtomicU64,
82 rejected: AtomicU64,
83 invalid_envelope: AtomicU64,
84 unauthorized: AtomicU64,
85 payload_too_large: AtomicU64,
86 backlog_full: AtomicU64,
87 storage_failed: AtomicU64,
88 activation_failed: AtomicU64,
89 active_notified: AtomicU64,
90 activation_reserved: AtomicU64,
91 activation_coalesced: AtomicU64,
92 delivery_latency_micros: AtomicU64,
93}
94
95#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
96pub struct SessionMessagingMetricsSnapshot {
97 pub delivered: u64,
98 pub rejected: u64,
99 pub invalid_envelope: u64,
100 pub unauthorized: u64,
101 pub payload_too_large: u64,
102 pub backlog_full: u64,
103 pub storage_failed: u64,
104 pub activation_failed: u64,
105 pub active_notified: u64,
106 pub activation_reserved: u64,
107 pub activation_coalesced: u64,
108 pub delivery_latency_micros: u64,
109}
110
111impl SessionMessagingMetrics {
112 pub fn snapshot(&self) -> SessionMessagingMetricsSnapshot {
113 SessionMessagingMetricsSnapshot {
114 delivered: self.delivered.load(Ordering::Relaxed),
115 rejected: self.rejected.load(Ordering::Relaxed),
116 invalid_envelope: self.invalid_envelope.load(Ordering::Relaxed),
117 unauthorized: self.unauthorized.load(Ordering::Relaxed),
118 payload_too_large: self.payload_too_large.load(Ordering::Relaxed),
119 backlog_full: self.backlog_full.load(Ordering::Relaxed),
120 storage_failed: self.storage_failed.load(Ordering::Relaxed),
121 activation_failed: self.activation_failed.load(Ordering::Relaxed),
122 active_notified: self.active_notified.load(Ordering::Relaxed),
123 activation_reserved: self.activation_reserved.load(Ordering::Relaxed),
124 activation_coalesced: self.activation_coalesced.load(Ordering::Relaxed),
125 delivery_latency_micros: self.delivery_latency_micros.load(Ordering::Relaxed),
126 }
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct SessionMessengerReceipt {
132 pub delivery: SessionInboxReceipt,
133 pub activation: SessionActivationDisposition,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct SessionPeerDeliveryReceipt {
140 pub delivery: SessionInboxReceipt,
141 pub activation: Option<SessionActivationDisposition>,
142 pub active_delivery_notified: bool,
143}
144
145impl From<SessionMessengerReceipt> for SessionPeerDeliveryReceipt {
146 fn from(receipt: SessionMessengerReceipt) -> Self {
147 Self {
148 delivery: receipt.delivery,
149 activation: Some(receipt.activation),
150 active_delivery_notified: false,
151 }
152 }
153}
154
155impl SessionPeerDeliveryReceipt {
156 fn into_legacy(self) -> SessionMessengerReceipt {
157 SessionMessengerReceipt {
158 delivery: self.delivery,
159 activation: self
160 .activation
161 .expect("legacy peer delivery requests activation"),
162 }
163 }
164}
165
166#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct SessionMessengerAdmission {
170 pub envelope_id: String,
171 pub target_session_id: String,
172 pub delivery: SessionInboxReceipt,
173}
174
175#[derive(Debug, thiserror::Error)]
176pub enum SessionMessengerError {
177 #[error("invalid session message: {0}")]
178 InvalidEnvelope(String),
179 #[error("session message source not found: {0}")]
180 SourceNotFound(String),
181 #[error("session message target not found: {0}")]
182 TargetNotFound(String),
183 #[error("session {source_session_id} is not authorized to message session {target}")]
184 Unauthorized {
185 source_session_id: String,
186 target: String,
187 },
188 #[error(transparent)]
189 Inbox(#[from] SessionInboxError),
190 #[error("message {receipt_id} was durably delivered but activation failed: {source}")]
194 Activation {
195 receipt_id: String,
196 receipt: SessionInboxReceipt,
197 #[source]
198 source: SessionActivationError,
199 },
200 #[error(
205 "message {receipt_id} was delivered but activation eligibility was not persisted: {source}"
206 )]
207 ActivationEligibility {
208 receipt_id: String,
209 receipt: SessionInboxReceipt,
210 #[source]
211 source: SessionInboxError,
212 },
213 #[error("session store failure: {0}")]
214 Storage(String),
215}
216
217pub struct SessionMessenger {
220 sessions: Arc<dyn Storage>,
221 inbox: Arc<dyn SessionInboxPort>,
222 activation: Arc<dyn SessionActivationPort>,
223 metrics: Arc<SessionMessagingMetrics>,
224}
225
226impl SessionMessenger {
227 pub fn new(
228 sessions: Arc<dyn Storage>,
229 inbox: Arc<dyn SessionInboxPort>,
230 activation: Arc<dyn SessionActivationPort>,
231 ) -> Self {
232 Self {
233 sessions,
234 inbox,
235 activation,
236 metrics: Arc::new(SessionMessagingMetrics::default()),
237 }
238 }
239
240 pub fn inbox(&self) -> &Arc<dyn SessionInboxPort> {
241 &self.inbox
242 }
243
244 pub fn activation(&self) -> &Arc<dyn SessionActivationPort> {
245 &self.activation
246 }
247
248 pub fn metrics(&self) -> &Arc<SessionMessagingMetrics> {
249 &self.metrics
250 }
251
252 pub async fn list_peer_targets(
256 &self,
257 caller_session_id: &str,
258 ) -> Result<SessionPeerTargets, SessionMessengerError> {
259 let caller = self.peer_member(caller_session_id, true).await?;
260 let mut pending = VecDeque::from([(caller.root_session_id.clone(), None)]);
261 let mut seen = HashSet::new();
262 let mut result = SessionPeerTargets {
263 targets: Vec::new(),
264 truncated: false,
265 };
266 let mut result_bytes = serde_json::to_vec(&result)
269 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?
270 .len();
271 while let Some((id, indexed_parent)) = pending.pop_front() {
272 if !seen.insert(id.clone()) {
273 continue;
274 }
275 let current = match self.peer_member(&id, false).await {
276 Ok(current)
277 if current.root_session_id == caller.root_session_id
278 && current.project_id_meta() == caller.project_id_meta()
279 && current.parent_session_id == indexed_parent =>
280 {
281 current
282 }
283 Ok(_)
284 | Err(SessionMessengerError::TargetNotFound(_))
285 | Err(SessionMessengerError::Unauthorized { .. })
286 | Err(SessionMessengerError::InvalidEnvelope(_)) => continue,
287 Err(error) => return Err(error),
288 };
289 if id != caller_session_id {
290 if result.targets.len() == MAX_PEER_TARGETS {
291 result.truncated = true;
292 break;
293 }
294 let target = SessionPeerTarget {
295 session_id: current.id.clone(),
296 parent_session_id: current.parent_session_id.clone(),
297 kind: current.kind,
298 title: Self::bounded_peer_label(¤t.title, MAX_PEER_TARGET_TITLE_BYTES),
299 status: current.last_run_status().map(|status| {
300 Self::bounded_peer_label(&status, MAX_PEER_TARGET_STATUS_BYTES)
301 }),
302 };
303 let target_bytes = serde_json::to_vec(&target)
304 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?
305 .len()
306 + usize::from(!result.targets.is_empty());
307 if result_bytes.saturating_add(target_bytes) > MAX_PEER_TARGET_RESULT_BYTES {
308 result.truncated = true;
309 break;
310 }
311 result_bytes += target_bytes;
312 result.targets.push(target);
313 }
314 let mut children = self
315 .sessions
316 .list_child_run_statuses(&id)
317 .await
318 .map_err(|error| SessionMessengerError::Storage(error.to_string()))?;
319 children.sort_by(|left, right| left.0.cmp(&right.0));
320 let budget = MAX_PEER_TARGET_SCAN.saturating_sub(seen.len() + pending.len());
321 result.truncated |= children.len() > budget;
322 pending.extend(
323 children
324 .into_iter()
325 .take(budget)
326 .map(|(child, _)| (child, Some(id.clone()))),
327 );
328 }
329 Ok(result)
330 }
331
332 pub async fn send_peer(
336 &self,
337 caller_session_id: &str,
338 target_session_id: &str,
339 operation_id: &str,
340 message: &str,
341 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
342 self.send_peer_delivery(
343 caller_session_id,
344 target_session_id,
345 operation_id,
346 message,
347 None,
348 None,
349 )
350 .await
351 .map(SessionPeerDeliveryReceipt::into_legacy)
352 }
353
354 pub async fn send_peer_with_intent(
357 &self,
358 caller_session_id: &str,
359 target_session_id: &str,
360 operation_id: &str,
361 message: &str,
362 intent: SessionMessageDeliveryIntent,
363 ) -> Result<SessionPeerDeliveryReceipt, SessionMessengerError> {
364 self.send_peer_delivery(
365 caller_session_id,
366 target_session_id,
367 operation_id,
368 message,
369 Some(intent),
370 None,
371 )
372 .await
373 }
374
375 pub async fn send_peer_observed(
380 &self,
381 caller: &SessionCallerReference,
382 target_session_id: &str,
383 operation_id: &str,
384 message: &str,
385 intent: Option<SessionMessageDeliveryIntent>,
386 ) -> Result<SessionPeerDeliveryReceipt, SessionMessengerError> {
387 self.send_peer_delivery(
388 &caller.session_id,
389 target_session_id,
390 operation_id,
391 message,
392 intent,
393 Some(caller),
394 )
395 .await
396 }
397
398 pub async fn send_host_scoped_peer(
403 &self,
404 token: &bamboo_domain::HostSessionScopeToken,
405 caller: &SessionCallerReference,
406 target_session_id: &str,
407 operation_id: &str,
408 message: &str,
409 intent: SessionMessageDeliveryIntent,
410 ) -> Result<SessionPeerDeliveryReceipt, SessionMessengerError> {
411 Self::validate_peer_input(operation_id, message)?;
412 Self::validate_peer_session_id(target_session_id)?;
413 let unauthorized = || SessionMessengerError::Unauthorized {
414 source_session_id: caller.session_id.clone(),
415 target: target_session_id.into(),
416 };
417 if !token.binding().caller.identity.matches_caller(caller) {
418 return Err(unauthorized());
419 }
420 let target = &token
421 .binding()
422 .target(target_session_id, bamboo_domain::HostSessionRight::SendPeer)
423 .map_err(|_| unauthorized())?
424 .observation
425 .identity;
426 let identity = bamboo_domain::HostSessionPeerIdentity::for_caller(
427 caller,
428 &target.session_id,
429 target.created_at,
430 operation_id,
431 );
432 let envelope = Self::peer_envelope_for_ids(
433 &caller.session_id,
434 &target.session_id,
435 operation_id,
436 message,
437 identity.message_id,
438 identity.thread_id,
439 None,
440 );
441 let started = Instant::now();
442 let delivery = self
443 .inbox
444 .deliver_host_scoped_peer_message(token, caller, &envelope, intent)
445 .await
446 .map_err(|error| {
447 let error = SessionMessengerError::Inbox(error);
448 self.record_rejection(&error);
449 error
450 })?;
451 self.finish_peer_delivery(envelope, delivery, started, Some(intent))
452 .await
453 }
454
455 pub async fn send_host_root_first_input(
459 &self,
460 token: &bamboo_domain::HostSessionScopeToken,
461 caller: &SessionCallerReference,
462 operation_id: uuid::Uuid,
463 reserved_root: &Session,
464 message: &str,
465 ) -> Result<SessionPeerDeliveryReceipt, SessionMessengerError> {
466 let envelope =
467 Self::root_first_input_envelope(caller, operation_id, reserved_root, message)?;
468 let started = Instant::now();
469 let delivery = self
470 .inbox
471 .deliver_host_root_first_input(token, caller, operation_id, reserved_root, &envelope)
472 .await
473 .map_err(|error| {
474 let error = SessionMessengerError::Inbox(error);
475 self.record_rejection(&error);
476 error
477 })?;
478 self.finish_peer_delivery(
479 envelope,
480 delivery,
481 started,
482 Some(SessionMessageDeliveryIntent::TriggerTurn),
483 )
484 .await
485 }
486
487 pub async fn supervisor_root_first_input_observed(
490 &self,
491 caller: &SessionCallerReference,
492 supervisor: &SupervisorReference,
493 operation_id: uuid::Uuid,
494 reserved_root: &Session,
495 message: &str,
496 ) -> Result<SessionPeerDeliveryReceipt, SessionMessengerError> {
497 let envelope =
498 Self::root_first_input_envelope(caller, operation_id, reserved_root, message)?;
499 let started = Instant::now();
500 let delivery = self
501 .inbox
502 .deliver_unscoped_supervisor_root_first_input(
503 caller,
504 supervisor,
505 operation_id,
506 reserved_root,
507 &envelope,
508 )
509 .await
510 .map_err(|error| {
511 let error = SessionMessengerError::Inbox(error);
512 self.record_rejection(&error);
513 error
514 })?;
515 self.finish_peer_delivery(
516 envelope,
517 delivery,
518 started,
519 Some(SessionMessageDeliveryIntent::TriggerTurn),
520 )
521 .await
522 }
523
524 fn root_first_input_envelope(
525 caller: &SessionCallerReference,
526 operation_id: uuid::Uuid,
527 reserved_root: &Session,
528 message: &str,
529 ) -> Result<SessionMessageEnvelope, SessionMessengerError> {
530 let operation = operation_id.to_string();
531 Self::validate_peer_input(&operation, message)?;
532 let (id, thread) =
533 bamboo_domain::host_root_first_input_identity(caller, operation_id, reserved_root)
534 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.into()))?;
535 Ok(Self::peer_envelope_for_ids(
536 &caller.session_id,
537 &reserved_root.id,
538 &operation,
539 message,
540 id,
541 thread,
542 None,
543 ))
544 }
545
546 pub async fn steer_peer(
550 &self,
551 caller_session_id: &str,
552 target_session_id: &str,
553 operation_id: &str,
554 message: &str,
555 expected_run_id: &str,
556 ) -> Result<bamboo_domain::SessionInboxSteerReceipt, SessionMessengerError> {
557 Self::validate_peer_input(operation_id, message)?;
558 if !bamboo_domain::valid_session_run_id(expected_run_id) {
559 return Err(SessionMessengerError::InvalidEnvelope(
560 "invalid expected run id".into(),
561 ));
562 }
563 let (caller, target) = self.peer_pair(caller_session_id, target_session_id).await?;
564 let id = Self::peer_message_id(&caller, &target, operation_id);
565 let envelope = Self::peer_envelope(
566 &caller,
567 &target,
568 operation_id,
569 message,
570 id.clone(),
571 id.to_string(),
572 None,
573 );
574 let source = ActorSession::from_session(&caller)
575 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
576 let target = ActorSession::from_session(&target)
577 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
578 let started = Instant::now();
579 let receipt = self
580 .inbox
581 .deliver_peer_steer(
582 &envelope,
583 &source,
584 &target,
585 expected_run_id,
586 self.activation.clone(),
587 )
588 .await
589 .map_err(|error| {
590 let error = SessionMessengerError::Inbox(error);
591 self.record_rejection(&error);
592 error
593 })?;
594 self.record_admission(envelope, receipt.delivery.clone(), started);
595 Ok(receipt)
596 }
597
598 async fn send_peer_delivery(
599 &self,
600 caller_session_id: &str,
601 target_session_id: &str,
602 operation_id: &str,
603 message: &str,
604 intent: Option<SessionMessageDeliveryIntent>,
605 original_caller: Option<&SessionCallerReference>,
606 ) -> Result<SessionPeerDeliveryReceipt, SessionMessengerError> {
607 Self::validate_peer_input(operation_id, message)?;
608 let (caller, target) = self.peer_pair(caller_session_id, target_session_id).await?;
609 if original_caller.is_some_and(|original| !original.matches_session(&caller)) {
610 return Err(SessionMessengerError::Unauthorized {
611 source_session_id: caller_session_id.into(),
612 target: target_session_id.into(),
613 });
614 }
615 let id = Self::peer_message_id(&caller, &target, operation_id);
616 let envelope = Self::peer_envelope(
617 &caller,
618 &target,
619 operation_id,
620 message,
621 id.clone(),
622 id.to_string(),
623 None,
624 );
625 self.send_peer_envelope(envelope, &caller, &target, None, intent, original_caller)
626 .await
627 }
628
629 pub async fn reply_peer(
632 &self,
633 caller_session_id: &str,
634 received_message_id: &str,
635 operation_id: &str,
636 message: &str,
637 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
638 self.reply_peer_delivery(
639 caller_session_id,
640 received_message_id,
641 operation_id,
642 message,
643 None,
644 )
645 .await
646 .map(SessionPeerDeliveryReceipt::into_legacy)
647 }
648
649 pub async fn reply_peer_with_intent(
652 &self,
653 caller_session_id: &str,
654 received_message_id: &str,
655 operation_id: &str,
656 message: &str,
657 intent: SessionMessageDeliveryIntent,
658 ) -> Result<SessionPeerDeliveryReceipt, SessionMessengerError> {
659 self.reply_peer_delivery(
660 caller_session_id,
661 received_message_id,
662 operation_id,
663 message,
664 Some(intent),
665 )
666 .await
667 }
668
669 async fn reply_peer_delivery(
670 &self,
671 caller_session_id: &str,
672 received_message_id: &str,
673 operation_id: &str,
674 message: &str,
675 intent: Option<SessionMessageDeliveryIntent>,
676 ) -> Result<SessionPeerDeliveryReceipt, SessionMessengerError> {
677 Self::validate_peer_input(operation_id, message)?;
678 Self::validate_peer_session_id(caller_session_id)?;
679 let received_id = SessionMessageId::parse(received_message_id)
680 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
681 let received = self
682 .load_session(caller_session_id)
683 .await?
684 .ok_or_else(|| SessionMessengerError::SourceNotFound(caller_session_id.into()))?;
685 let original = received
686 .messages
687 .iter()
688 .filter(|message| message.id == received_message_id)
689 .find_map(|message| {
690 let envelope: SessionMessageEnvelope = serde_json::from_value(
691 message.metadata.as_ref()?.get("session_message")?.clone(),
692 )
693 .ok()?;
694 (envelope.id == received_id
695 && envelope.kind == SessionMessageKind::PeerMessage
696 && envelope.target_session_id == caller_session_id
697 && bamboo_domain::is_matching_session_message(message, &envelope))
698 .then_some(envelope)
699 })
700 .ok_or_else(|| {
701 SessionMessengerError::InvalidEnvelope(
702 "reply requires a canonical received peer message".into(),
703 )
704 })?;
705 let SessionMessageSource::Session {
706 session_id: target_id,
707 } = &original.source
708 else {
709 return Err(SessionMessengerError::InvalidEnvelope(
710 "reply has no logical sender".into(),
711 ));
712 };
713 let (caller, target) = self.peer_pair(caller_session_id, target_id).await?;
714 let thread = original.thread_id.clone().ok_or_else(|| {
715 SessionMessengerError::InvalidEnvelope("reply requires a threaded peer message".into())
716 })?;
717 let original_operation = original.correlation_id.as_deref().ok_or_else(|| {
718 SessionMessengerError::InvalidEnvelope(
719 "reply requires a peer operation identity".into(),
720 )
721 })?;
722 if original.id != Self::peer_message_id(&target, &caller, original_operation) {
723 return Err(SessionMessengerError::InvalidEnvelope(
724 "received peer message belongs to a different session lifetime".into(),
725 ));
726 }
727 SessionMessageId::parse(&thread)
728 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
729 let envelope = Self::peer_envelope(
730 &caller,
731 &target,
732 operation_id,
733 message,
734 Self::peer_message_id(&caller, &target, operation_id),
735 thread,
736 Some(original.id.clone()),
737 );
738 self.send_peer_envelope(envelope, &caller, &target, Some(&original), intent, None)
739 .await
740 }
741
742 fn validate_peer_input(operation_id: &str, message: &str) -> Result<(), SessionMessengerError> {
743 SessionMessageId::parse(operation_id)
744 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
745 if message.trim().is_empty()
746 || message.len() > MAX_PEER_MESSAGE_BYTES
747 || message.contains('\0')
748 {
749 return Err(SessionMessengerError::InvalidEnvelope(format!(
750 "peer message must contain 1..={MAX_PEER_MESSAGE_BYTES} UTF-8 bytes without NUL"
751 )));
752 }
753 Ok(())
754 }
755
756 fn validate_peer_session_id(id: &str) -> Result<(), SessionMessengerError> {
757 bamboo_domain::validate_supervisor_target_id(id)
758 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.into()))?;
759 SessionMessageId::parse(id)
760 .map(|_| ())
761 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))
762 }
763
764 fn bounded_peer_label(value: &str, max_bytes: usize) -> String {
765 let mut end = value.len().min(max_bytes);
766 while !value.is_char_boundary(end) {
767 end -= 1;
768 }
769 value[..end].to_owned()
770 }
771
772 async fn peer_member(&self, id: &str, source: bool) -> Result<Session, SessionMessengerError> {
773 Self::validate_peer_session_id(id)?;
774 let session = self
775 .sessions
776 .load_runtime_control_plane(id)
777 .await
778 .map_err(|error| SessionMessengerError::Storage(error.to_string()))?
779 .ok_or_else(|| {
780 if source {
781 SessionMessengerError::SourceNotFound(id.into())
782 } else {
783 SessionMessengerError::TargetNotFound(id.into())
784 }
785 })?;
786 let unauthorized = || SessionMessengerError::Unauthorized {
787 source_session_id: id.into(),
788 target: session.root_session_id.clone(),
789 };
790 if session.id != id
791 || session
792 .runtime_metadata
793 .as_ref()
794 .and_then(|meta| meta.project_id.as_ref())
795 .zip(session.metadata.get("project_id"))
796 .is_some_and(|(typed, legacy)| typed != legacy)
797 || bamboo_domain::ActorSession::from_session(&session).is_err()
798 {
799 return Err(unauthorized());
800 }
801 if session.kind == bamboo_domain::SessionKind::Root {
802 return Ok(session);
803 }
804 crate::session_app::child_session::owned_tree::load_owned_descendant(
805 &PeerTreePort(self.sessions.as_ref()),
806 &session.root_session_id,
807 id,
808 )
809 .await
810 .map_err(|_| unauthorized())
811 }
812
813 async fn peer_pair(
814 &self,
815 caller_id: &str,
816 target_id: &str,
817 ) -> Result<(Session, Session), SessionMessengerError> {
818 let caller = self.peer_member(caller_id, true).await?;
819 let target = self.peer_member(target_id, false).await?;
820 if caller_id == target_id
821 || caller.root_session_id != target.root_session_id
822 || caller.project_id_meta() != target.project_id_meta()
823 {
824 return Err(SessionMessengerError::Unauthorized {
825 source_session_id: caller_id.into(),
826 target: target_id.into(),
827 });
828 }
829 Ok((caller, target))
830 }
831
832 fn peer_message_id(caller: &Session, target: &Session, operation_id: &str) -> SessionMessageId {
833 bamboo_domain::session_peer_message_id(
834 &caller.id,
835 caller.created_at,
836 &target.id,
837 target.created_at,
838 operation_id,
839 )
840 }
841
842 #[allow(clippy::too_many_arguments)]
843 fn peer_envelope(
844 caller: &Session,
845 target: &Session,
846 operation_id: &str,
847 message: &str,
848 id: SessionMessageId,
849 thread_id: String,
850 in_reply_to: Option<SessionMessageId>,
851 ) -> SessionMessageEnvelope {
852 Self::peer_envelope_for_ids(
853 &caller.id,
854 &target.id,
855 operation_id,
856 message,
857 id,
858 Some(thread_id),
859 in_reply_to,
860 )
861 }
862
863 #[allow(clippy::too_many_arguments)]
864 fn peer_envelope_for_ids(
865 caller_id: &str,
866 target_id: &str,
867 operation_id: &str,
868 message: &str,
869 id: SessionMessageId,
870 thread_id: Option<String>,
871 in_reply_to: Option<SessionMessageId>,
872 ) -> SessionMessageEnvelope {
873 SessionMessageEnvelope {
874 id,
875 source: SessionMessageSource::Session {
876 session_id: caller_id.into(),
877 },
878 target_session_id: target_id.into(),
879 kind: SessionMessageKind::PeerMessage,
880 body: SessionMessageBody::Content(SessionMessageContent::text(message)),
881 created_at: chrono::Utc::now(),
882 thread_id,
883 in_reply_to,
884 attempt: None,
885 correlation_id: Some(operation_id.into()),
886 }
887 }
888
889 async fn send_peer_envelope(
890 &self,
891 envelope: SessionMessageEnvelope,
892 caller: &Session,
893 target: &Session,
894 reply_to: Option<&SessionMessageEnvelope>,
895 intent: Option<SessionMessageDeliveryIntent>,
896 original_caller: Option<&SessionCallerReference>,
897 ) -> Result<SessionPeerDeliveryReceipt, SessionMessengerError> {
898 let started = Instant::now();
899 let source = ActorSession::from_session(caller)
900 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
901 let target = ActorSession::from_session(target)
902 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
903 let result = if let Some(original) = original_caller {
907 self.inbox
908 .deliver_unscoped_peer_message(original, &envelope, &source, &target, intent)
909 .await
910 } else {
911 match intent {
912 Some(intent) => {
913 self.inbox
914 .deliver_peer_message_with_intent(
915 &envelope, &source, &target, reply_to, intent,
916 )
917 .await
918 }
919 None => {
920 self.inbox
921 .deliver_peer_message(&envelope, &source, &target, reply_to)
922 .await
923 .map(|delivery| bamboo_domain::SessionInboxDeliveryReceipt {
924 delivery,
925 pending: true,
927 })
928 }
929 }
930 };
931 let delivery = match result {
932 Ok(receipt) => receipt,
933 Err(error) => {
934 let error = SessionMessengerError::Inbox(error);
935 self.record_rejection(&error);
936 return Err(error);
937 }
938 };
939 self.finish_peer_delivery(envelope, delivery, started, intent)
940 .await
941 }
942
943 async fn finish_peer_delivery(
944 &self,
945 envelope: SessionMessageEnvelope,
946 delivery: bamboo_domain::SessionInboxDeliveryReceipt,
947 started: Instant,
948 intent: Option<SessionMessageDeliveryIntent>,
949 ) -> Result<SessionPeerDeliveryReceipt, SessionMessengerError> {
950 let pending = delivery.pending;
951 let admission = self.record_admission(envelope, delivery.delivery, started);
952 if !pending {
956 return Ok(SessionPeerDeliveryReceipt {
957 delivery: admission.delivery,
958 activation: None,
959 active_delivery_notified: false,
960 });
961 }
962 if intent == Some(SessionMessageDeliveryIntent::QueueOnly) {
963 let active_delivery_notified = self
964 .activation
965 .notify_active_delivery(&admission.target_session_id, admission.delivery.generation)
966 .await;
967 return Ok(SessionPeerDeliveryReceipt {
968 delivery: admission.delivery,
969 activation: None,
970 active_delivery_notified,
971 });
972 }
973 self.activate_prepared(&admission).await.map(Into::into)
974 }
975
976 pub async fn supervisor_loop_control(
980 &self,
981 supervisor: &SupervisorReference,
982 target_session_id: &str,
983 expected_link_revision: Option<u64>,
984 command: &bamboo_domain::LoopControlRequest,
985 receipt_operation_id: Option<&str>,
986 ) -> Result<serde_json::Value, SessionMessengerError> {
987 use bamboo_domain::{LoopControlAction, LoopControlRequest, ScopedLoopControlInput};
988 let invalid = |message: String| SessionMessengerError::InvalidEnvelope(message);
989 bamboo_domain::validate_supervisor_target_id(target_session_id)
990 .map_err(|error| invalid(error.into()))?;
991 bamboo_domain::validate_scoped_loop_control_command(command).map_err(invalid)?;
992 let status = matches!(command, LoopControlRequest::Status);
993 if expected_link_revision == Some(0)
994 || (!status && expected_link_revision.is_none())
995 || (!status && receipt_operation_id.is_some())
996 {
997 return Err(invalid("loop mutations require the exact nonzero link revision; receipt lookup is status-only".into()));
998 }
999 if let Some(operation) = receipt_operation_id {
1000 uuid::Uuid::parse_str(operation)
1001 .map_err(|_| invalid("receipt operation must be a UUID".into()))?;
1002 }
1003 let binding = self
1004 .activation
1005 .observe_supervisor_loop(
1006 supervisor,
1007 target_session_id,
1008 expected_link_revision,
1009 LoopControlAction::of(command),
1010 )
1011 .await?;
1012 binding.authorize(command).map_err(invalid)?;
1013 let canonical = self
1014 .sessions
1015 .load_scoped_loop_session(&binding, self.activation.clone())
1016 .await
1017 .map_err(|error| SessionMessengerError::Storage(error.to_string()))?
1018 .ok_or_else(|| SessionMessengerError::TargetNotFound(target_session_id.into()))?;
1019 if status {
1020 let mut snapshot = serde_json::to_value(
1021 crate::runtime::loop_control::inspect_loop(&canonical)
1022 .map_err(|error| SessionMessengerError::Storage(error.to_string()))?,
1023 )
1024 .map_err(|error| SessionMessengerError::Storage(error.to_string()))?;
1025 for field in ["objective", "pending_objective"] {
1028 let text = snapshot[field].as_str().map(str::to_owned);
1029 if let Some(text) = text {
1030 let mut end = text.len().min(1024);
1031 while !text.is_char_boundary(end) {
1032 end -= 1;
1033 }
1034 snapshot[field] = serde_json::Value::String(text[..end].into());
1035 snapshot[format!("{field}_truncated")] =
1036 serde_json::Value::Bool(end < text.len());
1037 }
1038 }
1039 let records = bamboo_domain::scoped_loop_consumptions(&canonical)
1040 .map_err(SessionMessengerError::Storage)?;
1041 let partial_checkpoint = records.iter().any(|record| {
1042 record.binding == binding
1043 && receipt_operation_id.is_none_or(|id| id == record.operation_id)
1044 && !canonical
1045 .session_inbox_admission()
1046 .is_some_and(|cursor| cursor.contains(&record.input_id))
1047 });
1048 let record = records.into_iter().rev().find(|record| {
1049 record.binding == binding
1050 && receipt_operation_id.is_none_or(|id| id == record.operation_id)
1051 && canonical
1052 .session_inbox_admission()
1053 .is_some_and(|cursor| cursor.contains(&record.input_id))
1054 });
1055 let state = crate::runtime::counted_loop::read_loop_state(&canonical)
1056 .map_err(|error| SessionMessengerError::Storage(error.to_string()))?;
1057 let objective_applied_actual = record
1058 .as_ref()
1059 .filter(|record| record.configured)
1060 .and_then(|record| record.receipt.as_ref())
1061 .zip(state.as_ref())
1062 .is_some_and(|(receipt, state)| {
1063 receipt["generation"].as_str() == Some(state.generation.as_str())
1064 && canonical.messages.iter().any(|message| {
1065 message.role == bamboo_domain::Role::User
1066 && message.metadata.as_ref().is_some_and(|metadata| {
1067 metadata["runtime_kind"].as_str()
1068 == Some("counted_loop_iteration")
1069 && metadata["loop_generation"] == receipt["generation"]
1070 && metadata["objective_revision"]
1071 == receipt["objective_revision"]
1072 })
1073 })
1074 && state.control.as_ref().is_some_and(|control| {
1075 receipt["accepted_revision"]
1076 .as_u64()
1077 .is_some_and(|revision| control.applied_revision >= revision)
1078 && receipt["objective_revision"]
1079 .as_u64()
1080 .is_some_and(|revision| {
1081 revision > 0
1082 && control.iteration_objective_revision >= revision
1083 })
1084 })
1085 });
1086 return Ok(serde_json::json!({
1087 "action":"loop", "command":"status", "target_session_id":target_session_id,
1088 "link_revision":binding.link_revision, "owner_run_id":binding.owner_run_id,
1089 "execution_id":binding.execution_id, "loop":snapshot,
1090 "host_max_iterations":binding.max_iterations, "host_max_total_rounds":binding.max_total_rounds,
1091 "configured":record.as_ref().is_some_and(|record|record.configured),
1092 "checkpoint_observation":if partial_checkpoint { "partial_or_unknown" } else if record.is_some() { "confirmed" } else { "not_observed" },
1093 "operation_receipt":record, "objective_applied_actual":objective_applied_actual,
1094 "note":"Configuration receipts are immutable target checkpoints; objective_applied_actual additionally requires its real iteration-admission checkpoint. Neither is a task result."
1095 }));
1096 }
1097 let input = ScopedLoopControlInput {
1098 binding,
1099 command: command.clone(),
1100 };
1101 let envelope = input.envelope().map_err(invalid)?;
1102 if let Some(record) = bamboo_domain::scoped_loop_consumptions(&canonical)
1103 .map_err(SessionMessengerError::Storage)?
1104 .into_iter()
1105 .find(|record| record.input_id == envelope.id)
1106 {
1107 if !bamboo_domain::has_scoped_loop_consumption(&canonical, &envelope) {
1108 return Err(invalid(
1109 "operation payload conflicts with its immutable target receipt".into(),
1110 ));
1111 }
1112 if canonical
1113 .session_inbox_admission()
1114 .is_some_and(|cursor| cursor.contains(&record.input_id))
1115 {
1116 return Ok(serde_json::json!({
1117 "action":"loop", "target_session_id":target_session_id,
1118 "operation_id":input.operation_id(), "receipt_id":envelope.id,
1119 "admitted":true, "replayed":true, "configured":record.configured,
1120 "operation_receipt":record, "objective_applied_actual":false,
1121 "note":"The original configuration receipt is replayed; use status for current objective application."
1122 }));
1123 }
1124 }
1128 let started = Instant::now();
1129 let receipt = self
1130 .inbox
1131 .deliver_supervisor_loop_control(&input.binding, &envelope, self.activation.clone())
1132 .await?;
1133 self.record_admission(envelope, receipt.delivery.clone(), started);
1134 Ok(serde_json::json!({
1135 "action":"loop", "target_session_id":target_session_id,
1136 "operation_id":input.operation_id(), "receipt_id":receipt.delivery.id,
1137 "generation":receipt.delivery.generation, "admitted":true,
1138 "replayed":receipt.replayed, "active_delivery_notified":receipt.active_delivery_notified,
1139 "configured":false, "target_application_observed":false,
1140 "objective_applied_actual":false, "operation_receipt":null,
1141 "note":"Transport admission is historical on replay; the input may no longer be pending. Target application remains unobserved here; query status with receipt_operation_id. This input never starts a successor or answers a wait."
1142 }))
1143 }
1144
1145 pub async fn supervisor_followup(
1149 &self,
1150 supervisor: &SupervisorReference,
1151 target_session_id: &str,
1152 operation_id: &str,
1153 message: &str,
1154 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
1155 self.supervisor_followup_delivery(
1156 supervisor,
1157 target_session_id,
1158 operation_id,
1159 message,
1160 None,
1161 )
1162 .await
1163 }
1164
1165 pub async fn supervisor_followup_observed(
1168 &self,
1169 caller: &SessionCallerReference,
1170 supervisor: &SupervisorReference,
1171 target_session_id: &str,
1172 operation_id: &str,
1173 message: &str,
1174 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
1175 self.supervisor_followup_delivery(
1176 supervisor,
1177 target_session_id,
1178 operation_id,
1179 message,
1180 Some(caller),
1181 )
1182 .await
1183 }
1184
1185 async fn supervisor_followup_delivery(
1186 &self,
1187 supervisor: &SupervisorReference,
1188 target_session_id: &str,
1189 operation_id: &str,
1190 message: &str,
1191 original_caller: Option<&SessionCallerReference>,
1192 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
1193 bamboo_domain::validate_supervisor_target_id(target_session_id)
1194 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
1195 SessionMessageId::parse(operation_id)
1196 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
1197 if message.trim().is_empty() || message.len() > MAX_SUPERVISOR_FOLLOWUP_BYTES {
1198 return Err(SessionMessengerError::InvalidEnvelope(format!(
1199 "followup message must contain 1..={MAX_SUPERVISOR_FOLLOWUP_BYTES} UTF-8 bytes"
1200 )));
1201 }
1202 let envelope = SessionMessageEnvelope {
1203 id: bamboo_domain::supervisor_followup_message_id(
1204 supervisor,
1205 target_session_id,
1206 operation_id,
1207 ),
1208 source: SessionMessageSource::Session {
1209 session_id: supervisor.session_id.clone(),
1210 },
1211 target_session_id: target_session_id.to_string(),
1212 kind: SessionMessageKind::PeerMessage,
1213 body: SessionMessageBody::Content(SessionMessageContent::text(message)),
1214 created_at: chrono::Utc::now(),
1215 thread_id: None,
1216 in_reply_to: None,
1217 attempt: None,
1218 correlation_id: Some(operation_id.to_string()),
1219 };
1220 let started = Instant::now();
1221 let result = match original_caller {
1222 Some(caller) => {
1223 self.inbox
1224 .deliver_unscoped_supervisor_followup(caller, supervisor, &envelope)
1225 .await
1226 }
1227 None => {
1228 self.inbox
1229 .deliver_supervisor_followup(supervisor, &envelope)
1230 .await
1231 }
1232 };
1233 let delivery = result.map_err(|error| {
1234 let error = SessionMessengerError::Inbox(error);
1235 self.record_rejection(&error);
1236 error
1237 })?;
1238 let admission = self.record_admission(envelope, delivery, started);
1239 self.activate_prepared(&admission).await
1242 }
1243
1244 async fn load_session(&self, id: &str) -> Result<Option<Session>, SessionMessengerError> {
1245 self.sessions
1246 .load_session(id)
1247 .await
1248 .map_err(|error| SessionMessengerError::Storage(error.to_string()))
1249 }
1250
1251 fn logical_root(session: &Session) -> &str {
1252 if session.root_session_id.trim().is_empty() {
1253 &session.id
1254 } else {
1255 &session.root_session_id
1256 }
1257 }
1258
1259 async fn validate_relationship(
1260 &self,
1261 envelope: &SessionMessageEnvelope,
1262 ) -> Result<(), SessionMessengerError> {
1263 envelope
1264 .validate()
1265 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
1266 let target = self
1267 .load_session(&envelope.target_session_id)
1268 .await?
1269 .ok_or_else(|| {
1270 SessionMessengerError::TargetNotFound(envelope.target_session_id.clone())
1271 })?;
1272
1273 let SessionMessageSource::Session { session_id } = &envelope.source else {
1274 return Ok(());
1275 };
1276 let source = self
1277 .load_session(session_id)
1278 .await?
1279 .ok_or_else(|| SessionMessengerError::SourceNotFound(session_id.clone()))?;
1280
1281 let same_root = Self::logical_root(&source) == Self::logical_root(&target);
1282 let source_project = source.project_id_meta();
1283 let target_project = target.project_id_meta();
1284 let project_compatible = match (source_project.as_deref(), target_project.as_deref()) {
1285 (Some(left), Some(right)) => left == right,
1286 _ => true,
1289 };
1290 if !same_root || !project_compatible {
1291 return Err(SessionMessengerError::Unauthorized {
1292 source_session_id: source.id,
1293 target: target.id,
1294 });
1295 }
1296 Ok(())
1297 }
1298
1299 fn record_rejection(&self, error: &SessionMessengerError) {
1300 self.metrics.rejected.fetch_add(1, Ordering::Relaxed);
1301 match error {
1302 SessionMessengerError::InvalidEnvelope(_) => {
1303 self.metrics
1304 .invalid_envelope
1305 .fetch_add(1, Ordering::Relaxed);
1306 }
1307 SessionMessengerError::Unauthorized { .. }
1308 | SessionMessengerError::SourceNotFound(_)
1309 | SessionMessengerError::TargetNotFound(_) => {
1310 self.metrics.unauthorized.fetch_add(1, Ordering::Relaxed);
1311 }
1312 SessionMessengerError::Inbox(SessionInboxError::AdmissionCancelled)
1313 | SessionMessengerError::Inbox(SessionInboxError::ExpectedRunConflict(_)) => {}
1314 SessionMessengerError::Inbox(SessionInboxError::PayloadTooLarge { .. }) => {
1315 self.metrics
1316 .payload_too_large
1317 .fetch_add(1, Ordering::Relaxed);
1318 }
1319 SessionMessengerError::Inbox(SessionInboxError::BacklogFull { .. }) => {
1320 self.metrics.backlog_full.fetch_add(1, Ordering::Relaxed);
1321 }
1322 SessionMessengerError::Inbox(_) | SessionMessengerError::Storage(_) => {
1323 self.metrics.storage_failed.fetch_add(1, Ordering::Relaxed);
1324 }
1325 SessionMessengerError::Activation { .. }
1326 | SessionMessengerError::ActivationEligibility { .. } => {}
1327 }
1328 }
1329
1330 pub async fn admit(
1331 &self,
1332 envelope: SessionMessageEnvelope,
1333 ) -> Result<SessionMessengerAdmission, SessionMessengerError> {
1334 self.admit_with_gate(envelope, None).await
1335 }
1336
1337 pub async fn admit_with_gate(
1338 &self,
1339 envelope: SessionMessageEnvelope,
1340 gate: Option<&bamboo_domain::AdmissionGate>,
1341 ) -> Result<SessionMessengerAdmission, SessionMessengerError> {
1342 self.admit_with_delivery_intent(envelope, gate, None).await
1343 }
1344
1345 pub async fn admit_with_activation_intent(
1348 &self,
1349 envelope: SessionMessageEnvelope,
1350 policy: SessionActivationPolicy,
1351 gate: Option<&bamboo_domain::AdmissionGate>,
1352 ) -> Result<SessionMessengerAdmission, SessionMessengerError> {
1353 self.admit_with_delivery_intent(envelope, gate, Some(policy))
1354 .await
1355 }
1356
1357 async fn admit_with_delivery_intent(
1358 &self,
1359 envelope: SessionMessageEnvelope,
1360 gate: Option<&bamboo_domain::AdmissionGate>,
1361 policy: Option<SessionActivationPolicy>,
1362 ) -> Result<SessionMessengerAdmission, SessionMessengerError> {
1363 let started = Instant::now();
1364 if let Err(error) = self.validate_relationship(&envelope).await {
1365 self.record_rejection(&error);
1366 tracing::warn!(
1367 message_id = %envelope.id,
1368 target_session_id = %envelope.target_session_id,
1369 error = %error,
1370 "session message rejected"
1371 );
1372 return Err(error);
1373 }
1374
1375 let delivery = match (policy, gate) {
1376 (Some(policy), gate) => {
1377 self.inbox
1378 .deliver_with_activation_intent(&envelope, policy, gate)
1379 .await
1380 }
1381 (None, Some(gate)) => self.inbox.deliver_with_gate(&envelope, gate).await,
1382 (None, None) => self.inbox.deliver(&envelope).await,
1383 };
1384 let delivery = match delivery {
1385 Ok(receipt) => receipt,
1386 Err(error) => {
1387 let error = SessionMessengerError::Inbox(error);
1388 self.record_rejection(&error);
1389 return Err(error);
1390 }
1391 };
1392 Ok(self.record_admission(envelope, delivery, started))
1393 }
1394
1395 fn record_admission(
1396 &self,
1397 envelope: SessionMessageEnvelope,
1398 delivery: SessionInboxReceipt,
1399 started: Instant,
1400 ) -> SessionMessengerAdmission {
1401 self.metrics.delivered.fetch_add(1, Ordering::Relaxed);
1402 self.metrics.delivery_latency_micros.fetch_add(
1403 started.elapsed().as_micros().min(u128::from(u64::MAX)) as u64,
1404 Ordering::Relaxed,
1405 );
1406 SessionMessengerAdmission {
1407 envelope_id: envelope.id.as_str().to_string(),
1408 target_session_id: envelope.target_session_id,
1409 delivery,
1410 }
1411 }
1412
1413 pub async fn activate(
1414 &self,
1415 admission: &SessionMessengerAdmission,
1416 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
1417 self.activate_with_policy(admission, SessionActivationPolicy::InterruptSpecificWait)
1418 .await
1419 }
1420
1421 pub async fn activate_with_policy(
1422 &self,
1423 admission: &SessionMessengerAdmission,
1424 policy: SessionActivationPolicy,
1425 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
1426 if let Err(error) = self
1427 .inbox
1428 .mark_activation_eligible(
1429 &admission.target_session_id,
1430 admission.delivery.generation,
1431 policy,
1432 )
1433 .await
1434 {
1435 self.metrics
1436 .activation_failed
1437 .fetch_add(1, Ordering::Relaxed);
1438 return Err(SessionMessengerError::ActivationEligibility {
1439 receipt_id: admission.delivery.id.to_string(),
1440 receipt: admission.delivery.clone(),
1441 source: error,
1442 });
1443 }
1444 self.activate_prepared(admission).await
1445 }
1446
1447 pub async fn activate_prepared(
1450 &self,
1451 admission: &SessionMessengerAdmission,
1452 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
1453 let activation = match self
1454 .activation
1455 .request_activation(&admission.target_session_id, admission.delivery.generation)
1456 .await
1457 {
1458 Ok(disposition) => disposition,
1459 Err(source) => {
1460 self.metrics
1461 .activation_failed
1462 .fetch_add(1, Ordering::Relaxed);
1463 tracing::error!(
1464 message_id = %admission.envelope_id,
1465 target_session_id = %admission.target_session_id,
1466 generation = admission.delivery.generation,
1467 error = %source,
1468 "session message durable but activation failed"
1469 );
1470 return Err(SessionMessengerError::Activation {
1471 receipt_id: admission.delivery.id.to_string(),
1472 receipt: admission.delivery.clone(),
1473 source,
1474 });
1475 }
1476 };
1477 match activation {
1478 SessionActivationDisposition::ActiveNotified => {
1479 self.metrics.active_notified.fetch_add(1, Ordering::Relaxed);
1480 }
1481 SessionActivationDisposition::ActivationReserved => {
1482 self.metrics
1483 .activation_reserved
1484 .fetch_add(1, Ordering::Relaxed);
1485 }
1486 SessionActivationDisposition::ActivationCoalesced => {
1487 self.metrics
1488 .activation_coalesced
1489 .fetch_add(1, Ordering::Relaxed);
1490 }
1491 }
1492 tracing::info!(
1493 message_id = %admission.envelope_id,
1494 target_session_id = %admission.target_session_id,
1495 generation = admission.delivery.generation,
1496 ?activation,
1497 "session message durably delivered"
1498 );
1499 Ok(SessionMessengerReceipt {
1500 delivery: admission.delivery.clone(),
1501 activation,
1502 })
1503 }
1504
1505 pub async fn prepare_activation(
1514 &self,
1515 admission: &SessionMessengerAdmission,
1516 ) -> Result<(), SessionMessengerError> {
1517 self.inbox
1518 .mark_activation_eligible(
1519 &admission.target_session_id,
1520 admission.delivery.generation,
1521 SessionActivationPolicy::RespectSpecificWait,
1522 )
1523 .await
1524 .map_err(SessionMessengerError::Inbox)
1525 }
1526
1527 pub async fn prepare_message_activation(
1530 &self,
1531 admission: &SessionMessengerAdmission,
1532 policy: SessionActivationPolicy,
1533 ) -> Result<(), SessionMessengerError> {
1534 self.inbox
1535 .mark_message_activation_eligible(
1536 &admission.target_session_id,
1537 &admission.delivery,
1538 policy,
1539 )
1540 .await
1541 .map_err(SessionMessengerError::Inbox)
1542 }
1543
1544 pub async fn send(
1545 &self,
1546 envelope: SessionMessageEnvelope,
1547 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
1548 self.send_with_gate(envelope, None).await
1549 }
1550
1551 pub async fn send_with_gate(
1552 &self,
1553 envelope: SessionMessageEnvelope,
1554 gate: Option<&bamboo_domain::AdmissionGate>,
1555 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
1556 let admission = self
1557 .admit_with_activation_intent(
1558 envelope,
1559 SessionActivationPolicy::InterruptSpecificWait,
1560 gate,
1561 )
1562 .await?;
1563 self.activate_prepared(&admission).await
1564 }
1565
1566 pub async fn send_with_policy(
1567 &self,
1568 envelope: SessionMessageEnvelope,
1569 policy: SessionActivationPolicy,
1570 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
1571 let admission = self
1572 .admit_with_activation_intent(envelope, policy, None)
1573 .await?;
1574 self.activate_prepared(&admission).await
1575 }
1576}
1577
1578#[cfg(test)]
1579mod tests {
1580 use super::*;
1581 use async_trait::async_trait;
1582 use bamboo_domain::{
1583 SessionActivationDisposition, SessionInboxLimits, SessionMessageBody,
1584 SessionMessageContent, SessionMessageId, SessionMessageKind,
1585 };
1586 use bamboo_storage::{FileSessionInbox, SessionStoreV2};
1587 use tempfile::TempDir;
1588
1589 struct RecordingActivation {
1590 calls: tokio::sync::Mutex<Vec<(String, u64)>>,
1591 }
1592
1593 struct FailingActivation;
1594
1595 #[async_trait]
1596 impl SessionActivationPort for FailingActivation {
1597 async fn request_activation(
1598 &self,
1599 _target_session_id: &str,
1600 _inbox_generation: u64,
1601 ) -> Result<SessionActivationDisposition, SessionActivationError> {
1602 Err(SessionActivationError::Internal(
1603 "injected wakeup failure".into(),
1604 ))
1605 }
1606 }
1607
1608 #[tokio::test]
1609 async fn immediate_activation_failure_preserves_receipt_and_restart_permission() {
1610 let (temp, store, recording, _messenger) = fixture().await;
1611 store
1612 .save_session(&Session::new("root", "model"))
1613 .await
1614 .unwrap();
1615 let inbox = Arc::new(FileSessionInbox::new(
1616 store.clone(),
1617 SessionInboxLimits::default(),
1618 ));
1619 let messenger = SessionMessenger::new(store, inbox, Arc::new(FailingActivation));
1620 let envelope = SessionMessageEnvelope::user_input("root", "steer");
1621 let gate = bamboo_domain::AdmissionGate::default();
1622 let receipt = match messenger
1623 .send_with_gate(envelope.clone(), Some(&gate))
1624 .await
1625 .unwrap_err()
1626 {
1627 SessionMessengerError::Activation { receipt, .. } => receipt,
1628 error => panic!("expected durable delivery with failed wakeup: {error}"),
1629 };
1630 assert!(gate.is_committed());
1631 gate.cancel_if_pending();
1632 let restarted_store = Arc::new(SessionStoreV2::new(temp.path().into()).await.unwrap());
1633 let restarted = Arc::new(FileSessionInbox::new(
1634 restarted_store.clone(),
1635 SessionInboxLimits::default(),
1636 ));
1637 let backlog = restarted.inspect("root").await.unwrap();
1638 assert!(backlog.activation_pending());
1639 assert_eq!(backlog.coordinator_generation, 0);
1640 recording
1643 .request_activation("root", backlog.activation_generation)
1644 .await
1645 .unwrap();
1646 let retry = SessionMessenger::new(restarted_store, restarted.clone(), recording.clone());
1647 assert_eq!(
1648 retry
1649 .send_with_gate(envelope, Some(&gate))
1650 .await
1651 .unwrap()
1652 .delivery,
1653 receipt
1654 );
1655 assert_eq!(restarted.inspect("root").await.unwrap().pending, 1);
1656 assert_eq!(messenger.metrics().snapshot().activation_failed, 1);
1657 let claims = restarted.claim("root", 128).await.unwrap();
1658 assert_eq!(claims.len(), 1);
1659 assert_eq!(claims[0].envelope.id, receipt.id);
1660 }
1661
1662 struct FailEligibilityOnce {
1663 inner: Arc<FileSessionInbox>,
1664 fail_next: std::sync::atomic::AtomicBool,
1665 }
1666
1667 #[async_trait]
1668 impl SessionInboxPort for FailEligibilityOnce {
1669 async fn deliver(
1670 &self,
1671 envelope: &SessionMessageEnvelope,
1672 ) -> Result<SessionInboxReceipt, SessionInboxError> {
1673 self.inner.deliver(envelope).await
1674 }
1675
1676 async fn deliver_with_gate(
1677 &self,
1678 envelope: &SessionMessageEnvelope,
1679 gate: &bamboo_domain::AdmissionGate,
1680 ) -> Result<SessionInboxReceipt, SessionInboxError> {
1681 self.inner.deliver_with_gate(envelope, gate).await
1682 }
1683
1684 async fn mark_activation_eligible(
1685 &self,
1686 target_session_id: &str,
1687 generation: u64,
1688 policy: SessionActivationPolicy,
1689 ) -> Result<(), SessionInboxError> {
1690 if self.fail_next.swap(false, Ordering::SeqCst) {
1691 return Err(SessionInboxError::Storage(
1692 "injected activation watermark failure".into(),
1693 ));
1694 }
1695 self.inner
1696 .mark_activation_eligible(target_session_id, generation, policy)
1697 .await
1698 }
1699
1700 async fn claim(
1701 &self,
1702 target_session_id: &str,
1703 limit: usize,
1704 ) -> Result<Vec<bamboo_domain::SessionInboxClaim>, SessionInboxError> {
1705 self.inner.claim(target_session_id, limit).await
1706 }
1707
1708 async fn was_admitted(
1709 &self,
1710 target_session_id: &str,
1711 id: &SessionMessageId,
1712 ) -> Result<bool, SessionInboxError> {
1713 self.inner.was_admitted(target_session_id, id).await
1714 }
1715
1716 async fn ack(
1717 &self,
1718 target_session_id: &str,
1719 claim: &bamboo_domain::SessionInboxClaim,
1720 ) -> Result<(), SessionInboxError> {
1721 self.inner.ack(target_session_id, claim).await
1722 }
1723
1724 async fn inspect(
1725 &self,
1726 target_session_id: &str,
1727 ) -> Result<bamboo_domain::SessionInboxBacklog, SessionInboxError> {
1728 self.inner.inspect(target_session_id).await
1729 }
1730 }
1731
1732 #[async_trait]
1733 impl SessionActivationPort for RecordingActivation {
1734 async fn request_activation(
1735 &self,
1736 target_session_id: &str,
1737 inbox_generation: u64,
1738 ) -> Result<SessionActivationDisposition, SessionActivationError> {
1739 self.calls
1740 .lock()
1741 .await
1742 .push((target_session_id.to_string(), inbox_generation));
1743 Ok(SessionActivationDisposition::ActivationReserved)
1744 }
1745 }
1746
1747 async fn fixture() -> (
1748 TempDir,
1749 Arc<SessionStoreV2>,
1750 Arc<RecordingActivation>,
1751 SessionMessenger,
1752 ) {
1753 let temp = TempDir::new().unwrap();
1754 let store = Arc::new(
1755 SessionStoreV2::new(temp.path().to_path_buf())
1756 .await
1757 .unwrap(),
1758 );
1759 let activation = Arc::new(RecordingActivation {
1760 calls: tokio::sync::Mutex::new(Vec::new()),
1761 });
1762 let inbox = Arc::new(FileSessionInbox::new(
1763 store.clone(),
1764 SessionInboxLimits::default(),
1765 ));
1766 let messenger = SessionMessenger::new(store.clone(), inbox, activation.clone());
1767 (temp, store, activation, messenger)
1768 }
1769
1770 fn peer(source: &str, target: &str, id: &str) -> SessionMessageEnvelope {
1771 SessionMessageEnvelope {
1772 id: SessionMessageId::parse(id).unwrap(),
1773 source: SessionMessageSource::Session {
1774 session_id: source.to_string(),
1775 },
1776 target_session_id: target.to_string(),
1777 kind: SessionMessageKind::PeerMessage,
1778 body: SessionMessageBody::Content(SessionMessageContent::text("hello")),
1779 created_at: chrono::Utc::now(),
1780 thread_id: None,
1781 in_reply_to: None,
1782 attempt: None,
1783 correlation_id: None,
1784 }
1785 }
1786
1787 #[derive(Default)]
1788 struct PeerMemoryStorage(tokio::sync::RwLock<std::collections::HashMap<String, Session>>);
1789
1790 #[async_trait]
1791 impl Storage for PeerMemoryStorage {
1792 async fn save_session(&self, session: &Session) -> std::io::Result<()> {
1793 self.0
1794 .write()
1795 .await
1796 .insert(session.id.clone(), session.clone());
1797 Ok(())
1798 }
1799
1800 async fn load_session(&self, id: &str) -> std::io::Result<Option<Session>> {
1801 Ok(self.0.read().await.get(id).cloned())
1802 }
1803
1804 async fn delete_session(&self, id: &str) -> std::io::Result<bool> {
1805 Ok(self.0.write().await.remove(id).is_some())
1806 }
1807
1808 async fn list_child_run_statuses(
1809 &self,
1810 parent: &str,
1811 ) -> std::io::Result<Vec<(String, Option<String>)>> {
1812 Ok(self
1813 .0
1814 .read()
1815 .await
1816 .values()
1817 .filter(|session| session.parent_session_id.as_deref() == Some(parent))
1818 .map(|session| (session.id.clone(), session.last_run_status()))
1819 .collect())
1820 }
1821 }
1822
1823 async fn seed_peer_tree(store: &dyn Storage) {
1824 let mut root = Session::new("peer-root", "model");
1825 root.title = "Logical root".into();
1826 root.set_project_id_meta("peer-project");
1827 let left = Session::new_child_of("peer-left", &root, "model", "Left");
1828 let right = Session::new_child_of("peer-right", &root, "model", "Right");
1829 let cousin_left = Session::new_child_of("peer-left-leaf", &left, "model", "Left leaf");
1830 let cousin_right = Session::new_child_of("peer-right-leaf", &right, "model", "Right leaf");
1831 for mut session in [root, left, right, cousin_left, cousin_right] {
1832 session.set_project_id_meta("peer-project");
1833 store.save_session(&session).await.unwrap();
1834 }
1835 }
1836
1837 struct PausedPeerAdmission {
1838 inner: Arc<FileSessionInbox>,
1839 entered: Arc<tokio::sync::Notify>,
1840 release: Arc<tokio::sync::Notify>,
1841 }
1842
1843 #[async_trait]
1844 impl SessionInboxPort for PausedPeerAdmission {
1845 async fn deliver(
1846 &self,
1847 envelope: &SessionMessageEnvelope,
1848 ) -> Result<SessionInboxReceipt, SessionInboxError> {
1849 self.inner.deliver(envelope).await
1850 }
1851
1852 async fn deliver_peer_message(
1853 &self,
1854 envelope: &SessionMessageEnvelope,
1855 source: &ActorSession,
1856 target: &ActorSession,
1857 reply_to: Option<&SessionMessageEnvelope>,
1858 ) -> Result<SessionInboxReceipt, SessionInboxError> {
1859 self.entered.notify_one();
1862 self.release.notified().await;
1863 self.inner
1864 .deliver_peer_message(envelope, source, target, reply_to)
1865 .await
1866 }
1867
1868 async fn mark_activation_eligible(
1869 &self,
1870 target: &str,
1871 generation: u64,
1872 policy: SessionActivationPolicy,
1873 ) -> Result<(), SessionInboxError> {
1874 self.inner
1875 .mark_activation_eligible(target, generation, policy)
1876 .await
1877 }
1878
1879 async fn claim(
1880 &self,
1881 target: &str,
1882 limit: usize,
1883 ) -> Result<Vec<bamboo_domain::SessionInboxClaim>, SessionInboxError> {
1884 self.inner.claim(target, limit).await
1885 }
1886
1887 async fn was_admitted(
1888 &self,
1889 target: &str,
1890 id: &SessionMessageId,
1891 ) -> Result<bool, SessionInboxError> {
1892 self.inner.was_admitted(target, id).await
1893 }
1894
1895 async fn ack(
1896 &self,
1897 target: &str,
1898 claim: &bamboo_domain::SessionInboxClaim,
1899 ) -> Result<(), SessionInboxError> {
1900 self.inner.ack(target, claim).await
1901 }
1902
1903 async fn inspect(
1904 &self,
1905 target: &str,
1906 ) -> Result<bamboo_domain::SessionInboxBacklog, SessionInboxError> {
1907 self.inner.inspect(target).await
1908 }
1909 }
1910
1911 #[tokio::test]
1912 async fn peer_send_and_reply_reject_either_lifetime_recreated_before_admission() {
1913 for reply in [false, true] {
1914 for replaced_id in ["peer-left", "peer-right"] {
1915 let (_temp, store, activation, messenger) = fixture().await;
1916 seed_peer_tree(store.as_ref()).await;
1917 let received_id = if reply {
1918 let incoming = messenger
1919 .send_peer("peer-left", "peer-right", "incoming", "question")
1920 .await
1921 .unwrap();
1922 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right")
1923 .await;
1924 Some(incoming.delivery.id.to_string())
1925 } else {
1926 None
1927 };
1928 activation.calls.lock().await.clear();
1929 let (caller, target) = if reply {
1930 ("peer-right", "peer-left")
1931 } else {
1932 ("peer-left", "peer-right")
1933 };
1934 let entered = Arc::new(tokio::sync::Notify::new());
1935 let release = Arc::new(tokio::sync::Notify::new());
1936 let inbox = Arc::new(FileSessionInbox::new(
1937 store.clone(),
1938 SessionInboxLimits::default(),
1939 ));
1940 let paused = SessionMessenger::new(
1941 store.clone(),
1942 Arc::new(PausedPeerAdmission {
1943 inner: inbox.clone(),
1944 entered: entered.clone(),
1945 release: release.clone(),
1946 }),
1947 activation.clone(),
1948 );
1949 let sending = tokio::spawn(async move {
1950 if let Some(id) = received_id {
1951 paused
1952 .reply_peer(caller, &id, "racing", "old lifetime")
1953 .await
1954 } else {
1955 paused
1956 .send_peer(caller, target, "racing", "old lifetime")
1957 .await
1958 }
1959 });
1960 tokio::time::timeout(std::time::Duration::from_secs(5), entered.notified())
1961 .await
1962 .unwrap();
1963 let old = store.load_session(replaced_id).await.unwrap().unwrap();
1964 assert!(store.delete_session(replaced_id).await.unwrap());
1965 let root = store.load_session("peer-root").await.unwrap().unwrap();
1966 let mut replacement =
1967 Session::new_child_of(replaced_id, &root, "model", "Replacement");
1968 replacement.created_at = old.created_at + chrono::Duration::seconds(1);
1969 replacement.set_project_id_meta("peer-project");
1970 store.save_session(&replacement).await.unwrap();
1971 release.notify_one();
1972 let result = tokio::time::timeout(std::time::Duration::from_secs(5), sending)
1973 .await
1974 .unwrap()
1975 .unwrap();
1976 assert!(result.is_err(), "reply={reply}, replaced={replaced_id}");
1977 let backlog = inbox.inspect(target).await.unwrap();
1978 assert_eq!(backlog.generation, 0);
1979 assert_eq!(backlog.pending, 0);
1980 assert!(!backlog.activation_pending());
1981 assert!(activation.calls.lock().await.is_empty());
1982 let current = messenger
1985 .send_peer(caller, target, "current-lifetime", "current")
1986 .await
1987 .unwrap();
1988 assert_eq!(current.delivery.generation, 1);
1989 assert_eq!(activation.calls.lock().await.len(), 1);
1990 }
1991 }
1992 }
1993
1994 #[tokio::test]
1995 async fn peer_reply_revalidates_received_proof_after_concurrent_clear_or_save() {
1996 for clear in [true, false] {
1997 let (_temp, store, activation, messenger) = fixture().await;
1998 seed_peer_tree(store.as_ref()).await;
1999 let incoming = messenger
2000 .send_peer("peer-left", "peer-right", "incoming", "question")
2001 .await
2002 .unwrap();
2003 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right").await;
2004 activation.calls.lock().await.clear();
2005 let before = store.load_session("peer-right").await.unwrap().unwrap();
2006 let received_id = incoming.delivery.id.to_string();
2007 let entered = Arc::new(tokio::sync::Notify::new());
2008 let release = Arc::new(tokio::sync::Notify::new());
2009 let inbox = Arc::new(FileSessionInbox::new(
2010 store.clone(),
2011 SessionInboxLimits::default(),
2012 ));
2013 let paused = SessionMessenger::new(
2014 store.clone(),
2015 Arc::new(PausedPeerAdmission {
2016 inner: inbox.clone(),
2017 entered: entered.clone(),
2018 release: release.clone(),
2019 }),
2020 activation.clone(),
2021 );
2022 let old_id = received_id.clone();
2023 let replying = tokio::spawn(async move {
2024 paused
2025 .reply_peer("peer-right", &old_id, "racing-proof", "reply")
2026 .await
2027 });
2028 tokio::time::timeout(std::time::Duration::from_secs(5), entered.notified())
2029 .await
2030 .unwrap();
2031 if clear {
2032 assert!(store.clear_session("peer-right").await.unwrap());
2033 } else {
2034 let mut changed = before.clone();
2035 changed.messages.retain(|message| message.id != received_id);
2036 store.save_session(&changed).await.unwrap();
2037 }
2038 let after = store.load_session("peer-right").await.unwrap().unwrap();
2039 assert_eq!(before.created_at, after.created_at);
2040 assert_eq!(before.root_session_id, after.root_session_id);
2041 assert_eq!(before.project_id_meta(), after.project_id_meta());
2042 assert!(!after
2043 .messages
2044 .iter()
2045 .any(|message| message.id == received_id));
2046 release.notify_one();
2047 let result = tokio::time::timeout(std::time::Duration::from_secs(5), replying)
2048 .await
2049 .unwrap()
2050 .unwrap();
2051 assert!(
2052 result.is_err(),
2053 "clear={clear}: removed proof must not admit a reply"
2054 );
2055 let backlog = inbox.inspect("peer-left").await.unwrap();
2056 assert_eq!(backlog.generation, 0);
2057 assert_eq!(backlog.pending, 0);
2058 assert!(!backlog.activation_pending());
2059 assert!(activation.calls.lock().await.is_empty());
2060
2061 let current = messenger
2062 .send_peer("peer-left", "peer-right", "current-question", "current")
2063 .await
2064 .unwrap();
2065 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right").await;
2066 let reply = messenger
2067 .reply_peer(
2068 "peer-right",
2069 current.delivery.id.as_str(),
2070 "racing-proof",
2071 "reply",
2072 )
2073 .await
2074 .unwrap();
2075 let retry = messenger
2076 .reply_peer(
2077 "peer-right",
2078 current.delivery.id.as_str(),
2079 "racing-proof",
2080 "reply",
2081 )
2082 .await
2083 .unwrap();
2084 assert_eq!(reply.delivery.generation, 1);
2085 assert_eq!(retry.delivery.generation, reply.delivery.generation);
2086 assert_eq!(retry.delivery.id, reply.delivery.id);
2087 let backlog = inbox.inspect("peer-left").await.unwrap();
2088 assert_eq!(backlog.generation, 1);
2089 assert_eq!(backlog.pending, 1);
2090 }
2091 }
2092
2093 #[tokio::test]
2094 async fn peer_admission_without_integrated_lifetime_fence_is_unsupported() {
2095 let (_temp, store, activation, _messenger) = fixture().await;
2096 seed_peer_tree(store.as_ref()).await;
2097 let inbox = Arc::new(FileSessionInbox::new(
2098 store.clone(),
2099 SessionInboxLimits::default(),
2100 ));
2101 let messenger = SessionMessenger::new(
2102 store,
2103 Arc::new(FailEligibilityOnce {
2104 inner: inbox.clone(),
2105 fail_next: std::sync::atomic::AtomicBool::new(false),
2106 }),
2107 activation.clone(),
2108 );
2109 let error = messenger
2110 .send_peer("peer-left", "peer-right", "unsupported", "message")
2111 .await
2112 .unwrap_err();
2113 assert!(error
2114 .to_string()
2115 .contains("lifetime-fenced peer SessionInbox admission is unsupported"));
2116 for intent in [
2117 SessionMessageDeliveryIntent::QueueOnly,
2118 SessionMessageDeliveryIntent::TriggerTurn,
2119 ] {
2120 assert!(messenger
2121 .send_peer_with_intent(
2122 "peer-left",
2123 "peer-right",
2124 "unsupported-explicit",
2125 "message",
2126 intent
2127 )
2128 .await
2129 .unwrap_err()
2130 .to_string()
2131 .contains("explicit peer delivery intent is unsupported"));
2132 }
2133 assert_eq!(inbox.inspect("peer-right").await.unwrap().pending, 0);
2134 assert!(activation.calls.lock().await.is_empty());
2135 }
2136
2137 async fn admit_peer_for_test(
2138 store: &dyn Storage,
2139 inbox: &dyn SessionInboxPort,
2140 target: &str,
2141 ) -> SessionMessageEnvelope {
2142 let claims = inbox.claim(target, 16).await.unwrap();
2143 assert_eq!(claims.len(), 1);
2144 let claim = &claims[0];
2145 let mut session = store.load_session(target).await.unwrap().unwrap();
2146 session.add_message(claim.envelope.to_provider_message().unwrap());
2147 store.save_session(&session).await.unwrap();
2148 inbox.ack(target, claim).await.unwrap();
2149 claim.envelope.clone()
2150 }
2151
2152 #[tokio::test]
2153 async fn peer_api_discovers_and_messages_parent_child_siblings_and_cousins() {
2154 let (_temp, store, activation, messenger) = fixture().await;
2155 seed_peer_tree(store.as_ref()).await;
2156 store
2157 .save_session(&Session::new("foreign-root", "model"))
2158 .await
2159 .unwrap();
2160 let targets = messenger.list_peer_targets("peer-left-leaf").await.unwrap();
2161 assert!(!targets.truncated);
2162 let ids: Vec<_> = targets
2163 .targets
2164 .iter()
2165 .map(|target| target.session_id.as_str())
2166 .collect();
2167 assert_eq!(
2168 ids,
2169 ["peer-root", "peer-left", "peer-right", "peer-right-leaf"]
2170 );
2171 assert_eq!(targets.targets[0].title, "Logical root");
2172 assert!(targets.targets[0].parent_session_id.is_none());
2173 assert!(!serde_json::to_string(&targets)
2174 .unwrap()
2175 .contains("foreign-root"));
2176
2177 for (index, (source, target)) in [
2178 ("peer-root", "peer-left"),
2179 ("peer-left", "peer-root"),
2180 ("peer-left", "peer-right"),
2181 ("peer-left-leaf", "peer-right-leaf"),
2182 ]
2183 .into_iter()
2184 .enumerate()
2185 {
2186 let receipt = messenger
2187 .send_peer(
2188 source,
2189 target,
2190 &format!("operation-{index}"),
2191 "bounded peer instruction",
2192 )
2193 .await
2194 .unwrap();
2195 let backlog = messenger.inbox().inspect(target).await.unwrap();
2196 assert_eq!(
2197 backlog.coordinator_generation, 0,
2198 "peer permission must not release a prefix"
2199 );
2200 assert!(backlog.interrupt_pending());
2201 let envelope =
2202 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), target).await;
2203 assert_eq!(envelope.id, receipt.delivery.id);
2204 assert_eq!(
2205 envelope.source,
2206 SessionMessageSource::Session {
2207 session_id: source.into()
2208 }
2209 );
2210 assert_eq!(envelope.thread_id.as_deref(), Some(envelope.id.as_str()));
2211 assert_eq!(
2212 envelope.correlation_id.as_deref(),
2213 Some(format!("operation-{index}").as_str())
2214 );
2215 assert!(envelope.in_reply_to.is_none());
2216 }
2217 assert_eq!(activation.calls.lock().await.len(), 4);
2218 }
2219
2220 #[tokio::test]
2221 async fn peer_api_denies_unknown_deleted_cross_root_and_invalid_project_or_lineage() {
2222 let (_temp, store, activation, messenger) = fixture().await;
2223 seed_peer_tree(store.as_ref()).await;
2224 store
2225 .save_session(&Session::new("foreign-root", "model"))
2226 .await
2227 .unwrap();
2228 for (source, target) in [
2229 ("peer-left", "foreign-root"),
2230 ("unknown", "peer-left"),
2231 ("peer-left", "unknown"),
2232 ("peer-left", "peer-left"),
2233 ] {
2234 assert!(messenger
2235 .send_peer(source, target, "denied-operation", "instruction")
2236 .await
2237 .is_err());
2238 }
2239 store.delete_session("peer-right-leaf").await.unwrap();
2240 assert!(matches!(
2241 messenger
2242 .send_peer("peer-left", "peer-right-leaf", "deleted", "instruction")
2243 .await,
2244 Err(SessionMessengerError::TargetNotFound(_))
2245 ));
2246 assert!(matches!(
2247 messenger
2248 .send_peer(
2249 "peer-right-leaf",
2250 "peer-left",
2251 "deleted-source",
2252 "instruction"
2253 )
2254 .await,
2255 Err(SessionMessengerError::SourceNotFound(_))
2256 ));
2257
2258 let memory = Arc::new(PeerMemoryStorage::default());
2261 seed_peer_tree(memory.as_ref()).await;
2262 let strict = SessionMessenger::new(
2263 memory.clone(),
2264 messenger.inbox().clone(),
2265 activation.clone(),
2266 );
2267 let original = memory.load_session("peer-right").await.unwrap().unwrap();
2268 for defect in [
2269 "project",
2270 "missing_project",
2271 "project_mirror",
2272 "parent",
2273 "depth",
2274 "root",
2275 "cycle",
2276 ] {
2277 let mut invalid = original.clone();
2278 match defect {
2279 "project" => invalid.set_project_id_meta("other-project"),
2280 "missing_project" => {
2281 invalid.metadata.remove("project_id");
2282 if let Some(metadata) = invalid.runtime_metadata.as_mut() {
2283 metadata.project_id = None;
2284 }
2285 }
2286 "project_mirror" => {
2287 invalid
2288 .metadata
2289 .insert("project_id".into(), "other-project".into());
2290 }
2291 "parent" => invalid.parent_session_id = Some("missing-parent".into()),
2292 "depth" => invalid.spawn_depth += 1,
2293 "root" => invalid.root_session_id = "foreign-root".into(),
2294 "cycle" => invalid.parent_session_id = Some("peer-right-leaf".into()),
2295 _ => unreachable!(),
2296 }
2297 memory.save_session(&invalid).await.unwrap();
2298 assert!(
2299 strict
2300 .send_peer("peer-left", "peer-right", "invalid", "instruction")
2301 .await
2302 .is_err(),
2303 "{defect}"
2304 );
2305 let targets = strict.list_peer_targets("peer-left").await.unwrap();
2306 assert!(
2307 !targets
2308 .targets
2309 .iter()
2310 .any(|target| target.session_id == "peer-right"),
2311 "{defect}"
2312 );
2313 memory.save_session(&original).await.unwrap();
2314 }
2315 assert!(activation.calls.lock().await.is_empty());
2316 assert_eq!(
2317 messenger
2318 .inbox()
2319 .inspect("peer-left")
2320 .await
2321 .unwrap()
2322 .generation,
2323 0
2324 );
2325 }
2326
2327 #[tokio::test]
2328 async fn peer_api_discovery_is_bounded_and_sender_is_runtime_selected() {
2329 let (_temp, _store, activation, messenger) = fixture().await;
2330 let memory = Arc::new(PeerMemoryStorage::default());
2331 let root = Session::new("many-peers", "model");
2332 memory.save_session(&root).await.unwrap();
2333 for index in 0..MAX_PEER_TARGETS + 2 {
2334 let child = Session::new_child_of(format!("peer-{index:03}"), &root, "model", "Peer");
2335 memory.save_session(&child).await.unwrap();
2336 }
2337 let bounded = SessionMessenger::new(
2338 memory.clone(),
2339 messenger.inbox().clone(),
2340 activation.clone(),
2341 );
2342 let page = bounded.list_peer_targets(&root.id).await.unwrap();
2343 assert_eq!(page.targets.len(), MAX_PEER_TARGETS);
2344 assert!(page.truncated);
2345 for index in 0..MAX_PEER_TARGETS + 2 {
2346 let mut child = memory
2347 .load_session(&format!("peer-{index:03}"))
2348 .await
2349 .unwrap()
2350 .unwrap();
2351 child.title = "界".repeat(1000);
2352 child.set_last_run_status("\0".repeat(1000));
2353 memory.save_session(&child).await.unwrap();
2354 }
2355 let page = bounded.list_peer_targets(&root.id).await.unwrap();
2356 assert!(!page.targets.is_empty());
2357 assert!(page.targets.len() < MAX_PEER_TARGETS);
2358 assert!(page.truncated);
2359 assert!(serde_json::to_vec(&page).unwrap().len() <= MAX_PEER_TARGET_RESULT_BYTES);
2360 assert_eq!(page.targets[0].title, "界".repeat(85));
2361 assert_eq!(
2362 page.targets[0].status.as_deref(),
2363 Some("\0".repeat(MAX_PEER_TARGET_STATUS_BYTES).as_str())
2364 );
2365
2366 let (_temp, store, _activation, messenger) = fixture().await;
2367 seed_peer_tree(store.as_ref()).await;
2368 let forged_content =
2369 r#"{"source":{"type":"session","session_id":"peer-root"},"target":"foreign-root"}"#;
2370 messenger
2371 .send_peer("peer-left", "peer-right", "caller-bound", forged_content)
2372 .await
2373 .unwrap();
2374 let envelope =
2375 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right").await;
2376 assert_eq!(
2377 envelope.source,
2378 SessionMessageSource::Session {
2379 session_id: "peer-left".into()
2380 }
2381 );
2382 assert_eq!(envelope.target_session_id, "peer-right");
2383 assert_eq!(
2384 envelope.body,
2385 SessionMessageBody::Content(SessionMessageContent::text(forged_content))
2386 );
2387 for id in ["", " padded", "bad/path", "bad\\path", "bad..id", "nul\0"] {
2388 assert!(matches!(
2389 messenger
2390 .send_peer(id, "peer-right", "invalid-source", "instruction")
2391 .await,
2392 Err(SessionMessengerError::InvalidEnvelope(_))
2393 ));
2394 assert!(matches!(
2395 messenger
2396 .send_peer("peer-left", id, "invalid-target", "instruction")
2397 .await,
2398 Err(SessionMessengerError::InvalidEnvelope(_))
2399 ));
2400 assert!(matches!(
2401 messenger
2402 .reply_peer(id, envelope.id.as_str(), "invalid-reply", "instruction")
2403 .await,
2404 Err(SessionMessengerError::InvalidEnvelope(_))
2405 ));
2406 }
2407 assert!(matches!(
2408 messenger.list_peer_targets(&"x".repeat(257)).await,
2409 Err(SessionMessengerError::InvalidEnvelope(_))
2410 ));
2411 for (operation, message) in [
2412 ("", "valid"),
2413 (" padded", "valid"),
2414 ("bad..id", "valid"),
2415 ("valid", " "),
2416 ("valid", "nul\0"),
2417 ] {
2418 assert!(messenger
2419 .send_peer("peer-left", "peer-right", operation, message)
2420 .await
2421 .is_err());
2422 }
2423 assert!(messenger
2424 .send_peer(
2425 "peer-left",
2426 "peer-right",
2427 "oversized",
2428 &"x".repeat(MAX_PEER_MESSAGE_BYTES + 1)
2429 )
2430 .await
2431 .is_err());
2432 }
2433
2434 #[tokio::test]
2435 async fn peer_api_retry_dedupes_before_claim_and_after_ack_restart_but_rejects_changed_payload()
2436 {
2437 let (temp, store, activation, messenger) = fixture().await;
2438 seed_peer_tree(store.as_ref()).await;
2439 let first = messenger
2440 .send_peer("peer-left", "peer-right", "stable-operation", "original")
2441 .await
2442 .unwrap();
2443 let retry = messenger
2444 .send_peer("peer-left", "peer-right", "stable-operation", "original")
2445 .await
2446 .unwrap();
2447 assert_eq!(retry.delivery, first.delivery);
2448 assert!(matches!(
2449 messenger
2450 .send_peer("peer-left", "peer-right", "stable-operation", "changed")
2451 .await,
2452 Err(SessionMessengerError::Inbox(
2453 SessionInboxError::InvalidClaim(_)
2454 ))
2455 ));
2456 assert_eq!(
2457 messenger
2458 .inbox()
2459 .inspect("peer-right")
2460 .await
2461 .unwrap()
2462 .generation,
2463 1
2464 );
2465 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right").await;
2466 drop(messenger);
2467 drop(store);
2468 let reopened = Arc::new(SessionStoreV2::new(temp.path().into()).await.unwrap());
2469 let inbox = Arc::new(FileSessionInbox::new(
2470 reopened.clone(),
2471 SessionInboxLimits::default(),
2472 ));
2473 let messenger = SessionMessenger::new(reopened, inbox.clone(), activation);
2474 let after_ack = messenger
2475 .send_peer("peer-left", "peer-right", "stable-operation", "original")
2476 .await
2477 .unwrap();
2478 assert_eq!(after_ack.delivery, first.delivery);
2479 assert!(inbox.claim("peer-right", 16).await.unwrap().is_empty());
2480 assert!(matches!(
2481 messenger
2482 .send_peer("peer-left", "peer-right", "stable-operation", "changed")
2483 .await,
2484 Err(SessionMessengerError::Inbox(
2485 SessionInboxError::InvalidClaim(_)
2486 ))
2487 ));
2488 assert_eq!(inbox.inspect("peer-right").await.unwrap().generation, 1);
2489 }
2490
2491 #[tokio::test]
2492 async fn peer_api_reply_recovers_durable_recipient_and_thread_after_restart() {
2493 let (temp, store, activation, messenger) = fixture().await;
2494 seed_peer_tree(store.as_ref()).await;
2495 let first = messenger
2496 .send_peer("peer-left-leaf", "peer-right-leaf", "initial", "question")
2497 .await
2498 .unwrap();
2499 let incoming = admit_peer_for_test(
2500 store.as_ref(),
2501 messenger.inbox().as_ref(),
2502 "peer-right-leaf",
2503 )
2504 .await;
2505 drop(messenger);
2506 drop(store);
2507 let store = Arc::new(SessionStoreV2::new(temp.path().into()).await.unwrap());
2508 let inbox = Arc::new(FileSessionInbox::new(
2509 store.clone(),
2510 SessionInboxLimits::default(),
2511 ));
2512 let messenger = SessionMessenger::new(store.clone(), inbox.clone(), activation);
2513 let reply = messenger
2514 .reply_peer(
2515 "peer-right-leaf",
2516 first.delivery.id.as_str(),
2517 "answer",
2518 "answer text",
2519 )
2520 .await
2521 .unwrap();
2522 let retry = messenger
2523 .reply_peer(
2524 "peer-right-leaf",
2525 first.delivery.id.as_str(),
2526 "answer",
2527 "answer text",
2528 )
2529 .await
2530 .unwrap();
2531 assert_eq!(reply.delivery, retry.delivery);
2532 let response = admit_peer_for_test(store.as_ref(), inbox.as_ref(), "peer-left-leaf").await;
2533 assert_eq!(
2534 response.source,
2535 SessionMessageSource::Session {
2536 session_id: "peer-right-leaf".into()
2537 }
2538 );
2539 assert_eq!(response.in_reply_to, Some(incoming.id.clone()));
2540 assert_eq!(response.thread_id, incoming.thread_id);
2541 assert_eq!(response.correlation_id.as_deref(), Some("answer"));
2542 let second_reply = messenger
2543 .reply_peer("peer-left-leaf", response.id.as_str(), "thanks", "thanks")
2544 .await
2545 .unwrap();
2546 let second_response =
2547 admit_peer_for_test(store.as_ref(), inbox.as_ref(), "peer-right-leaf").await;
2548 assert_eq!(second_reply.delivery.id, second_response.id);
2549 assert_eq!(second_response.in_reply_to, Some(response.id));
2550 assert_eq!(
2551 second_response.thread_id.as_deref(),
2552 Some(first.delivery.id.as_str())
2553 );
2554 let after_ack = messenger
2555 .reply_peer(
2556 "peer-right-leaf",
2557 first.delivery.id.as_str(),
2558 "answer",
2559 "answer text",
2560 )
2561 .await
2562 .unwrap();
2563 assert_eq!(after_ack.delivery, reply.delivery);
2564 assert!(inbox.claim("peer-left-leaf", 16).await.unwrap().is_empty());
2565 assert!(messenger
2566 .reply_peer(
2567 "peer-right-leaf",
2568 first.delivery.id.as_str(),
2569 "answer",
2570 "different"
2571 )
2572 .await
2573 .is_err());
2574 }
2575
2576 #[tokio::test]
2577 async fn peer_api_reply_rejects_missing_forged_legacy_and_recreated_source() {
2578 let (_temp, store, activation, messenger) = fixture().await;
2579 seed_peer_tree(store.as_ref()).await;
2580 let mut receiver = store.load_session("peer-right").await.unwrap().unwrap();
2581 let legacy = peer("peer-left", "peer-right", "legacy-correction");
2582 receiver.add_message(legacy.to_provider_message().unwrap());
2583 let user = SessionMessageEnvelope::user_input("peer-right", "ordinary input");
2584 receiver.add_message(user.to_provider_message().unwrap());
2585 let sender = store.load_session("peer-left").await.unwrap().unwrap();
2586 let mut forged = SessionMessenger::peer_envelope(
2587 &sender,
2588 &receiver,
2589 "forged",
2590 "original",
2591 SessionMessenger::peer_message_id(&sender, &receiver, "forged"),
2592 "thread".into(),
2593 None,
2594 )
2595 .to_provider_message()
2596 .unwrap();
2597 forged.content = "changed without canonical proof".into();
2598 receiver.add_message(forged);
2599 store.save_session(&receiver).await.unwrap();
2600 for id in [
2601 "missing",
2602 legacy.id.as_str(),
2603 user.id.as_str(),
2604 receiver.messages.last().unwrap().id.as_str(),
2605 ] {
2606 assert!(messenger
2607 .reply_peer("peer-right", id, "deny-reply", "reply")
2608 .await
2609 .is_err());
2610 }
2611 let incoming = messenger
2612 .send_peer("peer-left", "peer-right", "old-lifetime", "question")
2613 .await
2614 .unwrap();
2615 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right").await;
2616 let memory = Arc::new(PeerMemoryStorage::default());
2617 for id in ["peer-root", "peer-left", "peer-right"] {
2619 let mut session = store.load_session(id).await.unwrap().unwrap();
2620 if id == "peer-left" {
2621 session.created_at += chrono::Duration::seconds(1);
2622 }
2623 memory.save_session(&session).await.unwrap();
2624 }
2625 let fenced = SessionMessenger::new(
2626 memory.clone(),
2627 messenger.inbox().clone(),
2628 activation.clone(),
2629 );
2630 assert!(matches!(
2631 fenced
2632 .reply_peer(
2633 "peer-right",
2634 incoming.delivery.id.as_str(),
2635 "reply-old",
2636 "reply"
2637 )
2638 .await,
2639 Err(SessionMessengerError::InvalidEnvelope(_))
2640 ));
2641 memory.delete_session("peer-left").await.unwrap();
2642 assert!(matches!(
2643 fenced
2644 .reply_peer(
2645 "peer-right",
2646 incoming.delivery.id.as_str(),
2647 "reply-deleted",
2648 "reply"
2649 )
2650 .await,
2651 Err(SessionMessengerError::TargetNotFound(_))
2652 ));
2653 assert_eq!(activation.calls.lock().await.len(), 1);
2654 }
2655
2656 #[tokio::test]
2657 async fn same_root_delivery_enqueues_then_requests_activation() {
2658 let (_temp, store, activation, messenger) = fixture().await;
2659 let root = Session::new("root", "model");
2660 let mut child = Session::new("child", "model");
2661 child.kind = bamboo_domain::SessionKind::Child;
2662 child.parent_session_id = Some("root".to_string());
2663 child.root_session_id = "root".to_string();
2664 store.save_session(&root).await.unwrap();
2665 store.save_session(&child).await.unwrap();
2666
2667 let receipt = messenger
2668 .send(peer("root", "child", "msg-1"))
2669 .await
2670 .unwrap();
2671 assert_eq!(receipt.delivery.generation, 1);
2672 assert_eq!(
2673 activation.calls.lock().await.as_slice(),
2674 &[("child".to_string(), 1)]
2675 );
2676 assert_eq!(messenger.metrics().snapshot().delivered, 1);
2677 }
2678
2679 #[tokio::test]
2680 async fn staged_delivery_without_watermark_reports_exact_retry_required() {
2681 let temp = TempDir::new().unwrap();
2682 let store = Arc::new(
2683 SessionStoreV2::new(temp.path().to_path_buf())
2684 .await
2685 .unwrap(),
2686 );
2687 let root = Session::new("root", "model");
2688 let mut child = Session::new("child", "model");
2689 child.kind = bamboo_domain::SessionKind::Child;
2690 child.parent_session_id = Some("root".to_string());
2691 child.root_session_id = "root".to_string();
2692 store.save_session(&root).await.unwrap();
2693 store.save_session(&child).await.unwrap();
2694 let inbox = Arc::new(FailEligibilityOnce {
2695 inner: Arc::new(FileSessionInbox::new(
2696 store.clone(),
2697 SessionInboxLimits::default(),
2698 )),
2699 fail_next: std::sync::atomic::AtomicBool::new(true),
2700 });
2701 let activation = Arc::new(RecordingActivation {
2702 calls: tokio::sync::Mutex::new(Vec::new()),
2703 });
2704 let messenger = SessionMessenger::new(store.clone(), inbox, activation.clone());
2705 let envelope = peer("root", "child", "watermark-failure");
2706 let gate = bamboo_domain::AdmissionGate::default();
2707
2708 let admission = messenger
2709 .admit_with_gate(envelope.clone(), Some(&gate))
2710 .await
2711 .unwrap();
2712 let receipt = match messenger.activate(&admission).await.unwrap_err() {
2713 SessionMessengerError::ActivationEligibility { receipt, .. } => receipt,
2714 error => panic!("expected retry-required classification: {error}"),
2715 };
2716 assert!(gate.is_committed());
2717 assert!(activation.calls.lock().await.is_empty());
2718 let reopened = FileSessionInbox::new(store, SessionInboxLimits::default());
2719 let backlog = reopened.inspect("child").await.unwrap();
2720 assert_eq!(backlog.pending + backlog.claimed, 1);
2721 assert_eq!(backlog.activation_generation, 0);
2722 assert_eq!(backlog.generation, receipt.generation);
2723
2724 let retry = messenger
2725 .admit_with_gate(envelope, Some(&gate))
2726 .await
2727 .unwrap();
2728 let retried = messenger.activate(&retry).await.unwrap();
2729 assert_eq!(retried.delivery, receipt);
2730 assert_eq!(activation.calls.lock().await.len(), 1);
2731 let reopened = reopened.inspect("child").await.unwrap();
2732 assert_eq!(reopened.activation_generation, receipt.generation);
2733 assert_eq!(reopened.pending + reopened.claimed, 1);
2734 }
2735
2736 #[tokio::test]
2737 async fn cross_root_peer_is_rejected_before_enqueue() {
2738 let (_temp, store, activation, messenger) = fixture().await;
2739 store
2740 .save_session(&Session::new("root-a", "model"))
2741 .await
2742 .unwrap();
2743 store
2744 .save_session(&Session::new("root-b", "model"))
2745 .await
2746 .unwrap();
2747
2748 let error = messenger
2749 .send(peer("root-a", "root-b", "msg-2"))
2750 .await
2751 .unwrap_err();
2752 assert!(matches!(error, SessionMessengerError::Unauthorized { .. }));
2753 assert!(activation.calls.lock().await.is_empty());
2754 let metrics = messenger.metrics().snapshot();
2755 assert_eq!(metrics.rejected, 1);
2756 assert_eq!(metrics.unauthorized, 1);
2757 assert_eq!(metrics.payload_too_large, 0);
2758 assert_eq!(metrics.backlog_full, 0);
2759 }
2760
2761 #[tokio::test]
2762 async fn same_root_different_project_peer_is_rejected_before_enqueue() {
2763 let (_temp, store, activation, messenger) = fixture().await;
2764 let mut source = Session::new("project-root", "model");
2765 source.set_project_id_meta("project-a");
2766 let mut target = Session::new("project-child", "model");
2767 target.kind = bamboo_domain::SessionKind::Child;
2768 target.parent_session_id = Some(source.id.clone());
2769 target.root_session_id = source.id.clone();
2770 target.set_project_id_meta("project-b");
2771 store.save_session(&source).await.unwrap();
2772 store.save_session(&target).await.unwrap();
2773
2774 let error = messenger
2775 .send(peer(&source.id, &target.id, "different-project"))
2776 .await
2777 .unwrap_err();
2778 assert!(matches!(error, SessionMessengerError::Unauthorized { .. }));
2779 assert!(activation.calls.lock().await.is_empty());
2780 let metrics = messenger.metrics().snapshot();
2781 assert_eq!(metrics.delivered, 0);
2782 assert_eq!(metrics.rejected, 1);
2783 assert_eq!(metrics.unauthorized, 1);
2784 }
2785
2786 #[tokio::test]
2787 async fn limit_rejections_have_distinct_metrics() {
2788 let temp = TempDir::new().unwrap();
2789 let store = Arc::new(
2790 SessionStoreV2::new(temp.path().to_path_buf())
2791 .await
2792 .unwrap(),
2793 );
2794 store
2795 .save_session(&Session::new("target", "model"))
2796 .await
2797 .unwrap();
2798 let activation = Arc::new(RecordingActivation {
2799 calls: tokio::sync::Mutex::new(Vec::new()),
2800 });
2801 let inbox = Arc::new(FileSessionInbox::new(
2802 store.clone(),
2803 SessionInboxLimits {
2804 max_payload_bytes: 512,
2805 max_backlog: 1,
2806 max_claim_batch: 1,
2807 },
2808 ));
2809 let messenger = SessionMessenger::new(store, inbox, activation.clone());
2810
2811 let oversized = SessionMessageEnvelope::user_input("target", "x".repeat(2048));
2812 assert!(matches!(
2813 messenger.send(oversized).await,
2814 Err(SessionMessengerError::Inbox(
2815 SessionInboxError::PayloadTooLarge { .. }
2816 ))
2817 ));
2818 messenger
2819 .send(SessionMessageEnvelope::user_input("target", "first"))
2820 .await
2821 .unwrap();
2822 assert!(matches!(
2823 messenger
2824 .send(SessionMessageEnvelope::user_input("target", "second"))
2825 .await,
2826 Err(SessionMessengerError::Inbox(
2827 SessionInboxError::BacklogFull { .. }
2828 ))
2829 ));
2830
2831 let metrics = messenger.metrics().snapshot();
2832 assert_eq!(metrics.rejected, 2);
2833 assert_eq!(metrics.payload_too_large, 1);
2834 assert_eq!(metrics.backlog_full, 1);
2835 assert_eq!(activation.calls.lock().await.len(), 1);
2836 }
2837
2838 #[tokio::test]
2839 async fn explicit_peer_intent_keeps_legacy_compatibility_and_fences_changed_retry_after_ack() {
2840 let (_temp, store, activation, messenger) = fixture().await;
2841 seed_peer_tree(store.as_ref()).await;
2842 let receipt = messenger
2843 .send_peer_with_intent(
2844 "peer-left",
2845 "peer-right",
2846 "queue-operation",
2847 "input",
2848 SessionMessageDeliveryIntent::QueueOnly,
2849 )
2850 .await
2851 .unwrap();
2852 assert_eq!(receipt.activation, None);
2853 assert!(!receipt.active_delivery_notified);
2854 assert!(activation.calls.lock().await.is_empty());
2855 let original =
2856 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right").await;
2857 assert_eq!(
2858 messenger
2859 .send_peer_with_intent(
2860 "peer-left",
2861 "peer-right",
2862 "queue-operation",
2863 "input",
2864 SessionMessageDeliveryIntent::QueueOnly
2865 )
2866 .await
2867 .unwrap()
2868 .delivery,
2869 receipt.delivery
2870 );
2871 assert!(messenger
2872 .send_peer_with_intent(
2873 "peer-left",
2874 "peer-right",
2875 "queue-operation",
2876 "input",
2877 SessionMessageDeliveryIntent::TriggerTurn
2878 )
2879 .await
2880 .is_err());
2881 assert!(activation.calls.lock().await.is_empty());
2882 let reply = messenger
2883 .reply_peer_with_intent(
2884 "peer-right",
2885 original.id.as_str(),
2886 "reply-operation",
2887 "reply",
2888 SessionMessageDeliveryIntent::TriggerTurn,
2889 )
2890 .await
2891 .unwrap();
2892 assert_eq!(
2895 reply.activation,
2896 Some(SessionActivationDisposition::ActivationReserved)
2897 );
2898 assert_eq!(
2899 activation.calls.lock().await.as_slice(),
2900 &[("peer-left".to_string(), reply.delivery.generation)]
2901 );
2902 let claims = messenger.inbox().claim("peer-left", 8).await.unwrap();
2903 assert_eq!(claims[0].envelope.in_reply_to, Some(original.id));
2904 assert_eq!(
2905 claims[0].activation_policy,
2906 SessionActivationPolicy::RespectSpecificWait
2907 );
2908 messenger
2909 .send_peer("peer-left", "peer-right", "legacy-operation", "legacy")
2910 .await
2911 .unwrap();
2912 let claims = messenger.inbox().claim("peer-right", 8).await.unwrap();
2913 assert_eq!(
2914 claims[0].activation_policy,
2915 SessionActivationPolicy::InterruptSpecificWait
2916 );
2917 }
2918
2919 #[tokio::test]
2920 async fn explicit_peer_intents_deny_foreign_roots_before_admission_or_wake() {
2921 let (_temp, store, activation, messenger) = fixture().await;
2922 seed_peer_tree(store.as_ref()).await;
2923 store
2924 .save_session(&Session::new("foreign-root", "model"))
2925 .await
2926 .unwrap();
2927 for intent in [
2928 SessionMessageDeliveryIntent::QueueOnly,
2929 SessionMessageDeliveryIntent::TriggerTurn,
2930 ] {
2931 assert!(matches!(
2932 messenger
2933 .send_peer_with_intent("peer-left", "foreign-root", "denied", "input", intent)
2934 .await,
2935 Err(SessionMessengerError::Unauthorized { .. })
2936 ));
2937 }
2938 assert_eq!(
2939 messenger
2940 .inbox()
2941 .inspect("foreign-root")
2942 .await
2943 .unwrap()
2944 .pending,
2945 0
2946 );
2947 assert!(activation.calls.lock().await.is_empty());
2948 }
2949}