1use std::collections::HashMap;
8use std::fs::{File, OpenOptions};
9use std::io::ErrorKind;
10use std::path::{Path, PathBuf};
11use std::sync::{Arc, Mutex, Weak};
12
13use async_trait::async_trait;
14use bamboo_domain::{
15 SessionActivationPolicy, SessionInboxBacklog, SessionInboxClaim, SessionInboxError,
16 SessionInboxLimits, SessionInboxPort, SessionInboxReceipt, SessionMessageEnvelope,
17 SessionMessageId, SessionMessageSource,
18};
19use bamboo_subagent::{AgentRef, InboxKind, InboxMessage, Mailbox, MsgId};
20use base64::Engine;
21use chrono::{TimeZone, Utc};
22use fs2::FileExt;
23use sha2::{Digest, Sha256};
24use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard};
25
26use crate::v2::atomic_write;
27use crate::SessionStoreV2;
28
29const INBOX_DIR: &str = "inbox";
30const GENERATION_FILE: &str = "generation";
31const ACTIVATION_GENERATION_FILE: &str = "activation-generation";
32const INTERRUPT_GENERATION_FILE: &str = "interrupt-generation";
33const ADMITTED_DIR: &str = "admitted";
34const OPERATION_LOCK_FILE: &str = ".session-inbox.lock";
35
36struct FileOperationLock(File);
37
38impl Drop for FileOperationLock {
39 fn drop(&mut self) {
40 let _ = FileExt::unlock(&self.0);
41 }
42}
43
44#[derive(Clone)]
48pub struct FileSessionInbox {
49 sessions: Arc<SessionStoreV2>,
50 limits: SessionInboxLimits,
51 operation_locks: Arc<Mutex<HashMap<PathBuf, Weak<AsyncMutex<()>>>>>,
54}
55
56impl FileSessionInbox {
57 pub fn new(sessions: Arc<SessionStoreV2>, limits: SessionInboxLimits) -> Self {
58 Self {
59 sessions,
60 limits,
61 operation_locks: Arc::new(Mutex::new(HashMap::new())),
62 }
63 }
64
65 pub fn limits(&self) -> SessionInboxLimits {
66 self.limits
67 }
68
69 async fn lock_process(&self, dir: &Path) -> OwnedMutexGuard<()> {
70 let key = dir.to_path_buf();
71 let lock = {
72 let mut locks = self
73 .operation_locks
74 .lock()
75 .unwrap_or_else(std::sync::PoisonError::into_inner);
76 locks.retain(|_, lock| lock.strong_count() > 0);
77 match locks.get(&key).and_then(Weak::upgrade) {
78 Some(lock) => lock,
79 None => {
80 let lock = Arc::new(AsyncMutex::new(()));
81 locks.insert(key, Arc::downgrade(&lock));
82 lock
83 }
84 }
85 };
86 lock.lock_owned().await
87 }
88
89 async fn lock_file(dir: &Path) -> Result<FileOperationLock, SessionInboxError> {
90 tokio::fs::create_dir_all(dir).await.map_err(|error| {
91 SessionInboxError::Storage(format!(
92 "create session inbox directory {}: {error}",
93 dir.display()
94 ))
95 })?;
96 let path = dir.join(OPERATION_LOCK_FILE);
97 tokio::task::spawn_blocking(move || {
98 let file = OpenOptions::new()
99 .create(true)
100 .truncate(false)
101 .read(true)
102 .write(true)
103 .open(&path)
104 .map_err(|error| {
105 SessionInboxError::Storage(format!(
106 "open session inbox lock {}: {error}",
107 path.display()
108 ))
109 })?;
110 file.lock_exclusive().map_err(|error| {
111 SessionInboxError::Storage(format!(
112 "lock session inbox {}: {error}",
113 path.display()
114 ))
115 })?;
116 Ok(FileOperationLock(file))
117 })
118 .await
119 .map_err(|error| SessionInboxError::Storage(format!("join inbox lock task: {error}")))?
120 }
121
122 async fn lock_operation(
123 &self,
124 dir: &Path,
125 ) -> Result<(OwnedMutexGuard<()>, FileOperationLock), SessionInboxError> {
126 let process = self.lock_process(dir).await;
130 let file = Self::lock_file(dir).await?;
131 Ok((process, file))
132 }
133
134 async fn lock_lifecycle(
135 &self,
136 ) -> Result<crate::v2::SessionLifecycleReadGuard, SessionInboxError> {
137 self.sessions
138 .lock_session_lifecycle_shared()
139 .await
140 .map_err(|error| {
141 SessionInboxError::Storage(format!(
142 "lock session lifecycle for inbox operation: {error}"
143 ))
144 })
145 }
146
147 async fn inbox_dir(&self, session_id: &str) -> Result<PathBuf, SessionInboxError> {
148 let rel = self
149 .sessions
150 .resolve_rel_path(session_id)
151 .await
152 .ok_or_else(|| SessionInboxError::TargetNotFound(session_id.to_string()))?;
153 let session_dir = self.sessions.bamboo_home_dir().join(rel);
154 match tokio::fs::try_exists(session_dir.join("session.json")).await {
155 Ok(true) => Ok(session_dir.join(INBOX_DIR)),
156 Ok(false) => Err(SessionInboxError::TargetNotFound(session_id.to_string())),
157 Err(error) => Err(SessionInboxError::Storage(format!(
158 "validate SessionInbox target {session_id}: {error}"
159 ))),
160 }
161 }
162
163 async fn read_generation(dir: &Path) -> Result<u64, SessionInboxError> {
164 let path = dir.join(GENERATION_FILE);
165 match tokio::fs::read_to_string(&path).await {
166 Ok(raw) => raw.trim().parse::<u64>().map_err(|error| {
167 SessionInboxError::Storage(format!(
168 "decode inbox generation {}: {error}",
169 path.display()
170 ))
171 }),
172 Err(error) if error.kind() == ErrorKind::NotFound => Ok(0),
173 Err(error) => Err(SessionInboxError::Storage(format!(
174 "read inbox generation {}: {error}",
175 path.display()
176 ))),
177 }
178 }
179
180 async fn next_generation(dir: &Path) -> Result<u64, SessionInboxError> {
181 let next = Self::read_generation(dir).await?.saturating_add(1);
182 atomic_write(&dir.join(GENERATION_FILE), next.to_string().as_bytes())
183 .await
184 .map_err(|error| {
185 SessionInboxError::Storage(format!("persist inbox generation: {error}"))
186 })?;
187 Ok(next)
188 }
189
190 async fn read_activation_generation(dir: &Path) -> Result<u64, SessionInboxError> {
191 let path = dir.join(ACTIVATION_GENERATION_FILE);
192 match tokio::fs::read_to_string(&path).await {
193 Ok(raw) => raw.trim().parse::<u64>().map_err(|error| {
194 SessionInboxError::Storage(format!(
195 "decode inbox activation generation {}: {error}",
196 path.display()
197 ))
198 }),
199 Err(error) if error.kind() == ErrorKind::NotFound => Ok(0),
200 Err(error) => Err(SessionInboxError::Storage(format!(
201 "read inbox activation generation {}: {error}",
202 path.display()
203 ))),
204 }
205 }
206
207 async fn read_interrupt_generation(dir: &Path) -> Result<u64, SessionInboxError> {
208 let path = dir.join(INTERRUPT_GENERATION_FILE);
209 match tokio::fs::read_to_string(&path).await {
210 Ok(raw) => raw.trim().parse::<u64>().map_err(|error| {
211 SessionInboxError::Storage(format!(
212 "decode inbox interrupt generation {}: {error}",
213 path.display()
214 ))
215 }),
216 Err(error) if error.kind() == ErrorKind::NotFound => Ok(0),
217 Err(error) => Err(SessionInboxError::Storage(format!(
218 "read inbox interrupt generation {}: {error}",
219 path.display()
220 ))),
221 }
222 }
223
224 async fn oldest_backlog_generation(dir: &Path) -> Result<Option<u64>, SessionInboxError> {
225 let mut oldest = None;
226 for queue in ["new", "cur"] {
227 for (generation, _, _) in Self::valid_queue_entries(dir, queue).await? {
228 oldest = Some(oldest.map_or(generation, |current: u64| current.min(generation)));
229 }
230 }
231 Ok(oldest)
232 }
233
234 fn wrapper(envelope: &SessionMessageEnvelope, generation: u64) -> InboxMessage {
235 let from = match &envelope.source {
236 SessionMessageSource::User => AgentRef {
237 session_id: "user".to_string(),
238 role: None,
239 },
240 SessionMessageSource::Session { session_id } => AgentRef {
241 session_id: session_id.clone(),
242 role: None,
243 },
244 SessionMessageSource::Runtime { subsystem } => AgentRef {
245 session_id: format!("runtime:{subsystem}"),
246 role: None,
247 },
248 };
249 let transport_time = Utc.timestamp_nanos(generation.min(i64::MAX as u64) as i64);
252 InboxMessage {
253 id: MsgId(format!(
257 "sm-{}",
258 base64::engine::general_purpose::URL_SAFE_NO_PAD
259 .encode(Sha256::digest(envelope.id.as_str().as_bytes()))
260 )),
261 from,
262 kind: InboxKind::SessionEnvelope,
263 body: serde_json::to_value(envelope).unwrap_or(serde_json::Value::Null),
264 created_at: transport_time,
265 correlation_id: envelope
266 .correlation_id
267 .as_ref()
268 .map(|value| MsgId(value.clone())),
269 }
270 }
271
272 fn claim_generation(claim_id: &str) -> Result<u64, SessionInboxError> {
273 claim_id
274 .split_once('-')
275 .and_then(|(prefix, _)| prefix.parse::<u64>().ok())
276 .ok_or_else(|| {
277 SessionInboxError::InvalidClaim(format!(
278 "claim filename has no ordered generation: {claim_id}"
279 ))
280 })
281 }
282
283 fn admitted_path(dir: &Path, id: &SessionMessageId) -> PathBuf {
284 let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
289 .encode(Sha256::digest(id.as_str().as_bytes()));
290 dir.join(ADMITTED_DIR).join(format!("{encoded}.json"))
291 }
292
293 fn semantic_digest(envelope: &SessionMessageEnvelope) -> Result<String, SessionInboxError> {
301 let canonical = bamboo_domain::canonical_json_bytes(&envelope.idempotency_semantics());
302 Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(Sha256::digest(canonical)))
303 }
304
305 async fn quarantine_claim(
306 dir: &Path,
307 claim_path: &Path,
308 reason: &str,
309 ) -> Result<(), SessionInboxError> {
310 let name = claim_path
311 .file_name()
312 .ok_or_else(|| SessionInboxError::InvalidClaim("claim has no filename".to_string()))?;
313 let corrupt_dir = dir.join("corrupt");
314 tokio::fs::create_dir_all(&corrupt_dir)
315 .await
316 .map_err(|error| SessionInboxError::Storage(error.to_string()))?;
317 let target = corrupt_dir.join(name);
318 match tokio::fs::rename(claim_path, &target).await {
319 Ok(()) => {
320 tracing::warn!(
321 path = %target.display(),
322 reason,
323 "quarantined malformed typed session inbox envelope"
324 );
325 Ok(())
326 }
327 Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
328 Err(error) => Err(SessionInboxError::Storage(format!(
329 "quarantine malformed claim {}: {error}",
330 claim_path.display()
331 ))),
332 }
333 }
334
335 async fn valid_queue_entries(
341 dir: &Path,
342 queue: &str,
343 ) -> Result<Vec<(u64, String, PathBuf)>, SessionInboxError> {
344 let queue_dir = dir.join(queue);
345 let mut reader = match tokio::fs::read_dir(&queue_dir).await {
346 Ok(reader) => reader,
347 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
348 Err(error) => {
349 return Err(SessionInboxError::Storage(format!(
350 "read SessionInbox queue {}: {error}",
351 queue_dir.display()
352 )));
353 }
354 };
355 let mut valid = Vec::new();
356 while let Some(entry) = reader.next_entry().await.map_err(|error| {
357 SessionInboxError::Storage(format!(
358 "scan SessionInbox queue {}: {error}",
359 queue_dir.display()
360 ))
361 })? {
362 let name = entry.file_name().to_string_lossy().into_owned();
363 if name.starts_with('.') || !name.ends_with(".json") {
364 continue;
365 }
366 match Self::claim_generation(&name) {
367 Ok(generation) => valid.push((generation, name, entry.path())),
368 Err(error) => {
369 Self::quarantine_claim(dir, &entry.path(), &error.to_string()).await?;
370 }
371 }
372 }
373 Ok(valid)
374 }
375
376 #[cfg(test)]
377 async fn count_json(dir: &Path) -> Result<usize, SessionInboxError> {
378 let mut reader = match tokio::fs::read_dir(dir).await {
379 Ok(reader) => reader,
380 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(0),
381 Err(error) => {
382 return Err(SessionInboxError::Storage(format!(
383 "read inbox directory {}: {error}",
384 dir.display()
385 )));
386 }
387 };
388 let mut count = 0;
389 while let Some(entry) = reader.next_entry().await.map_err(|error| {
390 SessionInboxError::Storage(format!("scan inbox directory {}: {error}", dir.display()))
391 })? {
392 let name = entry.file_name();
393 let name = name.to_string_lossy();
394 if !name.starts_with('.') && name.ends_with(".json") {
395 count += 1;
396 }
397 }
398 Ok(count)
399 }
400
401 async fn admitted_receipt(
407 dir: &Path,
408 requested: &SessionMessageEnvelope,
409 ) -> Result<Option<SessionInboxReceipt>, SessionInboxError> {
410 let id = &requested.id;
411 let requested_digest = Self::semantic_digest(requested)?;
412 let admitted_path = Self::admitted_path(dir, id);
413 match tokio::fs::read(&admitted_path).await {
414 Ok(bytes) => {
415 let receipt: serde_json::Value = serde_json::from_slice(&bytes)
416 .map_err(|error| SessionInboxError::Storage(error.to_string()))?;
417 let stored_id = receipt["id"].as_str().ok_or_else(|| {
418 SessionInboxError::Storage(format!(
419 "admitted receipt {} has no message id",
420 admitted_path.display()
421 ))
422 })?;
423 if stored_id != id.as_str() {
424 return Err(SessionInboxError::InvalidClaim(format!(
425 "admitted receipt digest collision: requested {}, stored {}",
426 id, stored_id
427 )));
428 }
429 let generation = receipt["generation"].as_u64().ok_or_else(|| {
430 SessionInboxError::Storage(format!(
431 "admitted receipt {} has no generation",
432 admitted_path.display()
433 ))
434 })?;
435 let stored_digest = receipt["semantic_digest"].as_str().ok_or_else(|| {
436 SessionInboxError::InvalidClaim(format!(
437 "admitted receipt has no semantic digest for {}",
438 id
439 ))
440 })?;
441 if stored_digest != requested_digest {
442 return Err(SessionInboxError::InvalidClaim(format!(
443 "message id {} was reused with different delivery semantics",
444 id
445 )));
446 }
447 return Ok(Some(SessionInboxReceipt {
448 id: id.clone(),
449 generation,
450 }));
451 }
452 Err(error) if error.kind() == ErrorKind::NotFound => {}
453 Err(error) => {
454 return Err(SessionInboxError::Storage(format!(
455 "read admitted receipt {}: {error}",
456 admitted_path.display()
457 )));
458 }
459 }
460 Ok(None)
461 }
462
463 async fn existing_receipt(
464 dir: &Path,
465 requested: &SessionMessageEnvelope,
466 ) -> Result<Option<SessionInboxReceipt>, SessionInboxError> {
467 if let Some(receipt) = Self::admitted_receipt(dir, requested).await? {
468 return Ok(Some(receipt));
469 }
470 let id = &requested.id;
471 let requested_digest = Self::semantic_digest(requested)?;
472 for queue in ["new", "cur"] {
473 for (generation, _name, path) in Self::valid_queue_entries(dir, queue).await? {
474 let Ok(bytes) = tokio::fs::read(path).await else {
475 continue;
476 };
477 let Ok(wrapper) = serde_json::from_slice::<InboxMessage>(&bytes) else {
478 continue;
479 };
480 if wrapper.kind != InboxKind::SessionEnvelope {
481 continue;
482 }
483 let Ok(envelope) = serde_json::from_value::<SessionMessageEnvelope>(wrapper.body)
484 else {
485 continue;
486 };
487 if &envelope.id == id {
488 if Self::semantic_digest(&envelope)? != requested_digest {
489 return Err(SessionInboxError::InvalidClaim(format!(
490 "message id {} was reused with different delivery semantics",
491 id
492 )));
493 }
494 return Ok(Some(SessionInboxReceipt {
495 id: id.clone(),
496 generation,
497 }));
498 }
499 }
500 }
501 Ok(None)
502 }
503
504 fn validate_claim_name(claim_id: &str) -> Result<(), SessionInboxError> {
505 let path = Path::new(claim_id);
506 if claim_id.is_empty()
507 || path.components().count() != 1
508 || claim_id.contains('/')
509 || claim_id.contains('\\')
510 || !claim_id.ends_with(".json")
511 {
512 return Err(SessionInboxError::InvalidClaim(claim_id.to_string()));
513 }
514 Ok(())
515 }
516}
517
518#[async_trait]
519impl SessionInboxPort for FileSessionInbox {
520 async fn deliver(
521 &self,
522 envelope: &SessionMessageEnvelope,
523 ) -> Result<SessionInboxReceipt, SessionInboxError> {
524 envelope
525 .validate()
526 .map_err(|error| SessionInboxError::Storage(error.to_string()))?;
527 let payload = serde_json::to_vec(envelope)
528 .map_err(|error| SessionInboxError::Storage(error.to_string()))?;
529 if payload.len() > self.limits.max_payload_bytes {
530 return Err(SessionInboxError::PayloadTooLarge {
531 actual: payload.len(),
532 limit: self.limits.max_payload_bytes,
533 });
534 }
535
536 let _lifecycle = self.lock_lifecycle().await?;
537 let dir = self.inbox_dir(&envelope.target_session_id).await?;
538 let _guard = self.lock_operation(&dir).await?;
539 if let Some(receipt) = Self::existing_receipt(&dir, envelope).await? {
544 return Ok(receipt);
545 }
546 let mailbox = Mailbox::at(&dir);
547 let current = Self::valid_queue_entries(&dir, "new").await?.len()
548 + Self::valid_queue_entries(&dir, "cur").await?.len();
549 if current >= self.limits.max_backlog {
550 return Err(SessionInboxError::BacklogFull {
551 current,
552 limit: self.limits.max_backlog,
553 });
554 }
555
556 let generation = Self::next_generation(&dir).await?;
557 mailbox
558 .deliver(&Self::wrapper(envelope, generation))
559 .await
560 .map_err(|error| SessionInboxError::Storage(error.to_string()))?;
561 Ok(SessionInboxReceipt {
562 id: envelope.id.clone(),
563 generation,
564 })
565 }
566
567 async fn mark_activation_eligible(
568 &self,
569 target_session_id: &str,
570 generation: u64,
571 policy: SessionActivationPolicy,
572 ) -> Result<(), SessionInboxError> {
573 let _lifecycle = self.lock_lifecycle().await?;
574 let dir = self.inbox_dir(target_session_id).await?;
575 let _guard = self.lock_operation(&dir).await?;
576 let delivered_generation = Self::read_generation(&dir).await?;
577 if generation == 0 || generation > delivered_generation {
578 return Err(SessionInboxError::InvalidClaim(format!(
579 "activation generation {generation} is outside delivered range 1..={delivered_generation}"
580 )));
581 }
582 if policy == SessionActivationPolicy::InterruptSpecificWait {
587 let current_interrupt = Self::read_interrupt_generation(&dir).await?;
588 if generation > current_interrupt {
589 atomic_write(
590 &dir.join(INTERRUPT_GENERATION_FILE),
591 generation.to_string().as_bytes(),
592 )
593 .await
594 .map_err(|error| {
595 SessionInboxError::Storage(format!(
596 "persist inbox interrupt generation: {error}"
597 ))
598 })?;
599 }
600 }
601 let current = Self::read_activation_generation(&dir).await?;
602 if generation > current {
603 atomic_write(
604 &dir.join(ACTIVATION_GENERATION_FILE),
605 generation.to_string().as_bytes(),
606 )
607 .await
608 .map_err(|error| {
609 SessionInboxError::Storage(format!("persist inbox activation generation: {error}"))
610 })?;
611 }
612 Ok(())
613 }
614
615 async fn claim(
616 &self,
617 target_session_id: &str,
618 limit: usize,
619 ) -> Result<Vec<SessionInboxClaim>, SessionInboxError> {
620 let _lifecycle = self.lock_lifecycle().await?;
621 let dir = self.inbox_dir(target_session_id).await?;
622 let _guard = self.lock_operation(&dir).await?;
623 let mailbox = Mailbox::at(&dir);
624 mailbox
625 .ensure_dirs()
626 .await
627 .map_err(|error| SessionInboxError::Storage(error.to_string()))?;
628 let activation_generation = Self::read_activation_generation(&dir).await?;
629 let limit = limit.min(self.limits.max_claim_batch);
630 if activation_generation == 0 || limit == 0 {
631 return Ok(Vec::new());
632 }
633
634 let mut eligible = Vec::new();
639 for queue in ["cur", "new"] {
640 for (generation, name, _) in Self::valid_queue_entries(&dir, queue).await? {
641 if generation <= activation_generation {
642 eligible.push((generation, name, queue == "cur"));
643 }
644 }
645 }
646 eligible.sort_by(|left, right| {
647 left.0
648 .cmp(&right.0)
649 .then_with(|| left.1.cmp(&right.1))
650 .then_with(|| right.2.cmp(&left.2))
653 });
654
655 let mut claims = Vec::new();
656 for (_generation, claim_id, already_claimed) in eligible {
657 if claims.len() >= limit {
658 break;
659 }
660 let cur_path = dir.join("cur").join(&claim_id);
661 if !already_claimed {
662 let new_path = dir.join("new").join(&claim_id);
663 match tokio::fs::rename(&new_path, &cur_path).await {
664 Ok(()) => {}
665 Err(error) if error.kind() == ErrorKind::NotFound => continue,
666 Err(error) => {
667 return Err(SessionInboxError::Storage(format!(
668 "claim SessionInbox message {}: {error}",
669 new_path.display()
670 )));
671 }
672 }
673 }
674 let decoded = (|| {
675 let bytes = std::fs::read(&cur_path).map_err(|error| error.to_string())?;
676 let wrapper = serde_json::from_slice::<InboxMessage>(&bytes)
677 .map_err(|error| error.to_string())?;
678 if wrapper.kind != InboxKind::SessionEnvelope {
679 return Err(format!("unexpected inbox kind {:?}", wrapper.kind));
680 }
681 let generation =
682 Self::claim_generation(&claim_id).map_err(|error| error.to_string())?;
683 let envelope = serde_json::from_value::<SessionMessageEnvelope>(wrapper.body)
684 .map_err(|error| error.to_string())?;
685 envelope.validate().map_err(|error| error.to_string())?;
686 if envelope.target_session_id != target_session_id {
687 return Err(format!(
688 "claim target {} does not match inbox {target_session_id}",
689 envelope.target_session_id
690 ));
691 }
692 Ok(SessionInboxClaim {
693 envelope,
694 generation,
695 claim_id,
696 })
697 })();
698 match decoded {
699 Ok(claim) => claims.push(claim),
700 Err(reason) => {
701 Self::quarantine_claim(&dir, &cur_path, &reason).await?;
702 }
703 }
704 }
705 Ok(claims)
706 }
707
708 async fn was_admitted(
709 &self,
710 target_session_id: &str,
711 id: &SessionMessageId,
712 ) -> Result<bool, SessionInboxError> {
713 let _lifecycle = self.lock_lifecycle().await?;
714 let dir = self.inbox_dir(target_session_id).await?;
715 let path = Self::admitted_path(&dir, id);
716 match tokio::fs::read(&path).await {
717 Ok(bytes) => {
718 let receipt: serde_json::Value = serde_json::from_slice(&bytes)
719 .map_err(|error| SessionInboxError::Storage(error.to_string()))?;
720 let stored_id = receipt["id"].as_str().ok_or_else(|| {
721 SessionInboxError::Storage(format!(
722 "admitted receipt {} has no message id",
723 path.display()
724 ))
725 })?;
726 if stored_id != id.as_str() {
727 return Err(SessionInboxError::InvalidClaim(format!(
728 "admitted receipt digest collision: requested {}, stored {}",
729 id, stored_id
730 )));
731 }
732 Ok(true)
733 }
734 Err(error) if error.kind() == ErrorKind::NotFound => Ok(false),
735 Err(error) => Err(SessionInboxError::Storage(format!(
736 "read admitted receipt {}: {error}",
737 path.display()
738 ))),
739 }
740 }
741
742 async fn ack(
743 &self,
744 target_session_id: &str,
745 claim: &SessionInboxClaim,
746 ) -> Result<(), SessionInboxError> {
747 Self::validate_claim_name(&claim.claim_id)?;
748 if claim.envelope.target_session_id != target_session_id {
749 return Err(SessionInboxError::InvalidClaim(
750 "claim target mismatch".to_string(),
751 ));
752 }
753 let _lifecycle = self.lock_lifecycle().await?;
754 let dir = self.inbox_dir(target_session_id).await?;
755 let _guard = self.lock_operation(&dir).await?;
756 let cur_path = dir.join("cur").join(&claim.claim_id);
757 let bytes = match tokio::fs::read(&cur_path).await {
758 Ok(bytes) => bytes,
759 Err(error) if error.kind() == ErrorKind::NotFound => {
760 return match Self::admitted_receipt(&dir, &claim.envelope).await? {
763 Some(receipt) if receipt.generation == claim.generation => Ok(()),
764 _ => Err(SessionInboxError::InvalidClaim(format!(
765 "canonical claim no longer exists: {}",
766 claim.claim_id
767 ))),
768 };
769 }
770 Err(error) => {
771 return Err(SessionInboxError::Storage(format!(
772 "read claimed message {}: {error}",
773 cur_path.display()
774 )));
775 }
776 };
777 let wrapper: InboxMessage = serde_json::from_slice(&bytes).map_err(|error| {
778 SessionInboxError::InvalidClaim(format!(
779 "decode canonical claim {}: {error}",
780 claim.claim_id
781 ))
782 })?;
783 if wrapper.kind != InboxKind::SessionEnvelope {
784 return Err(SessionInboxError::InvalidClaim(format!(
785 "canonical claim {} has kind {:?}",
786 claim.claim_id, wrapper.kind
787 )));
788 }
789 let persisted: SessionMessageEnvelope =
790 serde_json::from_value(wrapper.body).map_err(|error| {
791 SessionInboxError::InvalidClaim(format!(
792 "decode canonical envelope {}: {error}",
793 claim.claim_id
794 ))
795 })?;
796 let filename_generation = Self::claim_generation(&claim.claim_id)?;
797 if filename_generation != claim.generation
798 || persisted.id != claim.envelope.id
799 || persisted.target_session_id != target_session_id
800 || persisted != claim.envelope
801 {
802 return Err(SessionInboxError::InvalidClaim(format!(
803 "canonical claim mismatch for {}",
804 claim.claim_id
805 )));
806 }
807
808 let admitted_path = Self::admitted_path(&dir, &claim.envelope.id);
809 if let Some(existing) = Self::admitted_receipt(&dir, &claim.envelope).await? {
810 if existing.generation != claim.generation {
811 return Err(SessionInboxError::InvalidClaim(format!(
812 "admitted receipt generation mismatch for {}",
813 claim.envelope.id
814 )));
815 }
816 }
817 let receipt = serde_json::to_vec_pretty(&serde_json::json!({
818 "id": claim.envelope.id,
819 "generation": claim.generation,
820 "semantic_digest": Self::semantic_digest(&claim.envelope)?,
821 "admitted_at": Utc::now(),
822 }))
823 .map_err(|error| SessionInboxError::Storage(error.to_string()))?;
824 let admitted_dir = admitted_path.parent().ok_or_else(|| {
825 SessionInboxError::Storage(format!(
826 "admitted receipt has no parent: {}",
827 admitted_path.display()
828 ))
829 })?;
830 tokio::fs::create_dir_all(admitted_dir)
831 .await
832 .map_err(|error| {
833 SessionInboxError::Storage(format!(
834 "create admitted receipt directory {}: {error}",
835 admitted_dir.display()
836 ))
837 })?;
838 atomic_write(&admitted_path, &receipt)
839 .await
840 .map_err(|error| {
841 SessionInboxError::Storage(format!("persist admitted receipt: {error}"))
842 })?;
843
844 match tokio::fs::remove_file(&cur_path).await {
845 Ok(()) => Ok(()),
846 Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
847 Err(error) => Err(SessionInboxError::Storage(format!(
848 "remove claimed message {}: {error}",
849 cur_path.display()
850 ))),
851 }
852 }
853
854 async fn inspect(
855 &self,
856 target_session_id: &str,
857 ) -> Result<SessionInboxBacklog, SessionInboxError> {
858 let _lifecycle = self.lock_lifecycle().await?;
859 let dir = self.inbox_dir(target_session_id).await?;
860 let _guard = self.lock_operation(&dir).await?;
861 let pending = Self::valid_queue_entries(&dir, "new").await?.len();
862 let claimed = Self::valid_queue_entries(&dir, "cur").await?.len();
863 Ok(SessionInboxBacklog {
864 pending,
865 claimed,
866 generation: Self::read_generation(&dir).await?,
867 activation_generation: Self::read_activation_generation(&dir).await?,
868 interrupt_generation: Self::read_interrupt_generation(&dir).await?,
869 oldest_generation: Self::oldest_backlog_generation(&dir).await?,
870 })
871 }
872}
873
874#[cfg(test)]
875mod tests {
876 use super::*;
877 use bamboo_domain::{Session, Storage};
878 use tempfile::TempDir;
879
880 async fn fixture(
881 limits: SessionInboxLimits,
882 ) -> (TempDir, Arc<SessionStoreV2>, FileSessionInbox) {
883 let temp = TempDir::new().unwrap();
884 let sessions = Arc::new(
885 SessionStoreV2::new(temp.path().to_path_buf())
886 .await
887 .unwrap(),
888 );
889 sessions
890 .save_session(&Session::new("session-1", "model"))
891 .await
892 .unwrap();
893 let inbox = FileSessionInbox::new(sessions.clone(), limits);
894 (temp, sessions, inbox)
895 }
896
897 async fn authorize_latest(inbox: &FileSessionInbox) {
898 let generation = inbox.inspect("session-1").await.unwrap().generation;
899 inbox
900 .mark_activation_eligible(
901 "session-1",
902 generation,
903 SessionActivationPolicy::InterruptSpecificWait,
904 )
905 .await
906 .unwrap();
907 }
908
909 #[tokio::test]
910 async fn concurrent_delivery_is_ordered_and_survives_reopen() {
911 let (_temp, sessions, _fixture_inbox) = fixture(SessionInboxLimits::default()).await;
912 let first = Arc::new(FileSessionInbox::new(
917 sessions.clone(),
918 SessionInboxLimits::default(),
919 ));
920 let second = Arc::new(FileSessionInbox::new(
921 sessions.clone(),
922 SessionInboxLimits::default(),
923 ));
924 let mut tasks = Vec::new();
925 for index in 0..40 {
926 let inbox = if index % 2 == 0 {
927 first.clone()
928 } else {
929 second.clone()
930 };
931 tasks.push(tokio::spawn(async move {
932 let mut envelope =
933 SessionMessageEnvelope::user_input("session-1", format!("message-{index}"));
934 envelope.id = SessionMessageId::parse(format!("id-{index}")).unwrap();
935 inbox.deliver(&envelope).await.unwrap()
936 }));
937 }
938 let mut receipts = Vec::new();
939 for task in tasks {
940 receipts.push(task.await.unwrap());
941 }
942 receipts.sort_by_key(|receipt| receipt.generation);
943 assert_eq!(
944 receipts
945 .iter()
946 .map(|receipt| receipt.generation)
947 .collect::<Vec<_>>(),
948 (1..=40).collect::<Vec<_>>()
949 );
950 second
951 .mark_activation_eligible(
952 "session-1",
953 40,
954 SessionActivationPolicy::InterruptSpecificWait,
955 )
956 .await
957 .unwrap();
958
959 let reopened = FileSessionInbox::new(sessions, SessionInboxLimits::default());
960 let claims = reopened.claim("session-1", 100).await.unwrap();
961 assert_eq!(claims.len(), 40);
962 assert_eq!(
963 claims
964 .iter()
965 .map(|claim| claim.generation)
966 .collect::<Vec<_>>(),
967 (1..=40).collect::<Vec<_>>()
968 );
969 }
970
971 #[tokio::test]
972 async fn activation_watermark_is_monotonic_and_tracks_oldest_backlog_across_reopen() {
973 let (_temp, sessions, inbox) = fixture(SessionInboxLimits::default()).await;
974 let first = SessionMessageEnvelope::user_input("session-1", "first");
975 let second = SessionMessageEnvelope::user_input("session-1", "second");
976 let first_receipt = inbox.deliver(&first).await.unwrap();
977 let second_receipt = inbox.deliver(&second).await.unwrap();
978
979 let backlog = inbox.inspect("session-1").await.unwrap();
980 assert_eq!(backlog.pending, 2);
981 assert_eq!(backlog.claimed, 0);
982 assert_eq!(backlog.oldest_generation, Some(first_receipt.generation));
983 assert_eq!(backlog.activation_generation, 0);
984 assert!(!backlog.activation_pending());
985
986 inbox
987 .mark_activation_eligible(
988 "session-1",
989 first_receipt.generation,
990 SessionActivationPolicy::RespectSpecificWait,
991 )
992 .await
993 .unwrap();
994 assert!(inbox
995 .inspect("session-1")
996 .await
997 .unwrap()
998 .activation_pending());
999
1000 let first_claim = inbox.claim("session-1", 1).await.unwrap().remove(0);
1003 let claimed = inbox.inspect("session-1").await.unwrap();
1004 assert_eq!(claimed.pending, 1);
1005 assert_eq!(claimed.claimed, 1);
1006 assert_eq!(claimed.oldest_generation, Some(first_receipt.generation));
1007 assert!(claimed.activation_pending());
1008
1009 inbox.ack("session-1", &first_claim).await.unwrap();
1010 let after_ack = inbox.inspect("session-1").await.unwrap();
1011 assert_eq!(after_ack.pending, 1);
1012 assert_eq!(after_ack.claimed, 0);
1013 assert_eq!(after_ack.oldest_generation, Some(second_receipt.generation));
1014 assert_eq!(after_ack.activation_generation, first_receipt.generation);
1015 assert!(
1016 !after_ack.activation_pending(),
1017 "a stale activation watermark cannot wake a newer staged item"
1018 );
1019 assert!(inbox.claim("session-1", 1).await.unwrap().is_empty());
1020
1021 let reopened = FileSessionInbox::new(sessions, SessionInboxLimits::default());
1022 let reopened_backlog = reopened.inspect("session-1").await.unwrap();
1023 assert_eq!(reopened_backlog, after_ack);
1024 assert!(reopened.claim("session-1", 1).await.unwrap().is_empty());
1025
1026 reopened
1030 .mark_activation_eligible(
1031 "session-1",
1032 first_receipt.generation,
1033 SessionActivationPolicy::RespectSpecificWait,
1034 )
1035 .await
1036 .unwrap();
1037 assert_eq!(
1038 reopened
1039 .inspect("session-1")
1040 .await
1041 .unwrap()
1042 .activation_generation,
1043 first_receipt.generation
1044 );
1045 reopened
1046 .mark_activation_eligible(
1047 "session-1",
1048 second_receipt.generation,
1049 SessionActivationPolicy::InterruptSpecificWait,
1050 )
1051 .await
1052 .unwrap();
1053 let eligible = reopened.inspect("session-1").await.unwrap();
1054 assert_eq!(eligible.activation_generation, second_receipt.generation);
1055 assert!(eligible.activation_pending());
1056 assert_eq!(eligible.interrupt_generation, second_receipt.generation);
1057 assert!(eligible.interrupt_pending());
1058 let second_claim = reopened.claim("session-1", 1).await.unwrap().remove(0);
1059 assert_eq!(second_claim.generation, second_receipt.generation);
1060 assert!(reopened
1061 .mark_activation_eligible(
1062 "session-1",
1063 second_receipt.generation + 1,
1064 SessionActivationPolicy::RespectSpecificWait,
1065 )
1066 .await
1067 .is_err());
1068 }
1069
1070 #[tokio::test]
1071 async fn interrupt_policy_is_durable_before_activation_publish_failure() {
1072 let (_temp, sessions, inbox) = fixture(SessionInboxLimits::default()).await;
1073 let receipt = inbox
1074 .deliver(&SessionMessageEnvelope::user_input(
1075 "session-1",
1076 "explicit steering",
1077 ))
1078 .await
1079 .unwrap();
1080 let dir = inbox.inbox_dir("session-1").await.unwrap();
1081 let activation_path = dir.join(ACTIVATION_GENERATION_FILE);
1082
1083 tokio::fs::create_dir(&activation_path).await.unwrap();
1087 assert!(inbox
1088 .mark_activation_eligible(
1089 "session-1",
1090 receipt.generation,
1091 SessionActivationPolicy::InterruptSpecificWait,
1092 )
1093 .await
1094 .is_err());
1095 assert_eq!(
1096 FileSessionInbox::read_interrupt_generation(&dir)
1097 .await
1098 .unwrap(),
1099 receipt.generation
1100 );
1101 assert!(
1102 !activation_path.is_file(),
1103 "a failed activation publish must not expose a downgraded prefix"
1104 );
1105
1106 tokio::fs::remove_dir(&activation_path).await.unwrap();
1109 let reopened = FileSessionInbox::new(sessions, SessionInboxLimits::default());
1110 reopened
1111 .mark_activation_eligible(
1112 "session-1",
1113 receipt.generation,
1114 SessionActivationPolicy::InterruptSpecificWait,
1115 )
1116 .await
1117 .unwrap();
1118 let backlog = reopened.inspect("session-1").await.unwrap();
1119 assert_eq!(backlog.activation_generation, receipt.generation);
1120 assert!(backlog.interrupt_generation >= backlog.activation_generation);
1121 assert!(backlog.interrupt_pending());
1122 }
1123
1124 #[tokio::test]
1125 async fn admitted_receipt_is_permanent_across_restart_and_duplicate_filename() {
1126 let (_temp, sessions, inbox) = fixture(SessionInboxLimits::default()).await;
1127 let mut envelope = SessionMessageEnvelope::user_input("session-1", "first");
1128 envelope.id = SessionMessageId::parse("same-id").unwrap();
1129 inbox.deliver(&envelope).await.unwrap();
1130 authorize_latest(&inbox).await;
1131 let claim = inbox.claim("session-1", 1).await.unwrap().remove(0);
1132 inbox.ack("session-1", &claim).await.unwrap();
1133
1134 let reopened = FileSessionInbox::new(sessions, SessionInboxLimits::default());
1135 assert!(reopened
1136 .was_admitted("session-1", &envelope.id)
1137 .await
1138 .unwrap());
1139 envelope.created_at = Utc::now();
1140 let duplicate_receipt = reopened.deliver(&envelope).await.unwrap();
1141 assert_eq!(duplicate_receipt.generation, claim.generation);
1142 assert!(reopened.claim("session-1", 1).await.unwrap().is_empty());
1143 assert_eq!(reopened.inspect("session-1").await.unwrap().pending, 0);
1144 }
1145
1146 #[tokio::test]
1147 async fn repeated_delivery_is_idempotent_before_claim_and_after_ack() {
1148 let (_temp, _sessions, inbox) = fixture(SessionInboxLimits::default()).await;
1149 let mut envelope = SessionMessageEnvelope::user_input("session-1", "same");
1150 envelope.id = SessionMessageId::parse("stable-id").unwrap();
1151
1152 let first = inbox.deliver(&envelope).await.unwrap();
1153 let second = inbox.deliver(&envelope).await.unwrap();
1154 assert_eq!(first, second);
1155 let backlog = inbox.inspect("session-1").await.unwrap();
1156 assert_eq!(backlog.pending, 1);
1157 assert_eq!(backlog.generation, 1);
1158
1159 authorize_latest(&inbox).await;
1160 let claim = inbox.claim("session-1", 1).await.unwrap().remove(0);
1161 inbox.ack("session-1", &claim).await.unwrap();
1162 let third = inbox.deliver(&envelope).await.unwrap();
1163 assert_eq!(third, first);
1164 let backlog = inbox.inspect("session-1").await.unwrap();
1165 assert_eq!(backlog.pending + backlog.claimed, 0);
1166 assert_eq!(backlog.generation, 1);
1167 }
1168
1169 #[tokio::test]
1170 async fn reordered_nested_json_uses_the_same_semantic_receipt() {
1171 let (_temp, _sessions, inbox) = fixture(SessionInboxLimits::default()).await;
1172 let mut first_inner = serde_json::Map::new();
1173 first_inner.insert("z".to_string(), serde_json::json!({"b": 2, "a": 1}));
1174 first_inner.insert("a".to_string(), serde_json::json!([{"y": 2, "x": 1}]));
1175 let mut second_nested = serde_json::Map::new();
1176 second_nested.insert("x".to_string(), serde_json::json!(1));
1177 second_nested.insert("y".to_string(), serde_json::json!(2));
1178 let mut second_inner = serde_json::Map::new();
1179 second_inner.insert(
1180 "a".to_string(),
1181 serde_json::Value::Array(vec![serde_json::Value::Object(second_nested)]),
1182 );
1183 second_inner.insert("z".to_string(), serde_json::json!({"a": 1, "b": 2}));
1184
1185 let make = |data| SessionMessageEnvelope {
1186 id: SessionMessageId::parse("nested-canonical-id").unwrap(),
1187 source: SessionMessageSource::Runtime {
1188 subsystem: "canonical-test".to_string(),
1189 },
1190 target_session_id: "session-1".to_string(),
1191 kind: bamboo_domain::SessionMessageKind::RuntimeInstruction,
1192 body: bamboo_domain::SessionMessageBody::RuntimeInstruction(
1193 bamboo_domain::SessionRuntimeInstruction {
1194 instruction: "nested".to_string(),
1195 content: None,
1196 data: Some(data),
1197 provider_message: None,
1198 },
1199 ),
1200 created_at: Utc::now(),
1201 thread_id: None,
1202 in_reply_to: None,
1203 attempt: None,
1204 correlation_id: Some("canonical-json".to_string()),
1205 };
1206 let first = make(serde_json::Value::Object(first_inner));
1207 let mut second = make(serde_json::Value::Object(second_inner));
1208 second.created_at = Utc::now();
1209 second.attempt = Some(9);
1210
1211 let first_receipt = inbox.deliver(&first).await.unwrap();
1212 let retry_receipt = inbox.deliver(&second).await.unwrap();
1213 assert_eq!(retry_receipt, first_receipt);
1214 let backlog = inbox.inspect("session-1").await.unwrap();
1215 assert_eq!(backlog.generation, 1);
1216 assert_eq!(backlog.pending, 1);
1217 }
1218
1219 #[tokio::test]
1220 async fn reused_id_with_changed_semantics_fails_before_claim_and_after_ack_restart() {
1221 let (_temp, sessions, inbox) = fixture(SessionInboxLimits::default()).await;
1222 let mut original = SessionMessageEnvelope::user_input("session-1", "original");
1223 original.id = SessionMessageId::parse("semantic-id").unwrap();
1224 inbox.deliver(&original).await.unwrap();
1225
1226 let mut changed = original.clone();
1227 changed.body = bamboo_domain::SessionMessageBody::Content(
1228 bamboo_domain::SessionMessageContent::text("different"),
1229 );
1230 assert!(matches!(
1231 inbox.deliver(&changed).await,
1232 Err(SessionInboxError::InvalidClaim(_))
1233 ));
1234 assert_eq!(inbox.inspect("session-1").await.unwrap().pending, 1);
1235
1236 authorize_latest(&inbox).await;
1237 let claim = inbox.claim("session-1", 1).await.unwrap().remove(0);
1238 inbox.ack("session-1", &claim).await.unwrap();
1239 let reopened = FileSessionInbox::new(sessions, SessionInboxLimits::default());
1240 assert!(matches!(
1241 reopened.deliver(&changed).await,
1242 Err(SessionInboxError::InvalidClaim(_))
1243 ));
1244 assert_eq!(
1245 reopened.inspect("session-1").await.unwrap().pending
1246 + reopened.inspect("session-1").await.unwrap().claimed,
1247 0
1248 );
1249 }
1250
1251 #[tokio::test]
1252 async fn deterministic_legacy_retry_ignores_only_retry_metadata() {
1253 let (_temp, sessions, inbox) = fixture(SessionInboxLimits::default()).await;
1254 let value = serde_json::json!({"content": "legacy retry"});
1255 let mut original = SessionMessageEnvelope {
1256 id: SessionMessageId::legacy("session-1", 0, &value),
1257 source: SessionMessageSource::Runtime {
1258 subsystem: "legacy_pending_injected_messages".to_string(),
1259 },
1260 target_session_id: "session-1".to_string(),
1261 kind: bamboo_domain::SessionMessageKind::RuntimeInstruction,
1262 body: bamboo_domain::SessionMessageBody::RuntimeInstruction(
1263 bamboo_domain::SessionRuntimeInstruction {
1264 instruction: "legacy_pending_injected_message".to_string(),
1265 content: Some(bamboo_domain::SessionMessageContent::text("legacy retry")),
1266 data: Some(value),
1267 provider_message: None,
1268 },
1269 ),
1270 created_at: Utc::now(),
1271 thread_id: None,
1272 in_reply_to: None,
1273 attempt: None,
1274 correlation_id: Some("legacy_pending_injected_messages".to_string()),
1275 };
1276 let first = inbox.deliver(&original).await.unwrap();
1277 original.created_at += chrono::Duration::seconds(5);
1278 original.attempt = Some(2);
1279 assert_eq!(inbox.deliver(&original).await.unwrap(), first);
1280
1281 authorize_latest(&inbox).await;
1282 let claim = inbox.claim("session-1", 1).await.unwrap().remove(0);
1283 inbox.ack("session-1", &claim).await.unwrap();
1284 let reopened = FileSessionInbox::new(sessions, SessionInboxLimits::default());
1285 original.created_at += chrono::Duration::seconds(5);
1286 original.attempt = Some(3);
1287 assert_eq!(reopened.deliver(&original).await.unwrap(), first);
1288 }
1289
1290 #[tokio::test]
1291 async fn maximum_length_id_uses_bounded_transport_and_receipt_names() {
1292 let (_temp, _sessions, inbox) = fixture(SessionInboxLimits::default()).await;
1293 let mut envelope = SessionMessageEnvelope::user_input("session-1", "long id");
1294 envelope.id = SessionMessageId::parse("x".repeat(256)).unwrap();
1295 inbox.deliver(&envelope).await.unwrap();
1296 authorize_latest(&inbox).await;
1297 let claim = inbox.claim("session-1", 1).await.unwrap().remove(0);
1298 inbox.ack("session-1", &claim).await.unwrap();
1299 assert!(inbox.was_admitted("session-1", &envelope.id).await.unwrap());
1300 }
1301
1302 #[tokio::test]
1303 async fn admitted_digest_mismatch_fails_closed() {
1304 let (_temp, _sessions, inbox) = fixture(SessionInboxLimits::default()).await;
1305 let requested = SessionMessageId::parse("requested").unwrap();
1306 let dir = inbox.inbox_dir("session-1").await.unwrap();
1307 let path = FileSessionInbox::admitted_path(&dir, &requested);
1308 tokio::fs::create_dir_all(path.parent().unwrap())
1309 .await
1310 .unwrap();
1311 atomic_write(
1312 &path,
1313 serde_json::to_vec(&serde_json::json!({
1314 "id": "different",
1315 "generation": 1,
1316 }))
1317 .unwrap()
1318 .as_slice(),
1319 )
1320 .await
1321 .unwrap();
1322
1323 assert!(matches!(
1324 inbox.was_admitted("session-1", &requested).await,
1325 Err(SessionInboxError::InvalidClaim(_))
1326 ));
1327 let mut envelope = SessionMessageEnvelope::user_input("session-1", "collision");
1328 envelope.id = requested;
1329 assert!(matches!(
1330 inbox.deliver(&envelope).await,
1331 Err(SessionInboxError::InvalidClaim(_))
1332 ));
1333 }
1334
1335 #[tokio::test]
1336 async fn ack_rejects_mismatched_id_and_generation_without_deleting_claim() {
1337 let (_temp, _sessions, inbox) = fixture(SessionInboxLimits::default()).await;
1338 let mut envelope = SessionMessageEnvelope::user_input("session-1", "exact");
1339 envelope.id = SessionMessageId::parse("exact-id").unwrap();
1340 inbox.deliver(&envelope).await.unwrap();
1341 authorize_latest(&inbox).await;
1342 let claim = inbox.claim("session-1", 1).await.unwrap().remove(0);
1343
1344 let mut wrong_generation = claim.clone();
1345 wrong_generation.generation += 1;
1346 assert!(matches!(
1347 inbox.ack("session-1", &wrong_generation).await,
1348 Err(SessionInboxError::InvalidClaim(_))
1349 ));
1350
1351 let mut wrong_id = claim.clone();
1352 wrong_id.envelope.id = SessionMessageId::parse("other-id").unwrap();
1353 assert!(matches!(
1354 inbox.ack("session-1", &wrong_id).await,
1355 Err(SessionInboxError::InvalidClaim(_))
1356 ));
1357 let backlog = inbox.inspect("session-1").await.unwrap();
1358 assert_eq!(backlog.claimed, 1);
1359 assert!(!inbox.was_admitted("session-1", &envelope.id).await.unwrap());
1360
1361 inbox.ack("session-1", &claim).await.unwrap();
1362 assert!(inbox.was_admitted("session-1", &envelope.id).await.unwrap());
1363 }
1364
1365 #[tokio::test]
1366 async fn ack_cannot_treat_matching_unclaimed_new_entry_as_permanent_proof() {
1367 let (_temp, _sessions, inbox) = fixture(SessionInboxLimits::default()).await;
1368 let mut envelope = SessionMessageEnvelope::user_input("session-1", "still pending");
1369 envelope.id = SessionMessageId::parse("pending-is-not-admitted").unwrap();
1370 let receipt = inbox.deliver(&envelope).await.unwrap();
1371 let dir = inbox.inbox_dir("session-1").await.unwrap();
1372 let mut entries = tokio::fs::read_dir(dir.join("new")).await.unwrap();
1373 let claim_id = loop {
1374 let entry = entries.next_entry().await.unwrap().unwrap();
1375 let name = entry.file_name().to_string_lossy().into_owned();
1376 if name.ends_with(".json") && !name.starts_with('.') {
1377 break name;
1378 }
1379 };
1380 let fabricated = SessionInboxClaim {
1381 envelope: envelope.clone(),
1382 generation: receipt.generation,
1383 claim_id,
1384 };
1385
1386 assert!(matches!(
1387 inbox.ack("session-1", &fabricated).await,
1388 Err(SessionInboxError::InvalidClaim(_))
1389 ));
1390 let backlog = inbox.inspect("session-1").await.unwrap();
1391 assert_eq!(backlog.pending, 1);
1392 assert_eq!(backlog.claimed, 0);
1393 assert!(!inbox.was_admitted("session-1", &envelope.id).await.unwrap());
1394 }
1395
1396 #[tokio::test]
1397 async fn ack_completes_receipt_then_remove_crash_window_idempotently() {
1398 let (_temp, _sessions, inbox) = fixture(SessionInboxLimits::default()).await;
1399 let mut envelope = SessionMessageEnvelope::user_input("session-1", "exactly once");
1400 envelope.id = SessionMessageId::parse("receipt-before-remove").unwrap();
1401 inbox.deliver(&envelope).await.unwrap();
1402 authorize_latest(&inbox).await;
1403 let claim = inbox.claim("session-1", 1).await.unwrap().remove(0);
1404
1405 let dir = inbox.inbox_dir("session-1").await.unwrap();
1408 let admitted_path = FileSessionInbox::admitted_path(&dir, &envelope.id);
1409 tokio::fs::create_dir_all(admitted_path.parent().unwrap())
1410 .await
1411 .unwrap();
1412 let receipt = serde_json::to_vec(&serde_json::json!({
1413 "id": envelope.id,
1414 "generation": claim.generation,
1415 "semantic_digest": FileSessionInbox::semantic_digest(&envelope).unwrap(),
1416 "admitted_at": Utc::now(),
1417 }))
1418 .unwrap();
1419 atomic_write(&admitted_path, &receipt).await.unwrap();
1420 assert_eq!(inbox.inspect("session-1").await.unwrap().claimed, 1);
1421
1422 inbox.ack("session-1", &claim).await.unwrap();
1423 assert_eq!(inbox.inspect("session-1").await.unwrap().claimed, 0);
1424 assert!(inbox.was_admitted("session-1", &envelope.id).await.unwrap());
1425 inbox.ack("session-1", &claim).await.unwrap();
1427 }
1428
1429 #[tokio::test]
1430 async fn malformed_typed_envelope_is_quarantined_without_poisoning_drain() {
1431 let (_temp, _sessions, inbox) = fixture(SessionInboxLimits::default()).await;
1432 let dir = inbox.inbox_dir("session-1").await.unwrap();
1433 let mailbox = Mailbox::at(&dir);
1434 mailbox
1435 .deliver(&InboxMessage {
1436 id: MsgId("malformed".to_string()),
1437 from: AgentRef {
1438 session_id: "runtime:test".to_string(),
1439 role: None,
1440 },
1441 kind: InboxKind::SessionEnvelope,
1442 body: serde_json::json!({"not": "an envelope"}),
1443 created_at: Utc.timestamp_nanos(0),
1444 correlation_id: None,
1445 })
1446 .await
1447 .unwrap();
1448 let valid = SessionMessageEnvelope::user_input("session-1", "valid");
1449 inbox.deliver(&valid).await.unwrap();
1450 authorize_latest(&inbox).await;
1451
1452 let claims = inbox.claim("session-1", 10).await.unwrap();
1453 assert_eq!(claims.len(), 1);
1454 assert_eq!(claims[0].envelope.id, valid.id);
1455 assert_eq!(
1456 FileSessionInbox::count_json(&dir.join("corrupt"))
1457 .await
1458 .unwrap(),
1459 1
1460 );
1461 }
1462
1463 #[tokio::test]
1464 async fn malformed_json_filename_is_quarantined_without_blocking_backlog_or_claim() {
1465 let (_temp, _sessions, inbox) = fixture(SessionInboxLimits {
1466 max_payload_bytes: 256 * 1024,
1467 max_backlog: 1,
1468 max_claim_batch: 128,
1469 })
1470 .await;
1471 let dir = inbox.inbox_dir("session-1").await.unwrap();
1472 let new_dir = dir.join("new");
1473 tokio::fs::create_dir_all(&new_dir).await.unwrap();
1474 tokio::fs::write(new_dir.join("not-a-generation.json"), b"{}")
1475 .await
1476 .unwrap();
1477
1478 let empty = inbox.inspect("session-1").await.unwrap();
1481 assert_eq!(empty.pending + empty.claimed, 0);
1482 assert_eq!(empty.oldest_generation, None);
1483 let valid = SessionMessageEnvelope::user_input("session-1", "valid after poison");
1484 let receipt = inbox.deliver(&valid).await.unwrap();
1485 inbox
1486 .mark_activation_eligible(
1487 "session-1",
1488 receipt.generation,
1489 SessionActivationPolicy::InterruptSpecificWait,
1490 )
1491 .await
1492 .unwrap();
1493 let claims = inbox.claim("session-1", 10).await.unwrap();
1494 assert_eq!(claims.len(), 1);
1495 assert_eq!(claims[0].envelope.id, valid.id);
1496 assert_eq!(
1497 FileSessionInbox::count_json(&dir.join("corrupt"))
1498 .await
1499 .unwrap(),
1500 1
1501 );
1502 }
1503
1504 #[tokio::test]
1505 async fn payload_and_backlog_limits_are_explicit() {
1506 let limits = SessionInboxLimits {
1507 max_payload_bytes: 512,
1508 max_backlog: 1,
1509 max_claim_batch: 1,
1510 };
1511 let (_temp, _sessions, inbox) = fixture(limits).await;
1512 let oversized = SessionMessageEnvelope::user_input("session-1", "x".repeat(1024));
1513 assert!(matches!(
1514 inbox.deliver(&oversized).await,
1515 Err(SessionInboxError::PayloadTooLarge { .. })
1516 ));
1517 inbox
1518 .deliver(&SessionMessageEnvelope::user_input("session-1", "one"))
1519 .await
1520 .unwrap();
1521 assert!(matches!(
1522 inbox
1523 .deliver(&SessionMessageEnvelope::user_input("session-1", "two"))
1524 .await,
1525 Err(SessionInboxError::BacklogFull {
1526 current: 1,
1527 limit: 1
1528 })
1529 ));
1530 }
1531}