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, SessionInboxError, SessionInboxPort,
16 SessionInboxReceipt, SessionMessageBody, SessionMessageContent, SessionMessageDeliveryIntent,
17 SessionMessageEnvelope, SessionMessageId, SessionMessageKind, SessionMessageSource,
18 SupervisorReference,
19};
20
21pub const MAX_SUPERVISOR_FOLLOWUP_BYTES: usize = 16 * 1024;
23
24pub const MAX_PEER_MESSAGE_BYTES: usize = 16 * 1024;
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 )
349 .await
350 .map(SessionPeerDeliveryReceipt::into_legacy)
351 }
352
353 pub async fn send_peer_with_intent(
356 &self,
357 caller_session_id: &str,
358 target_session_id: &str,
359 operation_id: &str,
360 message: &str,
361 intent: SessionMessageDeliveryIntent,
362 ) -> Result<SessionPeerDeliveryReceipt, SessionMessengerError> {
363 self.send_peer_delivery(
364 caller_session_id,
365 target_session_id,
366 operation_id,
367 message,
368 Some(intent),
369 )
370 .await
371 }
372
373 pub async fn steer_peer(
377 &self,
378 caller_session_id: &str,
379 target_session_id: &str,
380 operation_id: &str,
381 message: &str,
382 expected_run_id: &str,
383 ) -> Result<bamboo_domain::SessionInboxSteerReceipt, SessionMessengerError> {
384 Self::validate_peer_input(operation_id, message)?;
385 if !bamboo_domain::valid_session_run_id(expected_run_id) {
386 return Err(SessionMessengerError::InvalidEnvelope(
387 "invalid expected run id".into(),
388 ));
389 }
390 let (caller, target) = self.peer_pair(caller_session_id, target_session_id).await?;
391 let id = Self::peer_message_id(&caller, &target, operation_id);
392 let envelope = Self::peer_envelope(
393 &caller,
394 &target,
395 operation_id,
396 message,
397 id.clone(),
398 id.to_string(),
399 None,
400 );
401 let source = ActorSession::from_session(&caller)
402 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
403 let target = ActorSession::from_session(&target)
404 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
405 let started = Instant::now();
406 let receipt = self
407 .inbox
408 .deliver_peer_steer(
409 &envelope,
410 &source,
411 &target,
412 expected_run_id,
413 self.activation.clone(),
414 )
415 .await
416 .map_err(|error| {
417 let error = SessionMessengerError::Inbox(error);
418 self.record_rejection(&error);
419 error
420 })?;
421 self.record_admission(envelope, receipt.delivery.clone(), started);
422 Ok(receipt)
423 }
424
425 async fn send_peer_delivery(
426 &self,
427 caller_session_id: &str,
428 target_session_id: &str,
429 operation_id: &str,
430 message: &str,
431 intent: Option<SessionMessageDeliveryIntent>,
432 ) -> Result<SessionPeerDeliveryReceipt, SessionMessengerError> {
433 Self::validate_peer_input(operation_id, message)?;
434 let (caller, target) = self.peer_pair(caller_session_id, target_session_id).await?;
435 let id = Self::peer_message_id(&caller, &target, operation_id);
436 let envelope = Self::peer_envelope(
437 &caller,
438 &target,
439 operation_id,
440 message,
441 id.clone(),
442 id.to_string(),
443 None,
444 );
445 self.send_peer_envelope(envelope, &caller, &target, None, intent)
446 .await
447 }
448
449 pub async fn reply_peer(
452 &self,
453 caller_session_id: &str,
454 received_message_id: &str,
455 operation_id: &str,
456 message: &str,
457 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
458 self.reply_peer_delivery(
459 caller_session_id,
460 received_message_id,
461 operation_id,
462 message,
463 None,
464 )
465 .await
466 .map(SessionPeerDeliveryReceipt::into_legacy)
467 }
468
469 pub async fn reply_peer_with_intent(
472 &self,
473 caller_session_id: &str,
474 received_message_id: &str,
475 operation_id: &str,
476 message: &str,
477 intent: SessionMessageDeliveryIntent,
478 ) -> Result<SessionPeerDeliveryReceipt, SessionMessengerError> {
479 self.reply_peer_delivery(
480 caller_session_id,
481 received_message_id,
482 operation_id,
483 message,
484 Some(intent),
485 )
486 .await
487 }
488
489 async fn reply_peer_delivery(
490 &self,
491 caller_session_id: &str,
492 received_message_id: &str,
493 operation_id: &str,
494 message: &str,
495 intent: Option<SessionMessageDeliveryIntent>,
496 ) -> Result<SessionPeerDeliveryReceipt, SessionMessengerError> {
497 Self::validate_peer_input(operation_id, message)?;
498 Self::validate_peer_session_id(caller_session_id)?;
499 let received_id = SessionMessageId::parse(received_message_id)
500 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
501 let received = self
502 .load_session(caller_session_id)
503 .await?
504 .ok_or_else(|| SessionMessengerError::SourceNotFound(caller_session_id.into()))?;
505 let original = received
506 .messages
507 .iter()
508 .filter(|message| message.id == received_message_id)
509 .find_map(|message| {
510 let envelope: SessionMessageEnvelope = serde_json::from_value(
511 message.metadata.as_ref()?.get("session_message")?.clone(),
512 )
513 .ok()?;
514 (envelope.id == received_id
515 && envelope.kind == SessionMessageKind::PeerMessage
516 && envelope.target_session_id == caller_session_id
517 && bamboo_domain::is_matching_session_message(message, &envelope))
518 .then_some(envelope)
519 })
520 .ok_or_else(|| {
521 SessionMessengerError::InvalidEnvelope(
522 "reply requires a canonical received peer message".into(),
523 )
524 })?;
525 let SessionMessageSource::Session {
526 session_id: target_id,
527 } = &original.source
528 else {
529 return Err(SessionMessengerError::InvalidEnvelope(
530 "reply has no logical sender".into(),
531 ));
532 };
533 let (caller, target) = self.peer_pair(caller_session_id, target_id).await?;
534 let thread = original.thread_id.clone().ok_or_else(|| {
535 SessionMessengerError::InvalidEnvelope("reply requires a threaded peer message".into())
536 })?;
537 let original_operation = original.correlation_id.as_deref().ok_or_else(|| {
538 SessionMessengerError::InvalidEnvelope(
539 "reply requires a peer operation identity".into(),
540 )
541 })?;
542 if original.id != Self::peer_message_id(&target, &caller, original_operation) {
543 return Err(SessionMessengerError::InvalidEnvelope(
544 "received peer message belongs to a different session lifetime".into(),
545 ));
546 }
547 SessionMessageId::parse(&thread)
548 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
549 let envelope = Self::peer_envelope(
550 &caller,
551 &target,
552 operation_id,
553 message,
554 Self::peer_message_id(&caller, &target, operation_id),
555 thread,
556 Some(original.id.clone()),
557 );
558 self.send_peer_envelope(envelope, &caller, &target, Some(&original), intent)
559 .await
560 }
561
562 fn validate_peer_input(operation_id: &str, message: &str) -> Result<(), SessionMessengerError> {
563 SessionMessageId::parse(operation_id)
564 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
565 if message.trim().is_empty()
566 || message.len() > MAX_PEER_MESSAGE_BYTES
567 || message.contains('\0')
568 {
569 return Err(SessionMessengerError::InvalidEnvelope(format!(
570 "peer message must contain 1..={MAX_PEER_MESSAGE_BYTES} UTF-8 bytes without NUL"
571 )));
572 }
573 Ok(())
574 }
575
576 fn validate_peer_session_id(id: &str) -> Result<(), SessionMessengerError> {
577 bamboo_domain::validate_supervisor_target_id(id)
578 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.into()))?;
579 SessionMessageId::parse(id)
580 .map(|_| ())
581 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))
582 }
583
584 fn bounded_peer_label(value: &str, max_bytes: usize) -> String {
585 let mut end = value.len().min(max_bytes);
586 while !value.is_char_boundary(end) {
587 end -= 1;
588 }
589 value[..end].to_owned()
590 }
591
592 async fn peer_member(&self, id: &str, source: bool) -> Result<Session, SessionMessengerError> {
593 Self::validate_peer_session_id(id)?;
594 let session = self
595 .sessions
596 .load_runtime_control_plane(id)
597 .await
598 .map_err(|error| SessionMessengerError::Storage(error.to_string()))?
599 .ok_or_else(|| {
600 if source {
601 SessionMessengerError::SourceNotFound(id.into())
602 } else {
603 SessionMessengerError::TargetNotFound(id.into())
604 }
605 })?;
606 let unauthorized = || SessionMessengerError::Unauthorized {
607 source_session_id: id.into(),
608 target: session.root_session_id.clone(),
609 };
610 if session.id != id
611 || session
612 .runtime_metadata
613 .as_ref()
614 .and_then(|meta| meta.project_id.as_ref())
615 .zip(session.metadata.get("project_id"))
616 .is_some_and(|(typed, legacy)| typed != legacy)
617 || bamboo_domain::ActorSession::from_session(&session).is_err()
618 {
619 return Err(unauthorized());
620 }
621 if session.kind == bamboo_domain::SessionKind::Root {
622 return Ok(session);
623 }
624 crate::session_app::child_session::owned_tree::load_owned_descendant(
625 &PeerTreePort(self.sessions.as_ref()),
626 &session.root_session_id,
627 id,
628 )
629 .await
630 .map_err(|_| unauthorized())
631 }
632
633 async fn peer_pair(
634 &self,
635 caller_id: &str,
636 target_id: &str,
637 ) -> Result<(Session, Session), SessionMessengerError> {
638 let caller = self.peer_member(caller_id, true).await?;
639 let target = self.peer_member(target_id, false).await?;
640 if caller_id == target_id
641 || caller.root_session_id != target.root_session_id
642 || caller.project_id_meta() != target.project_id_meta()
643 {
644 return Err(SessionMessengerError::Unauthorized {
645 source_session_id: caller_id.into(),
646 target: target_id.into(),
647 });
648 }
649 Ok((caller, target))
650 }
651
652 fn peer_message_id(caller: &Session, target: &Session, operation_id: &str) -> SessionMessageId {
653 SessionMessageId::stable(
654 "session-peer-v1",
655 &serde_json::json!({
656 "source": caller.id, "source_birth": caller.created_at,
657 "target": target.id, "target_birth": target.created_at,
658 "operation_id": operation_id,
659 }),
660 )
661 }
662
663 #[allow(clippy::too_many_arguments)]
664 fn peer_envelope(
665 caller: &Session,
666 target: &Session,
667 operation_id: &str,
668 message: &str,
669 id: SessionMessageId,
670 thread_id: String,
671 in_reply_to: Option<SessionMessageId>,
672 ) -> SessionMessageEnvelope {
673 SessionMessageEnvelope {
674 id,
675 source: SessionMessageSource::Session {
676 session_id: caller.id.clone(),
677 },
678 target_session_id: target.id.clone(),
679 kind: SessionMessageKind::PeerMessage,
680 body: SessionMessageBody::Content(SessionMessageContent::text(message)),
681 created_at: chrono::Utc::now(),
682 thread_id: Some(thread_id),
683 in_reply_to,
684 attempt: None,
685 correlation_id: Some(operation_id.into()),
686 }
687 }
688
689 async fn send_peer_envelope(
690 &self,
691 envelope: SessionMessageEnvelope,
692 caller: &Session,
693 target: &Session,
694 reply_to: Option<&SessionMessageEnvelope>,
695 intent: Option<SessionMessageDeliveryIntent>,
696 ) -> Result<SessionPeerDeliveryReceipt, SessionMessengerError> {
697 let started = Instant::now();
698 let source = ActorSession::from_session(caller)
699 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
700 let target = ActorSession::from_session(target)
701 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
702 let result = match intent {
706 Some(intent) => {
707 self.inbox
708 .deliver_peer_message_with_intent(&envelope, &source, &target, reply_to, intent)
709 .await
710 }
711 None => {
712 self.inbox
713 .deliver_peer_message(&envelope, &source, &target, reply_to)
714 .await
715 .map(|delivery| bamboo_domain::SessionInboxDeliveryReceipt {
716 delivery,
717 pending: true,
719 })
720 }
721 };
722 let delivery = match result {
723 Ok(receipt) => receipt,
724 Err(error) => {
725 let error = SessionMessengerError::Inbox(error);
726 self.record_rejection(&error);
727 return Err(error);
728 }
729 };
730 let pending = delivery.pending;
731 let admission = self.record_admission(envelope, delivery.delivery, started);
732 if !pending {
736 return Ok(SessionPeerDeliveryReceipt {
737 delivery: admission.delivery,
738 activation: None,
739 active_delivery_notified: false,
740 });
741 }
742 if intent == Some(SessionMessageDeliveryIntent::QueueOnly) {
743 let active_delivery_notified = self
744 .activation
745 .notify_active_delivery(&admission.target_session_id, admission.delivery.generation)
746 .await;
747 return Ok(SessionPeerDeliveryReceipt {
748 delivery: admission.delivery,
749 activation: None,
750 active_delivery_notified,
751 });
752 }
753 self.activate_prepared(&admission).await.map(Into::into)
754 }
755
756 pub async fn supervisor_loop_control(
760 &self,
761 supervisor: &SupervisorReference,
762 target_session_id: &str,
763 expected_link_revision: Option<u64>,
764 command: &bamboo_domain::LoopControlRequest,
765 receipt_operation_id: Option<&str>,
766 ) -> Result<serde_json::Value, SessionMessengerError> {
767 use bamboo_domain::{LoopControlAction, LoopControlRequest, ScopedLoopControlInput};
768 let invalid = |message: String| SessionMessengerError::InvalidEnvelope(message);
769 bamboo_domain::validate_supervisor_target_id(target_session_id)
770 .map_err(|error| invalid(error.into()))?;
771 bamboo_domain::validate_scoped_loop_control_command(command).map_err(invalid)?;
772 let status = matches!(command, LoopControlRequest::Status);
773 if expected_link_revision == Some(0)
774 || (!status && expected_link_revision.is_none())
775 || (!status && receipt_operation_id.is_some())
776 {
777 return Err(invalid("loop mutations require the exact nonzero link revision; receipt lookup is status-only".into()));
778 }
779 if let Some(operation) = receipt_operation_id {
780 uuid::Uuid::parse_str(operation)
781 .map_err(|_| invalid("receipt operation must be a UUID".into()))?;
782 }
783 let binding = self
784 .activation
785 .observe_supervisor_loop(
786 supervisor,
787 target_session_id,
788 expected_link_revision,
789 LoopControlAction::of(command),
790 )
791 .await?;
792 binding.authorize(command).map_err(invalid)?;
793 let canonical = self
794 .sessions
795 .load_scoped_loop_session(&binding, self.activation.clone())
796 .await
797 .map_err(|error| SessionMessengerError::Storage(error.to_string()))?
798 .ok_or_else(|| SessionMessengerError::TargetNotFound(target_session_id.into()))?;
799 if status {
800 let mut snapshot = serde_json::to_value(
801 crate::runtime::loop_control::inspect_loop(&canonical)
802 .map_err(|error| SessionMessengerError::Storage(error.to_string()))?,
803 )
804 .map_err(|error| SessionMessengerError::Storage(error.to_string()))?;
805 for field in ["objective", "pending_objective"] {
808 let text = snapshot[field].as_str().map(str::to_owned);
809 if let Some(text) = text {
810 let mut end = text.len().min(1024);
811 while !text.is_char_boundary(end) {
812 end -= 1;
813 }
814 snapshot[field] = serde_json::Value::String(text[..end].into());
815 snapshot[format!("{field}_truncated")] =
816 serde_json::Value::Bool(end < text.len());
817 }
818 }
819 let records = bamboo_domain::scoped_loop_consumptions(&canonical)
820 .map_err(SessionMessengerError::Storage)?;
821 let partial_checkpoint = records.iter().any(|record| {
822 record.binding == binding
823 && receipt_operation_id.is_none_or(|id| id == record.operation_id)
824 && !canonical
825 .session_inbox_admission()
826 .is_some_and(|cursor| cursor.contains(&record.input_id))
827 });
828 let record = records.into_iter().rev().find(|record| {
829 record.binding == binding
830 && receipt_operation_id.is_none_or(|id| id == record.operation_id)
831 && canonical
832 .session_inbox_admission()
833 .is_some_and(|cursor| cursor.contains(&record.input_id))
834 });
835 let state = crate::runtime::counted_loop::read_loop_state(&canonical)
836 .map_err(|error| SessionMessengerError::Storage(error.to_string()))?;
837 let objective_applied_actual = record
838 .as_ref()
839 .filter(|record| record.configured)
840 .and_then(|record| record.receipt.as_ref())
841 .zip(state.as_ref())
842 .is_some_and(|(receipt, state)| {
843 receipt["generation"].as_str() == Some(state.generation.as_str())
844 && canonical.messages.iter().any(|message| {
845 message.role == bamboo_domain::Role::User
846 && message.metadata.as_ref().is_some_and(|metadata| {
847 metadata["runtime_kind"].as_str()
848 == Some("counted_loop_iteration")
849 && metadata["loop_generation"] == receipt["generation"]
850 && metadata["objective_revision"]
851 == receipt["objective_revision"]
852 })
853 })
854 && state.control.as_ref().is_some_and(|control| {
855 receipt["accepted_revision"]
856 .as_u64()
857 .is_some_and(|revision| control.applied_revision >= revision)
858 && receipt["objective_revision"]
859 .as_u64()
860 .is_some_and(|revision| {
861 revision > 0
862 && control.iteration_objective_revision >= revision
863 })
864 })
865 });
866 return Ok(serde_json::json!({
867 "action":"loop", "command":"status", "target_session_id":target_session_id,
868 "link_revision":binding.link_revision, "owner_run_id":binding.owner_run_id,
869 "execution_id":binding.execution_id, "loop":snapshot,
870 "host_max_iterations":binding.max_iterations, "host_max_total_rounds":binding.max_total_rounds,
871 "configured":record.as_ref().is_some_and(|record|record.configured),
872 "checkpoint_observation":if partial_checkpoint { "partial_or_unknown" } else if record.is_some() { "confirmed" } else { "not_observed" },
873 "operation_receipt":record, "objective_applied_actual":objective_applied_actual,
874 "note":"Configuration receipts are immutable target checkpoints; objective_applied_actual additionally requires its real iteration-admission checkpoint. Neither is a task result."
875 }));
876 }
877 let input = ScopedLoopControlInput {
878 binding,
879 command: command.clone(),
880 };
881 let envelope = input.envelope().map_err(invalid)?;
882 if let Some(record) = bamboo_domain::scoped_loop_consumptions(&canonical)
883 .map_err(SessionMessengerError::Storage)?
884 .into_iter()
885 .find(|record| record.input_id == envelope.id)
886 {
887 if !bamboo_domain::has_scoped_loop_consumption(&canonical, &envelope) {
888 return Err(invalid(
889 "operation payload conflicts with its immutable target receipt".into(),
890 ));
891 }
892 if canonical
893 .session_inbox_admission()
894 .is_some_and(|cursor| cursor.contains(&record.input_id))
895 {
896 return Ok(serde_json::json!({
897 "action":"loop", "target_session_id":target_session_id,
898 "operation_id":input.operation_id(), "receipt_id":envelope.id,
899 "admitted":true, "replayed":true, "configured":record.configured,
900 "operation_receipt":record, "objective_applied_actual":false,
901 "note":"The original configuration receipt is replayed; use status for current objective application."
902 }));
903 }
904 }
908 let started = Instant::now();
909 let receipt = self
910 .inbox
911 .deliver_supervisor_loop_control(&input.binding, &envelope, self.activation.clone())
912 .await?;
913 self.record_admission(envelope, receipt.delivery.clone(), started);
914 Ok(serde_json::json!({
915 "action":"loop", "target_session_id":target_session_id,
916 "operation_id":input.operation_id(), "receipt_id":receipt.delivery.id,
917 "generation":receipt.delivery.generation, "admitted":true,
918 "replayed":receipt.replayed, "active_delivery_notified":receipt.active_delivery_notified,
919 "configured":false, "target_application_observed":false,
920 "objective_applied_actual":false, "operation_receipt":null,
921 "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."
922 }))
923 }
924
925 pub async fn supervisor_followup(
929 &self,
930 supervisor: &SupervisorReference,
931 target_session_id: &str,
932 operation_id: &str,
933 message: &str,
934 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
935 bamboo_domain::validate_supervisor_target_id(target_session_id)
936 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
937 SessionMessageId::parse(operation_id)
938 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
939 if message.trim().is_empty() || message.len() > MAX_SUPERVISOR_FOLLOWUP_BYTES {
940 return Err(SessionMessengerError::InvalidEnvelope(format!(
941 "followup message must contain 1..={MAX_SUPERVISOR_FOLLOWUP_BYTES} UTF-8 bytes"
942 )));
943 }
944 let envelope = SessionMessageEnvelope {
945 id: SessionMessageId::stable(
946 "supervisor_followup",
947 &serde_json::json!({
948 "supervisor": supervisor,
949 "target_session_id": target_session_id,
950 "operation_id": operation_id,
951 }),
952 ),
953 source: SessionMessageSource::Session {
954 session_id: supervisor.session_id.clone(),
955 },
956 target_session_id: target_session_id.to_string(),
957 kind: SessionMessageKind::PeerMessage,
958 body: SessionMessageBody::Content(SessionMessageContent::text(message)),
959 created_at: chrono::Utc::now(),
960 thread_id: None,
961 in_reply_to: None,
962 attempt: None,
963 correlation_id: Some(operation_id.to_string()),
964 };
965 let started = Instant::now();
966 let delivery = self
967 .inbox
968 .deliver_supervisor_followup(supervisor, &envelope)
969 .await
970 .map_err(|error| {
971 let error = SessionMessengerError::Inbox(error);
972 self.record_rejection(&error);
973 error
974 })?;
975 let admission = self.record_admission(envelope, delivery, started);
976 self.activate_prepared(&admission).await
979 }
980
981 async fn load_session(&self, id: &str) -> Result<Option<Session>, SessionMessengerError> {
982 self.sessions
983 .load_session(id)
984 .await
985 .map_err(|error| SessionMessengerError::Storage(error.to_string()))
986 }
987
988 fn logical_root(session: &Session) -> &str {
989 if session.root_session_id.trim().is_empty() {
990 &session.id
991 } else {
992 &session.root_session_id
993 }
994 }
995
996 async fn validate_relationship(
997 &self,
998 envelope: &SessionMessageEnvelope,
999 ) -> Result<(), SessionMessengerError> {
1000 envelope
1001 .validate()
1002 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
1003 let target = self
1004 .load_session(&envelope.target_session_id)
1005 .await?
1006 .ok_or_else(|| {
1007 SessionMessengerError::TargetNotFound(envelope.target_session_id.clone())
1008 })?;
1009
1010 let SessionMessageSource::Session { session_id } = &envelope.source else {
1011 return Ok(());
1012 };
1013 let source = self
1014 .load_session(session_id)
1015 .await?
1016 .ok_or_else(|| SessionMessengerError::SourceNotFound(session_id.clone()))?;
1017
1018 let same_root = Self::logical_root(&source) == Self::logical_root(&target);
1019 let source_project = source.project_id_meta();
1020 let target_project = target.project_id_meta();
1021 let project_compatible = match (source_project.as_deref(), target_project.as_deref()) {
1022 (Some(left), Some(right)) => left == right,
1023 _ => true,
1026 };
1027 if !same_root || !project_compatible {
1028 return Err(SessionMessengerError::Unauthorized {
1029 source_session_id: source.id,
1030 target: target.id,
1031 });
1032 }
1033 Ok(())
1034 }
1035
1036 fn record_rejection(&self, error: &SessionMessengerError) {
1037 self.metrics.rejected.fetch_add(1, Ordering::Relaxed);
1038 match error {
1039 SessionMessengerError::InvalidEnvelope(_) => {
1040 self.metrics
1041 .invalid_envelope
1042 .fetch_add(1, Ordering::Relaxed);
1043 }
1044 SessionMessengerError::Unauthorized { .. }
1045 | SessionMessengerError::SourceNotFound(_)
1046 | SessionMessengerError::TargetNotFound(_) => {
1047 self.metrics.unauthorized.fetch_add(1, Ordering::Relaxed);
1048 }
1049 SessionMessengerError::Inbox(SessionInboxError::AdmissionCancelled)
1050 | SessionMessengerError::Inbox(SessionInboxError::ExpectedRunConflict(_)) => {}
1051 SessionMessengerError::Inbox(SessionInboxError::PayloadTooLarge { .. }) => {
1052 self.metrics
1053 .payload_too_large
1054 .fetch_add(1, Ordering::Relaxed);
1055 }
1056 SessionMessengerError::Inbox(SessionInboxError::BacklogFull { .. }) => {
1057 self.metrics.backlog_full.fetch_add(1, Ordering::Relaxed);
1058 }
1059 SessionMessengerError::Inbox(_) | SessionMessengerError::Storage(_) => {
1060 self.metrics.storage_failed.fetch_add(1, Ordering::Relaxed);
1061 }
1062 SessionMessengerError::Activation { .. }
1063 | SessionMessengerError::ActivationEligibility { .. } => {}
1064 }
1065 }
1066
1067 pub async fn admit(
1068 &self,
1069 envelope: SessionMessageEnvelope,
1070 ) -> Result<SessionMessengerAdmission, SessionMessengerError> {
1071 self.admit_with_gate(envelope, None).await
1072 }
1073
1074 pub async fn admit_with_gate(
1075 &self,
1076 envelope: SessionMessageEnvelope,
1077 gate: Option<&bamboo_domain::AdmissionGate>,
1078 ) -> Result<SessionMessengerAdmission, SessionMessengerError> {
1079 self.admit_with_delivery_intent(envelope, gate, None).await
1080 }
1081
1082 pub async fn admit_with_activation_intent(
1085 &self,
1086 envelope: SessionMessageEnvelope,
1087 policy: SessionActivationPolicy,
1088 gate: Option<&bamboo_domain::AdmissionGate>,
1089 ) -> Result<SessionMessengerAdmission, SessionMessengerError> {
1090 self.admit_with_delivery_intent(envelope, gate, Some(policy))
1091 .await
1092 }
1093
1094 async fn admit_with_delivery_intent(
1095 &self,
1096 envelope: SessionMessageEnvelope,
1097 gate: Option<&bamboo_domain::AdmissionGate>,
1098 policy: Option<SessionActivationPolicy>,
1099 ) -> Result<SessionMessengerAdmission, SessionMessengerError> {
1100 let started = Instant::now();
1101 if let Err(error) = self.validate_relationship(&envelope).await {
1102 self.record_rejection(&error);
1103 tracing::warn!(
1104 message_id = %envelope.id,
1105 target_session_id = %envelope.target_session_id,
1106 error = %error,
1107 "session message rejected"
1108 );
1109 return Err(error);
1110 }
1111
1112 let delivery = match (policy, gate) {
1113 (Some(policy), gate) => {
1114 self.inbox
1115 .deliver_with_activation_intent(&envelope, policy, gate)
1116 .await
1117 }
1118 (None, Some(gate)) => self.inbox.deliver_with_gate(&envelope, gate).await,
1119 (None, None) => self.inbox.deliver(&envelope).await,
1120 };
1121 let delivery = match delivery {
1122 Ok(receipt) => receipt,
1123 Err(error) => {
1124 let error = SessionMessengerError::Inbox(error);
1125 self.record_rejection(&error);
1126 return Err(error);
1127 }
1128 };
1129 Ok(self.record_admission(envelope, delivery, started))
1130 }
1131
1132 fn record_admission(
1133 &self,
1134 envelope: SessionMessageEnvelope,
1135 delivery: SessionInboxReceipt,
1136 started: Instant,
1137 ) -> SessionMessengerAdmission {
1138 self.metrics.delivered.fetch_add(1, Ordering::Relaxed);
1139 self.metrics.delivery_latency_micros.fetch_add(
1140 started.elapsed().as_micros().min(u128::from(u64::MAX)) as u64,
1141 Ordering::Relaxed,
1142 );
1143 SessionMessengerAdmission {
1144 envelope_id: envelope.id.as_str().to_string(),
1145 target_session_id: envelope.target_session_id,
1146 delivery,
1147 }
1148 }
1149
1150 pub async fn activate(
1151 &self,
1152 admission: &SessionMessengerAdmission,
1153 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
1154 self.activate_with_policy(admission, SessionActivationPolicy::InterruptSpecificWait)
1155 .await
1156 }
1157
1158 pub async fn activate_with_policy(
1159 &self,
1160 admission: &SessionMessengerAdmission,
1161 policy: SessionActivationPolicy,
1162 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
1163 if let Err(error) = self
1164 .inbox
1165 .mark_activation_eligible(
1166 &admission.target_session_id,
1167 admission.delivery.generation,
1168 policy,
1169 )
1170 .await
1171 {
1172 self.metrics
1173 .activation_failed
1174 .fetch_add(1, Ordering::Relaxed);
1175 return Err(SessionMessengerError::ActivationEligibility {
1176 receipt_id: admission.delivery.id.to_string(),
1177 receipt: admission.delivery.clone(),
1178 source: error,
1179 });
1180 }
1181 self.activate_prepared(admission).await
1182 }
1183
1184 pub async fn activate_prepared(
1187 &self,
1188 admission: &SessionMessengerAdmission,
1189 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
1190 let activation = match self
1191 .activation
1192 .request_activation(&admission.target_session_id, admission.delivery.generation)
1193 .await
1194 {
1195 Ok(disposition) => disposition,
1196 Err(source) => {
1197 self.metrics
1198 .activation_failed
1199 .fetch_add(1, Ordering::Relaxed);
1200 tracing::error!(
1201 message_id = %admission.envelope_id,
1202 target_session_id = %admission.target_session_id,
1203 generation = admission.delivery.generation,
1204 error = %source,
1205 "session message durable but activation failed"
1206 );
1207 return Err(SessionMessengerError::Activation {
1208 receipt_id: admission.delivery.id.to_string(),
1209 receipt: admission.delivery.clone(),
1210 source,
1211 });
1212 }
1213 };
1214 match activation {
1215 SessionActivationDisposition::ActiveNotified => {
1216 self.metrics.active_notified.fetch_add(1, Ordering::Relaxed);
1217 }
1218 SessionActivationDisposition::ActivationReserved => {
1219 self.metrics
1220 .activation_reserved
1221 .fetch_add(1, Ordering::Relaxed);
1222 }
1223 SessionActivationDisposition::ActivationCoalesced => {
1224 self.metrics
1225 .activation_coalesced
1226 .fetch_add(1, Ordering::Relaxed);
1227 }
1228 }
1229 tracing::info!(
1230 message_id = %admission.envelope_id,
1231 target_session_id = %admission.target_session_id,
1232 generation = admission.delivery.generation,
1233 ?activation,
1234 "session message durably delivered"
1235 );
1236 Ok(SessionMessengerReceipt {
1237 delivery: admission.delivery.clone(),
1238 activation,
1239 })
1240 }
1241
1242 pub async fn prepare_activation(
1251 &self,
1252 admission: &SessionMessengerAdmission,
1253 ) -> Result<(), SessionMessengerError> {
1254 self.inbox
1255 .mark_activation_eligible(
1256 &admission.target_session_id,
1257 admission.delivery.generation,
1258 SessionActivationPolicy::RespectSpecificWait,
1259 )
1260 .await
1261 .map_err(SessionMessengerError::Inbox)
1262 }
1263
1264 pub async fn prepare_message_activation(
1267 &self,
1268 admission: &SessionMessengerAdmission,
1269 policy: SessionActivationPolicy,
1270 ) -> Result<(), SessionMessengerError> {
1271 self.inbox
1272 .mark_message_activation_eligible(
1273 &admission.target_session_id,
1274 &admission.delivery,
1275 policy,
1276 )
1277 .await
1278 .map_err(SessionMessengerError::Inbox)
1279 }
1280
1281 pub async fn send(
1282 &self,
1283 envelope: SessionMessageEnvelope,
1284 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
1285 self.send_with_gate(envelope, None).await
1286 }
1287
1288 pub async fn send_with_gate(
1289 &self,
1290 envelope: SessionMessageEnvelope,
1291 gate: Option<&bamboo_domain::AdmissionGate>,
1292 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
1293 let admission = self
1294 .admit_with_activation_intent(
1295 envelope,
1296 SessionActivationPolicy::InterruptSpecificWait,
1297 gate,
1298 )
1299 .await?;
1300 self.activate_prepared(&admission).await
1301 }
1302
1303 pub async fn send_with_policy(
1304 &self,
1305 envelope: SessionMessageEnvelope,
1306 policy: SessionActivationPolicy,
1307 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
1308 let admission = self
1309 .admit_with_activation_intent(envelope, policy, None)
1310 .await?;
1311 self.activate_prepared(&admission).await
1312 }
1313}
1314
1315#[cfg(test)]
1316mod tests {
1317 use super::*;
1318 use async_trait::async_trait;
1319 use bamboo_domain::{
1320 SessionActivationDisposition, SessionInboxLimits, SessionMessageBody,
1321 SessionMessageContent, SessionMessageId, SessionMessageKind,
1322 };
1323 use bamboo_storage::{FileSessionInbox, SessionStoreV2};
1324 use tempfile::TempDir;
1325
1326 struct RecordingActivation {
1327 calls: tokio::sync::Mutex<Vec<(String, u64)>>,
1328 }
1329
1330 struct FailingActivation;
1331
1332 #[async_trait]
1333 impl SessionActivationPort for FailingActivation {
1334 async fn request_activation(
1335 &self,
1336 _target_session_id: &str,
1337 _inbox_generation: u64,
1338 ) -> Result<SessionActivationDisposition, SessionActivationError> {
1339 Err(SessionActivationError::Internal(
1340 "injected wakeup failure".into(),
1341 ))
1342 }
1343 }
1344
1345 #[tokio::test]
1346 async fn immediate_activation_failure_preserves_receipt_and_restart_permission() {
1347 let (temp, store, recording, _messenger) = fixture().await;
1348 store
1349 .save_session(&Session::new("root", "model"))
1350 .await
1351 .unwrap();
1352 let inbox = Arc::new(FileSessionInbox::new(
1353 store.clone(),
1354 SessionInboxLimits::default(),
1355 ));
1356 let messenger = SessionMessenger::new(store, inbox, Arc::new(FailingActivation));
1357 let envelope = SessionMessageEnvelope::user_input("root", "steer");
1358 let gate = bamboo_domain::AdmissionGate::default();
1359 let receipt = match messenger
1360 .send_with_gate(envelope.clone(), Some(&gate))
1361 .await
1362 .unwrap_err()
1363 {
1364 SessionMessengerError::Activation { receipt, .. } => receipt,
1365 error => panic!("expected durable delivery with failed wakeup: {error}"),
1366 };
1367 assert!(gate.is_committed());
1368 gate.cancel_if_pending();
1369 let restarted_store = Arc::new(SessionStoreV2::new(temp.path().into()).await.unwrap());
1370 let restarted = Arc::new(FileSessionInbox::new(
1371 restarted_store.clone(),
1372 SessionInboxLimits::default(),
1373 ));
1374 let backlog = restarted.inspect("root").await.unwrap();
1375 assert!(backlog.activation_pending());
1376 assert_eq!(backlog.coordinator_generation, 0);
1377 recording
1380 .request_activation("root", backlog.activation_generation)
1381 .await
1382 .unwrap();
1383 let retry = SessionMessenger::new(restarted_store, restarted.clone(), recording.clone());
1384 assert_eq!(
1385 retry
1386 .send_with_gate(envelope, Some(&gate))
1387 .await
1388 .unwrap()
1389 .delivery,
1390 receipt
1391 );
1392 assert_eq!(restarted.inspect("root").await.unwrap().pending, 1);
1393 assert_eq!(messenger.metrics().snapshot().activation_failed, 1);
1394 let claims = restarted.claim("root", 128).await.unwrap();
1395 assert_eq!(claims.len(), 1);
1396 assert_eq!(claims[0].envelope.id, receipt.id);
1397 }
1398
1399 struct FailEligibilityOnce {
1400 inner: Arc<FileSessionInbox>,
1401 fail_next: std::sync::atomic::AtomicBool,
1402 }
1403
1404 #[async_trait]
1405 impl SessionInboxPort for FailEligibilityOnce {
1406 async fn deliver(
1407 &self,
1408 envelope: &SessionMessageEnvelope,
1409 ) -> Result<SessionInboxReceipt, SessionInboxError> {
1410 self.inner.deliver(envelope).await
1411 }
1412
1413 async fn deliver_with_gate(
1414 &self,
1415 envelope: &SessionMessageEnvelope,
1416 gate: &bamboo_domain::AdmissionGate,
1417 ) -> Result<SessionInboxReceipt, SessionInboxError> {
1418 self.inner.deliver_with_gate(envelope, gate).await
1419 }
1420
1421 async fn mark_activation_eligible(
1422 &self,
1423 target_session_id: &str,
1424 generation: u64,
1425 policy: SessionActivationPolicy,
1426 ) -> Result<(), SessionInboxError> {
1427 if self.fail_next.swap(false, Ordering::SeqCst) {
1428 return Err(SessionInboxError::Storage(
1429 "injected activation watermark failure".into(),
1430 ));
1431 }
1432 self.inner
1433 .mark_activation_eligible(target_session_id, generation, policy)
1434 .await
1435 }
1436
1437 async fn claim(
1438 &self,
1439 target_session_id: &str,
1440 limit: usize,
1441 ) -> Result<Vec<bamboo_domain::SessionInboxClaim>, SessionInboxError> {
1442 self.inner.claim(target_session_id, limit).await
1443 }
1444
1445 async fn was_admitted(
1446 &self,
1447 target_session_id: &str,
1448 id: &SessionMessageId,
1449 ) -> Result<bool, SessionInboxError> {
1450 self.inner.was_admitted(target_session_id, id).await
1451 }
1452
1453 async fn ack(
1454 &self,
1455 target_session_id: &str,
1456 claim: &bamboo_domain::SessionInboxClaim,
1457 ) -> Result<(), SessionInboxError> {
1458 self.inner.ack(target_session_id, claim).await
1459 }
1460
1461 async fn inspect(
1462 &self,
1463 target_session_id: &str,
1464 ) -> Result<bamboo_domain::SessionInboxBacklog, SessionInboxError> {
1465 self.inner.inspect(target_session_id).await
1466 }
1467 }
1468
1469 #[async_trait]
1470 impl SessionActivationPort for RecordingActivation {
1471 async fn request_activation(
1472 &self,
1473 target_session_id: &str,
1474 inbox_generation: u64,
1475 ) -> Result<SessionActivationDisposition, SessionActivationError> {
1476 self.calls
1477 .lock()
1478 .await
1479 .push((target_session_id.to_string(), inbox_generation));
1480 Ok(SessionActivationDisposition::ActivationReserved)
1481 }
1482 }
1483
1484 async fn fixture() -> (
1485 TempDir,
1486 Arc<SessionStoreV2>,
1487 Arc<RecordingActivation>,
1488 SessionMessenger,
1489 ) {
1490 let temp = TempDir::new().unwrap();
1491 let store = Arc::new(
1492 SessionStoreV2::new(temp.path().to_path_buf())
1493 .await
1494 .unwrap(),
1495 );
1496 let activation = Arc::new(RecordingActivation {
1497 calls: tokio::sync::Mutex::new(Vec::new()),
1498 });
1499 let inbox = Arc::new(FileSessionInbox::new(
1500 store.clone(),
1501 SessionInboxLimits::default(),
1502 ));
1503 let messenger = SessionMessenger::new(store.clone(), inbox, activation.clone());
1504 (temp, store, activation, messenger)
1505 }
1506
1507 fn peer(source: &str, target: &str, id: &str) -> SessionMessageEnvelope {
1508 SessionMessageEnvelope {
1509 id: SessionMessageId::parse(id).unwrap(),
1510 source: SessionMessageSource::Session {
1511 session_id: source.to_string(),
1512 },
1513 target_session_id: target.to_string(),
1514 kind: SessionMessageKind::PeerMessage,
1515 body: SessionMessageBody::Content(SessionMessageContent::text("hello")),
1516 created_at: chrono::Utc::now(),
1517 thread_id: None,
1518 in_reply_to: None,
1519 attempt: None,
1520 correlation_id: None,
1521 }
1522 }
1523
1524 #[derive(Default)]
1525 struct PeerMemoryStorage(tokio::sync::RwLock<std::collections::HashMap<String, Session>>);
1526
1527 #[async_trait]
1528 impl Storage for PeerMemoryStorage {
1529 async fn save_session(&self, session: &Session) -> std::io::Result<()> {
1530 self.0
1531 .write()
1532 .await
1533 .insert(session.id.clone(), session.clone());
1534 Ok(())
1535 }
1536
1537 async fn load_session(&self, id: &str) -> std::io::Result<Option<Session>> {
1538 Ok(self.0.read().await.get(id).cloned())
1539 }
1540
1541 async fn delete_session(&self, id: &str) -> std::io::Result<bool> {
1542 Ok(self.0.write().await.remove(id).is_some())
1543 }
1544
1545 async fn list_child_run_statuses(
1546 &self,
1547 parent: &str,
1548 ) -> std::io::Result<Vec<(String, Option<String>)>> {
1549 Ok(self
1550 .0
1551 .read()
1552 .await
1553 .values()
1554 .filter(|session| session.parent_session_id.as_deref() == Some(parent))
1555 .map(|session| (session.id.clone(), session.last_run_status()))
1556 .collect())
1557 }
1558 }
1559
1560 async fn seed_peer_tree(store: &dyn Storage) {
1561 let mut root = Session::new("peer-root", "model");
1562 root.title = "Logical root".into();
1563 root.set_project_id_meta("peer-project");
1564 let left = Session::new_child_of("peer-left", &root, "model", "Left");
1565 let right = Session::new_child_of("peer-right", &root, "model", "Right");
1566 let cousin_left = Session::new_child_of("peer-left-leaf", &left, "model", "Left leaf");
1567 let cousin_right = Session::new_child_of("peer-right-leaf", &right, "model", "Right leaf");
1568 for mut session in [root, left, right, cousin_left, cousin_right] {
1569 session.set_project_id_meta("peer-project");
1570 store.save_session(&session).await.unwrap();
1571 }
1572 }
1573
1574 struct PausedPeerAdmission {
1575 inner: Arc<FileSessionInbox>,
1576 entered: Arc<tokio::sync::Notify>,
1577 release: Arc<tokio::sync::Notify>,
1578 }
1579
1580 #[async_trait]
1581 impl SessionInboxPort for PausedPeerAdmission {
1582 async fn deliver(
1583 &self,
1584 envelope: &SessionMessageEnvelope,
1585 ) -> Result<SessionInboxReceipt, SessionInboxError> {
1586 self.inner.deliver(envelope).await
1587 }
1588
1589 async fn deliver_peer_message(
1590 &self,
1591 envelope: &SessionMessageEnvelope,
1592 source: &ActorSession,
1593 target: &ActorSession,
1594 reply_to: Option<&SessionMessageEnvelope>,
1595 ) -> Result<SessionInboxReceipt, SessionInboxError> {
1596 self.entered.notify_one();
1599 self.release.notified().await;
1600 self.inner
1601 .deliver_peer_message(envelope, source, target, reply_to)
1602 .await
1603 }
1604
1605 async fn mark_activation_eligible(
1606 &self,
1607 target: &str,
1608 generation: u64,
1609 policy: SessionActivationPolicy,
1610 ) -> Result<(), SessionInboxError> {
1611 self.inner
1612 .mark_activation_eligible(target, generation, policy)
1613 .await
1614 }
1615
1616 async fn claim(
1617 &self,
1618 target: &str,
1619 limit: usize,
1620 ) -> Result<Vec<bamboo_domain::SessionInboxClaim>, SessionInboxError> {
1621 self.inner.claim(target, limit).await
1622 }
1623
1624 async fn was_admitted(
1625 &self,
1626 target: &str,
1627 id: &SessionMessageId,
1628 ) -> Result<bool, SessionInboxError> {
1629 self.inner.was_admitted(target, id).await
1630 }
1631
1632 async fn ack(
1633 &self,
1634 target: &str,
1635 claim: &bamboo_domain::SessionInboxClaim,
1636 ) -> Result<(), SessionInboxError> {
1637 self.inner.ack(target, claim).await
1638 }
1639
1640 async fn inspect(
1641 &self,
1642 target: &str,
1643 ) -> Result<bamboo_domain::SessionInboxBacklog, SessionInboxError> {
1644 self.inner.inspect(target).await
1645 }
1646 }
1647
1648 #[tokio::test]
1649 async fn peer_send_and_reply_reject_either_lifetime_recreated_before_admission() {
1650 for reply in [false, true] {
1651 for replaced_id in ["peer-left", "peer-right"] {
1652 let (_temp, store, activation, messenger) = fixture().await;
1653 seed_peer_tree(store.as_ref()).await;
1654 let received_id = if reply {
1655 let incoming = messenger
1656 .send_peer("peer-left", "peer-right", "incoming", "question")
1657 .await
1658 .unwrap();
1659 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right")
1660 .await;
1661 Some(incoming.delivery.id.to_string())
1662 } else {
1663 None
1664 };
1665 activation.calls.lock().await.clear();
1666 let (caller, target) = if reply {
1667 ("peer-right", "peer-left")
1668 } else {
1669 ("peer-left", "peer-right")
1670 };
1671 let entered = Arc::new(tokio::sync::Notify::new());
1672 let release = Arc::new(tokio::sync::Notify::new());
1673 let inbox = Arc::new(FileSessionInbox::new(
1674 store.clone(),
1675 SessionInboxLimits::default(),
1676 ));
1677 let paused = SessionMessenger::new(
1678 store.clone(),
1679 Arc::new(PausedPeerAdmission {
1680 inner: inbox.clone(),
1681 entered: entered.clone(),
1682 release: release.clone(),
1683 }),
1684 activation.clone(),
1685 );
1686 let sending = tokio::spawn(async move {
1687 if let Some(id) = received_id {
1688 paused
1689 .reply_peer(caller, &id, "racing", "old lifetime")
1690 .await
1691 } else {
1692 paused
1693 .send_peer(caller, target, "racing", "old lifetime")
1694 .await
1695 }
1696 });
1697 tokio::time::timeout(std::time::Duration::from_secs(5), entered.notified())
1698 .await
1699 .unwrap();
1700 let old = store.load_session(replaced_id).await.unwrap().unwrap();
1701 assert!(store.delete_session(replaced_id).await.unwrap());
1702 let root = store.load_session("peer-root").await.unwrap().unwrap();
1703 let mut replacement =
1704 Session::new_child_of(replaced_id, &root, "model", "Replacement");
1705 replacement.created_at = old.created_at + chrono::Duration::seconds(1);
1706 replacement.set_project_id_meta("peer-project");
1707 store.save_session(&replacement).await.unwrap();
1708 release.notify_one();
1709 let result = tokio::time::timeout(std::time::Duration::from_secs(5), sending)
1710 .await
1711 .unwrap()
1712 .unwrap();
1713 assert!(result.is_err(), "reply={reply}, replaced={replaced_id}");
1714 let backlog = inbox.inspect(target).await.unwrap();
1715 assert_eq!(backlog.generation, 0);
1716 assert_eq!(backlog.pending, 0);
1717 assert!(!backlog.activation_pending());
1718 assert!(activation.calls.lock().await.is_empty());
1719 let current = messenger
1722 .send_peer(caller, target, "current-lifetime", "current")
1723 .await
1724 .unwrap();
1725 assert_eq!(current.delivery.generation, 1);
1726 assert_eq!(activation.calls.lock().await.len(), 1);
1727 }
1728 }
1729 }
1730
1731 #[tokio::test]
1732 async fn peer_reply_revalidates_received_proof_after_concurrent_clear_or_save() {
1733 for clear in [true, false] {
1734 let (_temp, store, activation, messenger) = fixture().await;
1735 seed_peer_tree(store.as_ref()).await;
1736 let incoming = messenger
1737 .send_peer("peer-left", "peer-right", "incoming", "question")
1738 .await
1739 .unwrap();
1740 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right").await;
1741 activation.calls.lock().await.clear();
1742 let before = store.load_session("peer-right").await.unwrap().unwrap();
1743 let received_id = incoming.delivery.id.to_string();
1744 let entered = Arc::new(tokio::sync::Notify::new());
1745 let release = Arc::new(tokio::sync::Notify::new());
1746 let inbox = Arc::new(FileSessionInbox::new(
1747 store.clone(),
1748 SessionInboxLimits::default(),
1749 ));
1750 let paused = SessionMessenger::new(
1751 store.clone(),
1752 Arc::new(PausedPeerAdmission {
1753 inner: inbox.clone(),
1754 entered: entered.clone(),
1755 release: release.clone(),
1756 }),
1757 activation.clone(),
1758 );
1759 let old_id = received_id.clone();
1760 let replying = tokio::spawn(async move {
1761 paused
1762 .reply_peer("peer-right", &old_id, "racing-proof", "reply")
1763 .await
1764 });
1765 tokio::time::timeout(std::time::Duration::from_secs(5), entered.notified())
1766 .await
1767 .unwrap();
1768 if clear {
1769 assert!(store.clear_session("peer-right").await.unwrap());
1770 } else {
1771 let mut changed = before.clone();
1772 changed.messages.retain(|message| message.id != received_id);
1773 store.save_session(&changed).await.unwrap();
1774 }
1775 let after = store.load_session("peer-right").await.unwrap().unwrap();
1776 assert_eq!(before.created_at, after.created_at);
1777 assert_eq!(before.root_session_id, after.root_session_id);
1778 assert_eq!(before.project_id_meta(), after.project_id_meta());
1779 assert!(!after
1780 .messages
1781 .iter()
1782 .any(|message| message.id == received_id));
1783 release.notify_one();
1784 let result = tokio::time::timeout(std::time::Duration::from_secs(5), replying)
1785 .await
1786 .unwrap()
1787 .unwrap();
1788 assert!(
1789 result.is_err(),
1790 "clear={clear}: removed proof must not admit a reply"
1791 );
1792 let backlog = inbox.inspect("peer-left").await.unwrap();
1793 assert_eq!(backlog.generation, 0);
1794 assert_eq!(backlog.pending, 0);
1795 assert!(!backlog.activation_pending());
1796 assert!(activation.calls.lock().await.is_empty());
1797
1798 let current = messenger
1799 .send_peer("peer-left", "peer-right", "current-question", "current")
1800 .await
1801 .unwrap();
1802 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right").await;
1803 let reply = messenger
1804 .reply_peer(
1805 "peer-right",
1806 current.delivery.id.as_str(),
1807 "racing-proof",
1808 "reply",
1809 )
1810 .await
1811 .unwrap();
1812 let retry = messenger
1813 .reply_peer(
1814 "peer-right",
1815 current.delivery.id.as_str(),
1816 "racing-proof",
1817 "reply",
1818 )
1819 .await
1820 .unwrap();
1821 assert_eq!(reply.delivery.generation, 1);
1822 assert_eq!(retry.delivery.generation, reply.delivery.generation);
1823 assert_eq!(retry.delivery.id, reply.delivery.id);
1824 let backlog = inbox.inspect("peer-left").await.unwrap();
1825 assert_eq!(backlog.generation, 1);
1826 assert_eq!(backlog.pending, 1);
1827 }
1828 }
1829
1830 #[tokio::test]
1831 async fn peer_admission_without_integrated_lifetime_fence_is_unsupported() {
1832 let (_temp, store, activation, _messenger) = fixture().await;
1833 seed_peer_tree(store.as_ref()).await;
1834 let inbox = Arc::new(FileSessionInbox::new(
1835 store.clone(),
1836 SessionInboxLimits::default(),
1837 ));
1838 let messenger = SessionMessenger::new(
1839 store,
1840 Arc::new(FailEligibilityOnce {
1841 inner: inbox.clone(),
1842 fail_next: std::sync::atomic::AtomicBool::new(false),
1843 }),
1844 activation.clone(),
1845 );
1846 let error = messenger
1847 .send_peer("peer-left", "peer-right", "unsupported", "message")
1848 .await
1849 .unwrap_err();
1850 assert!(error
1851 .to_string()
1852 .contains("lifetime-fenced peer SessionInbox admission is unsupported"));
1853 for intent in [
1854 SessionMessageDeliveryIntent::QueueOnly,
1855 SessionMessageDeliveryIntent::TriggerTurn,
1856 ] {
1857 assert!(messenger
1858 .send_peer_with_intent(
1859 "peer-left",
1860 "peer-right",
1861 "unsupported-explicit",
1862 "message",
1863 intent
1864 )
1865 .await
1866 .unwrap_err()
1867 .to_string()
1868 .contains("explicit peer delivery intent is unsupported"));
1869 }
1870 assert_eq!(inbox.inspect("peer-right").await.unwrap().pending, 0);
1871 assert!(activation.calls.lock().await.is_empty());
1872 }
1873
1874 async fn admit_peer_for_test(
1875 store: &dyn Storage,
1876 inbox: &dyn SessionInboxPort,
1877 target: &str,
1878 ) -> SessionMessageEnvelope {
1879 let claims = inbox.claim(target, 16).await.unwrap();
1880 assert_eq!(claims.len(), 1);
1881 let claim = &claims[0];
1882 let mut session = store.load_session(target).await.unwrap().unwrap();
1883 session.add_message(claim.envelope.to_provider_message().unwrap());
1884 store.save_session(&session).await.unwrap();
1885 inbox.ack(target, claim).await.unwrap();
1886 claim.envelope.clone()
1887 }
1888
1889 #[tokio::test]
1890 async fn peer_api_discovers_and_messages_parent_child_siblings_and_cousins() {
1891 let (_temp, store, activation, messenger) = fixture().await;
1892 seed_peer_tree(store.as_ref()).await;
1893 store
1894 .save_session(&Session::new("foreign-root", "model"))
1895 .await
1896 .unwrap();
1897 let targets = messenger.list_peer_targets("peer-left-leaf").await.unwrap();
1898 assert!(!targets.truncated);
1899 let ids: Vec<_> = targets
1900 .targets
1901 .iter()
1902 .map(|target| target.session_id.as_str())
1903 .collect();
1904 assert_eq!(
1905 ids,
1906 ["peer-root", "peer-left", "peer-right", "peer-right-leaf"]
1907 );
1908 assert_eq!(targets.targets[0].title, "Logical root");
1909 assert!(targets.targets[0].parent_session_id.is_none());
1910 assert!(!serde_json::to_string(&targets)
1911 .unwrap()
1912 .contains("foreign-root"));
1913
1914 for (index, (source, target)) in [
1915 ("peer-root", "peer-left"),
1916 ("peer-left", "peer-root"),
1917 ("peer-left", "peer-right"),
1918 ("peer-left-leaf", "peer-right-leaf"),
1919 ]
1920 .into_iter()
1921 .enumerate()
1922 {
1923 let receipt = messenger
1924 .send_peer(
1925 source,
1926 target,
1927 &format!("operation-{index}"),
1928 "bounded peer instruction",
1929 )
1930 .await
1931 .unwrap();
1932 let backlog = messenger.inbox().inspect(target).await.unwrap();
1933 assert_eq!(
1934 backlog.coordinator_generation, 0,
1935 "peer permission must not release a prefix"
1936 );
1937 assert!(backlog.interrupt_pending());
1938 let envelope =
1939 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), target).await;
1940 assert_eq!(envelope.id, receipt.delivery.id);
1941 assert_eq!(
1942 envelope.source,
1943 SessionMessageSource::Session {
1944 session_id: source.into()
1945 }
1946 );
1947 assert_eq!(envelope.thread_id.as_deref(), Some(envelope.id.as_str()));
1948 assert_eq!(
1949 envelope.correlation_id.as_deref(),
1950 Some(format!("operation-{index}").as_str())
1951 );
1952 assert!(envelope.in_reply_to.is_none());
1953 }
1954 assert_eq!(activation.calls.lock().await.len(), 4);
1955 }
1956
1957 #[tokio::test]
1958 async fn peer_api_denies_unknown_deleted_cross_root_and_invalid_project_or_lineage() {
1959 let (_temp, store, activation, messenger) = fixture().await;
1960 seed_peer_tree(store.as_ref()).await;
1961 store
1962 .save_session(&Session::new("foreign-root", "model"))
1963 .await
1964 .unwrap();
1965 for (source, target) in [
1966 ("peer-left", "foreign-root"),
1967 ("unknown", "peer-left"),
1968 ("peer-left", "unknown"),
1969 ("peer-left", "peer-left"),
1970 ] {
1971 assert!(messenger
1972 .send_peer(source, target, "denied-operation", "instruction")
1973 .await
1974 .is_err());
1975 }
1976 store.delete_session("peer-right-leaf").await.unwrap();
1977 assert!(matches!(
1978 messenger
1979 .send_peer("peer-left", "peer-right-leaf", "deleted", "instruction")
1980 .await,
1981 Err(SessionMessengerError::TargetNotFound(_))
1982 ));
1983 assert!(matches!(
1984 messenger
1985 .send_peer(
1986 "peer-right-leaf",
1987 "peer-left",
1988 "deleted-source",
1989 "instruction"
1990 )
1991 .await,
1992 Err(SessionMessengerError::SourceNotFound(_))
1993 ));
1994
1995 let memory = Arc::new(PeerMemoryStorage::default());
1998 seed_peer_tree(memory.as_ref()).await;
1999 let strict = SessionMessenger::new(
2000 memory.clone(),
2001 messenger.inbox().clone(),
2002 activation.clone(),
2003 );
2004 let original = memory.load_session("peer-right").await.unwrap().unwrap();
2005 for defect in [
2006 "project",
2007 "missing_project",
2008 "project_mirror",
2009 "parent",
2010 "depth",
2011 "root",
2012 "cycle",
2013 ] {
2014 let mut invalid = original.clone();
2015 match defect {
2016 "project" => invalid.set_project_id_meta("other-project"),
2017 "missing_project" => {
2018 invalid.metadata.remove("project_id");
2019 if let Some(metadata) = invalid.runtime_metadata.as_mut() {
2020 metadata.project_id = None;
2021 }
2022 }
2023 "project_mirror" => {
2024 invalid
2025 .metadata
2026 .insert("project_id".into(), "other-project".into());
2027 }
2028 "parent" => invalid.parent_session_id = Some("missing-parent".into()),
2029 "depth" => invalid.spawn_depth += 1,
2030 "root" => invalid.root_session_id = "foreign-root".into(),
2031 "cycle" => invalid.parent_session_id = Some("peer-right-leaf".into()),
2032 _ => unreachable!(),
2033 }
2034 memory.save_session(&invalid).await.unwrap();
2035 assert!(
2036 strict
2037 .send_peer("peer-left", "peer-right", "invalid", "instruction")
2038 .await
2039 .is_err(),
2040 "{defect}"
2041 );
2042 let targets = strict.list_peer_targets("peer-left").await.unwrap();
2043 assert!(
2044 !targets
2045 .targets
2046 .iter()
2047 .any(|target| target.session_id == "peer-right"),
2048 "{defect}"
2049 );
2050 memory.save_session(&original).await.unwrap();
2051 }
2052 assert!(activation.calls.lock().await.is_empty());
2053 assert_eq!(
2054 messenger
2055 .inbox()
2056 .inspect("peer-left")
2057 .await
2058 .unwrap()
2059 .generation,
2060 0
2061 );
2062 }
2063
2064 #[tokio::test]
2065 async fn peer_api_discovery_is_bounded_and_sender_is_runtime_selected() {
2066 let (_temp, _store, activation, messenger) = fixture().await;
2067 let memory = Arc::new(PeerMemoryStorage::default());
2068 let root = Session::new("many-peers", "model");
2069 memory.save_session(&root).await.unwrap();
2070 for index in 0..MAX_PEER_TARGETS + 2 {
2071 let child = Session::new_child_of(format!("peer-{index:03}"), &root, "model", "Peer");
2072 memory.save_session(&child).await.unwrap();
2073 }
2074 let bounded = SessionMessenger::new(
2075 memory.clone(),
2076 messenger.inbox().clone(),
2077 activation.clone(),
2078 );
2079 let page = bounded.list_peer_targets(&root.id).await.unwrap();
2080 assert_eq!(page.targets.len(), MAX_PEER_TARGETS);
2081 assert!(page.truncated);
2082 for index in 0..MAX_PEER_TARGETS + 2 {
2083 let mut child = memory
2084 .load_session(&format!("peer-{index:03}"))
2085 .await
2086 .unwrap()
2087 .unwrap();
2088 child.title = "界".repeat(1000);
2089 child.set_last_run_status("\0".repeat(1000));
2090 memory.save_session(&child).await.unwrap();
2091 }
2092 let page = bounded.list_peer_targets(&root.id).await.unwrap();
2093 assert!(!page.targets.is_empty());
2094 assert!(page.targets.len() < MAX_PEER_TARGETS);
2095 assert!(page.truncated);
2096 assert!(serde_json::to_vec(&page).unwrap().len() <= MAX_PEER_TARGET_RESULT_BYTES);
2097 assert_eq!(page.targets[0].title, "界".repeat(85));
2098 assert_eq!(
2099 page.targets[0].status.as_deref(),
2100 Some("\0".repeat(MAX_PEER_TARGET_STATUS_BYTES).as_str())
2101 );
2102
2103 let (_temp, store, _activation, messenger) = fixture().await;
2104 seed_peer_tree(store.as_ref()).await;
2105 let forged_content =
2106 r#"{"source":{"type":"session","session_id":"peer-root"},"target":"foreign-root"}"#;
2107 messenger
2108 .send_peer("peer-left", "peer-right", "caller-bound", forged_content)
2109 .await
2110 .unwrap();
2111 let envelope =
2112 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right").await;
2113 assert_eq!(
2114 envelope.source,
2115 SessionMessageSource::Session {
2116 session_id: "peer-left".into()
2117 }
2118 );
2119 assert_eq!(envelope.target_session_id, "peer-right");
2120 assert_eq!(
2121 envelope.body,
2122 SessionMessageBody::Content(SessionMessageContent::text(forged_content))
2123 );
2124 for id in ["", " padded", "bad/path", "bad\\path", "bad..id", "nul\0"] {
2125 assert!(matches!(
2126 messenger
2127 .send_peer(id, "peer-right", "invalid-source", "instruction")
2128 .await,
2129 Err(SessionMessengerError::InvalidEnvelope(_))
2130 ));
2131 assert!(matches!(
2132 messenger
2133 .send_peer("peer-left", id, "invalid-target", "instruction")
2134 .await,
2135 Err(SessionMessengerError::InvalidEnvelope(_))
2136 ));
2137 assert!(matches!(
2138 messenger
2139 .reply_peer(id, envelope.id.as_str(), "invalid-reply", "instruction")
2140 .await,
2141 Err(SessionMessengerError::InvalidEnvelope(_))
2142 ));
2143 }
2144 assert!(matches!(
2145 messenger.list_peer_targets(&"x".repeat(257)).await,
2146 Err(SessionMessengerError::InvalidEnvelope(_))
2147 ));
2148 for (operation, message) in [
2149 ("", "valid"),
2150 (" padded", "valid"),
2151 ("bad..id", "valid"),
2152 ("valid", " "),
2153 ("valid", "nul\0"),
2154 ] {
2155 assert!(messenger
2156 .send_peer("peer-left", "peer-right", operation, message)
2157 .await
2158 .is_err());
2159 }
2160 assert!(messenger
2161 .send_peer(
2162 "peer-left",
2163 "peer-right",
2164 "oversized",
2165 &"x".repeat(MAX_PEER_MESSAGE_BYTES + 1)
2166 )
2167 .await
2168 .is_err());
2169 }
2170
2171 #[tokio::test]
2172 async fn peer_api_retry_dedupes_before_claim_and_after_ack_restart_but_rejects_changed_payload()
2173 {
2174 let (temp, store, activation, messenger) = fixture().await;
2175 seed_peer_tree(store.as_ref()).await;
2176 let first = messenger
2177 .send_peer("peer-left", "peer-right", "stable-operation", "original")
2178 .await
2179 .unwrap();
2180 let retry = messenger
2181 .send_peer("peer-left", "peer-right", "stable-operation", "original")
2182 .await
2183 .unwrap();
2184 assert_eq!(retry.delivery, first.delivery);
2185 assert!(matches!(
2186 messenger
2187 .send_peer("peer-left", "peer-right", "stable-operation", "changed")
2188 .await,
2189 Err(SessionMessengerError::Inbox(
2190 SessionInboxError::InvalidClaim(_)
2191 ))
2192 ));
2193 assert_eq!(
2194 messenger
2195 .inbox()
2196 .inspect("peer-right")
2197 .await
2198 .unwrap()
2199 .generation,
2200 1
2201 );
2202 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right").await;
2203 drop(messenger);
2204 drop(store);
2205 let reopened = Arc::new(SessionStoreV2::new(temp.path().into()).await.unwrap());
2206 let inbox = Arc::new(FileSessionInbox::new(
2207 reopened.clone(),
2208 SessionInboxLimits::default(),
2209 ));
2210 let messenger = SessionMessenger::new(reopened, inbox.clone(), activation);
2211 let after_ack = messenger
2212 .send_peer("peer-left", "peer-right", "stable-operation", "original")
2213 .await
2214 .unwrap();
2215 assert_eq!(after_ack.delivery, first.delivery);
2216 assert!(inbox.claim("peer-right", 16).await.unwrap().is_empty());
2217 assert!(matches!(
2218 messenger
2219 .send_peer("peer-left", "peer-right", "stable-operation", "changed")
2220 .await,
2221 Err(SessionMessengerError::Inbox(
2222 SessionInboxError::InvalidClaim(_)
2223 ))
2224 ));
2225 assert_eq!(inbox.inspect("peer-right").await.unwrap().generation, 1);
2226 }
2227
2228 #[tokio::test]
2229 async fn peer_api_reply_recovers_durable_recipient_and_thread_after_restart() {
2230 let (temp, store, activation, messenger) = fixture().await;
2231 seed_peer_tree(store.as_ref()).await;
2232 let first = messenger
2233 .send_peer("peer-left-leaf", "peer-right-leaf", "initial", "question")
2234 .await
2235 .unwrap();
2236 let incoming = admit_peer_for_test(
2237 store.as_ref(),
2238 messenger.inbox().as_ref(),
2239 "peer-right-leaf",
2240 )
2241 .await;
2242 drop(messenger);
2243 drop(store);
2244 let store = Arc::new(SessionStoreV2::new(temp.path().into()).await.unwrap());
2245 let inbox = Arc::new(FileSessionInbox::new(
2246 store.clone(),
2247 SessionInboxLimits::default(),
2248 ));
2249 let messenger = SessionMessenger::new(store.clone(), inbox.clone(), activation);
2250 let reply = messenger
2251 .reply_peer(
2252 "peer-right-leaf",
2253 first.delivery.id.as_str(),
2254 "answer",
2255 "answer text",
2256 )
2257 .await
2258 .unwrap();
2259 let retry = messenger
2260 .reply_peer(
2261 "peer-right-leaf",
2262 first.delivery.id.as_str(),
2263 "answer",
2264 "answer text",
2265 )
2266 .await
2267 .unwrap();
2268 assert_eq!(reply.delivery, retry.delivery);
2269 let response = admit_peer_for_test(store.as_ref(), inbox.as_ref(), "peer-left-leaf").await;
2270 assert_eq!(
2271 response.source,
2272 SessionMessageSource::Session {
2273 session_id: "peer-right-leaf".into()
2274 }
2275 );
2276 assert_eq!(response.in_reply_to, Some(incoming.id.clone()));
2277 assert_eq!(response.thread_id, incoming.thread_id);
2278 assert_eq!(response.correlation_id.as_deref(), Some("answer"));
2279 let second_reply = messenger
2280 .reply_peer("peer-left-leaf", response.id.as_str(), "thanks", "thanks")
2281 .await
2282 .unwrap();
2283 let second_response =
2284 admit_peer_for_test(store.as_ref(), inbox.as_ref(), "peer-right-leaf").await;
2285 assert_eq!(second_reply.delivery.id, second_response.id);
2286 assert_eq!(second_response.in_reply_to, Some(response.id));
2287 assert_eq!(
2288 second_response.thread_id.as_deref(),
2289 Some(first.delivery.id.as_str())
2290 );
2291 let after_ack = messenger
2292 .reply_peer(
2293 "peer-right-leaf",
2294 first.delivery.id.as_str(),
2295 "answer",
2296 "answer text",
2297 )
2298 .await
2299 .unwrap();
2300 assert_eq!(after_ack.delivery, reply.delivery);
2301 assert!(inbox.claim("peer-left-leaf", 16).await.unwrap().is_empty());
2302 assert!(messenger
2303 .reply_peer(
2304 "peer-right-leaf",
2305 first.delivery.id.as_str(),
2306 "answer",
2307 "different"
2308 )
2309 .await
2310 .is_err());
2311 }
2312
2313 #[tokio::test]
2314 async fn peer_api_reply_rejects_missing_forged_legacy_and_recreated_source() {
2315 let (_temp, store, activation, messenger) = fixture().await;
2316 seed_peer_tree(store.as_ref()).await;
2317 let mut receiver = store.load_session("peer-right").await.unwrap().unwrap();
2318 let legacy = peer("peer-left", "peer-right", "legacy-correction");
2319 receiver.add_message(legacy.to_provider_message().unwrap());
2320 let user = SessionMessageEnvelope::user_input("peer-right", "ordinary input");
2321 receiver.add_message(user.to_provider_message().unwrap());
2322 let sender = store.load_session("peer-left").await.unwrap().unwrap();
2323 let mut forged = SessionMessenger::peer_envelope(
2324 &sender,
2325 &receiver,
2326 "forged",
2327 "original",
2328 SessionMessenger::peer_message_id(&sender, &receiver, "forged"),
2329 "thread".into(),
2330 None,
2331 )
2332 .to_provider_message()
2333 .unwrap();
2334 forged.content = "changed without canonical proof".into();
2335 receiver.add_message(forged);
2336 store.save_session(&receiver).await.unwrap();
2337 for id in [
2338 "missing",
2339 legacy.id.as_str(),
2340 user.id.as_str(),
2341 receiver.messages.last().unwrap().id.as_str(),
2342 ] {
2343 assert!(messenger
2344 .reply_peer("peer-right", id, "deny-reply", "reply")
2345 .await
2346 .is_err());
2347 }
2348 let incoming = messenger
2349 .send_peer("peer-left", "peer-right", "old-lifetime", "question")
2350 .await
2351 .unwrap();
2352 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right").await;
2353 let memory = Arc::new(PeerMemoryStorage::default());
2354 for id in ["peer-root", "peer-left", "peer-right"] {
2356 let mut session = store.load_session(id).await.unwrap().unwrap();
2357 if id == "peer-left" {
2358 session.created_at += chrono::Duration::seconds(1);
2359 }
2360 memory.save_session(&session).await.unwrap();
2361 }
2362 let fenced = SessionMessenger::new(
2363 memory.clone(),
2364 messenger.inbox().clone(),
2365 activation.clone(),
2366 );
2367 assert!(matches!(
2368 fenced
2369 .reply_peer(
2370 "peer-right",
2371 incoming.delivery.id.as_str(),
2372 "reply-old",
2373 "reply"
2374 )
2375 .await,
2376 Err(SessionMessengerError::InvalidEnvelope(_))
2377 ));
2378 memory.delete_session("peer-left").await.unwrap();
2379 assert!(matches!(
2380 fenced
2381 .reply_peer(
2382 "peer-right",
2383 incoming.delivery.id.as_str(),
2384 "reply-deleted",
2385 "reply"
2386 )
2387 .await,
2388 Err(SessionMessengerError::TargetNotFound(_))
2389 ));
2390 assert_eq!(activation.calls.lock().await.len(), 1);
2391 }
2392
2393 #[tokio::test]
2394 async fn same_root_delivery_enqueues_then_requests_activation() {
2395 let (_temp, store, activation, messenger) = fixture().await;
2396 let root = Session::new("root", "model");
2397 let mut child = Session::new("child", "model");
2398 child.kind = bamboo_domain::SessionKind::Child;
2399 child.parent_session_id = Some("root".to_string());
2400 child.root_session_id = "root".to_string();
2401 store.save_session(&root).await.unwrap();
2402 store.save_session(&child).await.unwrap();
2403
2404 let receipt = messenger
2405 .send(peer("root", "child", "msg-1"))
2406 .await
2407 .unwrap();
2408 assert_eq!(receipt.delivery.generation, 1);
2409 assert_eq!(
2410 activation.calls.lock().await.as_slice(),
2411 &[("child".to_string(), 1)]
2412 );
2413 assert_eq!(messenger.metrics().snapshot().delivered, 1);
2414 }
2415
2416 #[tokio::test]
2417 async fn staged_delivery_without_watermark_reports_exact_retry_required() {
2418 let temp = TempDir::new().unwrap();
2419 let store = Arc::new(
2420 SessionStoreV2::new(temp.path().to_path_buf())
2421 .await
2422 .unwrap(),
2423 );
2424 let root = Session::new("root", "model");
2425 let mut child = Session::new("child", "model");
2426 child.kind = bamboo_domain::SessionKind::Child;
2427 child.parent_session_id = Some("root".to_string());
2428 child.root_session_id = "root".to_string();
2429 store.save_session(&root).await.unwrap();
2430 store.save_session(&child).await.unwrap();
2431 let inbox = Arc::new(FailEligibilityOnce {
2432 inner: Arc::new(FileSessionInbox::new(
2433 store.clone(),
2434 SessionInboxLimits::default(),
2435 )),
2436 fail_next: std::sync::atomic::AtomicBool::new(true),
2437 });
2438 let activation = Arc::new(RecordingActivation {
2439 calls: tokio::sync::Mutex::new(Vec::new()),
2440 });
2441 let messenger = SessionMessenger::new(store.clone(), inbox, activation.clone());
2442 let envelope = peer("root", "child", "watermark-failure");
2443 let gate = bamboo_domain::AdmissionGate::default();
2444
2445 let admission = messenger
2446 .admit_with_gate(envelope.clone(), Some(&gate))
2447 .await
2448 .unwrap();
2449 let receipt = match messenger.activate(&admission).await.unwrap_err() {
2450 SessionMessengerError::ActivationEligibility { receipt, .. } => receipt,
2451 error => panic!("expected retry-required classification: {error}"),
2452 };
2453 assert!(gate.is_committed());
2454 assert!(activation.calls.lock().await.is_empty());
2455 let reopened = FileSessionInbox::new(store, SessionInboxLimits::default());
2456 let backlog = reopened.inspect("child").await.unwrap();
2457 assert_eq!(backlog.pending + backlog.claimed, 1);
2458 assert_eq!(backlog.activation_generation, 0);
2459 assert_eq!(backlog.generation, receipt.generation);
2460
2461 let retry = messenger
2462 .admit_with_gate(envelope, Some(&gate))
2463 .await
2464 .unwrap();
2465 let retried = messenger.activate(&retry).await.unwrap();
2466 assert_eq!(retried.delivery, receipt);
2467 assert_eq!(activation.calls.lock().await.len(), 1);
2468 let reopened = reopened.inspect("child").await.unwrap();
2469 assert_eq!(reopened.activation_generation, receipt.generation);
2470 assert_eq!(reopened.pending + reopened.claimed, 1);
2471 }
2472
2473 #[tokio::test]
2474 async fn cross_root_peer_is_rejected_before_enqueue() {
2475 let (_temp, store, activation, messenger) = fixture().await;
2476 store
2477 .save_session(&Session::new("root-a", "model"))
2478 .await
2479 .unwrap();
2480 store
2481 .save_session(&Session::new("root-b", "model"))
2482 .await
2483 .unwrap();
2484
2485 let error = messenger
2486 .send(peer("root-a", "root-b", "msg-2"))
2487 .await
2488 .unwrap_err();
2489 assert!(matches!(error, SessionMessengerError::Unauthorized { .. }));
2490 assert!(activation.calls.lock().await.is_empty());
2491 let metrics = messenger.metrics().snapshot();
2492 assert_eq!(metrics.rejected, 1);
2493 assert_eq!(metrics.unauthorized, 1);
2494 assert_eq!(metrics.payload_too_large, 0);
2495 assert_eq!(metrics.backlog_full, 0);
2496 }
2497
2498 #[tokio::test]
2499 async fn same_root_different_project_peer_is_rejected_before_enqueue() {
2500 let (_temp, store, activation, messenger) = fixture().await;
2501 let mut source = Session::new("project-root", "model");
2502 source.set_project_id_meta("project-a");
2503 let mut target = Session::new("project-child", "model");
2504 target.kind = bamboo_domain::SessionKind::Child;
2505 target.parent_session_id = Some(source.id.clone());
2506 target.root_session_id = source.id.clone();
2507 target.set_project_id_meta("project-b");
2508 store.save_session(&source).await.unwrap();
2509 store.save_session(&target).await.unwrap();
2510
2511 let error = messenger
2512 .send(peer(&source.id, &target.id, "different-project"))
2513 .await
2514 .unwrap_err();
2515 assert!(matches!(error, SessionMessengerError::Unauthorized { .. }));
2516 assert!(activation.calls.lock().await.is_empty());
2517 let metrics = messenger.metrics().snapshot();
2518 assert_eq!(metrics.delivered, 0);
2519 assert_eq!(metrics.rejected, 1);
2520 assert_eq!(metrics.unauthorized, 1);
2521 }
2522
2523 #[tokio::test]
2524 async fn limit_rejections_have_distinct_metrics() {
2525 let temp = TempDir::new().unwrap();
2526 let store = Arc::new(
2527 SessionStoreV2::new(temp.path().to_path_buf())
2528 .await
2529 .unwrap(),
2530 );
2531 store
2532 .save_session(&Session::new("target", "model"))
2533 .await
2534 .unwrap();
2535 let activation = Arc::new(RecordingActivation {
2536 calls: tokio::sync::Mutex::new(Vec::new()),
2537 });
2538 let inbox = Arc::new(FileSessionInbox::new(
2539 store.clone(),
2540 SessionInboxLimits {
2541 max_payload_bytes: 512,
2542 max_backlog: 1,
2543 max_claim_batch: 1,
2544 },
2545 ));
2546 let messenger = SessionMessenger::new(store, inbox, activation.clone());
2547
2548 let oversized = SessionMessageEnvelope::user_input("target", "x".repeat(2048));
2549 assert!(matches!(
2550 messenger.send(oversized).await,
2551 Err(SessionMessengerError::Inbox(
2552 SessionInboxError::PayloadTooLarge { .. }
2553 ))
2554 ));
2555 messenger
2556 .send(SessionMessageEnvelope::user_input("target", "first"))
2557 .await
2558 .unwrap();
2559 assert!(matches!(
2560 messenger
2561 .send(SessionMessageEnvelope::user_input("target", "second"))
2562 .await,
2563 Err(SessionMessengerError::Inbox(
2564 SessionInboxError::BacklogFull { .. }
2565 ))
2566 ));
2567
2568 let metrics = messenger.metrics().snapshot();
2569 assert_eq!(metrics.rejected, 2);
2570 assert_eq!(metrics.payload_too_large, 1);
2571 assert_eq!(metrics.backlog_full, 1);
2572 assert_eq!(activation.calls.lock().await.len(), 1);
2573 }
2574
2575 #[tokio::test]
2576 async fn explicit_peer_intent_keeps_legacy_compatibility_and_fences_changed_retry_after_ack() {
2577 let (_temp, store, activation, messenger) = fixture().await;
2578 seed_peer_tree(store.as_ref()).await;
2579 let receipt = messenger
2580 .send_peer_with_intent(
2581 "peer-left",
2582 "peer-right",
2583 "queue-operation",
2584 "input",
2585 SessionMessageDeliveryIntent::QueueOnly,
2586 )
2587 .await
2588 .unwrap();
2589 assert_eq!(receipt.activation, None);
2590 assert!(!receipt.active_delivery_notified);
2591 assert!(activation.calls.lock().await.is_empty());
2592 let original =
2593 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right").await;
2594 assert_eq!(
2595 messenger
2596 .send_peer_with_intent(
2597 "peer-left",
2598 "peer-right",
2599 "queue-operation",
2600 "input",
2601 SessionMessageDeliveryIntent::QueueOnly
2602 )
2603 .await
2604 .unwrap()
2605 .delivery,
2606 receipt.delivery
2607 );
2608 assert!(messenger
2609 .send_peer_with_intent(
2610 "peer-left",
2611 "peer-right",
2612 "queue-operation",
2613 "input",
2614 SessionMessageDeliveryIntent::TriggerTurn
2615 )
2616 .await
2617 .is_err());
2618 assert!(activation.calls.lock().await.is_empty());
2619 let reply = messenger
2620 .reply_peer_with_intent(
2621 "peer-right",
2622 original.id.as_str(),
2623 "reply-operation",
2624 "reply",
2625 SessionMessageDeliveryIntent::TriggerTurn,
2626 )
2627 .await
2628 .unwrap();
2629 assert_eq!(
2632 reply.activation,
2633 Some(SessionActivationDisposition::ActivationReserved)
2634 );
2635 assert_eq!(
2636 activation.calls.lock().await.as_slice(),
2637 &[("peer-left".to_string(), reply.delivery.generation)]
2638 );
2639 let claims = messenger.inbox().claim("peer-left", 8).await.unwrap();
2640 assert_eq!(claims[0].envelope.in_reply_to, Some(original.id));
2641 assert_eq!(
2642 claims[0].activation_policy,
2643 SessionActivationPolicy::RespectSpecificWait
2644 );
2645 messenger
2646 .send_peer("peer-left", "peer-right", "legacy-operation", "legacy")
2647 .await
2648 .unwrap();
2649 let claims = messenger.inbox().claim("peer-right", 8).await.unwrap();
2650 assert_eq!(
2651 claims[0].activation_policy,
2652 SessionActivationPolicy::InterruptSpecificWait
2653 );
2654 }
2655
2656 #[tokio::test]
2657 async fn explicit_peer_intents_deny_foreign_roots_before_admission_or_wake() {
2658 let (_temp, store, activation, messenger) = fixture().await;
2659 seed_peer_tree(store.as_ref()).await;
2660 store
2661 .save_session(&Session::new("foreign-root", "model"))
2662 .await
2663 .unwrap();
2664 for intent in [
2665 SessionMessageDeliveryIntent::QueueOnly,
2666 SessionMessageDeliveryIntent::TriggerTurn,
2667 ] {
2668 assert!(matches!(
2669 messenger
2670 .send_peer_with_intent("peer-left", "foreign-root", "denied", "input", intent)
2671 .await,
2672 Err(SessionMessengerError::Unauthorized { .. })
2673 ));
2674 }
2675 assert_eq!(
2676 messenger
2677 .inbox()
2678 .inspect("foreign-root")
2679 .await
2680 .unwrap()
2681 .pending,
2682 0
2683 );
2684 assert!(activation.calls.lock().await.is_empty());
2685 }
2686}