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_current_user_followup(
1148 &self,
1149 caller: &SessionCallerReference,
1150 supervisor: &SupervisorReference,
1151 target: &bamboo_domain::SupervisorCurrentUserTarget,
1152 operation_id: &str,
1153 message: &str,
1154 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
1155 Self::validate_peer_input(operation_id, message)?;
1156 let envelope = SessionMessageEnvelope {
1157 id: bamboo_domain::supervisor_current_user_message_id(supervisor, target, operation_id),
1158 source: SessionMessageSource::Session {
1159 session_id: caller.session_id.clone(),
1160 },
1161 target_session_id: target.session_id.clone(),
1162 kind: SessionMessageKind::PeerMessage,
1163 body: SessionMessageBody::Content(SessionMessageContent::text(message)),
1164 created_at: chrono::Utc::now(),
1165 thread_id: None,
1166 in_reply_to: None,
1167 attempt: None,
1168 correlation_id: Some(operation_id.to_string()),
1169 };
1170 let started = Instant::now();
1171 let delivery = self
1172 .inbox
1173 .deliver_supervisor_current_user_followup(caller, supervisor, target, &envelope)
1174 .await
1175 .map_err(SessionMessengerError::Inbox)?;
1176 let admission = self.record_admission(envelope, delivery, started);
1177 self.activate_prepared(&admission).await
1178 }
1179
1180 pub async fn supervisor_followup(
1184 &self,
1185 supervisor: &SupervisorReference,
1186 target: &Session,
1187 operation_id: &str,
1188 message: &str,
1189 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
1190 self.supervisor_followup_delivery(
1191 supervisor,
1192 &target.id,
1193 target.created_at,
1194 operation_id,
1195 message,
1196 None,
1197 )
1198 .await
1199 }
1200
1201 pub async fn supervisor_followup_observed(
1204 &self,
1205 caller: &SessionCallerReference,
1206 supervisor: &SupervisorReference,
1207 target: &Session,
1208 operation_id: &str,
1209 message: &str,
1210 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
1211 self.supervisor_followup_delivery(
1212 supervisor,
1213 &target.id,
1214 target.created_at,
1215 operation_id,
1216 message,
1217 Some(caller),
1218 )
1219 .await
1220 }
1221
1222 async fn supervisor_followup_delivery(
1223 &self,
1224 supervisor: &SupervisorReference,
1225 target_session_id: &str,
1226 target_created_at: chrono::DateTime<chrono::Utc>,
1227 operation_id: &str,
1228 message: &str,
1229 original_caller: Option<&SessionCallerReference>,
1230 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
1231 bamboo_domain::validate_supervisor_target_id(target_session_id)
1232 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
1233 SessionMessageId::parse(operation_id)
1234 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
1235 if message.trim().is_empty() || message.len() > MAX_SUPERVISOR_FOLLOWUP_BYTES {
1236 return Err(SessionMessengerError::InvalidEnvelope(format!(
1237 "followup message must contain 1..={MAX_SUPERVISOR_FOLLOWUP_BYTES} UTF-8 bytes"
1238 )));
1239 }
1240 let envelope = SessionMessageEnvelope {
1241 id: bamboo_domain::supervisor_followup_message_id(
1242 supervisor,
1243 target_session_id,
1244 operation_id,
1245 ),
1246 source: SessionMessageSource::Session {
1247 session_id: supervisor.session_id.clone(),
1248 },
1249 target_session_id: target_session_id.to_string(),
1250 kind: SessionMessageKind::PeerMessage,
1251 body: SessionMessageBody::Content(SessionMessageContent::text(message)),
1252 created_at: chrono::Utc::now(),
1253 thread_id: None,
1254 in_reply_to: None,
1255 attempt: None,
1256 correlation_id: Some(operation_id.to_string()),
1257 };
1258 let started = Instant::now();
1259 let result = match original_caller {
1260 Some(caller) => {
1261 self.inbox
1262 .deliver_unscoped_supervisor_followup(
1263 caller,
1264 supervisor,
1265 target_created_at,
1266 &envelope,
1267 )
1268 .await
1269 }
1270 None => {
1271 self.inbox
1272 .deliver_supervisor_followup(supervisor, target_created_at, &envelope)
1273 .await
1274 }
1275 };
1276 let delivery = result.map_err(|error| {
1277 let error = SessionMessengerError::Inbox(error);
1278 self.record_rejection(&error);
1279 error
1280 })?;
1281 let admission = self.record_admission(envelope, delivery, started);
1282 self.activate_prepared(&admission).await
1285 }
1286
1287 async fn load_session(&self, id: &str) -> Result<Option<Session>, SessionMessengerError> {
1288 self.sessions
1289 .load_session(id)
1290 .await
1291 .map_err(|error| SessionMessengerError::Storage(error.to_string()))
1292 }
1293
1294 fn logical_root(session: &Session) -> &str {
1295 if session.root_session_id.trim().is_empty() {
1296 &session.id
1297 } else {
1298 &session.root_session_id
1299 }
1300 }
1301
1302 async fn validate_relationship(
1303 &self,
1304 envelope: &SessionMessageEnvelope,
1305 ) -> Result<(), SessionMessengerError> {
1306 envelope
1307 .validate()
1308 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
1309 let target = self
1310 .load_session(&envelope.target_session_id)
1311 .await?
1312 .ok_or_else(|| {
1313 SessionMessengerError::TargetNotFound(envelope.target_session_id.clone())
1314 })?;
1315
1316 let SessionMessageSource::Session { session_id } = &envelope.source else {
1317 return Ok(());
1318 };
1319 let source = self
1320 .load_session(session_id)
1321 .await?
1322 .ok_or_else(|| SessionMessengerError::SourceNotFound(session_id.clone()))?;
1323
1324 let same_root = Self::logical_root(&source) == Self::logical_root(&target);
1325 let source_project = source.project_id_meta();
1326 let target_project = target.project_id_meta();
1327 let project_compatible = match (source_project.as_deref(), target_project.as_deref()) {
1328 (Some(left), Some(right)) => left == right,
1329 _ => true,
1332 };
1333 if !same_root || !project_compatible {
1334 return Err(SessionMessengerError::Unauthorized {
1335 source_session_id: source.id,
1336 target: target.id,
1337 });
1338 }
1339 Ok(())
1340 }
1341
1342 fn record_rejection(&self, error: &SessionMessengerError) {
1343 self.metrics.rejected.fetch_add(1, Ordering::Relaxed);
1344 match error {
1345 SessionMessengerError::InvalidEnvelope(_) => {
1346 self.metrics
1347 .invalid_envelope
1348 .fetch_add(1, Ordering::Relaxed);
1349 }
1350 SessionMessengerError::Unauthorized { .. }
1351 | SessionMessengerError::SourceNotFound(_)
1352 | SessionMessengerError::TargetNotFound(_) => {
1353 self.metrics.unauthorized.fetch_add(1, Ordering::Relaxed);
1354 }
1355 SessionMessengerError::Inbox(SessionInboxError::AdmissionCancelled)
1356 | SessionMessengerError::Inbox(SessionInboxError::ExpectedRunConflict(_)) => {}
1357 SessionMessengerError::Inbox(SessionInboxError::PayloadTooLarge { .. }) => {
1358 self.metrics
1359 .payload_too_large
1360 .fetch_add(1, Ordering::Relaxed);
1361 }
1362 SessionMessengerError::Inbox(SessionInboxError::BacklogFull { .. }) => {
1363 self.metrics.backlog_full.fetch_add(1, Ordering::Relaxed);
1364 }
1365 SessionMessengerError::Inbox(_) | SessionMessengerError::Storage(_) => {
1366 self.metrics.storage_failed.fetch_add(1, Ordering::Relaxed);
1367 }
1368 SessionMessengerError::Activation { .. }
1369 | SessionMessengerError::ActivationEligibility { .. } => {}
1370 }
1371 }
1372
1373 pub async fn admit(
1374 &self,
1375 envelope: SessionMessageEnvelope,
1376 ) -> Result<SessionMessengerAdmission, SessionMessengerError> {
1377 self.admit_with_gate(envelope, None).await
1378 }
1379
1380 pub(crate) fn supports_runtime_target_lifetime(&self) -> bool {
1381 self.inbox.supports_runtime_target_lifetime()
1382 }
1383
1384 pub(crate) async fn admit_runtime_for_target(
1385 &self,
1386 envelope: SessionMessageEnvelope,
1387 target_created_at: chrono::DateTime<chrono::Utc>,
1388 ) -> Result<SessionMessengerAdmission, SessionMessengerError> {
1389 let started = Instant::now();
1390 let result = async {
1391 self.validate_relationship(&envelope).await?;
1392 self.inbox
1393 .deliver_runtime_for_target(&envelope, target_created_at)
1394 .await
1395 .map_err(SessionMessengerError::Inbox)
1396 }
1397 .await;
1398 match result {
1399 Ok(receipt) => Ok(self.record_admission(envelope, receipt, started)),
1400 Err(error) => {
1401 self.record_rejection(&error);
1402 Err(error)
1403 }
1404 }
1405 }
1406
1407 pub async fn admit_with_gate(
1408 &self,
1409 envelope: SessionMessageEnvelope,
1410 gate: Option<&bamboo_domain::AdmissionGate>,
1411 ) -> Result<SessionMessengerAdmission, SessionMessengerError> {
1412 self.admit_with_delivery_intent(envelope, gate, None).await
1413 }
1414
1415 pub async fn admit_with_activation_intent(
1418 &self,
1419 envelope: SessionMessageEnvelope,
1420 policy: SessionActivationPolicy,
1421 gate: Option<&bamboo_domain::AdmissionGate>,
1422 ) -> Result<SessionMessengerAdmission, SessionMessengerError> {
1423 self.admit_with_delivery_intent(envelope, gate, Some(policy))
1424 .await
1425 }
1426
1427 async fn admit_with_delivery_intent(
1428 &self,
1429 envelope: SessionMessageEnvelope,
1430 gate: Option<&bamboo_domain::AdmissionGate>,
1431 policy: Option<SessionActivationPolicy>,
1432 ) -> Result<SessionMessengerAdmission, SessionMessengerError> {
1433 let started = Instant::now();
1434 if let Err(error) = self.validate_relationship(&envelope).await {
1435 self.record_rejection(&error);
1436 tracing::warn!(
1437 message_id = %envelope.id,
1438 target_session_id = %envelope.target_session_id,
1439 error = %error,
1440 "session message rejected"
1441 );
1442 return Err(error);
1443 }
1444
1445 let delivery = match (policy, gate) {
1446 (Some(policy), gate) => {
1447 self.inbox
1448 .deliver_with_activation_intent(&envelope, policy, gate)
1449 .await
1450 }
1451 (None, Some(gate)) => self.inbox.deliver_with_gate(&envelope, gate).await,
1452 (None, None) => self.inbox.deliver(&envelope).await,
1453 };
1454 let delivery = match delivery {
1455 Ok(receipt) => receipt,
1456 Err(error) => {
1457 let error = SessionMessengerError::Inbox(error);
1458 self.record_rejection(&error);
1459 return Err(error);
1460 }
1461 };
1462 Ok(self.record_admission(envelope, delivery, started))
1463 }
1464
1465 fn record_admission(
1466 &self,
1467 envelope: SessionMessageEnvelope,
1468 delivery: SessionInboxReceipt,
1469 started: Instant,
1470 ) -> SessionMessengerAdmission {
1471 self.metrics.delivered.fetch_add(1, Ordering::Relaxed);
1472 self.metrics.delivery_latency_micros.fetch_add(
1473 started.elapsed().as_micros().min(u128::from(u64::MAX)) as u64,
1474 Ordering::Relaxed,
1475 );
1476 SessionMessengerAdmission {
1477 envelope_id: envelope.id.as_str().to_string(),
1478 target_session_id: envelope.target_session_id,
1479 delivery,
1480 }
1481 }
1482
1483 pub async fn activate(
1484 &self,
1485 admission: &SessionMessengerAdmission,
1486 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
1487 self.activate_with_policy(admission, SessionActivationPolicy::InterruptSpecificWait)
1488 .await
1489 }
1490
1491 pub async fn activate_with_policy(
1492 &self,
1493 admission: &SessionMessengerAdmission,
1494 policy: SessionActivationPolicy,
1495 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
1496 if let Err(error) = self
1497 .inbox
1498 .mark_activation_eligible(
1499 &admission.target_session_id,
1500 admission.delivery.generation,
1501 policy,
1502 )
1503 .await
1504 {
1505 self.metrics
1506 .activation_failed
1507 .fetch_add(1, Ordering::Relaxed);
1508 return Err(SessionMessengerError::ActivationEligibility {
1509 receipt_id: admission.delivery.id.to_string(),
1510 receipt: admission.delivery.clone(),
1511 source: error,
1512 });
1513 }
1514 self.activate_prepared(admission).await
1515 }
1516
1517 pub async fn activate_prepared(
1520 &self,
1521 admission: &SessionMessengerAdmission,
1522 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
1523 let activation = match self
1524 .activation
1525 .request_activation(&admission.target_session_id, admission.delivery.generation)
1526 .await
1527 {
1528 Ok(disposition) => disposition,
1529 Err(source) => {
1530 self.metrics
1531 .activation_failed
1532 .fetch_add(1, Ordering::Relaxed);
1533 tracing::error!(
1534 message_id = %admission.envelope_id,
1535 target_session_id = %admission.target_session_id,
1536 generation = admission.delivery.generation,
1537 error = %source,
1538 "session message durable but activation failed"
1539 );
1540 return Err(SessionMessengerError::Activation {
1541 receipt_id: admission.delivery.id.to_string(),
1542 receipt: admission.delivery.clone(),
1543 source,
1544 });
1545 }
1546 };
1547 match activation {
1548 SessionActivationDisposition::ActiveNotified => {
1549 self.metrics.active_notified.fetch_add(1, Ordering::Relaxed);
1550 }
1551 SessionActivationDisposition::ActivationReserved => {
1552 self.metrics
1553 .activation_reserved
1554 .fetch_add(1, Ordering::Relaxed);
1555 }
1556 SessionActivationDisposition::ActivationCoalesced => {
1557 self.metrics
1558 .activation_coalesced
1559 .fetch_add(1, Ordering::Relaxed);
1560 }
1561 }
1562 tracing::info!(
1563 message_id = %admission.envelope_id,
1564 target_session_id = %admission.target_session_id,
1565 generation = admission.delivery.generation,
1566 ?activation,
1567 "session message durably delivered"
1568 );
1569 Ok(SessionMessengerReceipt {
1570 delivery: admission.delivery.clone(),
1571 activation,
1572 })
1573 }
1574
1575 pub async fn prepare_activation(
1584 &self,
1585 admission: &SessionMessengerAdmission,
1586 ) -> Result<(), SessionMessengerError> {
1587 self.inbox
1588 .mark_activation_eligible(
1589 &admission.target_session_id,
1590 admission.delivery.generation,
1591 SessionActivationPolicy::RespectSpecificWait,
1592 )
1593 .await
1594 .map_err(SessionMessengerError::Inbox)
1595 }
1596
1597 pub async fn prepare_message_activation(
1600 &self,
1601 admission: &SessionMessengerAdmission,
1602 policy: SessionActivationPolicy,
1603 ) -> Result<(), SessionMessengerError> {
1604 self.inbox
1605 .mark_message_activation_eligible(
1606 &admission.target_session_id,
1607 &admission.delivery,
1608 policy,
1609 )
1610 .await
1611 .map_err(SessionMessengerError::Inbox)
1612 }
1613
1614 pub async fn send(
1615 &self,
1616 envelope: SessionMessageEnvelope,
1617 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
1618 self.send_with_gate(envelope, None).await
1619 }
1620
1621 pub async fn send_with_gate(
1622 &self,
1623 envelope: SessionMessageEnvelope,
1624 gate: Option<&bamboo_domain::AdmissionGate>,
1625 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
1626 let admission = self
1627 .admit_with_activation_intent(
1628 envelope,
1629 SessionActivationPolicy::InterruptSpecificWait,
1630 gate,
1631 )
1632 .await?;
1633 self.activate_prepared(&admission).await
1634 }
1635
1636 pub async fn send_with_policy(
1637 &self,
1638 envelope: SessionMessageEnvelope,
1639 policy: SessionActivationPolicy,
1640 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
1641 let admission = self
1642 .admit_with_activation_intent(envelope, policy, None)
1643 .await?;
1644 self.activate_prepared(&admission).await
1645 }
1646}
1647
1648#[cfg(test)]
1649mod tests {
1650 use super::*;
1651 use async_trait::async_trait;
1652 use bamboo_domain::{
1653 SessionActivationDisposition, SessionInboxLimits, SessionMessageBody,
1654 SessionMessageContent, SessionMessageId, SessionMessageKind,
1655 };
1656 use bamboo_storage::{FileSessionInbox, SessionStoreV2};
1657 use chrono::Utc;
1658 use std::time::Duration;
1659 use tempfile::TempDir;
1660
1661 struct RecordingActivation {
1662 calls: tokio::sync::Mutex<Vec<(String, u64)>>,
1663 }
1664
1665 struct FailingActivation;
1666
1667 #[async_trait]
1668 impl SessionActivationPort for FailingActivation {
1669 async fn request_activation(
1670 &self,
1671 _target_session_id: &str,
1672 _inbox_generation: u64,
1673 ) -> Result<SessionActivationDisposition, SessionActivationError> {
1674 Err(SessionActivationError::Internal(
1675 "injected wakeup failure".into(),
1676 ))
1677 }
1678 }
1679
1680 #[tokio::test]
1681 async fn immediate_activation_failure_preserves_receipt_and_restart_permission() {
1682 let (temp, store, recording, _messenger) = fixture().await;
1683 store
1684 .save_session(&Session::new("root", "model"))
1685 .await
1686 .unwrap();
1687 let inbox = Arc::new(FileSessionInbox::new(
1688 store.clone(),
1689 SessionInboxLimits::default(),
1690 ));
1691 let messenger = SessionMessenger::new(store, inbox, Arc::new(FailingActivation));
1692 let envelope = SessionMessageEnvelope::user_input("root", "steer");
1693 let gate = bamboo_domain::AdmissionGate::default();
1694 let receipt = match messenger
1695 .send_with_gate(envelope.clone(), Some(&gate))
1696 .await
1697 .unwrap_err()
1698 {
1699 SessionMessengerError::Activation { receipt, .. } => receipt,
1700 error => panic!("expected durable delivery with failed wakeup: {error}"),
1701 };
1702 assert!(gate.is_committed());
1703 gate.cancel_if_pending();
1704 let restarted_store = Arc::new(SessionStoreV2::new(temp.path().into()).await.unwrap());
1705 let restarted = Arc::new(FileSessionInbox::new(
1706 restarted_store.clone(),
1707 SessionInboxLimits::default(),
1708 ));
1709 let backlog = restarted.inspect("root").await.unwrap();
1710 assert!(backlog.activation_pending());
1711 assert_eq!(backlog.coordinator_generation, 0);
1712 recording
1715 .request_activation("root", backlog.activation_generation)
1716 .await
1717 .unwrap();
1718 let retry = SessionMessenger::new(restarted_store, restarted.clone(), recording.clone());
1719 assert_eq!(
1720 retry
1721 .send_with_gate(envelope, Some(&gate))
1722 .await
1723 .unwrap()
1724 .delivery,
1725 receipt
1726 );
1727 assert_eq!(restarted.inspect("root").await.unwrap().pending, 1);
1728 assert_eq!(messenger.metrics().snapshot().activation_failed, 1);
1729 let claims = restarted.claim("root", 128).await.unwrap();
1730 assert_eq!(claims.len(), 1);
1731 assert_eq!(claims[0].envelope.id, receipt.id);
1732 }
1733
1734 struct FailEligibilityOnce {
1735 inner: Arc<FileSessionInbox>,
1736 fail_next: std::sync::atomic::AtomicBool,
1737 }
1738
1739 #[async_trait]
1740 impl SessionInboxPort for FailEligibilityOnce {
1741 async fn deliver(
1742 &self,
1743 envelope: &SessionMessageEnvelope,
1744 ) -> Result<SessionInboxReceipt, SessionInboxError> {
1745 self.inner.deliver(envelope).await
1746 }
1747
1748 async fn deliver_with_gate(
1749 &self,
1750 envelope: &SessionMessageEnvelope,
1751 gate: &bamboo_domain::AdmissionGate,
1752 ) -> Result<SessionInboxReceipt, SessionInboxError> {
1753 self.inner.deliver_with_gate(envelope, gate).await
1754 }
1755
1756 async fn mark_activation_eligible(
1757 &self,
1758 target_session_id: &str,
1759 generation: u64,
1760 policy: SessionActivationPolicy,
1761 ) -> Result<(), SessionInboxError> {
1762 if self.fail_next.swap(false, Ordering::SeqCst) {
1763 return Err(SessionInboxError::Storage(
1764 "injected activation watermark failure".into(),
1765 ));
1766 }
1767 self.inner
1768 .mark_activation_eligible(target_session_id, generation, policy)
1769 .await
1770 }
1771
1772 async fn claim(
1773 &self,
1774 target_session_id: &str,
1775 limit: usize,
1776 ) -> Result<Vec<bamboo_domain::SessionInboxClaim>, SessionInboxError> {
1777 self.inner.claim(target_session_id, limit).await
1778 }
1779
1780 async fn was_admitted(
1781 &self,
1782 target_session_id: &str,
1783 id: &SessionMessageId,
1784 ) -> Result<bool, SessionInboxError> {
1785 self.inner.was_admitted(target_session_id, id).await
1786 }
1787
1788 async fn ack(
1789 &self,
1790 target_session_id: &str,
1791 claim: &bamboo_domain::SessionInboxClaim,
1792 ) -> Result<(), SessionInboxError> {
1793 self.inner.ack(target_session_id, claim).await
1794 }
1795
1796 async fn inspect(
1797 &self,
1798 target_session_id: &str,
1799 ) -> Result<bamboo_domain::SessionInboxBacklog, SessionInboxError> {
1800 self.inner.inspect(target_session_id).await
1801 }
1802 }
1803
1804 #[async_trait]
1805 impl SessionActivationPort for RecordingActivation {
1806 async fn request_activation(
1807 &self,
1808 target_session_id: &str,
1809 inbox_generation: u64,
1810 ) -> Result<SessionActivationDisposition, SessionActivationError> {
1811 self.calls
1812 .lock()
1813 .await
1814 .push((target_session_id.to_string(), inbox_generation));
1815 Ok(SessionActivationDisposition::ActivationReserved)
1816 }
1817 }
1818
1819 async fn fixture() -> (
1820 TempDir,
1821 Arc<SessionStoreV2>,
1822 Arc<RecordingActivation>,
1823 SessionMessenger,
1824 ) {
1825 let temp = TempDir::new().unwrap();
1826 let store = Arc::new(
1827 SessionStoreV2::new(temp.path().to_path_buf())
1828 .await
1829 .unwrap(),
1830 );
1831 let activation = Arc::new(RecordingActivation {
1832 calls: tokio::sync::Mutex::new(Vec::new()),
1833 });
1834 let inbox = Arc::new(FileSessionInbox::new(
1835 store.clone(),
1836 SessionInboxLimits::default(),
1837 ));
1838 let messenger = SessionMessenger::new(store.clone(), inbox, activation.clone());
1839 (temp, store, activation, messenger)
1840 }
1841
1842 fn peer(source: &str, target: &str, id: &str) -> SessionMessageEnvelope {
1843 SessionMessageEnvelope {
1844 id: SessionMessageId::parse(id).unwrap(),
1845 source: SessionMessageSource::Session {
1846 session_id: source.to_string(),
1847 },
1848 target_session_id: target.to_string(),
1849 kind: SessionMessageKind::PeerMessage,
1850 body: SessionMessageBody::Content(SessionMessageContent::text("hello")),
1851 created_at: chrono::Utc::now(),
1852 thread_id: None,
1853 in_reply_to: None,
1854 attempt: None,
1855 correlation_id: None,
1856 }
1857 }
1858
1859 #[derive(Default)]
1860 struct PeerMemoryStorage(tokio::sync::RwLock<std::collections::HashMap<String, Session>>);
1861
1862 #[async_trait]
1863 impl Storage for PeerMemoryStorage {
1864 async fn save_session(&self, session: &Session) -> std::io::Result<()> {
1865 self.0
1866 .write()
1867 .await
1868 .insert(session.id.clone(), session.clone());
1869 Ok(())
1870 }
1871
1872 async fn load_session(&self, id: &str) -> std::io::Result<Option<Session>> {
1873 Ok(self.0.read().await.get(id).cloned())
1874 }
1875
1876 async fn delete_session(&self, id: &str) -> std::io::Result<bool> {
1877 Ok(self.0.write().await.remove(id).is_some())
1878 }
1879
1880 async fn list_child_run_statuses(
1881 &self,
1882 parent: &str,
1883 ) -> std::io::Result<Vec<(String, Option<String>)>> {
1884 Ok(self
1885 .0
1886 .read()
1887 .await
1888 .values()
1889 .filter(|session| session.parent_session_id.as_deref() == Some(parent))
1890 .map(|session| (session.id.clone(), session.last_run_status()))
1891 .collect())
1892 }
1893 }
1894
1895 async fn seed_peer_tree(store: &dyn Storage) {
1896 let mut root = Session::new("peer-root", "model");
1897 root.title = "Logical root".into();
1898 root.set_project_id_meta("peer-project");
1899 let left = Session::new_child_of("peer-left", &root, "model", "Left");
1900 let right = Session::new_child_of("peer-right", &root, "model", "Right");
1901 let cousin_left = Session::new_child_of("peer-left-leaf", &left, "model", "Left leaf");
1902 let cousin_right = Session::new_child_of("peer-right-leaf", &right, "model", "Right leaf");
1903 for mut session in [root, left, right, cousin_left, cousin_right] {
1904 session.set_project_id_meta("peer-project");
1905 store.save_session(&session).await.unwrap();
1906 }
1907 }
1908
1909 struct PausedPeerAdmission {
1910 inner: Arc<FileSessionInbox>,
1911 entered: Arc<tokio::sync::Notify>,
1912 release: Arc<tokio::sync::Notify>,
1913 }
1914
1915 #[async_trait]
1916 impl SessionInboxPort for PausedPeerAdmission {
1917 fn supports_runtime_target_lifetime(&self) -> bool {
1918 true
1919 }
1920
1921 async fn deliver_runtime_for_target(
1922 &self,
1923 envelope: &SessionMessageEnvelope,
1924 target_created_at: chrono::DateTime<Utc>,
1925 ) -> Result<SessionInboxReceipt, SessionInboxError> {
1926 self.entered.notify_one();
1927 self.release.notified().await;
1928 self.inner
1929 .deliver_runtime_for_target(envelope, target_created_at)
1930 .await
1931 }
1932
1933 async fn deliver(
1934 &self,
1935 envelope: &SessionMessageEnvelope,
1936 ) -> Result<SessionInboxReceipt, SessionInboxError> {
1937 self.inner.deliver(envelope).await
1938 }
1939
1940 async fn deliver_peer_message(
1941 &self,
1942 envelope: &SessionMessageEnvelope,
1943 source: &ActorSession,
1944 target: &ActorSession,
1945 reply_to: Option<&SessionMessageEnvelope>,
1946 ) -> Result<SessionInboxReceipt, SessionInboxError> {
1947 self.entered.notify_one();
1950 self.release.notified().await;
1951 self.inner
1952 .deliver_peer_message(envelope, source, target, reply_to)
1953 .await
1954 }
1955
1956 async fn mark_activation_eligible(
1957 &self,
1958 target: &str,
1959 generation: u64,
1960 policy: SessionActivationPolicy,
1961 ) -> Result<(), SessionInboxError> {
1962 self.inner
1963 .mark_activation_eligible(target, generation, policy)
1964 .await
1965 }
1966
1967 async fn claim(
1968 &self,
1969 target: &str,
1970 limit: usize,
1971 ) -> Result<Vec<bamboo_domain::SessionInboxClaim>, SessionInboxError> {
1972 self.inner.claim(target, limit).await
1973 }
1974
1975 async fn was_admitted(
1976 &self,
1977 target: &str,
1978 id: &SessionMessageId,
1979 ) -> Result<bool, SessionInboxError> {
1980 self.inner.was_admitted(target, id).await
1981 }
1982
1983 async fn ack(
1984 &self,
1985 target: &str,
1986 claim: &bamboo_domain::SessionInboxClaim,
1987 ) -> Result<(), SessionInboxError> {
1988 self.inner.ack(target, claim).await
1989 }
1990
1991 async fn inspect(
1992 &self,
1993 target: &str,
1994 ) -> Result<bamboo_domain::SessionInboxBacklog, SessionInboxError> {
1995 self.inner.inspect(target).await
1996 }
1997 }
1998
1999 #[tokio::test]
2000 async fn peer_send_and_reply_reject_either_lifetime_recreated_before_admission() {
2001 for reply in [false, true] {
2002 for replaced_id in ["peer-left", "peer-right"] {
2003 let (_temp, store, activation, messenger) = fixture().await;
2004 seed_peer_tree(store.as_ref()).await;
2005 let received_id = if reply {
2006 let incoming = messenger
2007 .send_peer("peer-left", "peer-right", "incoming", "question")
2008 .await
2009 .unwrap();
2010 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right")
2011 .await;
2012 Some(incoming.delivery.id.to_string())
2013 } else {
2014 None
2015 };
2016 activation.calls.lock().await.clear();
2017 let (caller, target) = if reply {
2018 ("peer-right", "peer-left")
2019 } else {
2020 ("peer-left", "peer-right")
2021 };
2022 let entered = Arc::new(tokio::sync::Notify::new());
2023 let release = Arc::new(tokio::sync::Notify::new());
2024 let inbox = Arc::new(FileSessionInbox::new(
2025 store.clone(),
2026 SessionInboxLimits::default(),
2027 ));
2028 let paused = SessionMessenger::new(
2029 store.clone(),
2030 Arc::new(PausedPeerAdmission {
2031 inner: inbox.clone(),
2032 entered: entered.clone(),
2033 release: release.clone(),
2034 }),
2035 activation.clone(),
2036 );
2037 let sending = tokio::spawn(async move {
2038 if let Some(id) = received_id {
2039 paused
2040 .reply_peer(caller, &id, "racing", "old lifetime")
2041 .await
2042 } else {
2043 paused
2044 .send_peer(caller, target, "racing", "old lifetime")
2045 .await
2046 }
2047 });
2048 tokio::time::timeout(std::time::Duration::from_secs(5), entered.notified())
2049 .await
2050 .unwrap();
2051 let old = store.load_session(replaced_id).await.unwrap().unwrap();
2052 assert!(store.delete_session(replaced_id).await.unwrap());
2053 let root = store.load_session("peer-root").await.unwrap().unwrap();
2054 let mut replacement =
2055 Session::new_child_of(replaced_id, &root, "model", "Replacement");
2056 replacement.created_at = old.created_at + chrono::Duration::seconds(1);
2057 replacement.set_project_id_meta("peer-project");
2058 store.save_session(&replacement).await.unwrap();
2059 release.notify_one();
2060 let result = tokio::time::timeout(std::time::Duration::from_secs(5), sending)
2061 .await
2062 .unwrap()
2063 .unwrap();
2064 assert!(result.is_err(), "reply={reply}, replaced={replaced_id}");
2065 let backlog = inbox.inspect(target).await.unwrap();
2066 assert_eq!(backlog.generation, 0);
2067 assert_eq!(backlog.pending, 0);
2068 assert!(!backlog.activation_pending());
2069 assert!(activation.calls.lock().await.is_empty());
2070 let current = messenger
2073 .send_peer(caller, target, "current-lifetime", "current")
2074 .await
2075 .unwrap();
2076 assert_eq!(current.delivery.generation, 1);
2077 assert_eq!(activation.calls.lock().await.len(), 1);
2078 }
2079 }
2080 }
2081
2082 #[tokio::test]
2083 async fn background_exec_admission_rechecks_target_birth_at_actual_inbox_publication() {
2084 let (_temp, store, activation, _) = fixture().await;
2085 let old = Session::new("runtime-completion-target", "model");
2086 store.save_session(&old).await.unwrap();
2087 let entered = Arc::new(tokio::sync::Notify::new());
2088 let release = Arc::new(tokio::sync::Notify::new());
2089 let inbox = Arc::new(FileSessionInbox::new(
2090 store.clone(),
2091 SessionInboxLimits::default(),
2092 ));
2093 let paused = SessionMessenger::new(
2094 store.clone(),
2095 Arc::new(PausedPeerAdmission {
2096 inner: inbox.clone(),
2097 entered: entered.clone(),
2098 release: release.clone(),
2099 }),
2100 activation.clone(),
2101 );
2102 let mut envelope = SessionMessageEnvelope::user_input(&old.id, "unused");
2103 envelope.source = SessionMessageSource::Runtime {
2104 subsystem: "background_exec".into(),
2105 };
2106 envelope.kind = bamboo_domain::SessionMessageKind::RuntimeInstruction;
2107 envelope.body = bamboo_domain::SessionMessageBody::RuntimeInstruction(
2108 bamboo_domain::SessionRuntimeInstruction {
2109 instruction: "background_exec_completed".into(),
2110 content: Some(bamboo_domain::SessionMessageContent::text(
2111 "old command result",
2112 )),
2113 data: None,
2114 provider_message: None,
2115 },
2116 );
2117 let birth = old.created_at;
2118 let sending = tokio::spawn(async move {
2119 match paused.admit_runtime_for_target(envelope, birth).await {
2120 Ok(admission) => {
2121 paused.prepare_activation(&admission).await.unwrap();
2122 paused.activate_prepared(&admission).await.unwrap();
2123 true
2124 }
2125 Err(_) => false,
2126 }
2127 });
2128 tokio::time::timeout(Duration::from_secs(5), entered.notified())
2129 .await
2130 .unwrap();
2131 store.delete_session(&old.id).await.unwrap();
2132 let replacement = store.recreate_root_session(&old.id, "model").await.unwrap();
2133 assert_ne!(replacement.created_at, birth);
2134 release.notify_one();
2135 assert!(!tokio::time::timeout(Duration::from_secs(5), sending)
2136 .await
2137 .unwrap()
2138 .unwrap());
2139 assert_eq!(inbox.inspect(&old.id).await.unwrap().pending, 0);
2140 assert!(activation.calls.lock().await.is_empty());
2141 }
2142
2143 #[tokio::test]
2144 async fn background_exec_old_receipt_cannot_authorize_a_recreated_sessions_staged_message() {
2145 let (_temp, store, activation, messenger) = fixture().await;
2146 let old = Session::new("runtime-activation-target", "model");
2147 store.save_session(&old).await.unwrap();
2148 let mut envelope = SessionMessageEnvelope::user_input(&old.id, "unused");
2149 envelope.source = SessionMessageSource::Runtime {
2150 subsystem: "background_exec".into(),
2151 };
2152 envelope.kind = SessionMessageKind::RuntimeInstruction;
2153 envelope.body =
2154 SessionMessageBody::RuntimeInstruction(bamboo_domain::SessionRuntimeInstruction {
2155 instruction: "background_exec_completed".into(),
2156 content: Some(SessionMessageContent::text("old result")),
2157 data: None,
2158 provider_message: None,
2159 });
2160 let admission = messenger
2161 .admit_runtime_for_target(envelope, old.created_at)
2162 .await
2163 .unwrap();
2164 store.delete_session(&old.id).await.unwrap();
2165 let replacement = store.recreate_root_session(&old.id, "model").await.unwrap();
2166 assert_ne!(replacement.created_at, old.created_at);
2167 let staged = messenger
2168 .admit(SessionMessageEnvelope::user_input(
2169 &old.id,
2170 "new staged input",
2171 ))
2172 .await
2173 .unwrap();
2174 assert_eq!(admission.delivery.generation, staged.delivery.generation);
2175 assert_ne!(admission.delivery.id, staged.delivery.id);
2176 assert!(messenger
2177 .prepare_message_activation(&admission, SessionActivationPolicy::RespectSpecificWait)
2178 .await
2179 .is_err());
2180 assert!(!messenger
2181 .inbox()
2182 .inspect(&old.id)
2183 .await
2184 .unwrap()
2185 .activation_pending());
2186 assert!(activation.calls.lock().await.is_empty());
2187 }
2188
2189 #[tokio::test]
2190 async fn peer_reply_revalidates_received_proof_after_concurrent_clear_or_save() {
2191 for clear in [true, false] {
2192 let (_temp, store, activation, messenger) = fixture().await;
2193 seed_peer_tree(store.as_ref()).await;
2194 let incoming = messenger
2195 .send_peer("peer-left", "peer-right", "incoming", "question")
2196 .await
2197 .unwrap();
2198 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right").await;
2199 activation.calls.lock().await.clear();
2200 let before = store.load_session("peer-right").await.unwrap().unwrap();
2201 let received_id = incoming.delivery.id.to_string();
2202 let entered = Arc::new(tokio::sync::Notify::new());
2203 let release = Arc::new(tokio::sync::Notify::new());
2204 let inbox = Arc::new(FileSessionInbox::new(
2205 store.clone(),
2206 SessionInboxLimits::default(),
2207 ));
2208 let paused = SessionMessenger::new(
2209 store.clone(),
2210 Arc::new(PausedPeerAdmission {
2211 inner: inbox.clone(),
2212 entered: entered.clone(),
2213 release: release.clone(),
2214 }),
2215 activation.clone(),
2216 );
2217 let old_id = received_id.clone();
2218 let replying = tokio::spawn(async move {
2219 paused
2220 .reply_peer("peer-right", &old_id, "racing-proof", "reply")
2221 .await
2222 });
2223 tokio::time::timeout(std::time::Duration::from_secs(5), entered.notified())
2224 .await
2225 .unwrap();
2226 if clear {
2227 assert!(store.clear_session("peer-right").await.unwrap());
2228 } else {
2229 let mut changed = before.clone();
2230 changed.messages.retain(|message| message.id != received_id);
2231 store.save_session(&changed).await.unwrap();
2232 }
2233 let after = store.load_session("peer-right").await.unwrap().unwrap();
2234 assert_eq!(before.created_at, after.created_at);
2235 assert_eq!(before.root_session_id, after.root_session_id);
2236 assert_eq!(before.project_id_meta(), after.project_id_meta());
2237 assert!(!after
2238 .messages
2239 .iter()
2240 .any(|message| message.id == received_id));
2241 release.notify_one();
2242 let result = tokio::time::timeout(std::time::Duration::from_secs(5), replying)
2243 .await
2244 .unwrap()
2245 .unwrap();
2246 assert!(
2247 result.is_err(),
2248 "clear={clear}: removed proof must not admit a reply"
2249 );
2250 let backlog = inbox.inspect("peer-left").await.unwrap();
2251 assert_eq!(backlog.generation, 0);
2252 assert_eq!(backlog.pending, 0);
2253 assert!(!backlog.activation_pending());
2254 assert!(activation.calls.lock().await.is_empty());
2255
2256 let current = messenger
2257 .send_peer("peer-left", "peer-right", "current-question", "current")
2258 .await
2259 .unwrap();
2260 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right").await;
2261 let reply = messenger
2262 .reply_peer(
2263 "peer-right",
2264 current.delivery.id.as_str(),
2265 "racing-proof",
2266 "reply",
2267 )
2268 .await
2269 .unwrap();
2270 let retry = messenger
2271 .reply_peer(
2272 "peer-right",
2273 current.delivery.id.as_str(),
2274 "racing-proof",
2275 "reply",
2276 )
2277 .await
2278 .unwrap();
2279 assert_eq!(reply.delivery.generation, 1);
2280 assert_eq!(retry.delivery.generation, reply.delivery.generation);
2281 assert_eq!(retry.delivery.id, reply.delivery.id);
2282 let backlog = inbox.inspect("peer-left").await.unwrap();
2283 assert_eq!(backlog.generation, 1);
2284 assert_eq!(backlog.pending, 1);
2285 }
2286 }
2287
2288 #[tokio::test]
2289 async fn peer_admission_without_integrated_lifetime_fence_is_unsupported() {
2290 let (_temp, store, activation, _messenger) = fixture().await;
2291 seed_peer_tree(store.as_ref()).await;
2292 let inbox = Arc::new(FileSessionInbox::new(
2293 store.clone(),
2294 SessionInboxLimits::default(),
2295 ));
2296 let messenger = SessionMessenger::new(
2297 store,
2298 Arc::new(FailEligibilityOnce {
2299 inner: inbox.clone(),
2300 fail_next: std::sync::atomic::AtomicBool::new(false),
2301 }),
2302 activation.clone(),
2303 );
2304 let error = messenger
2305 .send_peer("peer-left", "peer-right", "unsupported", "message")
2306 .await
2307 .unwrap_err();
2308 assert!(error
2309 .to_string()
2310 .contains("lifetime-fenced peer SessionInbox admission is unsupported"));
2311 for intent in [
2312 SessionMessageDeliveryIntent::QueueOnly,
2313 SessionMessageDeliveryIntent::TriggerTurn,
2314 ] {
2315 assert!(messenger
2316 .send_peer_with_intent(
2317 "peer-left",
2318 "peer-right",
2319 "unsupported-explicit",
2320 "message",
2321 intent
2322 )
2323 .await
2324 .unwrap_err()
2325 .to_string()
2326 .contains("explicit peer delivery intent is unsupported"));
2327 }
2328 assert_eq!(inbox.inspect("peer-right").await.unwrap().pending, 0);
2329 assert!(activation.calls.lock().await.is_empty());
2330 }
2331
2332 async fn admit_peer_for_test(
2333 store: &dyn Storage,
2334 inbox: &dyn SessionInboxPort,
2335 target: &str,
2336 ) -> SessionMessageEnvelope {
2337 let claims = inbox.claim(target, 16).await.unwrap();
2338 assert_eq!(claims.len(), 1);
2339 let claim = &claims[0];
2340 let mut session = store.load_session(target).await.unwrap().unwrap();
2341 session.add_message(claim.envelope.to_provider_message().unwrap());
2342 store.save_session(&session).await.unwrap();
2343 inbox.ack(target, claim).await.unwrap();
2344 claim.envelope.clone()
2345 }
2346
2347 #[tokio::test]
2348 async fn peer_api_discovers_and_messages_parent_child_siblings_and_cousins() {
2349 let (_temp, store, activation, messenger) = fixture().await;
2350 seed_peer_tree(store.as_ref()).await;
2351 store
2352 .save_session(&Session::new("foreign-root", "model"))
2353 .await
2354 .unwrap();
2355 let targets = messenger.list_peer_targets("peer-left-leaf").await.unwrap();
2356 assert!(!targets.truncated);
2357 let ids: Vec<_> = targets
2358 .targets
2359 .iter()
2360 .map(|target| target.session_id.as_str())
2361 .collect();
2362 assert_eq!(
2363 ids,
2364 ["peer-root", "peer-left", "peer-right", "peer-right-leaf"]
2365 );
2366 assert_eq!(targets.targets[0].title, "Logical root");
2367 assert!(targets.targets[0].parent_session_id.is_none());
2368 assert!(!serde_json::to_string(&targets)
2369 .unwrap()
2370 .contains("foreign-root"));
2371
2372 for (index, (source, target)) in [
2373 ("peer-root", "peer-left"),
2374 ("peer-left", "peer-root"),
2375 ("peer-left", "peer-right"),
2376 ("peer-left-leaf", "peer-right-leaf"),
2377 ]
2378 .into_iter()
2379 .enumerate()
2380 {
2381 let receipt = messenger
2382 .send_peer(
2383 source,
2384 target,
2385 &format!("operation-{index}"),
2386 "bounded peer instruction",
2387 )
2388 .await
2389 .unwrap();
2390 let backlog = messenger.inbox().inspect(target).await.unwrap();
2391 assert_eq!(
2392 backlog.coordinator_generation, 0,
2393 "peer permission must not release a prefix"
2394 );
2395 assert!(backlog.interrupt_pending());
2396 let envelope =
2397 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), target).await;
2398 assert_eq!(envelope.id, receipt.delivery.id);
2399 assert_eq!(
2400 envelope.source,
2401 SessionMessageSource::Session {
2402 session_id: source.into()
2403 }
2404 );
2405 assert_eq!(envelope.thread_id.as_deref(), Some(envelope.id.as_str()));
2406 assert_eq!(
2407 envelope.correlation_id.as_deref(),
2408 Some(format!("operation-{index}").as_str())
2409 );
2410 assert!(envelope.in_reply_to.is_none());
2411 }
2412 assert_eq!(activation.calls.lock().await.len(), 4);
2413 }
2414
2415 #[tokio::test]
2416 async fn peer_api_denies_unknown_deleted_cross_root_and_invalid_project_or_lineage() {
2417 let (_temp, store, activation, messenger) = fixture().await;
2418 seed_peer_tree(store.as_ref()).await;
2419 store
2420 .save_session(&Session::new("foreign-root", "model"))
2421 .await
2422 .unwrap();
2423 for (source, target) in [
2424 ("peer-left", "foreign-root"),
2425 ("unknown", "peer-left"),
2426 ("peer-left", "unknown"),
2427 ("peer-left", "peer-left"),
2428 ] {
2429 assert!(messenger
2430 .send_peer(source, target, "denied-operation", "instruction")
2431 .await
2432 .is_err());
2433 }
2434 store.delete_session("peer-right-leaf").await.unwrap();
2435 assert!(matches!(
2436 messenger
2437 .send_peer("peer-left", "peer-right-leaf", "deleted", "instruction")
2438 .await,
2439 Err(SessionMessengerError::TargetNotFound(_))
2440 ));
2441 assert!(matches!(
2442 messenger
2443 .send_peer(
2444 "peer-right-leaf",
2445 "peer-left",
2446 "deleted-source",
2447 "instruction"
2448 )
2449 .await,
2450 Err(SessionMessengerError::SourceNotFound(_))
2451 ));
2452
2453 let memory = Arc::new(PeerMemoryStorage::default());
2456 seed_peer_tree(memory.as_ref()).await;
2457 let strict = SessionMessenger::new(
2458 memory.clone(),
2459 messenger.inbox().clone(),
2460 activation.clone(),
2461 );
2462 let original = memory.load_session("peer-right").await.unwrap().unwrap();
2463 for defect in [
2464 "project",
2465 "missing_project",
2466 "project_mirror",
2467 "parent",
2468 "depth",
2469 "root",
2470 "cycle",
2471 ] {
2472 let mut invalid = original.clone();
2473 match defect {
2474 "project" => invalid.set_project_id_meta("other-project"),
2475 "missing_project" => {
2476 invalid.metadata.remove("project_id");
2477 if let Some(metadata) = invalid.runtime_metadata.as_mut() {
2478 metadata.project_id = None;
2479 }
2480 }
2481 "project_mirror" => {
2482 invalid
2483 .metadata
2484 .insert("project_id".into(), "other-project".into());
2485 }
2486 "parent" => invalid.parent_session_id = Some("missing-parent".into()),
2487 "depth" => invalid.spawn_depth += 1,
2488 "root" => invalid.root_session_id = "foreign-root".into(),
2489 "cycle" => invalid.parent_session_id = Some("peer-right-leaf".into()),
2490 _ => unreachable!(),
2491 }
2492 memory.save_session(&invalid).await.unwrap();
2493 assert!(
2494 strict
2495 .send_peer("peer-left", "peer-right", "invalid", "instruction")
2496 .await
2497 .is_err(),
2498 "{defect}"
2499 );
2500 let targets = strict.list_peer_targets("peer-left").await.unwrap();
2501 assert!(
2502 !targets
2503 .targets
2504 .iter()
2505 .any(|target| target.session_id == "peer-right"),
2506 "{defect}"
2507 );
2508 memory.save_session(&original).await.unwrap();
2509 }
2510 assert!(activation.calls.lock().await.is_empty());
2511 assert_eq!(
2512 messenger
2513 .inbox()
2514 .inspect("peer-left")
2515 .await
2516 .unwrap()
2517 .generation,
2518 0
2519 );
2520 }
2521
2522 #[tokio::test]
2523 async fn peer_api_discovery_is_bounded_and_sender_is_runtime_selected() {
2524 let (_temp, _store, activation, messenger) = fixture().await;
2525 let memory = Arc::new(PeerMemoryStorage::default());
2526 let root = Session::new("many-peers", "model");
2527 memory.save_session(&root).await.unwrap();
2528 for index in 0..MAX_PEER_TARGETS + 2 {
2529 let child = Session::new_child_of(format!("peer-{index:03}"), &root, "model", "Peer");
2530 memory.save_session(&child).await.unwrap();
2531 }
2532 let bounded = SessionMessenger::new(
2533 memory.clone(),
2534 messenger.inbox().clone(),
2535 activation.clone(),
2536 );
2537 let page = bounded.list_peer_targets(&root.id).await.unwrap();
2538 assert_eq!(page.targets.len(), MAX_PEER_TARGETS);
2539 assert!(page.truncated);
2540 for index in 0..MAX_PEER_TARGETS + 2 {
2541 let mut child = memory
2542 .load_session(&format!("peer-{index:03}"))
2543 .await
2544 .unwrap()
2545 .unwrap();
2546 child.title = "界".repeat(1000);
2547 child.set_last_run_status("\0".repeat(1000));
2548 memory.save_session(&child).await.unwrap();
2549 }
2550 let page = bounded.list_peer_targets(&root.id).await.unwrap();
2551 assert!(!page.targets.is_empty());
2552 assert!(page.targets.len() < MAX_PEER_TARGETS);
2553 assert!(page.truncated);
2554 assert!(serde_json::to_vec(&page).unwrap().len() <= MAX_PEER_TARGET_RESULT_BYTES);
2555 assert_eq!(page.targets[0].title, "界".repeat(85));
2556 assert_eq!(
2557 page.targets[0].status.as_deref(),
2558 Some("\0".repeat(MAX_PEER_TARGET_STATUS_BYTES).as_str())
2559 );
2560
2561 let (_temp, store, _activation, messenger) = fixture().await;
2562 seed_peer_tree(store.as_ref()).await;
2563 let forged_content =
2564 r#"{"source":{"type":"session","session_id":"peer-root"},"target":"foreign-root"}"#;
2565 messenger
2566 .send_peer("peer-left", "peer-right", "caller-bound", forged_content)
2567 .await
2568 .unwrap();
2569 let envelope =
2570 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right").await;
2571 assert_eq!(
2572 envelope.source,
2573 SessionMessageSource::Session {
2574 session_id: "peer-left".into()
2575 }
2576 );
2577 assert_eq!(envelope.target_session_id, "peer-right");
2578 assert_eq!(
2579 envelope.body,
2580 SessionMessageBody::Content(SessionMessageContent::text(forged_content))
2581 );
2582 for id in ["", " padded", "bad/path", "bad\\path", "bad..id", "nul\0"] {
2583 assert!(matches!(
2584 messenger
2585 .send_peer(id, "peer-right", "invalid-source", "instruction")
2586 .await,
2587 Err(SessionMessengerError::InvalidEnvelope(_))
2588 ));
2589 assert!(matches!(
2590 messenger
2591 .send_peer("peer-left", id, "invalid-target", "instruction")
2592 .await,
2593 Err(SessionMessengerError::InvalidEnvelope(_))
2594 ));
2595 assert!(matches!(
2596 messenger
2597 .reply_peer(id, envelope.id.as_str(), "invalid-reply", "instruction")
2598 .await,
2599 Err(SessionMessengerError::InvalidEnvelope(_))
2600 ));
2601 }
2602 assert!(matches!(
2603 messenger.list_peer_targets(&"x".repeat(257)).await,
2604 Err(SessionMessengerError::InvalidEnvelope(_))
2605 ));
2606 for (operation, message) in [
2607 ("", "valid"),
2608 (" padded", "valid"),
2609 ("bad..id", "valid"),
2610 ("valid", " "),
2611 ("valid", "nul\0"),
2612 ] {
2613 assert!(messenger
2614 .send_peer("peer-left", "peer-right", operation, message)
2615 .await
2616 .is_err());
2617 }
2618 assert!(messenger
2619 .send_peer(
2620 "peer-left",
2621 "peer-right",
2622 "oversized",
2623 &"x".repeat(MAX_PEER_MESSAGE_BYTES + 1)
2624 )
2625 .await
2626 .is_err());
2627 }
2628
2629 #[tokio::test]
2630 async fn peer_api_retry_dedupes_before_claim_and_after_ack_restart_but_rejects_changed_payload()
2631 {
2632 let (temp, store, activation, messenger) = fixture().await;
2633 seed_peer_tree(store.as_ref()).await;
2634 let first = messenger
2635 .send_peer("peer-left", "peer-right", "stable-operation", "original")
2636 .await
2637 .unwrap();
2638 let retry = messenger
2639 .send_peer("peer-left", "peer-right", "stable-operation", "original")
2640 .await
2641 .unwrap();
2642 assert_eq!(retry.delivery, first.delivery);
2643 assert!(matches!(
2644 messenger
2645 .send_peer("peer-left", "peer-right", "stable-operation", "changed")
2646 .await,
2647 Err(SessionMessengerError::Inbox(
2648 SessionInboxError::InvalidClaim(_)
2649 ))
2650 ));
2651 assert_eq!(
2652 messenger
2653 .inbox()
2654 .inspect("peer-right")
2655 .await
2656 .unwrap()
2657 .generation,
2658 1
2659 );
2660 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right").await;
2661 drop(messenger);
2662 drop(store);
2663 let reopened = Arc::new(SessionStoreV2::new(temp.path().into()).await.unwrap());
2664 let inbox = Arc::new(FileSessionInbox::new(
2665 reopened.clone(),
2666 SessionInboxLimits::default(),
2667 ));
2668 let messenger = SessionMessenger::new(reopened, inbox.clone(), activation);
2669 let after_ack = messenger
2670 .send_peer("peer-left", "peer-right", "stable-operation", "original")
2671 .await
2672 .unwrap();
2673 assert_eq!(after_ack.delivery, first.delivery);
2674 assert!(inbox.claim("peer-right", 16).await.unwrap().is_empty());
2675 assert!(matches!(
2676 messenger
2677 .send_peer("peer-left", "peer-right", "stable-operation", "changed")
2678 .await,
2679 Err(SessionMessengerError::Inbox(
2680 SessionInboxError::InvalidClaim(_)
2681 ))
2682 ));
2683 assert_eq!(inbox.inspect("peer-right").await.unwrap().generation, 1);
2684 }
2685
2686 #[tokio::test]
2687 async fn peer_api_reply_recovers_durable_recipient_and_thread_after_restart() {
2688 let (temp, store, activation, messenger) = fixture().await;
2689 seed_peer_tree(store.as_ref()).await;
2690 let first = messenger
2691 .send_peer("peer-left-leaf", "peer-right-leaf", "initial", "question")
2692 .await
2693 .unwrap();
2694 let incoming = admit_peer_for_test(
2695 store.as_ref(),
2696 messenger.inbox().as_ref(),
2697 "peer-right-leaf",
2698 )
2699 .await;
2700 drop(messenger);
2701 drop(store);
2702 let store = Arc::new(SessionStoreV2::new(temp.path().into()).await.unwrap());
2703 let inbox = Arc::new(FileSessionInbox::new(
2704 store.clone(),
2705 SessionInboxLimits::default(),
2706 ));
2707 let messenger = SessionMessenger::new(store.clone(), inbox.clone(), activation);
2708 let reply = messenger
2709 .reply_peer(
2710 "peer-right-leaf",
2711 first.delivery.id.as_str(),
2712 "answer",
2713 "answer text",
2714 )
2715 .await
2716 .unwrap();
2717 let retry = messenger
2718 .reply_peer(
2719 "peer-right-leaf",
2720 first.delivery.id.as_str(),
2721 "answer",
2722 "answer text",
2723 )
2724 .await
2725 .unwrap();
2726 assert_eq!(reply.delivery, retry.delivery);
2727 let response = admit_peer_for_test(store.as_ref(), inbox.as_ref(), "peer-left-leaf").await;
2728 assert_eq!(
2729 response.source,
2730 SessionMessageSource::Session {
2731 session_id: "peer-right-leaf".into()
2732 }
2733 );
2734 assert_eq!(response.in_reply_to, Some(incoming.id.clone()));
2735 assert_eq!(response.thread_id, incoming.thread_id);
2736 assert_eq!(response.correlation_id.as_deref(), Some("answer"));
2737 let second_reply = messenger
2738 .reply_peer("peer-left-leaf", response.id.as_str(), "thanks", "thanks")
2739 .await
2740 .unwrap();
2741 let second_response =
2742 admit_peer_for_test(store.as_ref(), inbox.as_ref(), "peer-right-leaf").await;
2743 assert_eq!(second_reply.delivery.id, second_response.id);
2744 assert_eq!(second_response.in_reply_to, Some(response.id));
2745 assert_eq!(
2746 second_response.thread_id.as_deref(),
2747 Some(first.delivery.id.as_str())
2748 );
2749 let after_ack = messenger
2750 .reply_peer(
2751 "peer-right-leaf",
2752 first.delivery.id.as_str(),
2753 "answer",
2754 "answer text",
2755 )
2756 .await
2757 .unwrap();
2758 assert_eq!(after_ack.delivery, reply.delivery);
2759 assert!(inbox.claim("peer-left-leaf", 16).await.unwrap().is_empty());
2760 assert!(messenger
2761 .reply_peer(
2762 "peer-right-leaf",
2763 first.delivery.id.as_str(),
2764 "answer",
2765 "different"
2766 )
2767 .await
2768 .is_err());
2769 }
2770
2771 #[tokio::test]
2772 async fn peer_api_reply_rejects_missing_forged_legacy_and_recreated_source() {
2773 let (_temp, store, activation, messenger) = fixture().await;
2774 seed_peer_tree(store.as_ref()).await;
2775 let mut receiver = store.load_session("peer-right").await.unwrap().unwrap();
2776 let legacy = peer("peer-left", "peer-right", "legacy-correction");
2777 receiver.add_message(legacy.to_provider_message().unwrap());
2778 let user = SessionMessageEnvelope::user_input("peer-right", "ordinary input");
2779 receiver.add_message(user.to_provider_message().unwrap());
2780 let sender = store.load_session("peer-left").await.unwrap().unwrap();
2781 let mut forged = SessionMessenger::peer_envelope(
2782 &sender,
2783 &receiver,
2784 "forged",
2785 "original",
2786 SessionMessenger::peer_message_id(&sender, &receiver, "forged"),
2787 "thread".into(),
2788 None,
2789 )
2790 .to_provider_message()
2791 .unwrap();
2792 forged.content = "changed without canonical proof".into();
2793 receiver.add_message(forged);
2794 store.save_session(&receiver).await.unwrap();
2795 for id in [
2796 "missing",
2797 legacy.id.as_str(),
2798 user.id.as_str(),
2799 receiver.messages.last().unwrap().id.as_str(),
2800 ] {
2801 assert!(messenger
2802 .reply_peer("peer-right", id, "deny-reply", "reply")
2803 .await
2804 .is_err());
2805 }
2806 let incoming = messenger
2807 .send_peer("peer-left", "peer-right", "old-lifetime", "question")
2808 .await
2809 .unwrap();
2810 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right").await;
2811 let memory = Arc::new(PeerMemoryStorage::default());
2812 for id in ["peer-root", "peer-left", "peer-right"] {
2814 let mut session = store.load_session(id).await.unwrap().unwrap();
2815 if id == "peer-left" {
2816 session.created_at += chrono::Duration::seconds(1);
2817 }
2818 memory.save_session(&session).await.unwrap();
2819 }
2820 let fenced = SessionMessenger::new(
2821 memory.clone(),
2822 messenger.inbox().clone(),
2823 activation.clone(),
2824 );
2825 assert!(matches!(
2826 fenced
2827 .reply_peer(
2828 "peer-right",
2829 incoming.delivery.id.as_str(),
2830 "reply-old",
2831 "reply"
2832 )
2833 .await,
2834 Err(SessionMessengerError::InvalidEnvelope(_))
2835 ));
2836 memory.delete_session("peer-left").await.unwrap();
2837 assert!(matches!(
2838 fenced
2839 .reply_peer(
2840 "peer-right",
2841 incoming.delivery.id.as_str(),
2842 "reply-deleted",
2843 "reply"
2844 )
2845 .await,
2846 Err(SessionMessengerError::TargetNotFound(_))
2847 ));
2848 assert_eq!(activation.calls.lock().await.len(), 1);
2849 }
2850
2851 #[tokio::test]
2852 async fn same_root_delivery_enqueues_then_requests_activation() {
2853 let (_temp, store, activation, messenger) = fixture().await;
2854 let root = Session::new("root", "model");
2855 let mut child = Session::new("child", "model");
2856 child.kind = bamboo_domain::SessionKind::Child;
2857 child.parent_session_id = Some("root".to_string());
2858 child.root_session_id = "root".to_string();
2859 store.save_session(&root).await.unwrap();
2860 store.save_session(&child).await.unwrap();
2861
2862 let receipt = messenger
2863 .send(peer("root", "child", "msg-1"))
2864 .await
2865 .unwrap();
2866 assert_eq!(receipt.delivery.generation, 1);
2867 assert_eq!(
2868 activation.calls.lock().await.as_slice(),
2869 &[("child".to_string(), 1)]
2870 );
2871 assert_eq!(messenger.metrics().snapshot().delivered, 1);
2872 }
2873
2874 #[tokio::test]
2875 async fn staged_delivery_without_watermark_reports_exact_retry_required() {
2876 let temp = TempDir::new().unwrap();
2877 let store = Arc::new(
2878 SessionStoreV2::new(temp.path().to_path_buf())
2879 .await
2880 .unwrap(),
2881 );
2882 let root = Session::new("root", "model");
2883 let mut child = Session::new("child", "model");
2884 child.kind = bamboo_domain::SessionKind::Child;
2885 child.parent_session_id = Some("root".to_string());
2886 child.root_session_id = "root".to_string();
2887 store.save_session(&root).await.unwrap();
2888 store.save_session(&child).await.unwrap();
2889 let inbox = Arc::new(FailEligibilityOnce {
2890 inner: Arc::new(FileSessionInbox::new(
2891 store.clone(),
2892 SessionInboxLimits::default(),
2893 )),
2894 fail_next: std::sync::atomic::AtomicBool::new(true),
2895 });
2896 let activation = Arc::new(RecordingActivation {
2897 calls: tokio::sync::Mutex::new(Vec::new()),
2898 });
2899 let messenger = SessionMessenger::new(store.clone(), inbox, activation.clone());
2900 let envelope = peer("root", "child", "watermark-failure");
2901 let gate = bamboo_domain::AdmissionGate::default();
2902
2903 let admission = messenger
2904 .admit_with_gate(envelope.clone(), Some(&gate))
2905 .await
2906 .unwrap();
2907 let receipt = match messenger.activate(&admission).await.unwrap_err() {
2908 SessionMessengerError::ActivationEligibility { receipt, .. } => receipt,
2909 error => panic!("expected retry-required classification: {error}"),
2910 };
2911 assert!(gate.is_committed());
2912 assert!(activation.calls.lock().await.is_empty());
2913 let reopened = FileSessionInbox::new(store, SessionInboxLimits::default());
2914 let backlog = reopened.inspect("child").await.unwrap();
2915 assert_eq!(backlog.pending + backlog.claimed, 1);
2916 assert_eq!(backlog.activation_generation, 0);
2917 assert_eq!(backlog.generation, receipt.generation);
2918
2919 let retry = messenger
2920 .admit_with_gate(envelope, Some(&gate))
2921 .await
2922 .unwrap();
2923 let retried = messenger.activate(&retry).await.unwrap();
2924 assert_eq!(retried.delivery, receipt);
2925 assert_eq!(activation.calls.lock().await.len(), 1);
2926 let reopened = reopened.inspect("child").await.unwrap();
2927 assert_eq!(reopened.activation_generation, receipt.generation);
2928 assert_eq!(reopened.pending + reopened.claimed, 1);
2929 }
2930
2931 #[tokio::test]
2932 async fn cross_root_peer_is_rejected_before_enqueue() {
2933 let (_temp, store, activation, messenger) = fixture().await;
2934 store
2935 .save_session(&Session::new("root-a", "model"))
2936 .await
2937 .unwrap();
2938 store
2939 .save_session(&Session::new("root-b", "model"))
2940 .await
2941 .unwrap();
2942
2943 let error = messenger
2944 .send(peer("root-a", "root-b", "msg-2"))
2945 .await
2946 .unwrap_err();
2947 assert!(matches!(error, SessionMessengerError::Unauthorized { .. }));
2948 assert!(activation.calls.lock().await.is_empty());
2949 let metrics = messenger.metrics().snapshot();
2950 assert_eq!(metrics.rejected, 1);
2951 assert_eq!(metrics.unauthorized, 1);
2952 assert_eq!(metrics.payload_too_large, 0);
2953 assert_eq!(metrics.backlog_full, 0);
2954 }
2955
2956 #[tokio::test]
2957 async fn same_root_different_project_peer_is_rejected_before_enqueue() {
2958 let (_temp, store, activation, messenger) = fixture().await;
2959 let mut source = Session::new("project-root", "model");
2960 source.set_project_id_meta("project-a");
2961 let mut target = Session::new("project-child", "model");
2962 target.kind = bamboo_domain::SessionKind::Child;
2963 target.parent_session_id = Some(source.id.clone());
2964 target.root_session_id = source.id.clone();
2965 target.set_project_id_meta("project-b");
2966 store.save_session(&source).await.unwrap();
2967 store.save_session(&target).await.unwrap();
2968
2969 let error = messenger
2970 .send(peer(&source.id, &target.id, "different-project"))
2971 .await
2972 .unwrap_err();
2973 assert!(matches!(error, SessionMessengerError::Unauthorized { .. }));
2974 assert!(activation.calls.lock().await.is_empty());
2975 let metrics = messenger.metrics().snapshot();
2976 assert_eq!(metrics.delivered, 0);
2977 assert_eq!(metrics.rejected, 1);
2978 assert_eq!(metrics.unauthorized, 1);
2979 }
2980
2981 #[tokio::test]
2982 async fn limit_rejections_have_distinct_metrics() {
2983 let temp = TempDir::new().unwrap();
2984 let store = Arc::new(
2985 SessionStoreV2::new(temp.path().to_path_buf())
2986 .await
2987 .unwrap(),
2988 );
2989 store
2990 .save_session(&Session::new("target", "model"))
2991 .await
2992 .unwrap();
2993 let activation = Arc::new(RecordingActivation {
2994 calls: tokio::sync::Mutex::new(Vec::new()),
2995 });
2996 let inbox = Arc::new(FileSessionInbox::new(
2997 store.clone(),
2998 SessionInboxLimits {
2999 max_payload_bytes: 512,
3000 max_backlog: 1,
3001 max_claim_batch: 1,
3002 },
3003 ));
3004 let messenger = SessionMessenger::new(store, inbox, activation.clone());
3005
3006 let oversized = SessionMessageEnvelope::user_input("target", "x".repeat(2048));
3007 assert!(matches!(
3008 messenger.send(oversized).await,
3009 Err(SessionMessengerError::Inbox(
3010 SessionInboxError::PayloadTooLarge { .. }
3011 ))
3012 ));
3013 messenger
3014 .send(SessionMessageEnvelope::user_input("target", "first"))
3015 .await
3016 .unwrap();
3017 assert!(matches!(
3018 messenger
3019 .send(SessionMessageEnvelope::user_input("target", "second"))
3020 .await,
3021 Err(SessionMessengerError::Inbox(
3022 SessionInboxError::BacklogFull { .. }
3023 ))
3024 ));
3025
3026 let metrics = messenger.metrics().snapshot();
3027 assert_eq!(metrics.rejected, 2);
3028 assert_eq!(metrics.payload_too_large, 1);
3029 assert_eq!(metrics.backlog_full, 1);
3030 assert_eq!(activation.calls.lock().await.len(), 1);
3031 }
3032
3033 #[tokio::test]
3034 async fn explicit_peer_intent_keeps_legacy_compatibility_and_fences_changed_retry_after_ack() {
3035 let (_temp, store, activation, messenger) = fixture().await;
3036 seed_peer_tree(store.as_ref()).await;
3037 let receipt = messenger
3038 .send_peer_with_intent(
3039 "peer-left",
3040 "peer-right",
3041 "queue-operation",
3042 "input",
3043 SessionMessageDeliveryIntent::QueueOnly,
3044 )
3045 .await
3046 .unwrap();
3047 assert_eq!(receipt.activation, None);
3048 assert!(!receipt.active_delivery_notified);
3049 assert!(activation.calls.lock().await.is_empty());
3050 let original =
3051 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right").await;
3052 assert_eq!(
3053 messenger
3054 .send_peer_with_intent(
3055 "peer-left",
3056 "peer-right",
3057 "queue-operation",
3058 "input",
3059 SessionMessageDeliveryIntent::QueueOnly
3060 )
3061 .await
3062 .unwrap()
3063 .delivery,
3064 receipt.delivery
3065 );
3066 assert!(messenger
3067 .send_peer_with_intent(
3068 "peer-left",
3069 "peer-right",
3070 "queue-operation",
3071 "input",
3072 SessionMessageDeliveryIntent::TriggerTurn
3073 )
3074 .await
3075 .is_err());
3076 assert!(activation.calls.lock().await.is_empty());
3077 let reply = messenger
3078 .reply_peer_with_intent(
3079 "peer-right",
3080 original.id.as_str(),
3081 "reply-operation",
3082 "reply",
3083 SessionMessageDeliveryIntent::TriggerTurn,
3084 )
3085 .await
3086 .unwrap();
3087 assert_eq!(
3090 reply.activation,
3091 Some(SessionActivationDisposition::ActivationReserved)
3092 );
3093 assert_eq!(
3094 activation.calls.lock().await.as_slice(),
3095 &[("peer-left".to_string(), reply.delivery.generation)]
3096 );
3097 let claims = messenger.inbox().claim("peer-left", 8).await.unwrap();
3098 assert_eq!(claims[0].envelope.in_reply_to, Some(original.id));
3099 assert_eq!(
3100 claims[0].activation_policy,
3101 SessionActivationPolicy::RespectSpecificWait
3102 );
3103 messenger
3104 .send_peer("peer-left", "peer-right", "legacy-operation", "legacy")
3105 .await
3106 .unwrap();
3107 let claims = messenger.inbox().claim("peer-right", 8).await.unwrap();
3108 assert_eq!(
3109 claims[0].activation_policy,
3110 SessionActivationPolicy::InterruptSpecificWait
3111 );
3112 }
3113
3114 #[tokio::test]
3115 async fn explicit_peer_intents_deny_foreign_roots_before_admission_or_wake() {
3116 let (_temp, store, activation, messenger) = fixture().await;
3117 seed_peer_tree(store.as_ref()).await;
3118 store
3119 .save_session(&Session::new("foreign-root", "model"))
3120 .await
3121 .unwrap();
3122 for intent in [
3123 SessionMessageDeliveryIntent::QueueOnly,
3124 SessionMessageDeliveryIntent::TriggerTurn,
3125 ] {
3126 assert!(matches!(
3127 messenger
3128 .send_peer_with_intent("peer-left", "foreign-root", "denied", "input", intent)
3129 .await,
3130 Err(SessionMessengerError::Unauthorized { .. })
3131 ));
3132 }
3133 assert_eq!(
3134 messenger
3135 .inbox()
3136 .inspect("foreign-root")
3137 .await
3138 .unwrap()
3139 .pending,
3140 0
3141 );
3142 assert!(activation.calls.lock().await.is_empty());
3143 }
3144}