1use std::sync::Arc;
16
17use bamboo_agent_core::storage::Storage;
18use bamboo_agent_core::Session;
19use bamboo_domain::RetrievalWindowCheckpointOutcome;
20use bamboo_storage::LockedSessionStore;
21
22use crate::{read_cached_session, SessionCache};
23
24#[cfg(test)]
25type PostDurableHook = Arc<dyn Fn(&str, &str) + Send + Sync>;
26
27#[derive(Clone)]
30pub struct SessionRepository {
31 cache: SessionCache,
32 storage: Arc<dyn Storage>,
33 persistence: Arc<LockedSessionStore>,
34 #[cfg(test)]
35 post_durable_hook: Option<PostDurableHook>,
36}
37
38impl SessionRepository {
39 pub fn new(
40 cache: SessionCache,
41 storage: Arc<dyn Storage>,
42 persistence: Arc<LockedSessionStore>,
43 ) -> Self {
44 Self {
45 cache,
46 storage,
47 persistence,
48 #[cfg(test)]
49 post_durable_hook: None,
50 }
51 }
52
53 #[cfg(test)]
54 fn with_post_durable_hook(mut self, hook: PostDurableHook) -> Self {
55 self.post_durable_hook = Some(hook);
56 self
57 }
58
59 #[cfg(test)]
60 fn run_post_durable_hook(&self, operation: &str, marker: &str) {
61 if let Some(hook) = self.post_durable_hook.as_ref() {
62 hook(operation, marker);
63 }
64 }
65
66 pub fn cache(&self) -> &SessionCache {
67 &self.cache
68 }
69
70 pub fn storage(&self) -> &Arc<dyn Storage> {
71 &self.storage
72 }
73
74 pub fn persistence(&self) -> &Arc<LockedSessionStore> {
75 &self.persistence
76 }
77
78 pub async fn load(&self, session_id: &str) -> Option<Session> {
81 if let Some(session) = read_cached_session(&self.cache, session_id) {
82 return Some(session);
83 }
84
85 let _guard = self.persistence.acquire_lock(session_id).await;
86 if let Some(session) = read_cached_session(&self.cache, session_id) {
87 return Some(session);
88 }
89
90 let loaded = self.storage.load_session(session_id).await;
91 #[cfg(test)]
92 self.run_post_durable_hook("load", session_id);
93 match loaded {
94 Ok(Some(session)) => {
95 self.cache.insert(
96 session_id.to_string(),
97 Arc::new(crate::SessionSnapshot::new(session.clone())),
98 );
99 Some(session)
100 }
101 _ => None,
102 }
103 }
104
105 pub async fn try_load(&self, session_id: &str) -> std::io::Result<Option<Session>> {
109 if let Some(session) = read_cached_session(&self.cache, session_id) {
110 return Ok(Some(session));
111 }
112
113 let _guard = self.persistence.acquire_lock(session_id).await;
114 if let Some(session) = read_cached_session(&self.cache, session_id) {
115 return Ok(Some(session));
116 }
117
118 let loaded = self.storage.load_session(session_id).await?;
119 #[cfg(test)]
120 self.run_post_durable_hook("try_load", session_id);
121 if let Some(ref session) = loaded {
122 self.cache.insert(
123 session_id.to_string(),
124 Arc::new(crate::SessionSnapshot::new(session.clone())),
125 );
126 }
127 Ok(loaded)
128 }
129
130 pub async fn save(&self, session: &mut Session) -> std::io::Result<()> {
134 self.persistence
135 .merge_save_runtime_and_publish(session, |saved, committed| {
136 if committed {
137 #[cfg(test)]
138 self.run_post_durable_hook("save_full", &saved.id);
139 self.cache.insert(
140 saved.id.clone(),
141 Arc::new(crate::SessionSnapshot::new(saved.clone())),
142 );
143 }
144 })
145 .await
146 }
147
148 pub async fn update_runtime_session<F>(
152 &self,
153 session_id: &str,
154 metadata_keys: &[&str],
155 mutate: F,
156 ) -> std::io::Result<Option<Session>>
157 where
158 F: FnOnce(&mut Session),
159 {
160 self.persistence
161 .update_runtime_config_and_publish(session_id, mutate, |saved| {
162 if let Some(cached) = self.cache.get(session_id) {
163 cached.update(|cached| {
164 for key in metadata_keys {
165 if let Some(value) = saved.metadata.get(*key) {
166 cached.metadata.insert((*key).to_string(), value.clone());
167 } else {
168 cached.metadata.remove(*key);
169 }
170 }
171 });
172 }
173 })
174 .await
175 }
176
177 pub async fn load_or_create(&self, session_id: &str, model: &str) -> Session {
179 if let Some(session) = self.load(session_id).await {
180 return session;
181 }
182 Session::new(session_id.to_string(), model.to_string())
183 }
184
185 pub async fn load_merged_checked(&self, session_id: &str) -> std::io::Result<Option<Session>> {
194 let _guard = self.persistence.acquire_lock(session_id).await;
195 let memory_session = read_cached_session(&self.cache, session_id);
196 let storage_session = self.storage.load_session(session_id).await?;
197 #[cfg(test)]
198 self.run_post_durable_hook("load_merged", session_id);
199
200 Ok(match (memory_session, storage_session) {
201 (Some(memory), Some(storage)) => {
202 let prefer_storage = should_prefer_storage(&memory, &storage);
203 let diverged = prefer_storage || memory.messages.len() != storage.messages.len();
204 let chosen_len = if prefer_storage {
205 storage.messages.len()
206 } else {
207 memory.messages.len()
208 };
209 macro_rules! merged_log {
210 ($level:ident) => {
211 tracing::$level!(
212 "[{}] load_session_merged: memory={} msgs (updated_at={}), storage={} msgs (updated_at={}), prefer_storage={} -> chose {} msgs",
213 session_id,
214 memory.messages.len(),
215 memory.updated_at,
216 storage.messages.len(),
217 storage.updated_at,
218 prefer_storage,
219 chosen_len,
220 )
221 };
222 }
223 if diverged {
224 merged_log!(debug);
225 } else {
226 merged_log!(trace);
227 }
228 let memory_updated_at = memory.updated_at;
229 let chosen = if prefer_storage { storage } else { memory };
230 if prefer_storage && chosen.updated_at >= memory_updated_at {
238 self.cache.insert(
239 session_id.to_string(),
240 Arc::new(crate::SessionSnapshot::new(chosen.clone())),
241 );
242 }
243 Some(chosen)
244 }
245 (Some(memory), None) => Some(memory),
246 (None, Some(storage)) => {
247 self.cache.insert(
248 session_id.to_string(),
249 Arc::new(crate::SessionSnapshot::new(storage.clone())),
250 );
251 Some(storage)
252 }
253 (None, None) => None,
254 })
255 }
256
257 pub async fn load_merged(&self, session_id: &str) -> Option<Session> {
261 match self.load_merged_checked(session_id).await {
262 Ok(session) => session,
263 Err(error) => {
264 tracing::warn!(
265 "[{}] Failed to load merged session from storage: {}",
266 session_id,
267 error
268 );
269 read_cached_session(&self.cache, session_id)
270 }
271 }
272 }
273
274 pub async fn save_and_cache(&self, session: &mut Session) {
277 let result = self
278 .persistence
279 .merge_save_runtime_and_publish(session, |saved, _| {
280 #[cfg(test)]
281 self.run_post_durable_hook("save_and_cache", &saved.id);
282 self.cache.insert(
283 saved.id.clone(),
284 Arc::new(crate::SessionSnapshot::new(saved.clone())),
285 );
286 })
287 .await;
288 if let Err(error) = result {
289 tracing::warn!("[{}] Failed to save session: {}", session.id, error);
290 }
291 }
292
293 async fn refresh_cached_task_control_plane(&self, session_id: &str) {
294 match self.storage.load_runtime_control_plane(session_id).await {
295 Ok(Some(durable)) => {
296 if let Some(cached) = self.cache.get(session_id) {
297 cached.update(|cached| adopt_task_control_plane(cached, &durable));
298 }
299 }
300 Ok(None) => {}
301 Err(error) => tracing::warn!(
302 "[{}] Failed to refresh Task control-plane after write conflict: {}",
303 session_id,
304 error
305 ),
306 }
307 }
308}
309
310fn adopt_task_control_plane(target: &mut Session, durable: &Session) {
311 target.task_list = durable.task_list.clone();
312 target
313 .metadata
314 .remove(bamboo_domain::session::runtime_metadata::keys::TASK_LIST_VERSION);
315 if let Some(runtime_metadata) = target.runtime_metadata.as_mut() {
316 runtime_metadata.task_list_version = None;
317 }
318 if target
319 .runtime_metadata
320 .as_ref()
321 .is_some_and(bamboo_domain::session::SessionRuntimeMetadata::is_empty)
322 {
323 target.runtime_metadata = None;
324 }
325 if let Some(version) = durable.task_list_version_meta() {
326 target.set_task_list_version_meta(version);
327 }
328}
329
330fn should_prefer_storage(memory_session: &Session, storage_session: &Session) -> bool {
331 if storage_session.updated_at < memory_session.updated_at {
336 return false;
337 }
338 storage_session.updated_at > memory_session.updated_at
342 || (memory_session.pending_question.is_none() && storage_session.pending_question.is_some())
343}
344
345#[async_trait::async_trait]
349impl bamboo_domain::RuntimeSessionPersistence for SessionRepository {
350 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
351 self.persistence
356 .merge_save_runtime_and_publish(session, |saved, _| {
357 #[cfg(test)]
358 self.run_post_durable_hook("save_runtime_session", &saved.id);
359 self.cache.insert(
360 saved.id.clone(),
361 Arc::new(crate::SessionSnapshot::new(saved.clone())),
362 );
363 })
364 .await
365 }
366
367 async fn seed_runtime_activation(&self, session: &mut Session) -> std::io::Result<()> {
368 self.persistence
369 .seed_runtime_activation_and_publish(session, |saved, committed| {
370 #[cfg(test)]
371 self.run_post_durable_hook("seed_runtime_activation", &saved.id);
372 if committed {
373 self.cache.insert(
374 saved.id.clone(),
375 Arc::new(crate::SessionSnapshot::new(saved.clone())),
376 );
377 }
378 })
379 .await
380 }
381
382 async fn record_permission_posture_activation(
383 &self,
384 session_id: &str,
385 expected_audit_revision: Option<u64>,
386 seed: &bamboo_domain::PermissionAuditSeed,
387 ) -> std::io::Result<Option<Session>> {
388 self.persistence
389 .record_permission_posture_activation_and_publish(
390 session_id,
391 expected_audit_revision,
392 seed,
393 |saved| {
394 #[cfg(test)]
395 self.run_post_durable_hook("permission_posture_activation", &saved.id);
396 self.cache.insert(
397 saved.id.clone(),
398 Arc::new(crate::SessionSnapshot::new(saved.clone())),
399 );
400 },
401 )
402 .await
403 }
404
405 async fn save_runtime_control_plane(&self, session: &mut Session) -> std::io::Result<()> {
406 self.persistence
407 .save_runtime_only_and_publish(session, |saved| {
408 #[cfg(test)]
409 self.run_post_durable_hook("save", &saved.id);
410
411 if let Some(cached) = self.cache.get(&saved.id) {
419 cached.update(|cached| {
420 let messages = cached.messages.clone();
421 let provider_transcript = cached.provider_transcript.clone();
422 let admission = cached
423 .runtime_metadata
424 .as_ref()
425 .and_then(|metadata| metadata.session_inbox_admission.clone());
426 let mut refreshed = saved.clone();
427 refreshed.messages = messages;
428 refreshed.provider_transcript = provider_transcript;
429 if let Some(admission) = admission {
430 refreshed
431 .runtime_metadata
432 .get_or_insert_with(Default::default)
433 .session_inbox_admission = Some(admission);
434 } else if let Some(metadata) = refreshed.runtime_metadata.as_mut() {
435 metadata.session_inbox_admission = None;
436 }
437 *cached = refreshed;
438 });
439 }
440 })
441 .await
442 }
443
444 async fn load_runtime_control_plane(
445 &self,
446 session_id: &str,
447 ) -> std::io::Result<Option<Session>> {
448 bamboo_domain::RuntimeSessionPersistence::load_runtime_control_plane(
449 self.persistence.as_ref(),
450 session_id,
451 )
452 .await
453 }
454
455 async fn update_task_list_control_plane(
456 &self,
457 session_id: &str,
458 task_list: &bamboo_domain::TaskList,
459 version: &str,
460 ) -> std::io::Result<bool> {
461 let result = self
462 .persistence
463 .update_task_list_control_plane_and_publish(session_id, task_list, version, |_| {
464 #[cfg(test)]
465 self.run_post_durable_hook("task", version);
466
467 if let Some(cached) = self.cache.get(session_id) {
473 cached.update(|cached| {
474 cached.set_task_list(task_list.clone());
475 cached.set_task_list_version_meta(version.to_string());
476 });
477 }
478 })
479 .await;
480 if result
481 .as_ref()
482 .is_err_and(|error| error.kind() == std::io::ErrorKind::WouldBlock)
483 {
484 self.refresh_cached_task_control_plane(session_id).await;
489 }
490 result
491 }
492
493 async fn update_task_list_control_plane_if_version(
494 &self,
495 session_id: &str,
496 expected_version: &str,
497 expected_task_list: &bamboo_domain::TaskList,
498 task_list: &bamboo_domain::TaskList,
499 version: &str,
500 ) -> std::io::Result<bool> {
501 self.persistence
502 .update_task_list_control_plane_if_version_and_publish(
503 session_id,
504 expected_version,
505 expected_task_list,
506 task_list,
507 version,
508 |_| {
509 if let Some(cached) = self.cache.get(session_id) {
510 cached.update(|cached| {
511 cached.set_task_list(task_list.clone());
512 cached.set_task_list_version_meta(version.to_string());
513 });
514 }
515 },
516 )
517 .await
518 }
519
520 async fn update_task_list_control_planes_if_version(
521 &self,
522 session_id: &str,
523 shared_session_id: &str,
524 expected_version: &str,
525 expected_task_list: &bamboo_domain::TaskList,
526 task_list: &bamboo_domain::TaskList,
527 version: &str,
528 ) -> std::io::Result<bool> {
529 self.persistence
530 .update_task_list_control_planes_if_version_and_publish(
531 session_id,
532 shared_session_id,
533 expected_version,
534 expected_task_list,
535 task_list,
536 version,
537 |_, _| {
538 for id in [session_id, shared_session_id] {
539 if let Some(cached) = self.cache.get(id) {
540 cached.update(|cached| {
541 cached.set_task_list(task_list.clone());
542 cached.set_task_list_version_meta(version.to_string());
543 });
544 }
545 }
546 },
547 )
548 .await
549 }
550
551 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
552 self.persistence
559 .checkpoint_runtime_session_and_publish(session, |saved, committed| {
560 #[cfg(test)]
561 self.run_post_durable_hook("checkpoint", &saved.id);
562 if committed {
563 self.cache.insert(
564 saved.id.clone(),
565 Arc::new(crate::SessionSnapshot::new(saved.clone())),
566 );
567 }
568 })
569 .await
570 }
571
572 async fn checkpoint_retrieval_window(
573 &self,
574 expected_base: &Session,
575 staged: &mut Session,
576 ) -> std::io::Result<RetrievalWindowCheckpointOutcome> {
577 self.persistence
578 .checkpoint_retrieval_window_and_publish(expected_base, staged, |saved| {
579 #[cfg(test)]
580 self.run_post_durable_hook("checkpoint_retrieval_window", &saved.id);
581 self.cache.insert(
582 saved.id.clone(),
583 Arc::new(crate::SessionSnapshot::new(saved.clone())),
584 );
585 })
586 .await
587 }
588
589 async fn checkpoint_prompt_rewrite(
590 &self,
591 expected_base: &Session,
592 staged: &mut Session,
593 ) -> std::io::Result<RetrievalWindowCheckpointOutcome> {
594 self.persistence
595 .checkpoint_prompt_rewrite_and_publish(expected_base, staged, |saved| {
596 #[cfg(test)]
597 self.run_post_durable_hook("checkpoint_prompt_rewrite", &saved.id);
598 self.cache.insert(
599 saved.id.clone(),
600 Arc::new(crate::SessionSnapshot::new(saved.clone())),
601 );
602 })
603 .await
604 }
605
606 async fn checkpoint_manual_archive_rejection(
607 &self,
608 expected_base: &Session,
609 staged: &mut Session,
610 ) -> std::io::Result<RetrievalWindowCheckpointOutcome> {
611 self.persistence
612 .checkpoint_manual_archive_rejection_and_publish(expected_base, staged, |saved| {
613 #[cfg(test)]
614 self.run_post_durable_hook("checkpoint_manual_archive_rejection", &saved.id);
615 self.cache.insert(
616 saved.id.clone(),
617 Arc::new(crate::SessionSnapshot::new(saved.clone())),
618 );
619 })
620 .await
621 }
622
623 async fn checkpoint_manual_archive_consumption(
624 &self,
625 expected_base: &Session,
626 staged: &mut Session,
627 ) -> std::io::Result<RetrievalWindowCheckpointOutcome> {
628 self.persistence
629 .checkpoint_manual_archive_consumption_and_publish(expected_base, staged, |saved| {
630 #[cfg(test)]
631 self.run_post_durable_hook("checkpoint_manual_archive_consumption", &saved.id);
632 self.cache.insert(
633 saved.id.clone(),
634 Arc::new(crate::SessionSnapshot::new(saved.clone())),
635 );
636 })
637 .await
638 }
639
640 async fn load_runtime_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
641 self.try_load(session_id).await
642 }
643
644 async fn clear_legacy_pending_messages(
645 &self,
646 session_id: &str,
647 expected: &[serde_json::Value],
648 ) -> std::io::Result<bool> {
649 self.persistence
654 .clear_legacy_pending_messages_and_publish(session_id, expected, |latest| {
655 #[cfg(test)]
656 self.run_post_durable_hook("clear_legacy", session_id);
657 self.cache.insert(
658 session_id.to_string(),
659 Arc::new(crate::SessionSnapshot::new(latest.clone())),
660 );
661 })
662 .await
663 }
664
665 async fn append_token_usage_record(
666 &self,
667 session_id: &str,
668 json_line: &str,
669 ) -> std::io::Result<()> {
670 self.storage
671 .append_token_usage_record(session_id, json_line)
672 .await
673 }
674}
675
676#[cfg(test)]
677mod tests {
678 use super::*;
679 use bamboo_agent_core::storage::Storage;
680 use chrono::Utc;
681 use std::collections::HashMap;
682 use std::sync::atomic::{AtomicBool, Ordering};
683 use std::sync::{Condvar, Mutex};
684 use std::time::Duration;
685
686 #[derive(Default)]
687 struct MapStorage {
688 sessions: Mutex<HashMap<String, Session>>,
689 fail_pair_commit: AtomicBool,
690 }
691
692 struct FailingSaveStorage {
693 persisted: Mutex<Option<Session>>,
694 }
695
696 #[async_trait::async_trait]
697 impl Storage for MapStorage {
698 async fn save_session(&self, session: &Session) -> std::io::Result<()> {
699 self.sessions
700 .lock()
701 .unwrap()
702 .insert(session.id.clone(), session.clone());
703 Ok(())
704 }
705 async fn load_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
706 Ok(self.sessions.lock().unwrap().get(session_id).cloned())
707 }
708 async fn delete_session(&self, session_id: &str) -> std::io::Result<bool> {
709 Ok(self.sessions.lock().unwrap().remove(session_id).is_some())
710 }
711
712 async fn save_task_control_plane_if_matches(
713 &self,
714 original: &Session,
715 updated: &Session,
716 ) -> std::io::Result<bool> {
717 let mut sessions = self.sessions.lock().unwrap();
718 let matches_original = sessions.get(&original.id).is_some_and(|current| {
719 current.task_list_version_meta() == original.task_list_version_meta()
720 && serde_json::to_value(¤t.task_list).ok()
721 == serde_json::to_value(&original.task_list).ok()
722 });
723 if !matches_original {
724 return Ok(false);
725 }
726 let Some(current) = sessions.get(&original.id).cloned() else {
727 return Ok(false);
728 };
729 let mut committed = current;
730 committed.task_list = updated.task_list.clone();
731 if let Some(version) = updated.task_list_version_meta() {
732 committed.set_task_list_version_meta(version);
733 }
734 sessions.insert(committed.id.clone(), committed);
735 Ok(true)
736 }
737
738 async fn save_task_control_planes_atomically(
739 &self,
740 first_original: &Session,
741 first_updated: &Session,
742 second_original: &Session,
743 second_updated: &Session,
744 ) -> std::io::Result<bool> {
745 if self.fail_pair_commit.swap(false, Ordering::SeqCst) {
746 return Err(std::io::Error::other(
747 "injected paired Task transaction failure",
748 ));
749 }
750 let mut sessions = self.sessions.lock().unwrap();
751 let matches_original = |current: Option<&Session>, original: &Session| {
752 current.is_some_and(|current| {
753 current.task_list_version_meta() == original.task_list_version_meta()
754 && serde_json::to_value(¤t.task_list).ok()
755 == serde_json::to_value(&original.task_list).ok()
756 })
757 };
758 if !matches_original(sessions.get(&first_original.id), first_original)
759 || !matches_original(sessions.get(&second_original.id), second_original)
760 {
761 return Ok(false);
762 }
763 sessions.insert(first_updated.id.clone(), first_updated.clone());
764 sessions.insert(second_updated.id.clone(), second_updated.clone());
765 Ok(true)
766 }
767 }
768
769 #[async_trait::async_trait]
770 impl Storage for FailingSaveStorage {
771 async fn save_session(&self, _session: &Session) -> std::io::Result<()> {
772 Err(std::io::Error::other("injected save failure"))
773 }
774
775 async fn load_session(&self, _session_id: &str) -> std::io::Result<Option<Session>> {
776 Ok(self.persisted.lock().unwrap().clone())
777 }
778
779 async fn delete_session(&self, _session_id: &str) -> std::io::Result<bool> {
780 Ok(false)
781 }
782 }
783
784 fn test_repo(storage: Arc<dyn Storage>) -> SessionRepository {
785 let cache: SessionCache = Arc::default();
786 let persistence = Arc::new(LockedSessionStore::new(storage.clone()));
787 SessionRepository::new(cache, storage, persistence)
788 }
789
790 fn cache_put(repo: &SessionRepository, session: &Session) {
791 repo.cache().insert(
792 session.id.clone(),
793 Arc::new(crate::SessionSnapshot::new(session.clone())),
794 );
795 }
796
797 fn task_list(session_id: &str, title: &str) -> bamboo_domain::TaskList {
798 let now = Utc::now();
799 bamboo_domain::TaskList {
800 session_id: session_id.to_string(),
801 title: title.to_string(),
802 items: Vec::new(),
803 created_at: now,
804 updated_at: now,
805 }
806 }
807
808 fn staged_retrieval_window_archive(expected: &Session) -> Session {
809 let mut staged = expected.clone();
810 let mut event = bamboo_domain::CompressionEvent::new(
811 1,
812 1,
813 80.0,
814 60.0,
815 0,
816 bamboo_domain::CompressionTriggerType::Auto,
817 0.0,
818 None,
819 0,
820 );
821 event.kind = bamboo_domain::CompressionEventKind::RetrievalWindow;
822 staged.messages[0].compressed = true;
823 staged.messages[0].compressed_by_event_id = Some(event.id.clone());
824 staged.compression_events.push(event);
825 staged.reset_model_context_epoch(bamboo_domain::ModelContextResetReason::Compression);
826 staged
827 }
828
829 #[tokio::test]
830 async fn paired_task_cas_narrowly_updates_child_and_root_cache() {
831 let storage: Arc<dyn Storage> = Arc::new(MapStorage::default());
832 let repo = test_repo(storage.clone());
833 let root_id = "paired-cache-root";
834 let child_id = "paired-cache-child";
835 let expected_task_list = task_list(root_id, "old shared");
836
837 let mut root = Session::new(root_id, "model");
838 root.add_message(bamboo_agent_core::Message::user("root transcript"));
839 root.metadata
840 .insert("unrelated.root".to_string(), "keep".to_string());
841 root.set_task_list(expected_task_list.clone());
842 root.set_task_list_version_meta("1");
843 storage.save_session(&root).await.unwrap();
844 cache_put(&repo, &root);
845
846 let mut child = Session::new_child(child_id, root_id, "model", "child");
847 child.add_message(bamboo_agent_core::Message::user("child transcript"));
848 child
849 .metadata
850 .insert("unrelated.child".to_string(), "keep".to_string());
851 child.set_task_list(expected_task_list.clone());
852 child.set_task_list_version_meta("1");
853 storage.save_session(&child).await.unwrap();
854 cache_put(&repo, &child);
855
856 assert!(
857 bamboo_domain::RuntimeSessionPersistence::update_task_list_control_planes_if_version(
858 &repo,
859 child_id,
860 root_id,
861 "1",
862 &expected_task_list,
863 &task_list(root_id, "evaluated"),
864 "2",
865 )
866 .await
867 .expect("paired cache CAS succeeds")
868 );
869
870 let cached_root = read_cached_session(repo.cache(), root_id).expect("cached root");
871 let cached_child = read_cached_session(repo.cache(), child_id).expect("cached child");
872 for (session, transcript, metadata_key) in [
873 (&cached_root, "root transcript", "unrelated.root"),
874 (&cached_child, "child transcript", "unrelated.child"),
875 ] {
876 assert_eq!(session.task_list_version_meta().as_deref(), Some("2"));
877 assert_eq!(
878 session.task_list.as_ref().map(|list| list.title.as_str()),
879 Some("evaluated")
880 );
881 assert_eq!(session.messages.len(), 1);
882 assert_eq!(session.messages[0].content, transcript);
883 assert_eq!(
884 session.metadata.get(metadata_key).map(String::as_str),
885 Some("keep")
886 );
887 }
888 }
889
890 #[tokio::test]
891 async fn failed_paired_task_transaction_does_not_publish_child_or_root_cache() {
892 let concrete = Arc::new(MapStorage::default());
893 let storage: Arc<dyn Storage> = concrete.clone();
894 let repo = test_repo(storage.clone());
895 let root_id = "paired-cache-failure-root";
896 let child_id = "paired-cache-failure-child";
897 let expected_task_list = task_list(root_id, "old shared");
898
899 let mut root = Session::new(root_id, "model");
900 root.add_message(bamboo_agent_core::Message::user("root transcript"));
901 root.metadata
902 .insert("unrelated.root".to_string(), "keep".to_string());
903 root.set_task_list(expected_task_list.clone());
904 root.set_task_list_version_meta("1");
905 storage.save_session(&root).await.unwrap();
906 cache_put(&repo, &root);
907
908 let mut child = Session::new_child(child_id, root_id, "model", "child");
909 child.add_message(bamboo_agent_core::Message::user("child transcript"));
910 child
911 .metadata
912 .insert("unrelated.child".to_string(), "keep".to_string());
913 child.set_task_list(expected_task_list.clone());
914 child.set_task_list_version_meta("1");
915 storage.save_session(&child).await.unwrap();
916 cache_put(&repo, &child);
917
918 concrete.fail_pair_commit.store(true, Ordering::SeqCst);
919 let error =
920 bamboo_domain::RuntimeSessionPersistence::update_task_list_control_planes_if_version(
921 &repo,
922 child_id,
923 root_id,
924 "1",
925 &expected_task_list,
926 &task_list(root_id, "must not publish"),
927 "2",
928 )
929 .await
930 .expect_err("paired durable transaction fails");
931 assert!(error.to_string().contains("injected paired"));
932
933 for (id, expected_title, transcript, metadata_key) in [
934 (root_id, "old shared", "root transcript", "unrelated.root"),
935 (
936 child_id,
937 "old shared",
938 "child transcript",
939 "unrelated.child",
940 ),
941 ] {
942 let cached = read_cached_session(repo.cache(), id).expect("cached session remains");
943 assert_eq!(cached.task_list_version_meta().as_deref(), Some("1"));
944 assert_eq!(
945 cached.task_list.as_ref().map(|list| list.title.as_str()),
946 Some(expected_title)
947 );
948 assert_eq!(cached.messages[0].content, transcript);
949 assert_eq!(
950 cached.metadata.get(metadata_key).map(String::as_str),
951 Some("keep")
952 );
953
954 let durable = storage.load_session(id).await.unwrap().unwrap();
955 assert_eq!(durable.task_list_version_meta().as_deref(), Some("1"));
956 assert_eq!(
957 durable.task_list.as_ref().map(|list| list.title.as_str()),
958 Some(expected_title)
959 );
960 }
961 }
962
963 fn durable_cache_fence(
964 operation: impl Into<String>,
965 marker: impl Into<String>,
966 ) -> (
967 PostDurableHook,
968 tokio::sync::oneshot::Receiver<()>,
969 Arc<(Mutex<bool>, Condvar)>,
970 ) {
971 let operation = operation.into();
972 let marker = marker.into();
973 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
974 let started_tx = Arc::new(Mutex::new(Some(started_tx)));
975 let release = Arc::new((Mutex::new(false), Condvar::new()));
976 let hook_release = release.clone();
977 let hook: PostDurableHook = Arc::new(move |actual_operation, actual_marker| {
978 if actual_operation != operation || actual_marker != marker {
979 return;
980 }
981 if let Some(started_tx) = started_tx.lock().unwrap().take() {
982 started_tx.send(()).expect("fence observer still present");
983 }
984 let (released, wake) = &*hook_release;
985 let mut released = released.lock().unwrap();
986 while !*released {
987 released = wake.wait(released).unwrap();
988 }
989 });
990 (hook, started_rx, release)
991 }
992
993 fn release_fence(release: &Arc<(Mutex<bool>, Condvar)>) {
994 let (released, wake) = &**release;
995 *released.lock().unwrap() = true;
996 wake.notify_all();
997 }
998
999 async fn assert_second_write_waits_for_cache_publish<T>(
1000 first: tokio::task::JoinHandle<std::io::Result<T>>,
1001 mut second: tokio::task::JoinHandle<std::io::Result<T>>,
1002 release: Arc<(Mutex<bool>, Condvar)>,
1003 ) -> (T, T) {
1004 let second_before_release =
1005 tokio::time::timeout(Duration::from_millis(100), &mut second).await;
1006 let completed_before_release = second_before_release.is_ok();
1007 release_fence(&release);
1008
1009 let first = first
1010 .await
1011 .expect("first writer joins")
1012 .expect("first writer succeeds");
1013 let second = match second_before_release {
1014 Ok(joined) => joined
1015 .expect("second writer joins")
1016 .expect("second writer succeeds"),
1017 Err(_) => second
1018 .await
1019 .expect("second writer joins")
1020 .expect("second writer succeeds"),
1021 };
1022 assert!(
1023 !completed_before_release,
1024 "the second write must remain behind the first write's durable-to-cache fence"
1025 );
1026 (first, second)
1027 }
1028
1029 #[derive(Clone, Copy, Debug)]
1030 enum FullSaveRoute {
1031 InherentSave,
1032 SaveAndCache,
1033 RuntimePersistence,
1034 }
1035
1036 impl FullSaveRoute {
1037 fn operation(self) -> &'static str {
1038 match self {
1039 Self::InherentSave => "save_full",
1040 Self::SaveAndCache => "save_and_cache",
1041 Self::RuntimePersistence => "save_runtime_session",
1042 }
1043 }
1044
1045 fn name(self) -> &'static str {
1046 match self {
1047 Self::InherentSave => "inherent",
1048 Self::SaveAndCache => "save-and-cache",
1049 Self::RuntimePersistence => "runtime-persistence",
1050 }
1051 }
1052 }
1053
1054 async fn assert_full_save_route_serializes_cache_publish(route: FullSaveRoute) {
1055 let temp = tempfile::tempdir().unwrap();
1056 let concrete_storage = Arc::new(
1057 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
1058 .await
1059 .expect("SessionStoreV2"),
1060 );
1061 let storage: Arc<dyn Storage> = concrete_storage;
1062 let id = format!("root-full-cache-order-{}", route.name());
1063 let (hook, first_durable, release) = durable_cache_fence(route.operation(), id.clone());
1064 let repo = Arc::new(test_repo(storage.clone()).with_post_durable_hook(hook));
1065
1066 let mut initial = Session::new(&id, "model");
1067 initial.add_message(bamboo_agent_core::Message::user("durable transcript"));
1068 initial.set_task_list(task_list(&id, "initial"));
1069 initial.set_task_list_version_meta("0");
1070 initial
1071 .metadata
1072 .insert("unrelated.runtime".to_string(), "keep".to_string());
1073 storage.save_session(&initial).await.unwrap();
1074 cache_put(&repo, &initial);
1075
1076 let first_repo = repo.clone();
1077 let mut root_snapshot = initial.clone();
1078 root_snapshot.add_message(bamboo_agent_core::Message::assistant(
1079 "full-save transcript suffix",
1080 None,
1081 ));
1082 root_snapshot.set_task_list(task_list(&id, "root"));
1083 root_snapshot.set_task_list_version_meta("1");
1084 let first = tokio::spawn(async move {
1085 match route {
1086 FullSaveRoute::InherentSave => first_repo.save(&mut root_snapshot).await,
1087 FullSaveRoute::SaveAndCache => {
1088 first_repo.save_and_cache(&mut root_snapshot).await;
1089 Ok(())
1090 }
1091 FullSaveRoute::RuntimePersistence => {
1092 bamboo_domain::RuntimeSessionPersistence::save_runtime_session(
1093 first_repo.as_ref(),
1094 &mut root_snapshot,
1095 )
1096 .await
1097 }
1098 }
1099 });
1100 first_durable
1101 .await
1102 .expect("root full durable write reached");
1103
1104 let second_repo = repo.clone();
1105 let second_id = id.clone();
1106 let child_task_list = task_list(&id, "child");
1107 let second = tokio::spawn(async move {
1108 bamboo_domain::RuntimeSessionPersistence::update_task_list_control_plane(
1109 second_repo.as_ref(),
1110 &second_id,
1111 &child_task_list,
1112 "2",
1113 )
1114 .await
1115 .map(|updated| assert!(updated, "root must exist"))
1116 });
1117 assert_second_write_waits_for_cache_publish(first, second, release).await;
1118
1119 let durable = storage.load_session(&id).await.unwrap().unwrap();
1120 let cached = read_cached_session(repo.cache(), &id).expect("cached root");
1121 for (tier, session) in [("durable", durable), ("cache", cached)] {
1122 assert_eq!(
1123 session.task_list_version_meta().as_deref(),
1124 Some("2"),
1125 "{route:?} {tier} must retain the child transaction"
1126 );
1127 assert_eq!(
1128 session.task_list.as_ref().map(|list| list.title.as_str()),
1129 Some("child"),
1130 "{route:?} {tier} must retain the child transaction"
1131 );
1132 assert_eq!(
1133 session
1134 .metadata
1135 .get("unrelated.runtime")
1136 .map(String::as_str),
1137 Some("keep"),
1138 "{route:?} {tier} must preserve unrelated runtime state"
1139 );
1140 assert_eq!(
1141 session.messages.len(),
1142 2,
1143 "{route:?} {tier} must preserve the full-save transcript"
1144 );
1145 }
1146 }
1147
1148 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1149 async fn root_full_saves_and_child_task_patch_share_publish_order() {
1150 for route in [
1151 FullSaveRoute::InherentSave,
1152 FullSaveRoute::SaveAndCache,
1153 FullSaveRoute::RuntimePersistence,
1154 ] {
1155 assert_full_save_route_serializes_cache_publish(route).await;
1156 }
1157 }
1158
1159 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1160 async fn checkpoint_and_child_task_patch_share_publish_order() {
1161 let temp = tempfile::tempdir().unwrap();
1162 let concrete_storage = Arc::new(
1163 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
1164 .await
1165 .expect("SessionStoreV2"),
1166 );
1167 let storage: Arc<dyn Storage> = concrete_storage;
1168 let id = "checkpoint-cache-order";
1169 let (hook, checkpoint_durable, release) = durable_cache_fence("checkpoint", id);
1170 let repo = Arc::new(test_repo(storage.clone()).with_post_durable_hook(hook));
1171
1172 let mut initial = Session::new(id, "model");
1173 initial.add_message(bamboo_agent_core::Message::user("durable transcript"));
1174 initial.set_task_list(task_list(id, "initial"));
1175 initial.set_task_list_version_meta("0");
1176 initial
1177 .metadata
1178 .insert("unrelated.runtime".to_string(), "keep".to_string());
1179 storage.save_session(&initial).await.unwrap();
1180 cache_put(&repo, &initial);
1181
1182 let checkpoint_repo = repo.clone();
1183 let mut checkpoint_snapshot = initial.clone();
1184 checkpoint_snapshot.add_message(bamboo_agent_core::Message::assistant(
1185 "checkpoint transcript suffix",
1186 None,
1187 ));
1188 checkpoint_snapshot.set_task_list(task_list(id, "checkpoint"));
1189 checkpoint_snapshot.set_task_list_version_meta("1");
1190 let checkpoint = tokio::spawn(async move {
1191 bamboo_domain::RuntimeSessionPersistence::checkpoint_runtime_session(
1192 checkpoint_repo.as_ref(),
1193 &mut checkpoint_snapshot,
1194 )
1195 .await
1196 });
1197 checkpoint_durable
1198 .await
1199 .expect("checkpoint durable write reached");
1200
1201 let child_repo = repo.clone();
1202 let child_task_list = task_list(id, "child");
1203 let child_patch = tokio::spawn(async move {
1204 bamboo_domain::RuntimeSessionPersistence::update_task_list_control_plane(
1205 child_repo.as_ref(),
1206 id,
1207 &child_task_list,
1208 "2",
1209 )
1210 .await
1211 .map(|updated| assert!(updated, "root must exist"))
1212 });
1213 assert_second_write_waits_for_cache_publish(checkpoint, child_patch, release).await;
1214
1215 let durable = storage.load_session(id).await.unwrap().unwrap();
1216 let cached = read_cached_session(repo.cache(), id).expect("cached root");
1217 for (tier, session) in [("durable", durable), ("cache", cached)] {
1218 assert_eq!(
1219 session.task_list_version_meta().as_deref(),
1220 Some("2"),
1221 "{tier} must retain the child transaction"
1222 );
1223 assert_eq!(
1224 session.task_list.as_ref().map(|list| list.title.as_str()),
1225 Some("child"),
1226 "{tier} must retain the child transaction"
1227 );
1228 assert_eq!(
1229 session
1230 .metadata
1231 .get("unrelated.runtime")
1232 .map(String::as_str),
1233 Some("keep"),
1234 "{tier} must preserve unrelated runtime state"
1235 );
1236 assert_eq!(
1237 session.messages.len(),
1238 2,
1239 "{tier} must preserve the checkpoint transcript"
1240 );
1241 }
1242 }
1243
1244 #[derive(Clone, Copy, Debug)]
1245 enum CacheBackfillRoute {
1246 Load,
1247 TryLoad,
1248 }
1249
1250 impl CacheBackfillRoute {
1251 fn operation(self) -> &'static str {
1252 match self {
1253 Self::Load => "load",
1254 Self::TryLoad => "try_load",
1255 }
1256 }
1257
1258 fn name(self) -> &'static str {
1259 match self {
1260 Self::Load => "load",
1261 Self::TryLoad => "try-load",
1262 }
1263 }
1264 }
1265
1266 async fn assert_cache_backfill_serializes_with_task_patch(route: CacheBackfillRoute) {
1267 let temp = tempfile::tempdir().unwrap();
1268 let concrete_storage = Arc::new(
1269 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
1270 .await
1271 .expect("SessionStoreV2"),
1272 );
1273 let storage: Arc<dyn Storage> = concrete_storage;
1274 let id = format!("cache-backfill-order-{}", route.name());
1275 let (hook, loaded_old_durable, release) =
1276 durable_cache_fence(route.operation(), id.clone());
1277 let repo = Arc::new(test_repo(storage.clone()).with_post_durable_hook(hook));
1278
1279 let mut initial = Session::new(&id, "model");
1280 initial.set_task_list(task_list(&id, "initial"));
1281 initial.set_task_list_version_meta("0");
1282 storage.save_session(&initial).await.unwrap();
1283 assert!(
1284 read_cached_session(repo.cache(), &id).is_none(),
1285 "the race requires a genuine cache miss"
1286 );
1287
1288 let load_repo = repo.clone();
1289 let load_id = id.clone();
1290 let load = tokio::spawn(async move {
1291 let loaded = match route {
1292 CacheBackfillRoute::Load => load_repo.load(&load_id).await,
1293 CacheBackfillRoute::TryLoad => {
1294 load_repo.try_load(&load_id).await.expect("storage load")
1295 }
1296 };
1297 assert!(loaded.is_some(), "seeded session must load");
1298 Ok(())
1299 });
1300 loaded_old_durable
1301 .await
1302 .expect("old durable snapshot loaded");
1303
1304 let patch_repo = repo.clone();
1305 let patch_id = id.clone();
1306 let child_task_list = task_list(&id, "child");
1307 let patch = tokio::spawn(async move {
1308 bamboo_domain::RuntimeSessionPersistence::update_task_list_control_plane(
1309 patch_repo.as_ref(),
1310 &patch_id,
1311 &child_task_list,
1312 "1",
1313 )
1314 .await
1315 .map(|updated| assert!(updated, "root must exist"))
1316 });
1317 assert_second_write_waits_for_cache_publish(load, patch, release).await;
1318
1319 let durable = storage.load_session(&id).await.unwrap().unwrap();
1320 let cached = read_cached_session(repo.cache(), &id).expect("backfilled cache");
1321 for (tier, session) in [("durable", durable), ("cache", cached)] {
1322 assert_eq!(
1323 session.task_list_version_meta().as_deref(),
1324 Some("1"),
1325 "{route:?} {tier} must retain the child transaction"
1326 );
1327 assert_eq!(
1328 session.task_list.as_ref().map(|list| list.title.as_str()),
1329 Some("child"),
1330 "{route:?} {tier} must retain the child transaction"
1331 );
1332 }
1333 }
1334
1335 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1336 async fn cache_miss_backfills_and_child_task_patch_share_publish_order() {
1337 for route in [CacheBackfillRoute::Load, CacheBackfillRoute::TryLoad] {
1338 assert_cache_backfill_serializes_with_task_patch(route).await;
1339 }
1340 }
1341
1342 #[tokio::test]
1343 async fn cache_hits_do_not_wait_for_the_persistence_lock() {
1344 let storage: Arc<dyn Storage> = Arc::new(MapStorage::default());
1345 let repo = test_repo(storage);
1346 let id = "cache-hit-lock-free";
1347 let cached = Session::new(id, "cached-model");
1348 cache_put(&repo, &cached);
1349 let persistence_guard = repo.persistence().acquire_lock(id).await;
1350
1351 let loaded = tokio::time::timeout(Duration::from_millis(100), repo.load(id)).await;
1352 let try_loaded = tokio::time::timeout(Duration::from_millis(100), repo.try_load(id)).await;
1353 drop(persistence_guard);
1354
1355 assert_eq!(
1356 loaded
1357 .expect("cache hit must not wait for the persistence lock")
1358 .expect("cached session")
1359 .model,
1360 "cached-model"
1361 );
1362 assert_eq!(
1363 try_loaded
1364 .expect("fallible cache hit must not wait for the persistence lock")
1365 .expect("cache read succeeds")
1366 .expect("cached session")
1367 .model,
1368 "cached-model"
1369 );
1370 }
1371
1372 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1373 async fn load_merged_storage_refresh_and_child_task_patch_share_publish_order() {
1374 let temp = tempfile::tempdir().unwrap();
1375 let concrete_storage = Arc::new(
1376 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
1377 .await
1378 .expect("SessionStoreV2"),
1379 );
1380 let storage: Arc<dyn Storage> = concrete_storage;
1381 let id = "load-merged-cache-order";
1382 let (hook, loaded_old_durable, release) = durable_cache_fence("load_merged", id);
1383 let repo = Arc::new(test_repo(storage.clone()).with_post_durable_hook(hook));
1384
1385 let mut durable = Session::new(id, "model");
1386 durable.updated_at = Utc::now();
1387 durable.set_task_list(task_list(id, "initial"));
1388 durable.set_task_list_version_meta("0");
1389 storage.save_session(&durable).await.unwrap();
1390
1391 let mut memory = durable.clone();
1392 memory.updated_at = durable.updated_at - chrono::Duration::seconds(1);
1393 memory.set_task_list(task_list(id, "memory"));
1394 cache_put(&repo, &memory);
1395
1396 let load_repo = repo.clone();
1397 let load = tokio::spawn(async move {
1398 assert!(
1399 load_repo.load_merged(id).await.is_some(),
1400 "seeded session must load"
1401 );
1402 Ok(())
1403 });
1404 loaded_old_durable
1405 .await
1406 .expect("old durable snapshot loaded");
1407
1408 let patch_repo = repo.clone();
1409 let child_task_list = task_list(id, "child");
1410 let patch = tokio::spawn(async move {
1411 bamboo_domain::RuntimeSessionPersistence::update_task_list_control_plane(
1412 patch_repo.as_ref(),
1413 id,
1414 &child_task_list,
1415 "1",
1416 )
1417 .await
1418 .map(|updated| assert!(updated, "root must exist"))
1419 });
1420 assert_second_write_waits_for_cache_publish(load, patch, release).await;
1421
1422 let durable = storage.load_session(id).await.unwrap().unwrap();
1423 let cached = read_cached_session(repo.cache(), id).expect("refreshed cache");
1424 for (tier, session) in [("durable", durable), ("cache", cached)] {
1425 assert_eq!(
1426 session.task_list_version_meta().as_deref(),
1427 Some("1"),
1428 "{tier} must retain the child transaction"
1429 );
1430 assert_eq!(
1431 session.task_list.as_ref().map(|list| list.title.as_str()),
1432 Some("child"),
1433 "{tier} must retain the child transaction"
1434 );
1435 }
1436 }
1437
1438 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1439 async fn legacy_clear_refresh_and_child_task_patch_share_publish_order() {
1440 let temp = tempfile::tempdir().unwrap();
1441 let concrete_storage = Arc::new(
1442 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
1443 .await
1444 .expect("SessionStoreV2"),
1445 );
1446 let storage: Arc<dyn Storage> = concrete_storage;
1447 let id = "legacy-clear-cache-order";
1448 let expected = vec![serde_json::json!({"content": "legacy"})];
1449 let (hook, loaded_post_cas_snapshot, release) = durable_cache_fence("clear_legacy", id);
1450 let repo = Arc::new(test_repo(storage.clone()).with_post_durable_hook(hook));
1451
1452 let mut initial = Session::new(id, "model");
1453 initial.set_pending_injected_messages(expected.clone());
1454 initial.set_task_list(task_list(id, "initial"));
1455 initial.set_task_list_version_meta("0");
1456 storage.save_session(&initial).await.unwrap();
1457 cache_put(&repo, &initial);
1458
1459 let clear_repo = repo.clone();
1460 let clear_expected = expected.clone();
1461 let clear = tokio::spawn(async move {
1462 bamboo_domain::RuntimeSessionPersistence::clear_legacy_pending_messages(
1463 clear_repo.as_ref(),
1464 id,
1465 &clear_expected,
1466 )
1467 .await
1468 .map(|cleared| assert!(cleared, "legacy queue must match"))
1469 });
1470 loaded_post_cas_snapshot
1471 .await
1472 .expect("post-CAS snapshot loaded");
1473
1474 let patch_repo = repo.clone();
1475 let child_task_list = task_list(id, "child");
1476 let patch = tokio::spawn(async move {
1477 bamboo_domain::RuntimeSessionPersistence::update_task_list_control_plane(
1478 patch_repo.as_ref(),
1479 id,
1480 &child_task_list,
1481 "1",
1482 )
1483 .await
1484 .map(|updated| assert!(updated, "root must exist"))
1485 });
1486 assert_second_write_waits_for_cache_publish(clear, patch, release).await;
1487
1488 let durable = storage.load_session(id).await.unwrap().unwrap();
1489 let cached = read_cached_session(repo.cache(), id).expect("refreshed cache");
1490 for (tier, session) in [("durable", durable), ("cache", cached)] {
1491 assert_eq!(
1492 session.task_list_version_meta().as_deref(),
1493 Some("1"),
1494 "{tier} must retain the child transaction"
1495 );
1496 assert_eq!(
1497 session.task_list.as_ref().map(|list| list.title.as_str()),
1498 Some("child"),
1499 "{tier} must retain the child transaction"
1500 );
1501 assert!(
1502 !session.has_pending_injected_messages(),
1503 "{tier} must retain the successful legacy clear"
1504 );
1505 }
1506 }
1507
1508 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1509 async fn concurrent_task_patches_publish_cache_in_durable_order() {
1510 let storage: Arc<dyn Storage> = Arc::new(MapStorage::default());
1511 let (hook, first_durable, release) = durable_cache_fence("task", "1");
1512 let repo = Arc::new(test_repo(storage.clone()).with_post_durable_hook(hook));
1513 let id = "concurrent-task-cache-order";
1514 let mut initial = Session::new(id, "model");
1515 initial.set_task_list(task_list(id, "initial"));
1516 initial.set_task_list_version_meta("0");
1517 storage.save_session(&initial).await.unwrap();
1518 cache_put(&repo, &initial);
1519
1520 let first_repo = repo.clone();
1521 let first_task_list = task_list(id, "first");
1522 let first = tokio::spawn(async move {
1523 bamboo_domain::RuntimeSessionPersistence::update_task_list_control_plane(
1524 first_repo.as_ref(),
1525 id,
1526 &first_task_list,
1527 "1",
1528 )
1529 .await
1530 });
1531 first_durable.await.expect("first durable write reached");
1532
1533 let second_repo = repo.clone();
1534 let second_task_list = task_list(id, "second");
1535 let second = tokio::spawn(async move {
1536 bamboo_domain::RuntimeSessionPersistence::update_task_list_control_plane(
1537 second_repo.as_ref(),
1538 id,
1539 &second_task_list,
1540 "2",
1541 )
1542 .await
1543 });
1544 let (first_updated, second_updated) =
1545 assert_second_write_waits_for_cache_publish(first, second, release).await;
1546 assert!(first_updated && second_updated);
1547
1548 let durable = storage.load_session(id).await.unwrap().unwrap();
1549 let cached = read_cached_session(repo.cache(), id).expect("cached root");
1550 for (tier, session) in [("durable", durable), ("cache", cached)] {
1551 assert_eq!(
1552 session.task_list_version_meta().as_deref(),
1553 Some("2"),
1554 "{tier} must retain the second transaction"
1555 );
1556 assert_eq!(
1557 session.task_list.as_ref().map(|list| list.title.as_str()),
1558 Some("second"),
1559 "{tier} must retain the second transaction"
1560 );
1561 }
1562 }
1563
1564 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1565 async fn root_control_plane_save_and_child_task_patch_share_publish_order() {
1566 let storage: Arc<dyn Storage> = Arc::new(MapStorage::default());
1567 let (hook, root_durable, release) = durable_cache_fence("save", "root-control-plane-order");
1568 let repo = Arc::new(test_repo(storage.clone()).with_post_durable_hook(hook));
1569 let id = "root-control-plane-order";
1570
1571 let mut initial = Session::new(id, "model");
1572 initial.add_message(bamboo_agent_core::Message::user("durable transcript"));
1573 initial.set_task_list(task_list(id, "initial"));
1574 initial.set_task_list_version_meta("0");
1575 initial
1576 .metadata
1577 .insert("unrelated.runtime".to_string(), "keep".to_string());
1578 storage.save_session(&initial).await.unwrap();
1579 cache_put(&repo, &initial);
1580
1581 let root_repo = repo.clone();
1582 let mut root_snapshot = initial.clone();
1583 root_snapshot.set_task_list(task_list(id, "root"));
1584 root_snapshot.set_task_list_version_meta("1");
1585 let root_save = tokio::spawn(async move {
1586 bamboo_domain::RuntimeSessionPersistence::save_runtime_control_plane(
1587 root_repo.as_ref(),
1588 &mut root_snapshot,
1589 )
1590 .await
1591 });
1592 root_durable.await.expect("root durable write reached");
1593
1594 let child_repo = repo.clone();
1595 let child_task_list = task_list(id, "child");
1596 let child_patch = tokio::spawn(async move {
1597 bamboo_domain::RuntimeSessionPersistence::update_task_list_control_plane(
1598 child_repo.as_ref(),
1599 id,
1600 &child_task_list,
1601 "2",
1602 )
1603 .await
1604 .map(|updated| {
1605 assert!(updated, "root must exist");
1606 })
1607 });
1608 assert_second_write_waits_for_cache_publish(root_save, child_patch, release).await;
1609
1610 let durable = storage.load_session(id).await.unwrap().unwrap();
1611 let cached = read_cached_session(repo.cache(), id).expect("cached root");
1612 for (tier, session) in [("durable", durable), ("cache", cached)] {
1613 assert_eq!(
1614 session.task_list_version_meta().as_deref(),
1615 Some("2"),
1616 "{tier} must retain the child transaction"
1617 );
1618 assert_eq!(
1619 session.task_list.as_ref().map(|list| list.title.as_str()),
1620 Some("child"),
1621 "{tier} must retain the child transaction"
1622 );
1623 assert_eq!(
1624 session
1625 .metadata
1626 .get("unrelated.runtime")
1627 .map(String::as_str),
1628 Some("keep"),
1629 "{tier} must preserve unrelated runtime state"
1630 );
1631 assert_eq!(
1632 session.messages.len(),
1633 1,
1634 "{tier} must preserve the transcript"
1635 );
1636 }
1637 }
1638
1639 #[tokio::test]
1640 async fn stale_control_plane_save_keeps_checkpointed_ledger_in_durable_and_cache() {
1641 let temp = tempfile::tempdir().unwrap();
1642 let storage: Arc<dyn Storage> = Arc::new(
1643 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
1644 .await
1645 .expect("SessionStoreV2"),
1646 );
1647 let repo = test_repo(storage.clone());
1648 let id = "control-plane-ledger-cache";
1649 let mut initial = Session::new(id, "model");
1650 initial.add_message(bamboo_agent_core::Message::user("durable transcript"));
1651 let assistant = bamboo_agent_core::Message::assistant("normalized", None);
1652 let anchor = assistant.id.clone();
1653 initial.add_message(assistant);
1654 let native_item = bamboo_domain::ProviderTranscriptItem::try_from_payload(
1655 bamboo_domain::ProviderFamily::OpenAi,
1656 bamboo_domain::ProviderProtocol::OpenAiResponsesV1,
1657 bamboo_domain::ProviderTranscriptOrigin::Provider,
1658 bamboo_domain::ProviderTranscriptAuthor::Model,
1659 serde_json::json!({
1660 "type":"tool_search_call","id":"tsc_cache_native","execution":"client","call_id":"cache_native",
1661 "status":"completed","arguments":{"query":"CACHE_NATIVE_PAYLOAD_SENTINEL"}
1662 }),
1663 )
1664 .unwrap();
1665 initial
1666 .append_provider_transcript_group(&anchor, None, vec![native_item])
1667 .unwrap();
1668 storage.save_session(&initial).await.unwrap();
1669 cache_put(&repo, &initial);
1670 let mut stale = initial.clone();
1671
1672 let mut runner = initial;
1673 runner.model_context_state = Some(bamboo_domain::ModelContextState {
1674 state_revision: 1,
1675 prefix_epoch: 1,
1676 cache_scope_sha256: Some("scope".to_string()),
1677 transcript_item_sha256: vec!["runner-l1".to_string()],
1678 ..bamboo_domain::ModelContextState::default()
1679 });
1680 bamboo_domain::RuntimeSessionPersistence::checkpoint_runtime_session(&repo, &mut runner)
1681 .await
1682 .unwrap();
1683
1684 stale
1685 .metadata
1686 .insert("runtime.suspend_reason".to_string(), "waiting".to_string());
1687 bamboo_domain::RuntimeSessionPersistence::save_runtime_control_plane(&repo, &mut stale)
1688 .await
1689 .unwrap();
1690
1691 let expected = runner.model_context_state;
1692 let expected_native = runner.provider_transcript;
1693 let durable = storage.load_session(id).await.unwrap().unwrap();
1694 let cached = read_cached_session(repo.cache(), id).expect("cached session");
1695 for (tier, session) in [("durable", durable), ("cache", cached.clone())] {
1696 assert_eq!(session.model_context_state, expected, "tier={tier}");
1697 assert_eq!(
1698 session.provider_transcript, expected_native,
1699 "tier={tier} must retain the message-anchored native transcript"
1700 );
1701 assert_eq!(
1702 session
1703 .metadata
1704 .get("runtime.suspend_reason")
1705 .map(String::as_str),
1706 Some("waiting"),
1707 "tier={tier}"
1708 );
1709 assert_eq!(session.messages.len(), 2, "tier={tier}");
1710 }
1711
1712 let runtime_json =
1713 std::fs::read_to_string(temp.path().join("sessions").join(id).join("runtime.json"))
1714 .unwrap();
1715 assert!(!runtime_json.contains("CACHE_NATIVE_PAYLOAD_SENTINEL"));
1716
1717 let mut cache_writer = cached;
1720 bamboo_domain::RuntimeSessionPersistence::checkpoint_runtime_session(
1721 &repo,
1722 &mut cache_writer,
1723 )
1724 .await
1725 .unwrap();
1726 let restarted = storage.load_session(id).await.unwrap().unwrap();
1727 assert_eq!(restarted.provider_transcript, expected_native);
1728 }
1729
1730 #[tokio::test]
1731 async fn narrow_runtime_metadata_transaction_preserves_live_and_durable_non_owned_state() {
1732 let storage: Arc<dyn Storage> = Arc::new(MapStorage::default());
1733 let repo = test_repo(storage.clone());
1734 let id = "narrow-metadata";
1735 let mut durable = Session::new(id, "durable-model");
1736 durable.add_message(bamboo_agent_core::Message::user("durable user turn"));
1737 durable
1738 .metadata
1739 .insert("external.durable".to_string(), "keep".to_string());
1740 storage.save_session(&durable).await.expect("seed durable");
1741
1742 let mut live = durable.clone();
1743 live.add_message(bamboo_agent_core::Message::assistant(
1744 "in-flight assistant tool call",
1745 None,
1746 ));
1747 live.model = "live-model".to_string();
1748 live.metadata
1749 .insert("external.live".to_string(), "keep".to_string());
1750 cache_put(&repo, &live);
1751
1752 repo.update_runtime_session(id, &["workflow.owned"], |latest| {
1753 latest
1754 .metadata
1755 .insert("workflow.owned".to_string(), "active".to_string());
1756 })
1757 .await
1758 .expect("transaction")
1759 .expect("session exists");
1760
1761 let saved = storage
1762 .load_session(id)
1763 .await
1764 .expect("load durable")
1765 .expect("durable exists");
1766 assert_eq!(
1767 saved.messages.len(),
1768 1,
1769 "transaction never writes stale live messages"
1770 );
1771 assert_eq!(
1772 saved.metadata.get("external.durable").map(String::as_str),
1773 Some("keep")
1774 );
1775 assert_eq!(
1776 saved.metadata.get("workflow.owned").map(String::as_str),
1777 Some("active")
1778 );
1779
1780 let cached = read_cached_session(repo.cache(), id).expect("live cache");
1781 assert_eq!(
1782 cached.messages.len(),
1783 2,
1784 "cache live tool call is not replaced"
1785 );
1786 assert_eq!(cached.model, "live-model");
1787 assert_eq!(
1788 cached.metadata.get("external.live").map(String::as_str),
1789 Some("keep")
1790 );
1791 assert_eq!(
1792 cached.metadata.get("workflow.owned").map(String::as_str),
1793 Some("active")
1794 );
1795 }
1796
1797 #[tokio::test]
1802 async fn load_merged_does_not_regress_to_older_storage() {
1803 let storage: Arc<dyn Storage> = Arc::new(MapStorage::default());
1804 let repo = test_repo(storage.clone());
1805 let id = "s1";
1806
1807 let mut stale = Session::new(id.to_string(), "m");
1808 stale.set_pending_question(
1809 "tc1".into(),
1810 "kind".into(),
1811 "q?".into(),
1812 vec!["OK".into()],
1813 true,
1814 );
1815 stale.updated_at = Utc::now() - chrono::Duration::seconds(10);
1816 storage.save_session(&stale).await.unwrap();
1817
1818 let mut fresh = Session::new(id.to_string(), "m");
1819 fresh.updated_at = Utc::now();
1820 cache_put(&repo, &fresh);
1821
1822 let merged = repo.load_merged(id).await.expect("session exists");
1823 assert!(
1824 merged.pending_question.is_none(),
1825 "must return the newer answered memory copy, not the stale storage one"
1826 );
1827 let cached = read_cached_session(repo.cache(), id).expect("cached");
1828 assert!(
1829 cached.pending_question.is_none(),
1830 "load_merged must never regress the cache to a stale storage copy"
1831 );
1832 }
1833
1834 #[tokio::test]
1838 async fn load_merged_recovers_pending_question_from_same_age_storage() {
1839 let storage: Arc<dyn Storage> = Arc::new(MapStorage::default());
1840 let repo = test_repo(storage.clone());
1841 let id = "s2";
1842 let ts = Utc::now();
1843
1844 let mut with_pending = Session::new(id.to_string(), "m");
1845 with_pending.set_pending_question(
1846 "tc".into(),
1847 "k".into(),
1848 "q".into(),
1849 vec!["OK".into()],
1850 true,
1851 );
1852 with_pending.updated_at = ts;
1853 storage.save_session(&with_pending).await.unwrap();
1854
1855 let mut lost = with_pending.clone();
1856 lost.clear_pending_question();
1857 lost.updated_at = ts;
1858 cache_put(&repo, &lost);
1859
1860 let merged = repo.load_merged(id).await.expect("session exists");
1861 assert!(
1862 merged.pending_question.is_some(),
1863 "same-age storage carrying a pending question must still be recovered"
1864 );
1865 }
1866
1867 #[tokio::test]
1868 async fn runtime_publish_refreshes_cache_even_when_storage_fails() {
1869 let id = "runtime-selection";
1870 let mut previous = Session::new(id.to_string(), "m");
1871 previous.metadata.insert(
1872 "skill_runtime_selected_skill_ids".to_string(),
1873 "[\"plan\"]".to_string(),
1874 );
1875 let storage: Arc<dyn Storage> = Arc::new(FailingSaveStorage {
1876 persisted: Mutex::new(Some(previous.clone())),
1877 });
1878 let repo = test_repo(storage.clone());
1879 cache_put(&repo, &previous);
1880
1881 let mut current = previous.clone();
1882 current.metadata.insert(
1883 "skill_runtime_selected_skill_ids".to_string(),
1884 "[\"review\"]".to_string(),
1885 );
1886 current.updated_at = Utc::now();
1887
1888 let result =
1889 bamboo_domain::RuntimeSessionPersistence::save_runtime_session(&repo, &mut current)
1890 .await;
1891 assert!(result.is_err(), "durable failure must still be surfaced");
1892
1893 let cached = repo.load(id).await.expect("cached current session");
1894 assert_eq!(
1895 cached
1896 .metadata
1897 .get("skill_runtime_selected_skill_ids")
1898 .map(String::as_str),
1899 Some("[\"review\"]")
1900 );
1901 let allowlist = bamboo_skills::access_control::extract_skill_allowlist(&cached.metadata)
1902 .expect("runtime authorization allowlist");
1903 assert!(allowlist.contains("review"));
1904 assert!(!allowlist.contains("plan"));
1905 let durable = storage
1906 .load_session(id)
1907 .await
1908 .expect("load durable state")
1909 .expect("previous durable session");
1910 assert_eq!(
1911 durable
1912 .metadata
1913 .get("skill_runtime_selected_skill_ids")
1914 .map(String::as_str),
1915 Some("[\"plan\"]")
1916 );
1917 }
1918
1919 #[tokio::test]
1920 async fn inherent_save_leaves_existing_cache_untouched_when_storage_fails() {
1921 let id = "inherent-save-failure";
1922 let previous = Session::new(id, "previous");
1923 let storage: Arc<dyn Storage> = Arc::new(FailingSaveStorage {
1924 persisted: Mutex::new(Some(previous.clone())),
1925 });
1926 let repo = test_repo(storage);
1927 cache_put(&repo, &previous);
1928
1929 let mut current = previous.clone();
1930 current.model = "current".to_string();
1931 assert!(repo.save(&mut current).await.is_err());
1932 assert_eq!(
1933 read_cached_session(repo.cache(), id)
1934 .expect("existing cache")
1935 .model,
1936 "previous",
1937 "fallible inherent save must publish only after a durable commit"
1938 );
1939 }
1940
1941 #[tokio::test]
1942 async fn save_and_cache_still_refreshes_cache_when_storage_fails() {
1943 let id = "save-and-cache-failure";
1944 let previous = Session::new(id, "previous");
1945 let storage: Arc<dyn Storage> = Arc::new(FailingSaveStorage {
1946 persisted: Mutex::new(Some(previous.clone())),
1947 });
1948 let repo = test_repo(storage);
1949 cache_put(&repo, &previous);
1950
1951 let mut current = previous;
1952 current.model = "current".to_string();
1953 repo.save_and_cache(&mut current).await;
1954 assert_eq!(
1955 read_cached_session(repo.cache(), id)
1956 .expect("refreshed cache")
1957 .model,
1958 "current",
1959 "fire-and-forget save must retain its existing cache-on-failure behavior"
1960 );
1961 }
1962
1963 #[tokio::test]
1964 async fn checkpoint_leaves_existing_cache_untouched_when_storage_fails() {
1965 let id = "checkpoint-failure";
1966 let previous = Session::new(id, "previous");
1967 let storage: Arc<dyn Storage> = Arc::new(FailingSaveStorage {
1968 persisted: Mutex::new(Some(previous.clone())),
1969 });
1970 let repo = test_repo(storage);
1971 cache_put(&repo, &previous);
1972
1973 let mut current = previous.clone();
1974 current.model = "current".to_string();
1975 let result = bamboo_domain::RuntimeSessionPersistence::checkpoint_runtime_session(
1976 &repo,
1977 &mut current,
1978 )
1979 .await;
1980 assert!(result.is_err());
1981 assert_eq!(
1982 read_cached_session(repo.cache(), id)
1983 .expect("existing cache")
1984 .model,
1985 "previous",
1986 "checkpoint must publish only after a durable commit"
1987 );
1988 }
1989
1990 #[tokio::test]
1991 async fn retrieval_window_checkpoint_publishes_the_exact_archived_snapshot() {
1992 let temp = tempfile::tempdir().unwrap();
1993 let storage: Arc<dyn Storage> = Arc::new(
1994 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
1995 .await
1996 .expect("SessionStoreV2"),
1997 );
1998 let repo = test_repo(storage.clone());
1999 let id = "retrieval-window-repository-checkpoint";
2000 let mut expected = Session::new(id, "model");
2001 expected.add_message(bamboo_agent_core::Message::user("archive candidate"));
2002 expected.add_message(bamboo_agent_core::Message::assistant("retain", None));
2003 storage.save_session(&expected).await.unwrap();
2004 cache_put(&repo, &expected);
2005 let mut staged = staged_retrieval_window_archive(&expected);
2006
2007 let outcome = bamboo_domain::RuntimeSessionPersistence::checkpoint_retrieval_window(
2008 &repo,
2009 &expected,
2010 &mut staged,
2011 )
2012 .await
2013 .unwrap();
2014
2015 assert_eq!(outcome, RetrievalWindowCheckpointOutcome::Committed);
2016 let durable = storage.load_session(id).await.unwrap().unwrap();
2017 let cached = read_cached_session(repo.cache(), id).expect("cached archived Session");
2018 for (tier, saved) in [("durable", durable), ("cache", cached)] {
2019 assert!(saved.messages[0].compressed, "tier={tier}");
2020 assert_eq!(
2021 saved.messages[0].compressed_by_event_id, staged.messages[0].compressed_by_event_id,
2022 "tier={tier}"
2023 );
2024 assert_eq!(
2025 serde_json::to_value(&saved).unwrap(),
2026 serde_json::to_value(&staged).unwrap(),
2027 "tier={tier}"
2028 );
2029 }
2030 }
2031
2032 #[tokio::test]
2033 async fn legacy_clear_uses_durable_cas_and_never_erases_concurrent_append_from_stale_cache() {
2034 let storage: Arc<dyn Storage> = Arc::new(MapStorage::default());
2035 let repo = test_repo(storage.clone());
2036 let id = "legacy-cas-race";
2037 let expected = vec![serde_json::json!({"content": "first"})];
2038
2039 let mut stale_cache = Session::new(id, "m");
2040 stale_cache.set_pending_injected_messages(expected.clone());
2041 cache_put(&repo, &stale_cache);
2042
2043 let mut durable = stale_cache.clone();
2044 durable.set_pending_injected_messages(vec![
2045 serde_json::json!({"content": "first"}),
2046 serde_json::json!({"content": "concurrent"}),
2047 ]);
2048 storage.save_session(&durable).await.unwrap();
2049
2050 let cleared = bamboo_domain::RuntimeSessionPersistence::clear_legacy_pending_messages(
2051 &repo, id, &expected,
2052 )
2053 .await
2054 .unwrap();
2055 assert!(!cleared, "the durable compare-and-clear must reject drift");
2056 assert_eq!(
2057 storage
2058 .load_session(id)
2059 .await
2060 .unwrap()
2061 .unwrap()
2062 .pending_injected_messages()
2063 .unwrap(),
2064 durable.pending_injected_messages().unwrap(),
2065 "the concurrent durable append must remain intact"
2066 );
2067 assert_eq!(
2068 read_cached_session(repo.cache(), id)
2069 .unwrap()
2070 .pending_injected_messages()
2071 .unwrap(),
2072 expected,
2073 "a failed CAS must not mutate the existing cache"
2074 );
2075 }
2076
2077 #[tokio::test]
2078 async fn successful_legacy_clear_refreshes_stale_cache_from_durable_state() {
2079 let storage: Arc<dyn Storage> = Arc::new(MapStorage::default());
2080 let repo = test_repo(storage.clone());
2081 let id = "legacy-cas-success";
2082 let expected = vec![serde_json::json!({"content": "first"})];
2083
2084 let mut stale_cache = Session::new(id, "stale-model");
2085 stale_cache.set_pending_injected_messages(expected.clone());
2086 cache_put(&repo, &stale_cache);
2087
2088 let mut durable = Session::new(id, "durable-model");
2089 durable.set_pending_injected_messages(expected.clone());
2090 durable
2091 .metadata
2092 .insert("durable-only".to_string(), "keep".to_string());
2093 storage.save_session(&durable).await.unwrap();
2094
2095 assert!(
2096 bamboo_domain::RuntimeSessionPersistence::clear_legacy_pending_messages(
2097 &repo, id, &expected,
2098 )
2099 .await
2100 .unwrap()
2101 );
2102 let cached = read_cached_session(repo.cache(), id).unwrap();
2103 assert!(!cached.has_pending_injected_messages());
2104 assert_eq!(cached.model, "durable-model");
2105 assert_eq!(
2106 cached.metadata.get("durable-only").map(String::as_str),
2107 Some("keep")
2108 );
2109 }
2110}