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, SessionMessageEnvelope,
17 SessionMessageId, SessionMessageKind, SessionMessageSource, SupervisorReference,
18};
19
20pub const MAX_SUPERVISOR_FOLLOWUP_BYTES: usize = 16 * 1024;
22
23pub const MAX_PEER_MESSAGE_BYTES: usize = 16 * 1024;
24pub const MAX_PEER_TARGETS: usize = 128;
25pub const MAX_PEER_TARGET_RESULT_BYTES: usize = 32 * 1024;
26const MAX_PEER_TARGET_TITLE_BYTES: usize = 256;
27const MAX_PEER_TARGET_STATUS_BYTES: usize = 64;
28const MAX_PEER_TARGET_SCAN: usize = 1024;
29
30#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
32pub struct SessionPeerTarget {
33 pub session_id: String,
34 pub parent_session_id: Option<String>,
35 pub kind: bamboo_domain::SessionKind,
36 pub title: String,
37 pub status: Option<String>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
41pub struct SessionPeerTargets {
42 pub targets: Vec<SessionPeerTarget>,
43 pub truncated: bool,
44}
45
46struct PeerTreePort<'a>(&'a dyn Storage);
47
48#[async_trait::async_trait]
49impl crate::session_app::child_session::owned_tree::OwnedTreePort for PeerTreePort<'_> {
50 async fn load(
51 &self,
52 id: &str,
53 ) -> Result<Session, crate::session_app::child_session::owned_tree::OwnedTreeError> {
54 use crate::session_app::child_session::owned_tree::OwnedTreeError;
55 bamboo_domain::validate_supervisor_target_id(id)
56 .map_err(|_| OwnedTreeError::InvalidLineage)?;
57 self.0
58 .load_runtime_control_plane(id)
59 .await
60 .map_err(|_| OwnedTreeError::InvalidLineage)?
61 .ok_or(OwnedTreeError::InvalidLineage)
62 }
63
64 async fn child_ids(
65 &self,
66 parent_id: &str,
67 ) -> Result<Vec<String>, crate::session_app::child_session::owned_tree::OwnedTreeError> {
68 use crate::session_app::child_session::owned_tree::OwnedTreeError;
69 self.0
70 .list_child_run_statuses(parent_id)
71 .await
72 .map(|children| children.into_iter().map(|(id, _)| id).collect())
73 .map_err(|_| OwnedTreeError::InvalidLineage)
74 }
75}
76
77#[derive(Debug, Default)]
79pub struct SessionMessagingMetrics {
80 delivered: AtomicU64,
81 rejected: AtomicU64,
82 invalid_envelope: AtomicU64,
83 unauthorized: AtomicU64,
84 payload_too_large: AtomicU64,
85 backlog_full: AtomicU64,
86 storage_failed: AtomicU64,
87 activation_failed: AtomicU64,
88 active_notified: AtomicU64,
89 activation_reserved: AtomicU64,
90 activation_coalesced: AtomicU64,
91 delivery_latency_micros: AtomicU64,
92}
93
94#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
95pub struct SessionMessagingMetricsSnapshot {
96 pub delivered: u64,
97 pub rejected: u64,
98 pub invalid_envelope: u64,
99 pub unauthorized: u64,
100 pub payload_too_large: u64,
101 pub backlog_full: u64,
102 pub storage_failed: u64,
103 pub activation_failed: u64,
104 pub active_notified: u64,
105 pub activation_reserved: u64,
106 pub activation_coalesced: u64,
107 pub delivery_latency_micros: u64,
108}
109
110impl SessionMessagingMetrics {
111 pub fn snapshot(&self) -> SessionMessagingMetricsSnapshot {
112 SessionMessagingMetricsSnapshot {
113 delivered: self.delivered.load(Ordering::Relaxed),
114 rejected: self.rejected.load(Ordering::Relaxed),
115 invalid_envelope: self.invalid_envelope.load(Ordering::Relaxed),
116 unauthorized: self.unauthorized.load(Ordering::Relaxed),
117 payload_too_large: self.payload_too_large.load(Ordering::Relaxed),
118 backlog_full: self.backlog_full.load(Ordering::Relaxed),
119 storage_failed: self.storage_failed.load(Ordering::Relaxed),
120 activation_failed: self.activation_failed.load(Ordering::Relaxed),
121 active_notified: self.active_notified.load(Ordering::Relaxed),
122 activation_reserved: self.activation_reserved.load(Ordering::Relaxed),
123 activation_coalesced: self.activation_coalesced.load(Ordering::Relaxed),
124 delivery_latency_micros: self.delivery_latency_micros.load(Ordering::Relaxed),
125 }
126 }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct SessionMessengerReceipt {
131 pub delivery: SessionInboxReceipt,
132 pub activation: SessionActivationDisposition,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct SessionMessengerAdmission {
139 pub envelope_id: String,
140 pub target_session_id: String,
141 pub delivery: SessionInboxReceipt,
142}
143
144#[derive(Debug, thiserror::Error)]
145pub enum SessionMessengerError {
146 #[error("invalid session message: {0}")]
147 InvalidEnvelope(String),
148 #[error("session message source not found: {0}")]
149 SourceNotFound(String),
150 #[error("session message target not found: {0}")]
151 TargetNotFound(String),
152 #[error("session {source_session_id} is not authorized to message session {target}")]
153 Unauthorized {
154 source_session_id: String,
155 target: String,
156 },
157 #[error(transparent)]
158 Inbox(#[from] SessionInboxError),
159 #[error("message {receipt_id} was durably delivered but activation failed: {source}")]
163 Activation {
164 receipt_id: String,
165 receipt: SessionInboxReceipt,
166 #[source]
167 source: SessionActivationError,
168 },
169 #[error(
174 "message {receipt_id} was delivered but activation eligibility was not persisted: {source}"
175 )]
176 ActivationEligibility {
177 receipt_id: String,
178 receipt: SessionInboxReceipt,
179 #[source]
180 source: SessionInboxError,
181 },
182 #[error("session store failure: {0}")]
183 Storage(String),
184}
185
186pub struct SessionMessenger {
189 sessions: Arc<dyn Storage>,
190 inbox: Arc<dyn SessionInboxPort>,
191 activation: Arc<dyn SessionActivationPort>,
192 metrics: Arc<SessionMessagingMetrics>,
193}
194
195impl SessionMessenger {
196 pub fn new(
197 sessions: Arc<dyn Storage>,
198 inbox: Arc<dyn SessionInboxPort>,
199 activation: Arc<dyn SessionActivationPort>,
200 ) -> Self {
201 Self {
202 sessions,
203 inbox,
204 activation,
205 metrics: Arc::new(SessionMessagingMetrics::default()),
206 }
207 }
208
209 pub fn inbox(&self) -> &Arc<dyn SessionInboxPort> {
210 &self.inbox
211 }
212
213 pub fn activation(&self) -> &Arc<dyn SessionActivationPort> {
214 &self.activation
215 }
216
217 pub fn metrics(&self) -> &Arc<SessionMessagingMetrics> {
218 &self.metrics
219 }
220
221 pub async fn list_peer_targets(
225 &self,
226 caller_session_id: &str,
227 ) -> Result<SessionPeerTargets, SessionMessengerError> {
228 let caller = self.peer_member(caller_session_id, true).await?;
229 let mut pending = VecDeque::from([(caller.root_session_id.clone(), None)]);
230 let mut seen = HashSet::new();
231 let mut result = SessionPeerTargets {
232 targets: Vec::new(),
233 truncated: false,
234 };
235 let mut result_bytes = serde_json::to_vec(&result)
238 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?
239 .len();
240 while let Some((id, indexed_parent)) = pending.pop_front() {
241 if !seen.insert(id.clone()) {
242 continue;
243 }
244 let current = match self.peer_member(&id, false).await {
245 Ok(current)
246 if current.root_session_id == caller.root_session_id
247 && current.project_id_meta() == caller.project_id_meta()
248 && current.parent_session_id == indexed_parent =>
249 {
250 current
251 }
252 Ok(_)
253 | Err(SessionMessengerError::TargetNotFound(_))
254 | Err(SessionMessengerError::Unauthorized { .. })
255 | Err(SessionMessengerError::InvalidEnvelope(_)) => continue,
256 Err(error) => return Err(error),
257 };
258 if id != caller_session_id {
259 if result.targets.len() == MAX_PEER_TARGETS {
260 result.truncated = true;
261 break;
262 }
263 let target = SessionPeerTarget {
264 session_id: current.id.clone(),
265 parent_session_id: current.parent_session_id.clone(),
266 kind: current.kind,
267 title: Self::bounded_peer_label(¤t.title, MAX_PEER_TARGET_TITLE_BYTES),
268 status: current.last_run_status().map(|status| {
269 Self::bounded_peer_label(&status, MAX_PEER_TARGET_STATUS_BYTES)
270 }),
271 };
272 let target_bytes = serde_json::to_vec(&target)
273 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?
274 .len()
275 + usize::from(!result.targets.is_empty());
276 if result_bytes.saturating_add(target_bytes) > MAX_PEER_TARGET_RESULT_BYTES {
277 result.truncated = true;
278 break;
279 }
280 result_bytes += target_bytes;
281 result.targets.push(target);
282 }
283 let mut children = self
284 .sessions
285 .list_child_run_statuses(&id)
286 .await
287 .map_err(|error| SessionMessengerError::Storage(error.to_string()))?;
288 children.sort_by(|left, right| left.0.cmp(&right.0));
289 let budget = MAX_PEER_TARGET_SCAN.saturating_sub(seen.len() + pending.len());
290 result.truncated |= children.len() > budget;
291 pending.extend(
292 children
293 .into_iter()
294 .take(budget)
295 .map(|(child, _)| (child, Some(id.clone()))),
296 );
297 }
298 Ok(result)
299 }
300
301 pub async fn send_peer(
305 &self,
306 caller_session_id: &str,
307 target_session_id: &str,
308 operation_id: &str,
309 message: &str,
310 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
311 Self::validate_peer_input(operation_id, message)?;
312 let (caller, target) = self.peer_pair(caller_session_id, target_session_id).await?;
313 let id = Self::peer_message_id(&caller, &target, operation_id);
314 let envelope = Self::peer_envelope(
315 &caller,
316 &target,
317 operation_id,
318 message,
319 id.clone(),
320 id.to_string(),
321 None,
322 );
323 self.send_peer_envelope(envelope, &caller, &target, None)
324 .await
325 }
326
327 pub async fn reply_peer(
330 &self,
331 caller_session_id: &str,
332 received_message_id: &str,
333 operation_id: &str,
334 message: &str,
335 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
336 Self::validate_peer_input(operation_id, message)?;
337 Self::validate_peer_session_id(caller_session_id)?;
338 let received_id = SessionMessageId::parse(received_message_id)
339 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
340 let received = self
341 .load_session(caller_session_id)
342 .await?
343 .ok_or_else(|| SessionMessengerError::SourceNotFound(caller_session_id.into()))?;
344 let original = received
345 .messages
346 .iter()
347 .filter(|message| message.id == received_message_id)
348 .find_map(|message| {
349 let envelope: SessionMessageEnvelope = serde_json::from_value(
350 message.metadata.as_ref()?.get("session_message")?.clone(),
351 )
352 .ok()?;
353 (envelope.id == received_id
354 && envelope.kind == SessionMessageKind::PeerMessage
355 && envelope.target_session_id == caller_session_id
356 && bamboo_domain::is_matching_session_message(message, &envelope))
357 .then_some(envelope)
358 })
359 .ok_or_else(|| {
360 SessionMessengerError::InvalidEnvelope(
361 "reply requires a canonical received peer message".into(),
362 )
363 })?;
364 let SessionMessageSource::Session {
365 session_id: target_id,
366 } = &original.source
367 else {
368 return Err(SessionMessengerError::InvalidEnvelope(
369 "reply has no logical sender".into(),
370 ));
371 };
372 let (caller, target) = self.peer_pair(caller_session_id, target_id).await?;
373 let thread = original.thread_id.clone().ok_or_else(|| {
374 SessionMessengerError::InvalidEnvelope("reply requires a threaded peer message".into())
375 })?;
376 let original_operation = original.correlation_id.as_deref().ok_or_else(|| {
377 SessionMessengerError::InvalidEnvelope(
378 "reply requires a peer operation identity".into(),
379 )
380 })?;
381 if original.id != Self::peer_message_id(&target, &caller, original_operation) {
382 return Err(SessionMessengerError::InvalidEnvelope(
383 "received peer message belongs to a different session lifetime".into(),
384 ));
385 }
386 SessionMessageId::parse(&thread)
387 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
388 let envelope = Self::peer_envelope(
389 &caller,
390 &target,
391 operation_id,
392 message,
393 Self::peer_message_id(&caller, &target, operation_id),
394 thread,
395 Some(original.id.clone()),
396 );
397 self.send_peer_envelope(envelope, &caller, &target, Some(&original))
398 .await
399 }
400
401 fn validate_peer_input(operation_id: &str, message: &str) -> Result<(), SessionMessengerError> {
402 SessionMessageId::parse(operation_id)
403 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
404 if message.trim().is_empty()
405 || message.len() > MAX_PEER_MESSAGE_BYTES
406 || message.contains('\0')
407 {
408 return Err(SessionMessengerError::InvalidEnvelope(format!(
409 "peer message must contain 1..={MAX_PEER_MESSAGE_BYTES} UTF-8 bytes without NUL"
410 )));
411 }
412 Ok(())
413 }
414
415 fn validate_peer_session_id(id: &str) -> Result<(), SessionMessengerError> {
416 bamboo_domain::validate_supervisor_target_id(id)
417 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.into()))?;
418 SessionMessageId::parse(id)
419 .map(|_| ())
420 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))
421 }
422
423 fn bounded_peer_label(value: &str, max_bytes: usize) -> String {
424 let mut end = value.len().min(max_bytes);
425 while !value.is_char_boundary(end) {
426 end -= 1;
427 }
428 value[..end].to_owned()
429 }
430
431 async fn peer_member(&self, id: &str, source: bool) -> Result<Session, SessionMessengerError> {
432 Self::validate_peer_session_id(id)?;
433 let session = self
434 .sessions
435 .load_runtime_control_plane(id)
436 .await
437 .map_err(|error| SessionMessengerError::Storage(error.to_string()))?
438 .ok_or_else(|| {
439 if source {
440 SessionMessengerError::SourceNotFound(id.into())
441 } else {
442 SessionMessengerError::TargetNotFound(id.into())
443 }
444 })?;
445 let unauthorized = || SessionMessengerError::Unauthorized {
446 source_session_id: id.into(),
447 target: session.root_session_id.clone(),
448 };
449 if session.id != id
450 || session
451 .runtime_metadata
452 .as_ref()
453 .and_then(|meta| meta.project_id.as_ref())
454 .zip(session.metadata.get("project_id"))
455 .is_some_and(|(typed, legacy)| typed != legacy)
456 || bamboo_domain::ActorSession::from_session(&session).is_err()
457 {
458 return Err(unauthorized());
459 }
460 if session.kind == bamboo_domain::SessionKind::Root {
461 return Ok(session);
462 }
463 crate::session_app::child_session::owned_tree::load_owned_descendant(
464 &PeerTreePort(self.sessions.as_ref()),
465 &session.root_session_id,
466 id,
467 )
468 .await
469 .map_err(|_| unauthorized())
470 }
471
472 async fn peer_pair(
473 &self,
474 caller_id: &str,
475 target_id: &str,
476 ) -> Result<(Session, Session), SessionMessengerError> {
477 let caller = self.peer_member(caller_id, true).await?;
478 let target = self.peer_member(target_id, false).await?;
479 if caller_id == target_id
480 || caller.root_session_id != target.root_session_id
481 || caller.project_id_meta() != target.project_id_meta()
482 {
483 return Err(SessionMessengerError::Unauthorized {
484 source_session_id: caller_id.into(),
485 target: target_id.into(),
486 });
487 }
488 Ok((caller, target))
489 }
490
491 fn peer_message_id(caller: &Session, target: &Session, operation_id: &str) -> SessionMessageId {
492 SessionMessageId::stable(
493 "session-peer-v1",
494 &serde_json::json!({
495 "source": caller.id, "source_birth": caller.created_at,
496 "target": target.id, "target_birth": target.created_at,
497 "operation_id": operation_id,
498 }),
499 )
500 }
501
502 #[allow(clippy::too_many_arguments)]
503 fn peer_envelope(
504 caller: &Session,
505 target: &Session,
506 operation_id: &str,
507 message: &str,
508 id: SessionMessageId,
509 thread_id: String,
510 in_reply_to: Option<SessionMessageId>,
511 ) -> SessionMessageEnvelope {
512 SessionMessageEnvelope {
513 id,
514 source: SessionMessageSource::Session {
515 session_id: caller.id.clone(),
516 },
517 target_session_id: target.id.clone(),
518 kind: SessionMessageKind::PeerMessage,
519 body: SessionMessageBody::Content(SessionMessageContent::text(message)),
520 created_at: chrono::Utc::now(),
521 thread_id: Some(thread_id),
522 in_reply_to,
523 attempt: None,
524 correlation_id: Some(operation_id.into()),
525 }
526 }
527
528 async fn send_peer_envelope(
529 &self,
530 envelope: SessionMessageEnvelope,
531 caller: &Session,
532 target: &Session,
533 reply_to: Option<&SessionMessageEnvelope>,
534 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
535 let started = Instant::now();
536 let source = ActorSession::from_session(caller)
537 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
538 let target = ActorSession::from_session(target)
539 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
540 let delivery = match self
544 .inbox
545 .deliver_peer_message(&envelope, &source, &target, reply_to)
546 .await
547 {
548 Ok(receipt) => receipt,
549 Err(error) => {
550 let error = SessionMessengerError::Inbox(error);
551 self.record_rejection(&error);
552 return Err(error);
553 }
554 };
555 let admission = self.record_admission(envelope, delivery, started);
556 self.activate_prepared(&admission).await
557 }
558
559 pub async fn supervisor_followup(
563 &self,
564 supervisor: &SupervisorReference,
565 target_session_id: &str,
566 operation_id: &str,
567 message: &str,
568 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
569 bamboo_domain::validate_supervisor_target_id(target_session_id)
570 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
571 SessionMessageId::parse(operation_id)
572 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
573 if message.trim().is_empty() || message.len() > MAX_SUPERVISOR_FOLLOWUP_BYTES {
574 return Err(SessionMessengerError::InvalidEnvelope(format!(
575 "followup message must contain 1..={MAX_SUPERVISOR_FOLLOWUP_BYTES} UTF-8 bytes"
576 )));
577 }
578 let envelope = SessionMessageEnvelope {
579 id: SessionMessageId::stable(
580 "supervisor_followup",
581 &serde_json::json!({
582 "supervisor": supervisor,
583 "target_session_id": target_session_id,
584 "operation_id": operation_id,
585 }),
586 ),
587 source: SessionMessageSource::Session {
588 session_id: supervisor.session_id.clone(),
589 },
590 target_session_id: target_session_id.to_string(),
591 kind: SessionMessageKind::PeerMessage,
592 body: SessionMessageBody::Content(SessionMessageContent::text(message)),
593 created_at: chrono::Utc::now(),
594 thread_id: None,
595 in_reply_to: None,
596 attempt: None,
597 correlation_id: Some(operation_id.to_string()),
598 };
599 let started = Instant::now();
600 let delivery = self
601 .inbox
602 .deliver_supervisor_followup(supervisor, &envelope)
603 .await
604 .map_err(|error| {
605 let error = SessionMessengerError::Inbox(error);
606 self.record_rejection(&error);
607 error
608 })?;
609 let admission = self.record_admission(envelope, delivery, started);
610 self.activate_prepared(&admission).await
613 }
614
615 async fn load_session(&self, id: &str) -> Result<Option<Session>, SessionMessengerError> {
616 self.sessions
617 .load_session(id)
618 .await
619 .map_err(|error| SessionMessengerError::Storage(error.to_string()))
620 }
621
622 fn logical_root(session: &Session) -> &str {
623 if session.root_session_id.trim().is_empty() {
624 &session.id
625 } else {
626 &session.root_session_id
627 }
628 }
629
630 async fn validate_relationship(
631 &self,
632 envelope: &SessionMessageEnvelope,
633 ) -> Result<(), SessionMessengerError> {
634 envelope
635 .validate()
636 .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
637 let target = self
638 .load_session(&envelope.target_session_id)
639 .await?
640 .ok_or_else(|| {
641 SessionMessengerError::TargetNotFound(envelope.target_session_id.clone())
642 })?;
643
644 let SessionMessageSource::Session { session_id } = &envelope.source else {
645 return Ok(());
646 };
647 let source = self
648 .load_session(session_id)
649 .await?
650 .ok_or_else(|| SessionMessengerError::SourceNotFound(session_id.clone()))?;
651
652 let same_root = Self::logical_root(&source) == Self::logical_root(&target);
653 let source_project = source.project_id_meta();
654 let target_project = target.project_id_meta();
655 let project_compatible = match (source_project.as_deref(), target_project.as_deref()) {
656 (Some(left), Some(right)) => left == right,
657 _ => true,
660 };
661 if !same_root || !project_compatible {
662 return Err(SessionMessengerError::Unauthorized {
663 source_session_id: source.id,
664 target: target.id,
665 });
666 }
667 Ok(())
668 }
669
670 fn record_rejection(&self, error: &SessionMessengerError) {
671 self.metrics.rejected.fetch_add(1, Ordering::Relaxed);
672 match error {
673 SessionMessengerError::InvalidEnvelope(_) => {
674 self.metrics
675 .invalid_envelope
676 .fetch_add(1, Ordering::Relaxed);
677 }
678 SessionMessengerError::Unauthorized { .. }
679 | SessionMessengerError::SourceNotFound(_)
680 | SessionMessengerError::TargetNotFound(_) => {
681 self.metrics.unauthorized.fetch_add(1, Ordering::Relaxed);
682 }
683 SessionMessengerError::Inbox(SessionInboxError::AdmissionCancelled) => {}
684 SessionMessengerError::Inbox(SessionInboxError::PayloadTooLarge { .. }) => {
685 self.metrics
686 .payload_too_large
687 .fetch_add(1, Ordering::Relaxed);
688 }
689 SessionMessengerError::Inbox(SessionInboxError::BacklogFull { .. }) => {
690 self.metrics.backlog_full.fetch_add(1, Ordering::Relaxed);
691 }
692 SessionMessengerError::Inbox(_) | SessionMessengerError::Storage(_) => {
693 self.metrics.storage_failed.fetch_add(1, Ordering::Relaxed);
694 }
695 SessionMessengerError::Activation { .. }
696 | SessionMessengerError::ActivationEligibility { .. } => {}
697 }
698 }
699
700 pub async fn admit(
701 &self,
702 envelope: SessionMessageEnvelope,
703 ) -> Result<SessionMessengerAdmission, SessionMessengerError> {
704 self.admit_with_gate(envelope, None).await
705 }
706
707 pub async fn admit_with_gate(
708 &self,
709 envelope: SessionMessageEnvelope,
710 gate: Option<&bamboo_domain::AdmissionGate>,
711 ) -> Result<SessionMessengerAdmission, SessionMessengerError> {
712 self.admit_with_delivery_intent(envelope, gate, None).await
713 }
714
715 pub async fn admit_with_activation_intent(
718 &self,
719 envelope: SessionMessageEnvelope,
720 policy: SessionActivationPolicy,
721 gate: Option<&bamboo_domain::AdmissionGate>,
722 ) -> Result<SessionMessengerAdmission, SessionMessengerError> {
723 self.admit_with_delivery_intent(envelope, gate, Some(policy))
724 .await
725 }
726
727 async fn admit_with_delivery_intent(
728 &self,
729 envelope: SessionMessageEnvelope,
730 gate: Option<&bamboo_domain::AdmissionGate>,
731 policy: Option<SessionActivationPolicy>,
732 ) -> Result<SessionMessengerAdmission, SessionMessengerError> {
733 let started = Instant::now();
734 if let Err(error) = self.validate_relationship(&envelope).await {
735 self.record_rejection(&error);
736 tracing::warn!(
737 message_id = %envelope.id,
738 target_session_id = %envelope.target_session_id,
739 error = %error,
740 "session message rejected"
741 );
742 return Err(error);
743 }
744
745 let delivery = match (policy, gate) {
746 (Some(policy), gate) => {
747 self.inbox
748 .deliver_with_activation_intent(&envelope, policy, gate)
749 .await
750 }
751 (None, Some(gate)) => self.inbox.deliver_with_gate(&envelope, gate).await,
752 (None, None) => self.inbox.deliver(&envelope).await,
753 };
754 let delivery = match delivery {
755 Ok(receipt) => receipt,
756 Err(error) => {
757 let error = SessionMessengerError::Inbox(error);
758 self.record_rejection(&error);
759 return Err(error);
760 }
761 };
762 Ok(self.record_admission(envelope, delivery, started))
763 }
764
765 fn record_admission(
766 &self,
767 envelope: SessionMessageEnvelope,
768 delivery: SessionInboxReceipt,
769 started: Instant,
770 ) -> SessionMessengerAdmission {
771 self.metrics.delivered.fetch_add(1, Ordering::Relaxed);
772 self.metrics.delivery_latency_micros.fetch_add(
773 started.elapsed().as_micros().min(u128::from(u64::MAX)) as u64,
774 Ordering::Relaxed,
775 );
776 SessionMessengerAdmission {
777 envelope_id: envelope.id.as_str().to_string(),
778 target_session_id: envelope.target_session_id,
779 delivery,
780 }
781 }
782
783 pub async fn activate(
784 &self,
785 admission: &SessionMessengerAdmission,
786 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
787 self.activate_with_policy(admission, SessionActivationPolicy::InterruptSpecificWait)
788 .await
789 }
790
791 pub async fn activate_with_policy(
792 &self,
793 admission: &SessionMessengerAdmission,
794 policy: SessionActivationPolicy,
795 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
796 if let Err(error) = self
797 .inbox
798 .mark_activation_eligible(
799 &admission.target_session_id,
800 admission.delivery.generation,
801 policy,
802 )
803 .await
804 {
805 self.metrics
806 .activation_failed
807 .fetch_add(1, Ordering::Relaxed);
808 return Err(SessionMessengerError::ActivationEligibility {
809 receipt_id: admission.delivery.id.to_string(),
810 receipt: admission.delivery.clone(),
811 source: error,
812 });
813 }
814 self.activate_prepared(admission).await
815 }
816
817 pub async fn activate_prepared(
820 &self,
821 admission: &SessionMessengerAdmission,
822 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
823 let activation = match self
824 .activation
825 .request_activation(&admission.target_session_id, admission.delivery.generation)
826 .await
827 {
828 Ok(disposition) => disposition,
829 Err(source) => {
830 self.metrics
831 .activation_failed
832 .fetch_add(1, Ordering::Relaxed);
833 tracing::error!(
834 message_id = %admission.envelope_id,
835 target_session_id = %admission.target_session_id,
836 generation = admission.delivery.generation,
837 error = %source,
838 "session message durable but activation failed"
839 );
840 return Err(SessionMessengerError::Activation {
841 receipt_id: admission.delivery.id.to_string(),
842 receipt: admission.delivery.clone(),
843 source,
844 });
845 }
846 };
847 match activation {
848 SessionActivationDisposition::ActiveNotified => {
849 self.metrics.active_notified.fetch_add(1, Ordering::Relaxed);
850 }
851 SessionActivationDisposition::ActivationReserved => {
852 self.metrics
853 .activation_reserved
854 .fetch_add(1, Ordering::Relaxed);
855 }
856 SessionActivationDisposition::ActivationCoalesced => {
857 self.metrics
858 .activation_coalesced
859 .fetch_add(1, Ordering::Relaxed);
860 }
861 }
862 tracing::info!(
863 message_id = %admission.envelope_id,
864 target_session_id = %admission.target_session_id,
865 generation = admission.delivery.generation,
866 ?activation,
867 "session message durably delivered"
868 );
869 Ok(SessionMessengerReceipt {
870 delivery: admission.delivery.clone(),
871 activation,
872 })
873 }
874
875 pub async fn prepare_activation(
884 &self,
885 admission: &SessionMessengerAdmission,
886 ) -> Result<(), SessionMessengerError> {
887 self.inbox
888 .mark_activation_eligible(
889 &admission.target_session_id,
890 admission.delivery.generation,
891 SessionActivationPolicy::RespectSpecificWait,
892 )
893 .await
894 .map_err(SessionMessengerError::Inbox)
895 }
896
897 pub async fn prepare_message_activation(
900 &self,
901 admission: &SessionMessengerAdmission,
902 policy: SessionActivationPolicy,
903 ) -> Result<(), SessionMessengerError> {
904 self.inbox
905 .mark_message_activation_eligible(
906 &admission.target_session_id,
907 &admission.delivery,
908 policy,
909 )
910 .await
911 .map_err(SessionMessengerError::Inbox)
912 }
913
914 pub async fn send(
915 &self,
916 envelope: SessionMessageEnvelope,
917 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
918 self.send_with_gate(envelope, None).await
919 }
920
921 pub async fn send_with_gate(
922 &self,
923 envelope: SessionMessageEnvelope,
924 gate: Option<&bamboo_domain::AdmissionGate>,
925 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
926 let admission = self
927 .admit_with_activation_intent(
928 envelope,
929 SessionActivationPolicy::InterruptSpecificWait,
930 gate,
931 )
932 .await?;
933 self.activate_prepared(&admission).await
934 }
935
936 pub async fn send_with_policy(
937 &self,
938 envelope: SessionMessageEnvelope,
939 policy: SessionActivationPolicy,
940 ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
941 let admission = self
942 .admit_with_activation_intent(envelope, policy, None)
943 .await?;
944 self.activate_prepared(&admission).await
945 }
946}
947
948#[cfg(test)]
949mod tests {
950 use super::*;
951 use async_trait::async_trait;
952 use bamboo_domain::{
953 SessionActivationDisposition, SessionInboxLimits, SessionMessageBody,
954 SessionMessageContent, SessionMessageId, SessionMessageKind,
955 };
956 use bamboo_storage::{FileSessionInbox, SessionStoreV2};
957 use tempfile::TempDir;
958
959 struct RecordingActivation {
960 calls: tokio::sync::Mutex<Vec<(String, u64)>>,
961 }
962
963 struct FailingActivation;
964
965 #[async_trait]
966 impl SessionActivationPort for FailingActivation {
967 async fn request_activation(
968 &self,
969 _target_session_id: &str,
970 _inbox_generation: u64,
971 ) -> Result<SessionActivationDisposition, SessionActivationError> {
972 Err(SessionActivationError::Internal(
973 "injected wakeup failure".into(),
974 ))
975 }
976 }
977
978 #[tokio::test]
979 async fn immediate_activation_failure_preserves_receipt_and_restart_permission() {
980 let (temp, store, recording, _messenger) = fixture().await;
981 store
982 .save_session(&Session::new("root", "model"))
983 .await
984 .unwrap();
985 let inbox = Arc::new(FileSessionInbox::new(
986 store.clone(),
987 SessionInboxLimits::default(),
988 ));
989 let messenger = SessionMessenger::new(store, inbox, Arc::new(FailingActivation));
990 let envelope = SessionMessageEnvelope::user_input("root", "steer");
991 let gate = bamboo_domain::AdmissionGate::default();
992 let receipt = match messenger
993 .send_with_gate(envelope.clone(), Some(&gate))
994 .await
995 .unwrap_err()
996 {
997 SessionMessengerError::Activation { receipt, .. } => receipt,
998 error => panic!("expected durable delivery with failed wakeup: {error}"),
999 };
1000 assert!(gate.is_committed());
1001 gate.cancel_if_pending();
1002 let restarted_store = Arc::new(SessionStoreV2::new(temp.path().into()).await.unwrap());
1003 let restarted = Arc::new(FileSessionInbox::new(
1004 restarted_store.clone(),
1005 SessionInboxLimits::default(),
1006 ));
1007 let backlog = restarted.inspect("root").await.unwrap();
1008 assert!(backlog.activation_pending());
1009 assert_eq!(backlog.coordinator_generation, 0);
1010 recording
1013 .request_activation("root", backlog.activation_generation)
1014 .await
1015 .unwrap();
1016 let retry = SessionMessenger::new(restarted_store, restarted.clone(), recording.clone());
1017 assert_eq!(
1018 retry
1019 .send_with_gate(envelope, Some(&gate))
1020 .await
1021 .unwrap()
1022 .delivery,
1023 receipt
1024 );
1025 assert_eq!(restarted.inspect("root").await.unwrap().pending, 1);
1026 assert_eq!(messenger.metrics().snapshot().activation_failed, 1);
1027 let claims = restarted.claim("root", 128).await.unwrap();
1028 assert_eq!(claims.len(), 1);
1029 assert_eq!(claims[0].envelope.id, receipt.id);
1030 }
1031
1032 struct FailEligibilityOnce {
1033 inner: Arc<FileSessionInbox>,
1034 fail_next: std::sync::atomic::AtomicBool,
1035 }
1036
1037 #[async_trait]
1038 impl SessionInboxPort for FailEligibilityOnce {
1039 async fn deliver(
1040 &self,
1041 envelope: &SessionMessageEnvelope,
1042 ) -> Result<SessionInboxReceipt, SessionInboxError> {
1043 self.inner.deliver(envelope).await
1044 }
1045
1046 async fn deliver_with_gate(
1047 &self,
1048 envelope: &SessionMessageEnvelope,
1049 gate: &bamboo_domain::AdmissionGate,
1050 ) -> Result<SessionInboxReceipt, SessionInboxError> {
1051 self.inner.deliver_with_gate(envelope, gate).await
1052 }
1053
1054 async fn mark_activation_eligible(
1055 &self,
1056 target_session_id: &str,
1057 generation: u64,
1058 policy: SessionActivationPolicy,
1059 ) -> Result<(), SessionInboxError> {
1060 if self.fail_next.swap(false, Ordering::SeqCst) {
1061 return Err(SessionInboxError::Storage(
1062 "injected activation watermark failure".into(),
1063 ));
1064 }
1065 self.inner
1066 .mark_activation_eligible(target_session_id, generation, policy)
1067 .await
1068 }
1069
1070 async fn claim(
1071 &self,
1072 target_session_id: &str,
1073 limit: usize,
1074 ) -> Result<Vec<bamboo_domain::SessionInboxClaim>, SessionInboxError> {
1075 self.inner.claim(target_session_id, limit).await
1076 }
1077
1078 async fn was_admitted(
1079 &self,
1080 target_session_id: &str,
1081 id: &SessionMessageId,
1082 ) -> Result<bool, SessionInboxError> {
1083 self.inner.was_admitted(target_session_id, id).await
1084 }
1085
1086 async fn ack(
1087 &self,
1088 target_session_id: &str,
1089 claim: &bamboo_domain::SessionInboxClaim,
1090 ) -> Result<(), SessionInboxError> {
1091 self.inner.ack(target_session_id, claim).await
1092 }
1093
1094 async fn inspect(
1095 &self,
1096 target_session_id: &str,
1097 ) -> Result<bamboo_domain::SessionInboxBacklog, SessionInboxError> {
1098 self.inner.inspect(target_session_id).await
1099 }
1100 }
1101
1102 #[async_trait]
1103 impl SessionActivationPort for RecordingActivation {
1104 async fn request_activation(
1105 &self,
1106 target_session_id: &str,
1107 inbox_generation: u64,
1108 ) -> Result<SessionActivationDisposition, SessionActivationError> {
1109 self.calls
1110 .lock()
1111 .await
1112 .push((target_session_id.to_string(), inbox_generation));
1113 Ok(SessionActivationDisposition::ActivationReserved)
1114 }
1115 }
1116
1117 async fn fixture() -> (
1118 TempDir,
1119 Arc<SessionStoreV2>,
1120 Arc<RecordingActivation>,
1121 SessionMessenger,
1122 ) {
1123 let temp = TempDir::new().unwrap();
1124 let store = Arc::new(
1125 SessionStoreV2::new(temp.path().to_path_buf())
1126 .await
1127 .unwrap(),
1128 );
1129 let activation = Arc::new(RecordingActivation {
1130 calls: tokio::sync::Mutex::new(Vec::new()),
1131 });
1132 let inbox = Arc::new(FileSessionInbox::new(
1133 store.clone(),
1134 SessionInboxLimits::default(),
1135 ));
1136 let messenger = SessionMessenger::new(store.clone(), inbox, activation.clone());
1137 (temp, store, activation, messenger)
1138 }
1139
1140 fn peer(source: &str, target: &str, id: &str) -> SessionMessageEnvelope {
1141 SessionMessageEnvelope {
1142 id: SessionMessageId::parse(id).unwrap(),
1143 source: SessionMessageSource::Session {
1144 session_id: source.to_string(),
1145 },
1146 target_session_id: target.to_string(),
1147 kind: SessionMessageKind::PeerMessage,
1148 body: SessionMessageBody::Content(SessionMessageContent::text("hello")),
1149 created_at: chrono::Utc::now(),
1150 thread_id: None,
1151 in_reply_to: None,
1152 attempt: None,
1153 correlation_id: None,
1154 }
1155 }
1156
1157 #[derive(Default)]
1158 struct PeerMemoryStorage(tokio::sync::RwLock<std::collections::HashMap<String, Session>>);
1159
1160 #[async_trait]
1161 impl Storage for PeerMemoryStorage {
1162 async fn save_session(&self, session: &Session) -> std::io::Result<()> {
1163 self.0
1164 .write()
1165 .await
1166 .insert(session.id.clone(), session.clone());
1167 Ok(())
1168 }
1169
1170 async fn load_session(&self, id: &str) -> std::io::Result<Option<Session>> {
1171 Ok(self.0.read().await.get(id).cloned())
1172 }
1173
1174 async fn delete_session(&self, id: &str) -> std::io::Result<bool> {
1175 Ok(self.0.write().await.remove(id).is_some())
1176 }
1177
1178 async fn list_child_run_statuses(
1179 &self,
1180 parent: &str,
1181 ) -> std::io::Result<Vec<(String, Option<String>)>> {
1182 Ok(self
1183 .0
1184 .read()
1185 .await
1186 .values()
1187 .filter(|session| session.parent_session_id.as_deref() == Some(parent))
1188 .map(|session| (session.id.clone(), session.last_run_status()))
1189 .collect())
1190 }
1191 }
1192
1193 async fn seed_peer_tree(store: &dyn Storage) {
1194 let mut root = Session::new("peer-root", "model");
1195 root.title = "Logical root".into();
1196 root.set_project_id_meta("peer-project");
1197 let left = Session::new_child_of("peer-left", &root, "model", "Left");
1198 let right = Session::new_child_of("peer-right", &root, "model", "Right");
1199 let cousin_left = Session::new_child_of("peer-left-leaf", &left, "model", "Left leaf");
1200 let cousin_right = Session::new_child_of("peer-right-leaf", &right, "model", "Right leaf");
1201 for mut session in [root, left, right, cousin_left, cousin_right] {
1202 session.set_project_id_meta("peer-project");
1203 store.save_session(&session).await.unwrap();
1204 }
1205 }
1206
1207 struct PausedPeerAdmission {
1208 inner: Arc<FileSessionInbox>,
1209 entered: Arc<tokio::sync::Notify>,
1210 release: Arc<tokio::sync::Notify>,
1211 }
1212
1213 #[async_trait]
1214 impl SessionInboxPort for PausedPeerAdmission {
1215 async fn deliver(
1216 &self,
1217 envelope: &SessionMessageEnvelope,
1218 ) -> Result<SessionInboxReceipt, SessionInboxError> {
1219 self.inner.deliver(envelope).await
1220 }
1221
1222 async fn deliver_peer_message(
1223 &self,
1224 envelope: &SessionMessageEnvelope,
1225 source: &ActorSession,
1226 target: &ActorSession,
1227 reply_to: Option<&SessionMessageEnvelope>,
1228 ) -> Result<SessionInboxReceipt, SessionInboxError> {
1229 self.entered.notify_one();
1232 self.release.notified().await;
1233 self.inner
1234 .deliver_peer_message(envelope, source, target, reply_to)
1235 .await
1236 }
1237
1238 async fn mark_activation_eligible(
1239 &self,
1240 target: &str,
1241 generation: u64,
1242 policy: SessionActivationPolicy,
1243 ) -> Result<(), SessionInboxError> {
1244 self.inner
1245 .mark_activation_eligible(target, generation, policy)
1246 .await
1247 }
1248
1249 async fn claim(
1250 &self,
1251 target: &str,
1252 limit: usize,
1253 ) -> Result<Vec<bamboo_domain::SessionInboxClaim>, SessionInboxError> {
1254 self.inner.claim(target, limit).await
1255 }
1256
1257 async fn was_admitted(
1258 &self,
1259 target: &str,
1260 id: &SessionMessageId,
1261 ) -> Result<bool, SessionInboxError> {
1262 self.inner.was_admitted(target, id).await
1263 }
1264
1265 async fn ack(
1266 &self,
1267 target: &str,
1268 claim: &bamboo_domain::SessionInboxClaim,
1269 ) -> Result<(), SessionInboxError> {
1270 self.inner.ack(target, claim).await
1271 }
1272
1273 async fn inspect(
1274 &self,
1275 target: &str,
1276 ) -> Result<bamboo_domain::SessionInboxBacklog, SessionInboxError> {
1277 self.inner.inspect(target).await
1278 }
1279 }
1280
1281 #[tokio::test]
1282 async fn peer_send_and_reply_reject_either_lifetime_recreated_before_admission() {
1283 for reply in [false, true] {
1284 for replaced_id in ["peer-left", "peer-right"] {
1285 let (_temp, store, activation, messenger) = fixture().await;
1286 seed_peer_tree(store.as_ref()).await;
1287 let received_id = if reply {
1288 let incoming = messenger
1289 .send_peer("peer-left", "peer-right", "incoming", "question")
1290 .await
1291 .unwrap();
1292 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right")
1293 .await;
1294 Some(incoming.delivery.id.to_string())
1295 } else {
1296 None
1297 };
1298 activation.calls.lock().await.clear();
1299 let (caller, target) = if reply {
1300 ("peer-right", "peer-left")
1301 } else {
1302 ("peer-left", "peer-right")
1303 };
1304 let entered = Arc::new(tokio::sync::Notify::new());
1305 let release = Arc::new(tokio::sync::Notify::new());
1306 let inbox = Arc::new(FileSessionInbox::new(
1307 store.clone(),
1308 SessionInboxLimits::default(),
1309 ));
1310 let paused = SessionMessenger::new(
1311 store.clone(),
1312 Arc::new(PausedPeerAdmission {
1313 inner: inbox.clone(),
1314 entered: entered.clone(),
1315 release: release.clone(),
1316 }),
1317 activation.clone(),
1318 );
1319 let sending = tokio::spawn(async move {
1320 if let Some(id) = received_id {
1321 paused
1322 .reply_peer(caller, &id, "racing", "old lifetime")
1323 .await
1324 } else {
1325 paused
1326 .send_peer(caller, target, "racing", "old lifetime")
1327 .await
1328 }
1329 });
1330 tokio::time::timeout(std::time::Duration::from_secs(5), entered.notified())
1331 .await
1332 .unwrap();
1333 let old = store.load_session(replaced_id).await.unwrap().unwrap();
1334 assert!(store.delete_session(replaced_id).await.unwrap());
1335 let root = store.load_session("peer-root").await.unwrap().unwrap();
1336 let mut replacement =
1337 Session::new_child_of(replaced_id, &root, "model", "Replacement");
1338 replacement.created_at = old.created_at + chrono::Duration::seconds(1);
1339 replacement.set_project_id_meta("peer-project");
1340 store.save_session(&replacement).await.unwrap();
1341 release.notify_one();
1342 let result = tokio::time::timeout(std::time::Duration::from_secs(5), sending)
1343 .await
1344 .unwrap()
1345 .unwrap();
1346 assert!(result.is_err(), "reply={reply}, replaced={replaced_id}");
1347 let backlog = inbox.inspect(target).await.unwrap();
1348 assert_eq!(backlog.generation, 0);
1349 assert_eq!(backlog.pending, 0);
1350 assert!(!backlog.activation_pending());
1351 assert!(activation.calls.lock().await.is_empty());
1352 let current = messenger
1355 .send_peer(caller, target, "current-lifetime", "current")
1356 .await
1357 .unwrap();
1358 assert_eq!(current.delivery.generation, 1);
1359 assert_eq!(activation.calls.lock().await.len(), 1);
1360 }
1361 }
1362 }
1363
1364 #[tokio::test]
1365 async fn peer_reply_revalidates_received_proof_after_concurrent_clear_or_save() {
1366 for clear in [true, false] {
1367 let (_temp, store, activation, messenger) = fixture().await;
1368 seed_peer_tree(store.as_ref()).await;
1369 let incoming = messenger
1370 .send_peer("peer-left", "peer-right", "incoming", "question")
1371 .await
1372 .unwrap();
1373 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right").await;
1374 activation.calls.lock().await.clear();
1375 let before = store.load_session("peer-right").await.unwrap().unwrap();
1376 let received_id = incoming.delivery.id.to_string();
1377 let entered = Arc::new(tokio::sync::Notify::new());
1378 let release = Arc::new(tokio::sync::Notify::new());
1379 let inbox = Arc::new(FileSessionInbox::new(
1380 store.clone(),
1381 SessionInboxLimits::default(),
1382 ));
1383 let paused = SessionMessenger::new(
1384 store.clone(),
1385 Arc::new(PausedPeerAdmission {
1386 inner: inbox.clone(),
1387 entered: entered.clone(),
1388 release: release.clone(),
1389 }),
1390 activation.clone(),
1391 );
1392 let old_id = received_id.clone();
1393 let replying = tokio::spawn(async move {
1394 paused
1395 .reply_peer("peer-right", &old_id, "racing-proof", "reply")
1396 .await
1397 });
1398 tokio::time::timeout(std::time::Duration::from_secs(5), entered.notified())
1399 .await
1400 .unwrap();
1401 if clear {
1402 assert!(store.clear_session("peer-right").await.unwrap());
1403 } else {
1404 let mut changed = before.clone();
1405 changed.messages.retain(|message| message.id != received_id);
1406 store.save_session(&changed).await.unwrap();
1407 }
1408 let after = store.load_session("peer-right").await.unwrap().unwrap();
1409 assert_eq!(before.created_at, after.created_at);
1410 assert_eq!(before.root_session_id, after.root_session_id);
1411 assert_eq!(before.project_id_meta(), after.project_id_meta());
1412 assert!(!after
1413 .messages
1414 .iter()
1415 .any(|message| message.id == received_id));
1416 release.notify_one();
1417 let result = tokio::time::timeout(std::time::Duration::from_secs(5), replying)
1418 .await
1419 .unwrap()
1420 .unwrap();
1421 assert!(
1422 result.is_err(),
1423 "clear={clear}: removed proof must not admit a reply"
1424 );
1425 let backlog = inbox.inspect("peer-left").await.unwrap();
1426 assert_eq!(backlog.generation, 0);
1427 assert_eq!(backlog.pending, 0);
1428 assert!(!backlog.activation_pending());
1429 assert!(activation.calls.lock().await.is_empty());
1430
1431 let current = messenger
1432 .send_peer("peer-left", "peer-right", "current-question", "current")
1433 .await
1434 .unwrap();
1435 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right").await;
1436 let reply = messenger
1437 .reply_peer(
1438 "peer-right",
1439 current.delivery.id.as_str(),
1440 "racing-proof",
1441 "reply",
1442 )
1443 .await
1444 .unwrap();
1445 let retry = messenger
1446 .reply_peer(
1447 "peer-right",
1448 current.delivery.id.as_str(),
1449 "racing-proof",
1450 "reply",
1451 )
1452 .await
1453 .unwrap();
1454 assert_eq!(reply.delivery.generation, 1);
1455 assert_eq!(retry.delivery.generation, reply.delivery.generation);
1456 assert_eq!(retry.delivery.id, reply.delivery.id);
1457 let backlog = inbox.inspect("peer-left").await.unwrap();
1458 assert_eq!(backlog.generation, 1);
1459 assert_eq!(backlog.pending, 1);
1460 }
1461 }
1462
1463 #[tokio::test]
1464 async fn peer_admission_without_integrated_lifetime_fence_is_unsupported() {
1465 let (_temp, store, activation, _messenger) = fixture().await;
1466 seed_peer_tree(store.as_ref()).await;
1467 let inbox = Arc::new(FileSessionInbox::new(
1468 store.clone(),
1469 SessionInboxLimits::default(),
1470 ));
1471 let messenger = SessionMessenger::new(
1472 store,
1473 Arc::new(FailEligibilityOnce {
1474 inner: inbox.clone(),
1475 fail_next: std::sync::atomic::AtomicBool::new(false),
1476 }),
1477 activation.clone(),
1478 );
1479 let error = messenger
1480 .send_peer("peer-left", "peer-right", "unsupported", "message")
1481 .await
1482 .unwrap_err();
1483 assert!(error
1484 .to_string()
1485 .contains("lifetime-fenced peer SessionInbox admission is unsupported"));
1486 assert_eq!(inbox.inspect("peer-right").await.unwrap().pending, 0);
1487 assert!(activation.calls.lock().await.is_empty());
1488 }
1489
1490 async fn admit_peer_for_test(
1491 store: &dyn Storage,
1492 inbox: &dyn SessionInboxPort,
1493 target: &str,
1494 ) -> SessionMessageEnvelope {
1495 let claims = inbox.claim(target, 16).await.unwrap();
1496 assert_eq!(claims.len(), 1);
1497 let claim = &claims[0];
1498 let mut session = store.load_session(target).await.unwrap().unwrap();
1499 session.add_message(claim.envelope.to_provider_message().unwrap());
1500 store.save_session(&session).await.unwrap();
1501 inbox.ack(target, claim).await.unwrap();
1502 claim.envelope.clone()
1503 }
1504
1505 #[tokio::test]
1506 async fn peer_api_discovers_and_messages_parent_child_siblings_and_cousins() {
1507 let (_temp, store, activation, messenger) = fixture().await;
1508 seed_peer_tree(store.as_ref()).await;
1509 store
1510 .save_session(&Session::new("foreign-root", "model"))
1511 .await
1512 .unwrap();
1513 let targets = messenger.list_peer_targets("peer-left-leaf").await.unwrap();
1514 assert!(!targets.truncated);
1515 let ids: Vec<_> = targets
1516 .targets
1517 .iter()
1518 .map(|target| target.session_id.as_str())
1519 .collect();
1520 assert_eq!(
1521 ids,
1522 ["peer-root", "peer-left", "peer-right", "peer-right-leaf"]
1523 );
1524 assert_eq!(targets.targets[0].title, "Logical root");
1525 assert!(targets.targets[0].parent_session_id.is_none());
1526 assert!(!serde_json::to_string(&targets)
1527 .unwrap()
1528 .contains("foreign-root"));
1529
1530 for (index, (source, target)) in [
1531 ("peer-root", "peer-left"),
1532 ("peer-left", "peer-root"),
1533 ("peer-left", "peer-right"),
1534 ("peer-left-leaf", "peer-right-leaf"),
1535 ]
1536 .into_iter()
1537 .enumerate()
1538 {
1539 let receipt = messenger
1540 .send_peer(
1541 source,
1542 target,
1543 &format!("operation-{index}"),
1544 "bounded peer instruction",
1545 )
1546 .await
1547 .unwrap();
1548 let backlog = messenger.inbox().inspect(target).await.unwrap();
1549 assert_eq!(
1550 backlog.coordinator_generation, 0,
1551 "peer permission must not release a prefix"
1552 );
1553 assert!(backlog.interrupt_pending());
1554 let envelope =
1555 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), target).await;
1556 assert_eq!(envelope.id, receipt.delivery.id);
1557 assert_eq!(
1558 envelope.source,
1559 SessionMessageSource::Session {
1560 session_id: source.into()
1561 }
1562 );
1563 assert_eq!(envelope.thread_id.as_deref(), Some(envelope.id.as_str()));
1564 assert_eq!(
1565 envelope.correlation_id.as_deref(),
1566 Some(format!("operation-{index}").as_str())
1567 );
1568 assert!(envelope.in_reply_to.is_none());
1569 }
1570 assert_eq!(activation.calls.lock().await.len(), 4);
1571 }
1572
1573 #[tokio::test]
1574 async fn peer_api_denies_unknown_deleted_cross_root_and_invalid_project_or_lineage() {
1575 let (_temp, store, activation, messenger) = fixture().await;
1576 seed_peer_tree(store.as_ref()).await;
1577 store
1578 .save_session(&Session::new("foreign-root", "model"))
1579 .await
1580 .unwrap();
1581 for (source, target) in [
1582 ("peer-left", "foreign-root"),
1583 ("unknown", "peer-left"),
1584 ("peer-left", "unknown"),
1585 ("peer-left", "peer-left"),
1586 ] {
1587 assert!(messenger
1588 .send_peer(source, target, "denied-operation", "instruction")
1589 .await
1590 .is_err());
1591 }
1592 store.delete_session("peer-right-leaf").await.unwrap();
1593 assert!(matches!(
1594 messenger
1595 .send_peer("peer-left", "peer-right-leaf", "deleted", "instruction")
1596 .await,
1597 Err(SessionMessengerError::TargetNotFound(_))
1598 ));
1599 assert!(matches!(
1600 messenger
1601 .send_peer(
1602 "peer-right-leaf",
1603 "peer-left",
1604 "deleted-source",
1605 "instruction"
1606 )
1607 .await,
1608 Err(SessionMessengerError::SourceNotFound(_))
1609 ));
1610
1611 let memory = Arc::new(PeerMemoryStorage::default());
1614 seed_peer_tree(memory.as_ref()).await;
1615 let strict = SessionMessenger::new(
1616 memory.clone(),
1617 messenger.inbox().clone(),
1618 activation.clone(),
1619 );
1620 let original = memory.load_session("peer-right").await.unwrap().unwrap();
1621 for defect in [
1622 "project",
1623 "missing_project",
1624 "project_mirror",
1625 "parent",
1626 "depth",
1627 "root",
1628 "cycle",
1629 ] {
1630 let mut invalid = original.clone();
1631 match defect {
1632 "project" => invalid.set_project_id_meta("other-project"),
1633 "missing_project" => {
1634 invalid.metadata.remove("project_id");
1635 if let Some(metadata) = invalid.runtime_metadata.as_mut() {
1636 metadata.project_id = None;
1637 }
1638 }
1639 "project_mirror" => {
1640 invalid
1641 .metadata
1642 .insert("project_id".into(), "other-project".into());
1643 }
1644 "parent" => invalid.parent_session_id = Some("missing-parent".into()),
1645 "depth" => invalid.spawn_depth += 1,
1646 "root" => invalid.root_session_id = "foreign-root".into(),
1647 "cycle" => invalid.parent_session_id = Some("peer-right-leaf".into()),
1648 _ => unreachable!(),
1649 }
1650 memory.save_session(&invalid).await.unwrap();
1651 assert!(
1652 strict
1653 .send_peer("peer-left", "peer-right", "invalid", "instruction")
1654 .await
1655 .is_err(),
1656 "{defect}"
1657 );
1658 let targets = strict.list_peer_targets("peer-left").await.unwrap();
1659 assert!(
1660 !targets
1661 .targets
1662 .iter()
1663 .any(|target| target.session_id == "peer-right"),
1664 "{defect}"
1665 );
1666 memory.save_session(&original).await.unwrap();
1667 }
1668 assert!(activation.calls.lock().await.is_empty());
1669 assert_eq!(
1670 messenger
1671 .inbox()
1672 .inspect("peer-left")
1673 .await
1674 .unwrap()
1675 .generation,
1676 0
1677 );
1678 }
1679
1680 #[tokio::test]
1681 async fn peer_api_discovery_is_bounded_and_sender_is_runtime_selected() {
1682 let (_temp, _store, activation, messenger) = fixture().await;
1683 let memory = Arc::new(PeerMemoryStorage::default());
1684 let root = Session::new("many-peers", "model");
1685 memory.save_session(&root).await.unwrap();
1686 for index in 0..MAX_PEER_TARGETS + 2 {
1687 let child = Session::new_child_of(format!("peer-{index:03}"), &root, "model", "Peer");
1688 memory.save_session(&child).await.unwrap();
1689 }
1690 let bounded = SessionMessenger::new(
1691 memory.clone(),
1692 messenger.inbox().clone(),
1693 activation.clone(),
1694 );
1695 let page = bounded.list_peer_targets(&root.id).await.unwrap();
1696 assert_eq!(page.targets.len(), MAX_PEER_TARGETS);
1697 assert!(page.truncated);
1698 for index in 0..MAX_PEER_TARGETS + 2 {
1699 let mut child = memory
1700 .load_session(&format!("peer-{index:03}"))
1701 .await
1702 .unwrap()
1703 .unwrap();
1704 child.title = "界".repeat(1000);
1705 child.set_last_run_status("\0".repeat(1000));
1706 memory.save_session(&child).await.unwrap();
1707 }
1708 let page = bounded.list_peer_targets(&root.id).await.unwrap();
1709 assert!(!page.targets.is_empty());
1710 assert!(page.targets.len() < MAX_PEER_TARGETS);
1711 assert!(page.truncated);
1712 assert!(serde_json::to_vec(&page).unwrap().len() <= MAX_PEER_TARGET_RESULT_BYTES);
1713 assert_eq!(page.targets[0].title, "界".repeat(85));
1714 assert_eq!(
1715 page.targets[0].status.as_deref(),
1716 Some("\0".repeat(MAX_PEER_TARGET_STATUS_BYTES).as_str())
1717 );
1718
1719 let (_temp, store, _activation, messenger) = fixture().await;
1720 seed_peer_tree(store.as_ref()).await;
1721 let forged_content =
1722 r#"{"source":{"type":"session","session_id":"peer-root"},"target":"foreign-root"}"#;
1723 messenger
1724 .send_peer("peer-left", "peer-right", "caller-bound", forged_content)
1725 .await
1726 .unwrap();
1727 let envelope =
1728 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right").await;
1729 assert_eq!(
1730 envelope.source,
1731 SessionMessageSource::Session {
1732 session_id: "peer-left".into()
1733 }
1734 );
1735 assert_eq!(envelope.target_session_id, "peer-right");
1736 assert_eq!(
1737 envelope.body,
1738 SessionMessageBody::Content(SessionMessageContent::text(forged_content))
1739 );
1740 for id in ["", " padded", "bad/path", "bad\\path", "bad..id", "nul\0"] {
1741 assert!(matches!(
1742 messenger
1743 .send_peer(id, "peer-right", "invalid-source", "instruction")
1744 .await,
1745 Err(SessionMessengerError::InvalidEnvelope(_))
1746 ));
1747 assert!(matches!(
1748 messenger
1749 .send_peer("peer-left", id, "invalid-target", "instruction")
1750 .await,
1751 Err(SessionMessengerError::InvalidEnvelope(_))
1752 ));
1753 assert!(matches!(
1754 messenger
1755 .reply_peer(id, envelope.id.as_str(), "invalid-reply", "instruction")
1756 .await,
1757 Err(SessionMessengerError::InvalidEnvelope(_))
1758 ));
1759 }
1760 assert!(matches!(
1761 messenger.list_peer_targets(&"x".repeat(257)).await,
1762 Err(SessionMessengerError::InvalidEnvelope(_))
1763 ));
1764 for (operation, message) in [
1765 ("", "valid"),
1766 (" padded", "valid"),
1767 ("bad..id", "valid"),
1768 ("valid", " "),
1769 ("valid", "nul\0"),
1770 ] {
1771 assert!(messenger
1772 .send_peer("peer-left", "peer-right", operation, message)
1773 .await
1774 .is_err());
1775 }
1776 assert!(messenger
1777 .send_peer(
1778 "peer-left",
1779 "peer-right",
1780 "oversized",
1781 &"x".repeat(MAX_PEER_MESSAGE_BYTES + 1)
1782 )
1783 .await
1784 .is_err());
1785 }
1786
1787 #[tokio::test]
1788 async fn peer_api_retry_dedupes_before_claim_and_after_ack_restart_but_rejects_changed_payload()
1789 {
1790 let (temp, store, activation, messenger) = fixture().await;
1791 seed_peer_tree(store.as_ref()).await;
1792 let first = messenger
1793 .send_peer("peer-left", "peer-right", "stable-operation", "original")
1794 .await
1795 .unwrap();
1796 let retry = messenger
1797 .send_peer("peer-left", "peer-right", "stable-operation", "original")
1798 .await
1799 .unwrap();
1800 assert_eq!(retry.delivery, first.delivery);
1801 assert!(matches!(
1802 messenger
1803 .send_peer("peer-left", "peer-right", "stable-operation", "changed")
1804 .await,
1805 Err(SessionMessengerError::Inbox(
1806 SessionInboxError::InvalidClaim(_)
1807 ))
1808 ));
1809 assert_eq!(
1810 messenger
1811 .inbox()
1812 .inspect("peer-right")
1813 .await
1814 .unwrap()
1815 .generation,
1816 1
1817 );
1818 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right").await;
1819 drop(messenger);
1820 drop(store);
1821 let reopened = Arc::new(SessionStoreV2::new(temp.path().into()).await.unwrap());
1822 let inbox = Arc::new(FileSessionInbox::new(
1823 reopened.clone(),
1824 SessionInboxLimits::default(),
1825 ));
1826 let messenger = SessionMessenger::new(reopened, inbox.clone(), activation);
1827 let after_ack = messenger
1828 .send_peer("peer-left", "peer-right", "stable-operation", "original")
1829 .await
1830 .unwrap();
1831 assert_eq!(after_ack.delivery, first.delivery);
1832 assert!(inbox.claim("peer-right", 16).await.unwrap().is_empty());
1833 assert!(matches!(
1834 messenger
1835 .send_peer("peer-left", "peer-right", "stable-operation", "changed")
1836 .await,
1837 Err(SessionMessengerError::Inbox(
1838 SessionInboxError::InvalidClaim(_)
1839 ))
1840 ));
1841 assert_eq!(inbox.inspect("peer-right").await.unwrap().generation, 1);
1842 }
1843
1844 #[tokio::test]
1845 async fn peer_api_reply_recovers_durable_recipient_and_thread_after_restart() {
1846 let (temp, store, activation, messenger) = fixture().await;
1847 seed_peer_tree(store.as_ref()).await;
1848 let first = messenger
1849 .send_peer("peer-left-leaf", "peer-right-leaf", "initial", "question")
1850 .await
1851 .unwrap();
1852 let incoming = admit_peer_for_test(
1853 store.as_ref(),
1854 messenger.inbox().as_ref(),
1855 "peer-right-leaf",
1856 )
1857 .await;
1858 drop(messenger);
1859 drop(store);
1860 let store = Arc::new(SessionStoreV2::new(temp.path().into()).await.unwrap());
1861 let inbox = Arc::new(FileSessionInbox::new(
1862 store.clone(),
1863 SessionInboxLimits::default(),
1864 ));
1865 let messenger = SessionMessenger::new(store.clone(), inbox.clone(), activation);
1866 let reply = messenger
1867 .reply_peer(
1868 "peer-right-leaf",
1869 first.delivery.id.as_str(),
1870 "answer",
1871 "answer text",
1872 )
1873 .await
1874 .unwrap();
1875 let retry = messenger
1876 .reply_peer(
1877 "peer-right-leaf",
1878 first.delivery.id.as_str(),
1879 "answer",
1880 "answer text",
1881 )
1882 .await
1883 .unwrap();
1884 assert_eq!(reply.delivery, retry.delivery);
1885 let response = admit_peer_for_test(store.as_ref(), inbox.as_ref(), "peer-left-leaf").await;
1886 assert_eq!(
1887 response.source,
1888 SessionMessageSource::Session {
1889 session_id: "peer-right-leaf".into()
1890 }
1891 );
1892 assert_eq!(response.in_reply_to, Some(incoming.id.clone()));
1893 assert_eq!(response.thread_id, incoming.thread_id);
1894 assert_eq!(response.correlation_id.as_deref(), Some("answer"));
1895 let second_reply = messenger
1896 .reply_peer("peer-left-leaf", response.id.as_str(), "thanks", "thanks")
1897 .await
1898 .unwrap();
1899 let second_response =
1900 admit_peer_for_test(store.as_ref(), inbox.as_ref(), "peer-right-leaf").await;
1901 assert_eq!(second_reply.delivery.id, second_response.id);
1902 assert_eq!(second_response.in_reply_to, Some(response.id));
1903 assert_eq!(
1904 second_response.thread_id.as_deref(),
1905 Some(first.delivery.id.as_str())
1906 );
1907 let after_ack = messenger
1908 .reply_peer(
1909 "peer-right-leaf",
1910 first.delivery.id.as_str(),
1911 "answer",
1912 "answer text",
1913 )
1914 .await
1915 .unwrap();
1916 assert_eq!(after_ack.delivery, reply.delivery);
1917 assert!(inbox.claim("peer-left-leaf", 16).await.unwrap().is_empty());
1918 assert!(messenger
1919 .reply_peer(
1920 "peer-right-leaf",
1921 first.delivery.id.as_str(),
1922 "answer",
1923 "different"
1924 )
1925 .await
1926 .is_err());
1927 }
1928
1929 #[tokio::test]
1930 async fn peer_api_reply_rejects_missing_forged_legacy_and_recreated_source() {
1931 let (_temp, store, activation, messenger) = fixture().await;
1932 seed_peer_tree(store.as_ref()).await;
1933 let mut receiver = store.load_session("peer-right").await.unwrap().unwrap();
1934 let legacy = peer("peer-left", "peer-right", "legacy-correction");
1935 receiver.add_message(legacy.to_provider_message().unwrap());
1936 let user = SessionMessageEnvelope::user_input("peer-right", "ordinary input");
1937 receiver.add_message(user.to_provider_message().unwrap());
1938 let sender = store.load_session("peer-left").await.unwrap().unwrap();
1939 let mut forged = SessionMessenger::peer_envelope(
1940 &sender,
1941 &receiver,
1942 "forged",
1943 "original",
1944 SessionMessenger::peer_message_id(&sender, &receiver, "forged"),
1945 "thread".into(),
1946 None,
1947 )
1948 .to_provider_message()
1949 .unwrap();
1950 forged.content = "changed without canonical proof".into();
1951 receiver.add_message(forged);
1952 store.save_session(&receiver).await.unwrap();
1953 for id in [
1954 "missing",
1955 legacy.id.as_str(),
1956 user.id.as_str(),
1957 receiver.messages.last().unwrap().id.as_str(),
1958 ] {
1959 assert!(messenger
1960 .reply_peer("peer-right", id, "deny-reply", "reply")
1961 .await
1962 .is_err());
1963 }
1964 let incoming = messenger
1965 .send_peer("peer-left", "peer-right", "old-lifetime", "question")
1966 .await
1967 .unwrap();
1968 admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right").await;
1969 let memory = Arc::new(PeerMemoryStorage::default());
1970 for id in ["peer-root", "peer-left", "peer-right"] {
1972 let mut session = store.load_session(id).await.unwrap().unwrap();
1973 if id == "peer-left" {
1974 session.created_at += chrono::Duration::seconds(1);
1975 }
1976 memory.save_session(&session).await.unwrap();
1977 }
1978 let fenced = SessionMessenger::new(
1979 memory.clone(),
1980 messenger.inbox().clone(),
1981 activation.clone(),
1982 );
1983 assert!(matches!(
1984 fenced
1985 .reply_peer(
1986 "peer-right",
1987 incoming.delivery.id.as_str(),
1988 "reply-old",
1989 "reply"
1990 )
1991 .await,
1992 Err(SessionMessengerError::InvalidEnvelope(_))
1993 ));
1994 memory.delete_session("peer-left").await.unwrap();
1995 assert!(matches!(
1996 fenced
1997 .reply_peer(
1998 "peer-right",
1999 incoming.delivery.id.as_str(),
2000 "reply-deleted",
2001 "reply"
2002 )
2003 .await,
2004 Err(SessionMessengerError::TargetNotFound(_))
2005 ));
2006 assert_eq!(activation.calls.lock().await.len(), 1);
2007 }
2008
2009 #[tokio::test]
2010 async fn same_root_delivery_enqueues_then_requests_activation() {
2011 let (_temp, store, activation, messenger) = fixture().await;
2012 let root = Session::new("root", "model");
2013 let mut child = Session::new("child", "model");
2014 child.kind = bamboo_domain::SessionKind::Child;
2015 child.parent_session_id = Some("root".to_string());
2016 child.root_session_id = "root".to_string();
2017 store.save_session(&root).await.unwrap();
2018 store.save_session(&child).await.unwrap();
2019
2020 let receipt = messenger
2021 .send(peer("root", "child", "msg-1"))
2022 .await
2023 .unwrap();
2024 assert_eq!(receipt.delivery.generation, 1);
2025 assert_eq!(
2026 activation.calls.lock().await.as_slice(),
2027 &[("child".to_string(), 1)]
2028 );
2029 assert_eq!(messenger.metrics().snapshot().delivered, 1);
2030 }
2031
2032 #[tokio::test]
2033 async fn staged_delivery_without_watermark_reports_exact_retry_required() {
2034 let temp = TempDir::new().unwrap();
2035 let store = Arc::new(
2036 SessionStoreV2::new(temp.path().to_path_buf())
2037 .await
2038 .unwrap(),
2039 );
2040 let root = Session::new("root", "model");
2041 let mut child = Session::new("child", "model");
2042 child.kind = bamboo_domain::SessionKind::Child;
2043 child.parent_session_id = Some("root".to_string());
2044 child.root_session_id = "root".to_string();
2045 store.save_session(&root).await.unwrap();
2046 store.save_session(&child).await.unwrap();
2047 let inbox = Arc::new(FailEligibilityOnce {
2048 inner: Arc::new(FileSessionInbox::new(
2049 store.clone(),
2050 SessionInboxLimits::default(),
2051 )),
2052 fail_next: std::sync::atomic::AtomicBool::new(true),
2053 });
2054 let activation = Arc::new(RecordingActivation {
2055 calls: tokio::sync::Mutex::new(Vec::new()),
2056 });
2057 let messenger = SessionMessenger::new(store.clone(), inbox, activation.clone());
2058 let envelope = peer("root", "child", "watermark-failure");
2059 let gate = bamboo_domain::AdmissionGate::default();
2060
2061 let admission = messenger
2062 .admit_with_gate(envelope.clone(), Some(&gate))
2063 .await
2064 .unwrap();
2065 let receipt = match messenger.activate(&admission).await.unwrap_err() {
2066 SessionMessengerError::ActivationEligibility { receipt, .. } => receipt,
2067 error => panic!("expected retry-required classification: {error}"),
2068 };
2069 assert!(gate.is_committed());
2070 assert!(activation.calls.lock().await.is_empty());
2071 let reopened = FileSessionInbox::new(store, SessionInboxLimits::default());
2072 let backlog = reopened.inspect("child").await.unwrap();
2073 assert_eq!(backlog.pending + backlog.claimed, 1);
2074 assert_eq!(backlog.activation_generation, 0);
2075 assert_eq!(backlog.generation, receipt.generation);
2076
2077 let retry = messenger
2078 .admit_with_gate(envelope, Some(&gate))
2079 .await
2080 .unwrap();
2081 let retried = messenger.activate(&retry).await.unwrap();
2082 assert_eq!(retried.delivery, receipt);
2083 assert_eq!(activation.calls.lock().await.len(), 1);
2084 let reopened = reopened.inspect("child").await.unwrap();
2085 assert_eq!(reopened.activation_generation, receipt.generation);
2086 assert_eq!(reopened.pending + reopened.claimed, 1);
2087 }
2088
2089 #[tokio::test]
2090 async fn cross_root_peer_is_rejected_before_enqueue() {
2091 let (_temp, store, activation, messenger) = fixture().await;
2092 store
2093 .save_session(&Session::new("root-a", "model"))
2094 .await
2095 .unwrap();
2096 store
2097 .save_session(&Session::new("root-b", "model"))
2098 .await
2099 .unwrap();
2100
2101 let error = messenger
2102 .send(peer("root-a", "root-b", "msg-2"))
2103 .await
2104 .unwrap_err();
2105 assert!(matches!(error, SessionMessengerError::Unauthorized { .. }));
2106 assert!(activation.calls.lock().await.is_empty());
2107 let metrics = messenger.metrics().snapshot();
2108 assert_eq!(metrics.rejected, 1);
2109 assert_eq!(metrics.unauthorized, 1);
2110 assert_eq!(metrics.payload_too_large, 0);
2111 assert_eq!(metrics.backlog_full, 0);
2112 }
2113
2114 #[tokio::test]
2115 async fn same_root_different_project_peer_is_rejected_before_enqueue() {
2116 let (_temp, store, activation, messenger) = fixture().await;
2117 let mut source = Session::new("project-root", "model");
2118 source.set_project_id_meta("project-a");
2119 let mut target = Session::new("project-child", "model");
2120 target.kind = bamboo_domain::SessionKind::Child;
2121 target.parent_session_id = Some(source.id.clone());
2122 target.root_session_id = source.id.clone();
2123 target.set_project_id_meta("project-b");
2124 store.save_session(&source).await.unwrap();
2125 store.save_session(&target).await.unwrap();
2126
2127 let error = messenger
2128 .send(peer(&source.id, &target.id, "different-project"))
2129 .await
2130 .unwrap_err();
2131 assert!(matches!(error, SessionMessengerError::Unauthorized { .. }));
2132 assert!(activation.calls.lock().await.is_empty());
2133 let metrics = messenger.metrics().snapshot();
2134 assert_eq!(metrics.delivered, 0);
2135 assert_eq!(metrics.rejected, 1);
2136 assert_eq!(metrics.unauthorized, 1);
2137 }
2138
2139 #[tokio::test]
2140 async fn limit_rejections_have_distinct_metrics() {
2141 let temp = TempDir::new().unwrap();
2142 let store = Arc::new(
2143 SessionStoreV2::new(temp.path().to_path_buf())
2144 .await
2145 .unwrap(),
2146 );
2147 store
2148 .save_session(&Session::new("target", "model"))
2149 .await
2150 .unwrap();
2151 let activation = Arc::new(RecordingActivation {
2152 calls: tokio::sync::Mutex::new(Vec::new()),
2153 });
2154 let inbox = Arc::new(FileSessionInbox::new(
2155 store.clone(),
2156 SessionInboxLimits {
2157 max_payload_bytes: 512,
2158 max_backlog: 1,
2159 max_claim_batch: 1,
2160 },
2161 ));
2162 let messenger = SessionMessenger::new(store, inbox, activation.clone());
2163
2164 let oversized = SessionMessageEnvelope::user_input("target", "x".repeat(2048));
2165 assert!(matches!(
2166 messenger.send(oversized).await,
2167 Err(SessionMessengerError::Inbox(
2168 SessionInboxError::PayloadTooLarge { .. }
2169 ))
2170 ));
2171 messenger
2172 .send(SessionMessageEnvelope::user_input("target", "first"))
2173 .await
2174 .unwrap();
2175 assert!(matches!(
2176 messenger
2177 .send(SessionMessageEnvelope::user_input("target", "second"))
2178 .await,
2179 Err(SessionMessengerError::Inbox(
2180 SessionInboxError::BacklogFull { .. }
2181 ))
2182 ));
2183
2184 let metrics = messenger.metrics().snapshot();
2185 assert_eq!(metrics.rejected, 2);
2186 assert_eq!(metrics.payload_too_large, 1);
2187 assert_eq!(metrics.backlog_full, 1);
2188 assert_eq!(activation.calls.lock().await.len(), 1);
2189 }
2190}