1use std::sync::Arc;
35
36use bamboo_domain::session::types::Session;
37use bamboo_domain::storage::Storage;
38use bamboo_domain::RuntimeSessionPersistence;
39use dashmap::DashMap;
40use tokio::sync::{Mutex, OwnedMutexGuard};
41
42const AUTHORITATIVE_METADATA_KEYS: &[&str] = &["gold_config", "workflow.run_ids.v1"];
43
44pub struct LockedSessionStore {
52 storage: Arc<dyn Storage>,
53 locks: Arc<DashMap<String, Arc<Mutex<()>>>>,
54}
55
56pub struct SessionLockGuard {
79 guard: Option<OwnedMutexGuard<()>>,
81 locks: Arc<DashMap<String, Arc<Mutex<()>>>>,
82 session_id: String,
83}
84
85impl Drop for SessionLockGuard {
86 fn drop(&mut self) {
87 self.guard.take();
90 self.locks
91 .remove_if(&self.session_id, |_, arc| Arc::strong_count(arc) == 1);
92 }
93}
94
95impl LockedSessionStore {
96 pub fn new(storage: Arc<dyn Storage>) -> Self {
98 Self {
99 storage,
100 locks: Arc::new(DashMap::new()),
101 }
102 }
103
104 pub fn storage(&self) -> &Arc<dyn Storage> {
106 &self.storage
107 }
108
109 pub async fn acquire_lock(&self, session_id: &str) -> SessionLockGuard {
121 let lock = self
126 .locks
127 .entry(session_id.to_string())
128 .or_insert_with(|| Arc::new(Mutex::new(())))
129 .clone();
130 let guard = lock.lock_owned().await;
131 SessionLockGuard {
132 guard: Some(guard),
133 locks: self.locks.clone(),
134 session_id: session_id.to_string(),
135 }
136 }
137
138 pub async fn save_runtime_only(&self, session: &mut Session) -> std::io::Result<()> {
154 let _guard = self.acquire_lock(&session.id).await;
155 if let Ok(Some(latest)) = self.storage.load_runtime_control_plane(&session.id).await {
156 apply_authoritative_metadata(session, &latest);
157 adopt_disk_bypass_permissions(session, &latest);
160 }
161 self.storage.save_runtime_state(session).await
162 }
163
164 pub async fn commit_metadata(&self, session: &Session) -> std::io::Result<()> {
174 let _guard = self.acquire_lock(&session.id).await;
175 self.storage.save_session(session).await
176 }
177
178 pub async fn merge_save_runtime(&self, session: &mut Session) -> std::io::Result<()> {
195 self.merge_save_runtime_inner(session, true).await
196 }
197
198 pub async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
207 let _guard = self.acquire_lock(&session.id).await;
208 let latest = self.storage.load_session(&session.id).await?;
209
210 if let Some(latest) = latest.as_ref() {
211 let incoming_count = session.messages.len();
212 let durable_count = latest.messages.len();
213 let appended = bamboo_domain::append_missing_runtime_messages(session, latest);
214 tracing::debug!(
215 "[{}] append-safe runtime checkpoint: durable={}, incoming={}, appended={}, saved={}",
216 session.id,
217 durable_count,
218 incoming_count,
219 appended,
220 session.messages.len(),
221 );
222 apply_authoritative_metadata(session, latest);
223 adopt_disk_bypass_permissions(session, latest);
224 }
225
226 self.storage.save_session(session).await
227 }
228
229 pub async fn save_runtime_authoritative_flags(
238 &self,
239 session: &mut Session,
240 ) -> std::io::Result<()> {
241 self.merge_save_runtime_inner(session, false).await
242 }
243
244 async fn merge_save_runtime_inner(
245 &self,
246 session: &mut Session,
247 adopt_bypass: bool,
248 ) -> std::io::Result<()> {
249 let _guard = self.acquire_lock(&session.id).await;
250
251 let latest = self.storage.load_session(&session.id).await.ok().flatten();
258
259 let existing_message_count = latest.as_ref().map(|s| s.messages.len());
265 let incoming_message_count = session.messages.len();
266 if existing_message_count.is_some_and(|existing| existing > incoming_message_count) {
267 tracing::warn!(
268 "[{}] merge_save_runtime SHRINK: disk has {:?} messages, saving {} (last_role={:?}, updated_at={}); a stale writer is reverting a concurrent append",
269 session.id,
270 existing_message_count,
271 incoming_message_count,
272 session.messages.last().map(|m| format!("{:?}", m.role)),
273 session.updated_at,
274 );
275 } else {
276 tracing::debug!(
277 "[{}] merge_save_runtime: disk={:?} messages, saving {} (updated_at={})",
278 session.id,
279 existing_message_count,
280 incoming_message_count,
281 session.updated_at,
282 );
283 }
284
285 if let Some(latest) = latest.as_ref() {
286 apply_authoritative_metadata(session, latest);
287 if adopt_bypass {
291 adopt_disk_bypass_permissions(session, latest);
292 }
293 }
294 self.storage.save_session(session).await
295 }
296
297 pub async fn update_runtime_config<F>(
309 &self,
310 session_id: &str,
311 mutate: F,
312 ) -> std::io::Result<Option<Session>>
313 where
314 F: FnOnce(&mut Session),
315 {
316 let _guard = self.acquire_lock(session_id).await;
317 let Some(mut session) = self.storage.load_session(session_id).await? else {
318 return Ok(None);
319 };
320 mutate(&mut session);
321 self.storage.save_session(&session).await?;
322 Ok(Some(session))
323 }
324}
325
326#[async_trait::async_trait]
330impl RuntimeSessionPersistence for LockedSessionStore {
331 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
332 self.merge_save_runtime(session).await
333 }
334
335 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
336 LockedSessionStore::checkpoint_runtime_session(self, session).await
337 }
338
339 async fn load_runtime_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
340 self.storage.load_session(session_id).await
341 }
342}
343
344async fn merge_authoritative_metadata_into_stale(
353 storage: &Arc<dyn Storage>,
354 session: &mut Session,
355) {
356 if let Ok(Some(latest)) = storage.load_session(&session.id).await {
357 apply_authoritative_metadata(session, &latest);
358 adopt_disk_bypass_permissions(session, &latest);
359 }
360}
361
362fn adopt_disk_bypass_permissions(session: &mut Session, latest: &Session) {
372 let Some(disk_bypass) = latest
377 .agent_runtime_state
378 .as_ref()
379 .map(|state| state.bypass_permissions)
380 else {
381 return;
382 };
383 match session.agent_runtime_state.as_mut() {
384 Some(state) => state.bypass_permissions = disk_bypass,
385 None if disk_bypass => {
388 session
389 .agent_runtime_state
390 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
391 .bypass_permissions = true;
392 }
393 None => {}
394 }
395}
396
397fn apply_authoritative_metadata(session: &mut Session, latest: &Session) {
403 if latest.metadata_version >= session.metadata_version {
404 session.title = latest.title.clone();
405 session.title_version = latest.title_version;
406 session.pinned = latest.pinned;
407 for key in AUTHORITATIVE_METADATA_KEYS {
408 if let Some(value) = latest.metadata.get(*key) {
409 session.metadata.insert((*key).to_string(), value.clone());
410 } else {
411 session.metadata.remove(*key);
412 }
413 }
414 session.metadata_version = latest.metadata_version;
415 }
416}
417
418pub async fn merge_save_session(
431 storage: &Arc<dyn Storage>,
432 session: &mut Session,
433) -> std::io::Result<()> {
434 merge_authoritative_metadata_into_stale(storage, session).await;
435 storage.save_session(session).await
436}
437
438#[cfg(test)]
441mod tests {
442 use super::*;
443 use crate::v2::SessionStoreV2;
444 use bamboo_domain::session::types::Session;
445
446 async fn make_storage() -> (tempfile::TempDir, Arc<dyn Storage>) {
447 let temp = tempfile::tempdir().unwrap();
448 let storage = SessionStoreV2::new(temp.path().to_path_buf())
449 .await
450 .expect("storage init");
451 (temp, Arc::new(storage) as Arc<dyn Storage>)
452 }
453
454 fn fresh(id: &str) -> Session {
455 Session::new(id.to_string(), "test-model".to_string())
456 }
457
458 #[tokio::test]
461 async fn update_runtime_config_preserves_concurrently_appended_messages() {
462 use bamboo_domain::session::types::Message;
463 use bamboo_domain::ReasoningEffort;
464
465 let (_temp, storage) = make_storage().await;
466 let store = LockedSessionStore::new(storage.clone());
467 let session_id = "cfg-preserve";
468
469 let mut initial = fresh(session_id);
471 initial.add_message(Message::user("hello"));
472 initial.add_message(Message::assistant("hi", None));
473 storage.save_session(&initial).await.unwrap();
474
475 let mut after_chat = storage.load_session(session_id).await.unwrap().unwrap();
477 after_chat.add_message(Message::user("second question"));
478 storage.save_session(&after_chat).await.unwrap();
479 assert_eq!(after_chat.messages.len(), 3);
480
481 let updated = store
485 .update_runtime_config(session_id, |s| {
486 s.reasoning_effort = Some(ReasoningEffort::Max);
487 })
488 .await
489 .unwrap()
490 .expect("session exists");
491
492 assert_eq!(updated.reasoning_effort, Some(ReasoningEffort::Max));
493 assert_eq!(
494 updated.messages.len(),
495 3,
496 "config patch must not revert a concurrently-appended message"
497 );
498
499 let on_disk = storage.load_session(session_id).await.unwrap().unwrap();
500 assert_eq!(on_disk.messages.len(), 3);
501 assert_eq!(on_disk.reasoning_effort, Some(ReasoningEffort::Max));
502 }
503
504 #[tokio::test]
505 async fn update_runtime_config_returns_none_for_missing_session() {
506 use bamboo_domain::ReasoningEffort;
507
508 let (_temp, storage) = make_storage().await;
509 let store = LockedSessionStore::new(storage);
510 let result = store
511 .update_runtime_config("does-not-exist", |s| {
512 s.reasoning_effort = Some(ReasoningEffort::Low);
513 })
514 .await
515 .unwrap();
516 assert!(result.is_none());
517 }
518
519 #[tokio::test]
520 async fn merge_save_runtime_overwrites_messages_from_stale_snapshot() {
521 use bamboo_domain::session::types::Message;
526
527 let (_temp, storage) = make_storage().await;
528 let store = LockedSessionStore::new(storage.clone());
529 let session_id = "stale-clobber";
530
531 let mut baseline = fresh(session_id);
533 baseline.add_message(Message::user("hello"));
534 storage.save_session(&baseline).await.unwrap();
535 let mut stale_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
536
537 let mut after_chat = storage.load_session(session_id).await.unwrap().unwrap();
539 after_chat.add_message(Message::user("second"));
540 storage.save_session(&after_chat).await.unwrap();
541 assert_eq!(
542 storage
543 .load_session(session_id)
544 .await
545 .unwrap()
546 .unwrap()
547 .messages
548 .len(),
549 2
550 );
551
552 store.merge_save_runtime(&mut stale_snapshot).await.unwrap();
554 let after = storage.load_session(session_id).await.unwrap().unwrap();
555 assert_eq!(
556 after.messages.len(),
557 1,
558 "merge_save_runtime clobbers concurrent appends — this is why config patches must use update_runtime_config"
559 );
560 }
561
562 #[tokio::test]
563 async fn checkpoint_runtime_session_preserves_disk_suffix_and_appends_live_messages() {
564 use bamboo_domain::session::types::Message;
565
566 let (_temp, storage) = make_storage().await;
567 let store = LockedSessionStore::new(storage.clone());
568 let session_id = "checkpoint-no-shrink";
569
570 let mut baseline = fresh(session_id);
571 baseline.add_message(Message::user("base"));
572 storage.save_session(&baseline).await.unwrap();
573 let mut runner_snapshot = baseline.clone();
574
575 let mut durable = baseline;
576 let mut disk_only = Message::user("concurrent injected message");
577 disk_only.id = "disk-only".to_string();
578 durable.add_message(disk_only);
579 storage.save_session(&durable).await.unwrap();
580
581 let mut live_only = Message::assistant("partial runner output", None);
582 live_only.id = "live-only".to_string();
583 runner_snapshot.add_message(live_only);
584
585 store
586 .checkpoint_runtime_session(&mut runner_snapshot)
587 .await
588 .unwrap();
589
590 let saved = storage.load_session(session_id).await.unwrap().unwrap();
591 let ids = saved
592 .messages
593 .iter()
594 .map(|message| message.id.as_str())
595 .collect::<Vec<_>>();
596 assert_eq!(
597 ids,
598 vec![durable.messages[0].id.as_str(), "disk-only", "live-only"]
599 );
600 assert_eq!(runner_snapshot.messages.len(), saved.messages.len());
601 assert_eq!(runner_snapshot.messages[1].id, saved.messages[1].id);
602 assert_eq!(runner_snapshot.messages[2].id, saved.messages[2].id);
603 assert_eq!(saved.messages[1].content, "concurrent injected message");
604 assert_eq!(saved.messages[2].content, "partial runner output");
605 }
606
607 #[tokio::test]
608 async fn merge_save_runtime_preserves_disk_authoritative_metadata_with_single_load() {
609 let (_temp, storage) = make_storage().await;
615 let store = LockedSessionStore::new(storage.clone());
616 let session_id = "runtime-merge-meta";
617
618 let mut baseline = fresh(session_id);
620 baseline.title = "Auto Title".to_string();
621 baseline.metadata_version = 0;
622 storage.save_session(&baseline).await.unwrap();
623
624 let mut stale_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
626
627 let mut renamed = storage.load_session(session_id).await.unwrap().unwrap();
629 renamed.title = "User Renamed".to_string();
630 renamed.title_version = 1;
631 renamed.pinned = true;
632 renamed.metadata_version = 1;
633 store.commit_metadata(&renamed).await.unwrap();
634
635 stale_snapshot.title = "Auto Title".to_string();
637 store.merge_save_runtime(&mut stale_snapshot).await.unwrap();
638
639 let after = storage.load_session(session_id).await.unwrap().unwrap();
640 assert_eq!(after.title, "User Renamed");
641 assert!(after.pinned);
642 assert_eq!(after.metadata_version, 1);
643 assert_eq!(stale_snapshot.title, "User Renamed");
645 assert_eq!(stale_snapshot.metadata_version, 1);
646 }
647
648 #[tokio::test]
649 async fn merge_save_runtime_preserves_durable_workflow_run_index_from_stale_runner() {
650 let (_temp, storage) = make_storage().await;
651 let store = LockedSessionStore::new(storage.clone());
652 let session_id = "runtime-workflow-run-index";
653
654 let baseline = fresh(session_id);
655 storage.save_session(&baseline).await.unwrap();
656 let mut stale_runner = storage.load_session(session_id).await.unwrap().unwrap();
657
658 store
659 .update_runtime_config(session_id, |session| {
660 session.metadata.insert(
661 "workflow.run_ids.v1".to_string(),
662 r#"["http-started-run"]"#.to_string(),
663 );
664 })
665 .await
666 .unwrap()
667 .expect("session exists");
668
669 store.merge_save_runtime(&mut stale_runner).await.unwrap();
670
671 assert_eq!(
672 stale_runner
673 .metadata
674 .get("workflow.run_ids.v1")
675 .map(String::as_str),
676 Some(r#"["http-started-run"]"#)
677 );
678 let durable = storage.load_session(session_id).await.unwrap().unwrap();
679 assert_eq!(
680 durable
681 .metadata
682 .get("workflow.run_ids.v1")
683 .map(String::as_str),
684 Some(r#"["http-started-run"]"#)
685 );
686 }
687
688 #[tokio::test]
692 async fn merge_save_runtime_adopts_disk_bypass_permissions() {
693 use bamboo_domain::AgentRuntimeState;
694
695 let (_temp, storage) = make_storage().await;
696 let store = LockedSessionStore::new(storage.clone());
697 let session_id = "runtime-bypass";
698
699 let baseline = fresh(session_id);
701 storage.save_session(&baseline).await.unwrap();
702
703 let mut loop_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
705 loop_snapshot.agent_runtime_state = Some(AgentRuntimeState::default());
706
707 store
709 .update_runtime_config(session_id, |s| {
710 s.agent_runtime_state
711 .get_or_insert_with(AgentRuntimeState::default)
712 .bypass_permissions = true;
713 })
714 .await
715 .unwrap()
716 .expect("session exists");
717
718 store.merge_save_runtime(&mut loop_snapshot).await.unwrap();
721
722 let after = storage.load_session(session_id).await.unwrap().unwrap();
723 assert!(
724 after
725 .agent_runtime_state
726 .as_ref()
727 .is_some_and(|s| s.bypass_permissions),
728 "disk bypass=ON must survive a stale runtime save (#540)"
729 );
730 assert!(loop_snapshot
732 .agent_runtime_state
733 .as_ref()
734 .is_some_and(|s| s.bypass_permissions));
735 }
736
737 #[tokio::test]
740 async fn merge_save_runtime_adopts_disk_bypass_off() {
741 use bamboo_domain::AgentRuntimeState;
742
743 let (_temp, storage) = make_storage().await;
744 let store = LockedSessionStore::new(storage.clone());
745 let session_id = "runtime-bypass-off";
746
747 let mut baseline = fresh(session_id);
749 let mut on_state = AgentRuntimeState::default();
750 on_state.bypass_permissions = true;
751 baseline.agent_runtime_state = Some(on_state);
752 storage.save_session(&baseline).await.unwrap();
753
754 let mut loop_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
756
757 store
759 .update_runtime_config(session_id, |s| {
760 s.agent_runtime_state
761 .get_or_insert_with(AgentRuntimeState::default)
762 .bypass_permissions = false;
763 })
764 .await
765 .unwrap()
766 .expect("session exists");
767
768 store.merge_save_runtime(&mut loop_snapshot).await.unwrap();
769
770 let after = storage.load_session(session_id).await.unwrap().unwrap();
771 assert!(
772 !after
773 .agent_runtime_state
774 .as_ref()
775 .is_some_and(|s| s.bypass_permissions),
776 "disk bypass=OFF must survive a stale runtime save (#540)"
777 );
778 }
779
780 #[tokio::test]
783 async fn save_runtime_authoritative_flags_persists_in_memory_bypass() {
784 use bamboo_domain::AgentRuntimeState;
785
786 let (_temp, storage) = make_storage().await;
787 let store = LockedSessionStore::new(storage.clone());
788 let session_id = "child-reseed";
789
790 let mut baseline = fresh(session_id);
792 let mut on_state = AgentRuntimeState::default();
793 on_state.bypass_permissions = true;
794 baseline.agent_runtime_state = Some(on_state);
795 storage.save_session(&baseline).await.unwrap();
796
797 let mut child = storage.load_session(session_id).await.unwrap().unwrap();
800 child
801 .agent_runtime_state
802 .get_or_insert_with(AgentRuntimeState::default)
803 .bypass_permissions = false;
804
805 store
807 .save_runtime_authoritative_flags(&mut child)
808 .await
809 .unwrap();
810
811 let after = storage.load_session(session_id).await.unwrap().unwrap();
812 assert!(
813 !after
814 .agent_runtime_state
815 .as_ref()
816 .is_some_and(|s| s.bypass_permissions),
817 "authoritative re-seed of bypass=OFF must persist, not be reverted (#540/#74)"
818 );
819 }
820
821 #[tokio::test]
823 async fn merge_save_runtime_leaves_bypass_when_disk_has_no_runtime_state() {
824 use bamboo_domain::AgentRuntimeState;
825
826 let (_temp, storage) = make_storage().await;
827 let store = LockedSessionStore::new(storage.clone());
828 let session_id = "no-runtime-state";
829
830 let baseline = fresh(session_id);
832 assert!(baseline.agent_runtime_state.is_none());
833 storage.save_session(&baseline).await.unwrap();
834
835 let mut running = storage.load_session(session_id).await.unwrap().unwrap();
837 let mut on_state = AgentRuntimeState::default();
838 on_state.bypass_permissions = true;
839 running.agent_runtime_state = Some(on_state);
840
841 store.merge_save_runtime(&mut running).await.unwrap();
842
843 assert!(
844 running
845 .agent_runtime_state
846 .as_ref()
847 .is_some_and(|s| s.bypass_permissions),
848 "a runtime-state-less disk copy must not force bypass OFF (#540)"
849 );
850 }
851
852 #[tokio::test]
855 async fn merge_preserves_disk_title_when_versions_equal() {
856 let (_temp, storage) = make_storage().await;
857 let session_id = "merge-equal";
858
859 let mut on_disk = fresh(session_id);
860 on_disk.title = "User Set This".to_string();
861 on_disk.title_version = 0;
862 on_disk.metadata_version = 0;
863 storage.save_session(&on_disk).await.unwrap();
864
865 let mut runtime_copy = fresh(session_id);
866 runtime_copy.title = "Stale Default".to_string();
867 runtime_copy.title_version = 0;
868 runtime_copy.metadata_version = 0;
869 runtime_copy.messages = vec![];
870
871 merge_save_session(&storage, &mut runtime_copy)
872 .await
873 .unwrap();
874
875 let after = storage.load_session(session_id).await.unwrap().unwrap();
876 assert_eq!(after.title, "User Set This");
877 assert_eq!(after.title_version, 0);
878 assert_eq!(runtime_copy.title, "User Set This");
879 }
880
881 #[tokio::test]
882 async fn merge_preserves_disk_when_disk_version_higher() {
883 let (_temp, storage) = make_storage().await;
884 let session_id = "merge-higher";
885
886 let mut on_disk = fresh(session_id);
887 on_disk.title = "User Title v3".to_string();
888 on_disk.title_version = 3;
889 on_disk.metadata_version = 5;
890 storage.save_session(&on_disk).await.unwrap();
891
892 let mut runtime_copy = fresh(session_id);
893 runtime_copy.title = "Stale".to_string();
894 runtime_copy.title_version = 1;
895 runtime_copy.metadata_version = 0;
896
897 merge_save_session(&storage, &mut runtime_copy)
898 .await
899 .unwrap();
900
901 let after = storage.load_session(session_id).await.unwrap().unwrap();
902 assert_eq!(after.title, "User Title v3");
903 assert_eq!(after.title_version, 3);
904 assert_eq!(after.metadata_version, 5);
905 }
906
907 #[tokio::test]
908 async fn merge_now_preserves_disk_pinned_in_metadata_group() {
909 let (_temp, storage) = make_storage().await;
910 let session_id = "pinned-merge";
911
912 let mut on_disk = fresh(session_id);
913 on_disk.pinned = true;
914 on_disk.metadata_version = 2;
915 storage.save_session(&on_disk).await.unwrap();
916
917 let mut runtime_copy = fresh(session_id);
918 runtime_copy.pinned = false;
919 runtime_copy.metadata_version = 0;
920
921 merge_save_session(&storage, &mut runtime_copy)
922 .await
923 .unwrap();
924
925 let after = storage.load_session(session_id).await.unwrap().unwrap();
926 assert!(
927 after.pinned,
928 "disk pinned=true should win over runtime false"
929 );
930 assert_eq!(after.metadata_version, 2);
931 }
932
933 #[tokio::test]
934 async fn merge_keeps_in_memory_when_session_version_higher() {
935 let (_temp, storage) = make_storage().await;
936 let session_id = "merge-bumped";
937
938 let mut on_disk = fresh(session_id);
939 on_disk.title = "Old".to_string();
940 on_disk.title_version = 1;
941 on_disk.metadata_version = 3;
942 storage.save_session(&on_disk).await.unwrap();
943
944 let mut authoritative_copy = fresh(session_id);
945 authoritative_copy.title = "New Authoritative".to_string();
946 authoritative_copy.title_version = 2;
947 authoritative_copy.metadata_version = 4;
948 authoritative_copy.pinned = true;
949
950 merge_save_session(&storage, &mut authoritative_copy)
951 .await
952 .unwrap();
953
954 let after = storage.load_session(session_id).await.unwrap().unwrap();
955 assert_eq!(after.title, "New Authoritative");
956 assert_eq!(after.title_version, 2);
957 assert_eq!(after.metadata_version, 4);
958 assert!(after.pinned);
959 }
960
961 #[tokio::test]
962 async fn merge_keeps_runtime_messages_when_disk_only_changed_metadata() {
963 let (_temp, storage) = make_storage().await;
964 let session_id = "merge-messages";
965
966 let mut on_disk = fresh(session_id);
967 on_disk.title = "Fresh Title".to_string();
968 on_disk.title_version = 2;
969 on_disk.metadata_version = 5;
970 storage.save_session(&on_disk).await.unwrap();
971
972 let mut runtime_copy = fresh(session_id);
973 runtime_copy.title = "Stale".to_string();
974 runtime_copy.metadata_version = 0;
975 runtime_copy.messages = vec![bamboo_domain::session::types::Message {
976 role: bamboo_domain::session::types::Role::User,
977 content: "keep me".to_string(),
978 id: "msg-1".to_string(),
979 created_at: chrono::Utc::now(),
980 reasoning: None,
981 reasoning_signature: None,
982 content_parts: None,
983 image_ocr: None,
984 phase: None,
985 tool_calls: None,
986 tool_call_id: None,
987 tool_success: None,
988 compressed: false,
989 compressed_by_event_id: None,
990 never_compress: false,
991 compression_level: 0,
992 metadata: None,
993 }];
994
995 merge_save_session(&storage, &mut runtime_copy)
996 .await
997 .unwrap();
998
999 let after = storage.load_session(session_id).await.unwrap().unwrap();
1000 assert_eq!(after.title, "Fresh Title");
1001 assert_eq!(after.metadata_version, 5);
1002 assert_eq!(after.messages.len(), 1);
1003 assert_eq!(after.messages[0].content, "keep me");
1004 }
1005
1006 #[tokio::test]
1009 async fn locked_merge_save_runtime_serialises_concurrent_writes() {
1010 let (_temp, storage) = make_storage().await;
1011 let store = Arc::new(LockedSessionStore::new(storage));
1012 let session_id = "lock-serial".to_string();
1013
1014 let base = fresh(&session_id);
1016 store.storage().save_session(&base).await.unwrap();
1017
1018 let store_a = store.clone();
1021 let store_b = store.clone();
1022 let sid_a = session_id.clone();
1023 let sid_b = session_id.clone();
1024
1025 let a = tokio::spawn(async move {
1026 let _guard = store_a.acquire_lock(&sid_a).await;
1027 let mut s = store_a
1028 .storage()
1029 .load_session(&sid_a)
1030 .await
1031 .unwrap()
1032 .unwrap();
1033 s.title = "Writer A".to_string();
1034 s.title_version = s.title_version.saturating_add(1);
1035 s.metadata_version = s.metadata_version.saturating_add(1);
1036 s.updated_at = chrono::Utc::now();
1037 store_a.storage().save_session(&s).await.unwrap();
1038 s.title_version
1039 });
1040
1041 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1043
1044 let b = tokio::spawn(async move {
1045 let _guard = store_b.acquire_lock(&sid_b).await;
1046 let mut s = store_b
1047 .storage()
1048 .load_session(&sid_b)
1049 .await
1050 .unwrap()
1051 .unwrap();
1052 s.title = "Writer B".to_string();
1053 s.title_version = s.title_version.saturating_add(1);
1054 s.metadata_version = s.metadata_version.saturating_add(1);
1055 s.updated_at = chrono::Utc::now();
1056 store_b.storage().save_session(&s).await.unwrap();
1057 s.title_version
1058 });
1059
1060 let (ver_a, ver_b) = tokio::join!(a, b);
1061 let final_s = store
1062 .storage()
1063 .load_session(&session_id)
1064 .await
1065 .unwrap()
1066 .unwrap();
1067 assert!(
1068 ver_a.unwrap() != ver_b.unwrap(),
1069 "concurrent writers must produce distinct versions"
1070 );
1071 assert_eq!(final_s.metadata_version, 2);
1072 }
1073
1074 #[tokio::test]
1075 async fn commit_metadata_is_plain_save_inside_lock() {
1076 let (_temp, storage) = make_storage().await;
1077 let store = LockedSessionStore::new(storage);
1078 let session_id = "commit-plain";
1079
1080 let mut s = fresh(session_id);
1081 s.title = "Committed".to_string();
1082 s.metadata_version = 1;
1083 s.title_version = 2;
1084
1085 store.commit_metadata(&s).await.unwrap();
1086
1087 let after = store
1088 .storage()
1089 .load_session(session_id)
1090 .await
1091 .unwrap()
1092 .unwrap();
1093 assert_eq!(after.title, "Committed");
1094 assert_eq!(after.metadata_version, 1);
1095 assert_eq!(after.title_version, 2);
1096 }
1097
1098 #[tokio::test]
1101 async fn acquire_lock_self_evicts_when_no_other_holder() {
1102 let (_temp, storage) = make_storage().await;
1103 let store = LockedSessionStore::new(storage);
1104
1105 {
1106 let _guard = store.acquire_lock("solo").await;
1107 assert_eq!(store.locks.len(), 1, "entry present while the lock is held");
1108 }
1109 assert_eq!(
1112 store.locks.len(),
1113 0,
1114 "lock entry must be evicted once released with no other holder"
1115 );
1116 }
1117
1118 #[tokio::test]
1119 async fn acquire_lock_many_distinct_ids_do_not_accumulate() {
1120 let (_temp, storage) = make_storage().await;
1121 let store = LockedSessionStore::new(storage);
1122
1123 for i in 0..100 {
1125 let _guard = store.acquire_lock(&format!("sess-{i}")).await;
1126 }
1127 assert_eq!(
1128 store.locks.len(),
1129 0,
1130 "acquiring locks for many distinct ids must not grow the map"
1131 );
1132 }
1133
1134 #[tokio::test]
1135 async fn acquire_lock_concurrent_waiter_keeps_valid_lock_and_map_drains() {
1136 use std::sync::atomic::{AtomicUsize, Ordering};
1137
1138 let (_temp, storage) = make_storage().await;
1139 let store = Arc::new(LockedSessionStore::new(storage));
1140
1141 let active = Arc::new(AtomicUsize::new(0));
1143 let max_seen = Arc::new(AtomicUsize::new(0));
1144
1145 let mut handles = Vec::new();
1146 for _ in 0..8 {
1147 let store = store.clone();
1148 let active = active.clone();
1149 let max_seen = max_seen.clone();
1150 handles.push(tokio::spawn(async move {
1151 let _guard = store.acquire_lock("contended").await;
1152 let now = active.fetch_add(1, Ordering::SeqCst) + 1;
1153 max_seen.fetch_max(now, Ordering::SeqCst);
1154 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1156 active.fetch_sub(1, Ordering::SeqCst);
1157 }));
1158 }
1159 for h in handles {
1160 h.await.unwrap();
1161 }
1162
1163 assert_eq!(
1168 max_seen.load(Ordering::SeqCst),
1169 1,
1170 "at most one holder of a given session lock at a time"
1171 );
1172 assert_eq!(
1173 store.locks.len(),
1174 0,
1175 "after all holders release, the contended entry must be fully evicted"
1176 );
1177 }
1178}