1use std::sync::Arc;
35
36use bamboo_domain::session::types::Session;
37use bamboo_domain::storage::Storage;
38use bamboo_domain::{PermissionAuditSeed, PermissionAuditSnapshot, 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 self.save_runtime_only_and_publish(session, |_| {}).await
155 }
156
157 pub async fn save_runtime_only_and_publish<F>(
167 &self,
168 session: &mut Session,
169 publish: F,
170 ) -> std::io::Result<()>
171 where
172 F: FnOnce(&Session) + Send,
173 {
174 let _guard = self.acquire_lock(&session.id).await;
175 if let Ok(Some(latest)) = self.storage.load_runtime_control_plane(&session.id).await {
176 apply_authoritative_metadata(session, &latest);
177 adopt_fresher_disk_permission_posture(session, &latest);
180 }
181 let result = self.storage.save_runtime_state(session).await;
182 publish(session);
183 result
184 }
185
186 pub async fn update_task_list_control_plane_and_publish<F>(
193 &self,
194 session_id: &str,
195 task_list: &bamboo_domain::TaskList,
196 version: &str,
197 publish: F,
198 ) -> std::io::Result<bool>
199 where
200 F: FnOnce(&Session) + Send,
201 {
202 let _guard = self.acquire_lock(session_id).await;
203 let Some(mut latest) = self.storage.load_runtime_control_plane(session_id).await? else {
204 return Ok(false);
205 };
206 latest.set_task_list(task_list.clone());
207 latest.set_task_list_version_meta(version.to_string());
208 self.storage.save_runtime_state(&latest).await?;
209 publish(&latest);
210 Ok(true)
211 }
212
213 pub async fn commit_metadata(&self, session: &Session) -> std::io::Result<()> {
223 let _guard = self.acquire_lock(&session.id).await;
224 self.storage.save_session(session).await
225 }
226
227 pub async fn merge_save_runtime(&self, session: &mut Session) -> std::io::Result<()> {
244 self.merge_save_runtime_and_publish(session, |_, _| {})
245 .await
246 }
247
248 pub async fn merge_save_runtime_and_publish<F>(
255 &self,
256 session: &mut Session,
257 publish: F,
258 ) -> std::io::Result<()>
259 where
260 F: FnOnce(&Session, bool) + Send,
261 {
262 self.merge_save_runtime_inner_and_publish(session, true, publish)
263 .await
264 }
265
266 pub async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
275 self.checkpoint_runtime_session_and_publish(session, |_, _| {})
276 .await
277 }
278
279 pub async fn checkpoint_runtime_session_and_publish<F>(
286 &self,
287 session: &mut Session,
288 publish: F,
289 ) -> std::io::Result<()>
290 where
291 F: FnOnce(&Session, bool) + Send,
292 {
293 let _guard = self.acquire_lock(&session.id).await;
294 let latest = self.storage.load_session(&session.id).await?;
295
296 if let Some(latest) = latest.as_ref() {
297 let incoming_count = session.messages.len();
298 let durable_count = latest.messages.len();
299 let appended = bamboo_domain::append_missing_runtime_messages(session, latest);
300 bamboo_domain::merge_session_inbox_admission(session, latest);
301 tracing::debug!(
302 "[{}] append-safe runtime checkpoint: durable={}, incoming={}, appended={}, saved={}",
303 session.id,
304 durable_count,
305 incoming_count,
306 appended,
307 session.messages.len(),
308 );
309 apply_authoritative_metadata(session, latest);
310 adopt_fresher_disk_permission_posture(session, latest);
311 }
312
313 let result = self.storage.save_session(session).await;
314 publish(session, result.is_ok());
315 result
316 }
317
318 pub async fn save_runtime_authoritative_flags(
327 &self,
328 session: &mut Session,
329 ) -> std::io::Result<()> {
330 self.merge_save_runtime_inner_and_publish(session, false, |_, _| {})
331 .await
332 }
333
334 async fn merge_save_runtime_inner_and_publish<F>(
335 &self,
336 session: &mut Session,
337 adopt_bypass: bool,
338 publish: F,
339 ) -> std::io::Result<()>
340 where
341 F: FnOnce(&Session, bool) + Send,
342 {
343 let _guard = self.acquire_lock(&session.id).await;
344
345 let latest = self.storage.load_session(&session.id).await.ok().flatten();
352
353 let existing_message_count = latest.as_ref().map(|s| s.messages.len());
359 let incoming_message_count = session.messages.len();
360 if existing_message_count.is_some_and(|existing| existing > incoming_message_count) {
361 tracing::warn!(
362 "[{}] merge_save_runtime SHRINK: disk has {:?} messages, saving {} (last_role={:?}, updated_at={}); a stale writer is reverting a concurrent append",
363 session.id,
364 existing_message_count,
365 incoming_message_count,
366 session.messages.last().map(|m| format!("{:?}", m.role)),
367 session.updated_at,
368 );
369 } else {
370 tracing::debug!(
371 "[{}] merge_save_runtime: disk={:?} messages, saving {} (updated_at={})",
372 session.id,
373 existing_message_count,
374 incoming_message_count,
375 session.updated_at,
376 );
377 }
378
379 if let Some(latest) = latest.as_ref() {
380 apply_authoritative_metadata(session, latest);
381 let restored = bamboo_domain::restore_missing_admitted_inbox_messages(session, latest);
382 if restored > 0 {
383 tracing::warn!(
384 session_id = %session.id,
385 restored,
386 "restored durable SessionInbox transcript messages into stale runtime save"
387 );
388 }
389 bamboo_domain::merge_session_inbox_admission(session, latest);
390 if adopt_bypass {
395 adopt_fresher_disk_permission_posture(session, latest);
396 }
397 }
398 let result = self.storage.save_session(session).await;
399 publish(session, result.is_ok());
400 result
401 }
402
403 pub async fn seed_runtime_activation_and_publish<F>(
413 &self,
414 session: &mut Session,
415 publish: F,
416 ) -> std::io::Result<()>
417 where
418 F: FnOnce(&Session, bool) + Send,
419 {
420 let _guard = self.acquire_lock(&session.id).await;
421 let mut incoming_audit = PermissionAuditSnapshot::from_metadata(&session.metadata)
422 .ok_or_else(|| {
423 std::io::Error::new(
424 std::io::ErrorKind::InvalidInput,
425 "activation seed requires a complete permission audit record",
426 )
427 })?;
428
429 if let Some(latest) = self.storage.load_session(&session.id).await? {
430 apply_authoritative_metadata(session, &latest);
431 bamboo_domain::restore_missing_admitted_inbox_messages(session, &latest);
432 bamboo_domain::merge_session_inbox_admission(session, &latest);
433
434 let durable_audit = PermissionAuditSnapshot::from_metadata(&latest.metadata);
435 let durable_floor = durable_audit
436 .as_ref()
437 .map(|snapshot| snapshot.audit_revision)
438 .unwrap_or_default();
439 if let Some(durable_audit) = durable_audit {
440 if durable_audit.resolution == incoming_audit.resolution {
441 incoming_audit.transitioned_at = durable_audit.transitioned_at;
442 }
443 }
444 incoming_audit.audit_revision = bamboo_domain::next_permission_audit_revision_after(
445 durable_floor.max(incoming_audit.audit_revision),
446 )
447 .map_err(|error| {
448 std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string())
449 })?;
450 }
451
452 session
453 .agent_runtime_state
454 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
455 .set_permission_mode(incoming_audit.resolution.requested);
456 incoming_audit.write_to(&mut session.metadata);
457
458 let result = self.storage.save_session(session).await;
459 publish(session, result.is_ok());
460 result
461 }
462
463 pub async fn update_authoritative_permission_posture_and_publish<M, P>(
469 &self,
470 session_id: &str,
471 seed: &PermissionAuditSeed,
472 mutate: M,
473 publish: P,
474 ) -> std::io::Result<Option<Session>>
475 where
476 M: FnOnce(&mut Session),
477 P: FnOnce(&Session),
478 {
479 let _guard = self.acquire_lock(session_id).await;
480 let Some(mut latest) = self.storage.load_session(session_id).await? else {
481 return Ok(None);
482 };
483 let previous_mode = latest
484 .agent_runtime_state
485 .as_ref()
486 .map(|state| state.effective_permission_mode())
487 .unwrap_or_default();
488 let previous_resolution = PermissionAuditSnapshot::from_metadata(&latest.metadata)
489 .map(|snapshot| snapshot.resolution);
490 mutate(&mut latest);
491 latest
492 .agent_runtime_state
493 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
494 .set_permission_mode(seed.resolution.requested);
495 let mode_changed = previous_mode != seed.resolution.requested;
496 let posture_changed = previous_resolution != Some(seed.resolution);
497 let transitioned_at = posture_changed.then(|| chrono::Utc::now().to_rfc3339());
498 bamboo_domain::record_permission_audit(
499 &mut latest.metadata,
500 seed,
501 transitioned_at.as_deref(),
502 )
503 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string()))?;
504 if mode_changed {
505 latest.metadata_version = latest.metadata_version.saturating_add(1);
506 }
507 self.storage.save_session(&latest).await?;
508 publish(&latest);
509 Ok(Some(latest))
510 }
511
512 pub async fn record_permission_posture_activation_and_publish<P>(
517 &self,
518 session_id: &str,
519 expected_audit_revision: Option<u64>,
520 seed: &PermissionAuditSeed,
521 publish: P,
522 ) -> std::io::Result<Option<Session>>
523 where
524 P: FnOnce(&Session),
525 {
526 let _guard = self.acquire_lock(session_id).await;
527 let Some(mut latest) = self.storage.load_session(session_id).await? else {
528 return Ok(None);
529 };
530 let durable_audit = PermissionAuditSnapshot::from_metadata(&latest.metadata);
531 let durable_revision = durable_audit
532 .as_ref()
533 .map(|snapshot| snapshot.audit_revision);
534 if durable_revision != expected_audit_revision {
535 return Err(std::io::Error::new(
536 std::io::ErrorKind::InvalidData,
537 "stale permission posture activation: durable audit changed after dispatch",
538 ));
539 }
540 let durable_requested = latest
541 .agent_runtime_state
542 .as_ref()
543 .map(|state| state.effective_permission_mode())
544 .unwrap_or_default();
545 if durable_requested != seed.resolution.requested || !seed.resolution.is_consistent() {
546 return Err(std::io::Error::new(
547 std::io::ErrorKind::InvalidData,
548 "stale or inconsistent permission posture activation",
549 ));
550 }
551 bamboo_domain::record_permission_audit(&mut latest.metadata, seed, None).map_err(
552 |error| std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string()),
553 )?;
554 self.storage.save_session(&latest).await?;
555 publish(&latest);
556 Ok(Some(latest))
557 }
558
559 pub async fn update_runtime_config<F>(
571 &self,
572 session_id: &str,
573 mutate: F,
574 ) -> std::io::Result<Option<Session>>
575 where
576 F: FnOnce(&mut Session),
577 {
578 self.update_runtime_config_and_publish(session_id, mutate, |_| {})
579 .await
580 }
581
582 pub async fn update_runtime_config_and_publish<M, P>(
585 &self,
586 session_id: &str,
587 mutate: M,
588 publish: P,
589 ) -> std::io::Result<Option<Session>>
590 where
591 M: FnOnce(&mut Session),
592 P: FnOnce(&Session),
593 {
594 let _guard = self.acquire_lock(session_id).await;
595 let Some(mut session) = self.storage.load_session(session_id).await? else {
596 return Ok(None);
597 };
598 mutate(&mut session);
599 self.storage.save_session(&session).await?;
600 publish(&session);
601 Ok(Some(session))
602 }
603
604 pub async fn clear_legacy_pending_messages_and_publish<F>(
607 &self,
608 session_id: &str,
609 expected: &[serde_json::Value],
610 publish: F,
611 ) -> std::io::Result<bool>
612 where
613 F: FnOnce(&Session) + Send,
614 {
615 let _guard = self.acquire_lock(session_id).await;
616 let Some(mut latest) = self.storage.load_session(session_id).await? else {
617 return Ok(false);
618 };
619 if latest.pending_injected_messages().as_deref() != Some(expected) {
620 return Ok(false);
621 }
622 latest.clear_pending_injected_messages();
623 self.storage.save_runtime_state(&latest).await?;
624 publish(&latest);
625 Ok(true)
626 }
627}
628
629#[async_trait::async_trait]
633impl RuntimeSessionPersistence for LockedSessionStore {
634 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
635 self.merge_save_runtime(session).await
636 }
637
638 async fn seed_runtime_activation(&self, session: &mut Session) -> std::io::Result<()> {
639 self.seed_runtime_activation_and_publish(session, |_, _| {})
640 .await
641 }
642
643 async fn record_permission_posture_activation(
644 &self,
645 session_id: &str,
646 expected_audit_revision: Option<u64>,
647 seed: &PermissionAuditSeed,
648 ) -> std::io::Result<Option<Session>> {
649 self.record_permission_posture_activation_and_publish(
650 session_id,
651 expected_audit_revision,
652 seed,
653 |_| {},
654 )
655 .await
656 }
657
658 async fn save_runtime_control_plane(&self, session: &mut Session) -> std::io::Result<()> {
659 self.save_runtime_only(session).await
660 }
661
662 async fn load_runtime_control_plane(
663 &self,
664 session_id: &str,
665 ) -> std::io::Result<Option<Session>> {
666 self.storage.load_runtime_control_plane(session_id).await
667 }
668
669 async fn update_task_list_control_plane(
670 &self,
671 session_id: &str,
672 task_list: &bamboo_domain::TaskList,
673 version: &str,
674 ) -> std::io::Result<bool> {
675 self.update_task_list_control_plane_and_publish(session_id, task_list, version, |_| {})
676 .await
677 }
678
679 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
680 LockedSessionStore::checkpoint_runtime_session(self, session).await
681 }
682
683 async fn load_runtime_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
684 self.storage.load_session(session_id).await
685 }
686
687 async fn clear_legacy_pending_messages(
688 &self,
689 session_id: &str,
690 expected: &[serde_json::Value],
691 ) -> std::io::Result<bool> {
692 self.clear_legacy_pending_messages_and_publish(session_id, expected, |_| {})
693 .await
694 }
695}
696
697async fn merge_authoritative_metadata_into_stale(
706 storage: &Arc<dyn Storage>,
707 session: &mut Session,
708) {
709 if let Ok(Some(latest)) = storage.load_session(&session.id).await {
710 apply_authoritative_metadata(session, &latest);
711 bamboo_domain::restore_missing_admitted_inbox_messages(session, &latest);
712 bamboo_domain::merge_session_inbox_admission(session, &latest);
713 adopt_fresher_disk_permission_posture(session, &latest);
714 }
715}
716
717fn adopt_fresher_disk_permission_posture(session: &mut Session, latest: &Session) {
729 let Some(disk_mode) = latest
734 .agent_runtime_state
735 .as_ref()
736 .map(|state| state.effective_permission_mode())
737 else {
738 return;
739 };
740 let current_mode = session
741 .agent_runtime_state
742 .as_ref()
743 .map(|state| state.effective_permission_mode())
744 .unwrap_or_default();
745 let Some(disk_audit) = bamboo_domain::fresher_disk_permission_audit(
746 current_mode,
747 &session.metadata,
748 disk_mode,
749 &latest.metadata,
750 ) else {
751 return;
752 };
753
754 match session.agent_runtime_state.as_mut() {
755 Some(state) => state.set_permission_mode(disk_mode),
756 None if disk_mode != bamboo_domain::SessionPermissionMode::Default => {
759 let state = session
760 .agent_runtime_state
761 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default);
762 state.set_permission_mode(disk_mode);
763 }
764 None => {}
765 }
766
767 disk_audit.write_to(&mut session.metadata);
769}
770
771fn apply_authoritative_metadata(session: &mut Session, latest: &Session) {
777 if latest.metadata_version >= session.metadata_version {
778 session.title = latest.title.clone();
779 session.title_version = latest.title_version;
780 session.pinned = latest.pinned;
781 for key in AUTHORITATIVE_METADATA_KEYS {
782 if let Some(value) = latest.metadata.get(*key) {
783 session.metadata.insert((*key).to_string(), value.clone());
784 } else {
785 session.metadata.remove(*key);
786 }
787 }
788 session.metadata_version = latest.metadata_version;
789 }
790}
791
792pub async fn merge_save_session(
805 storage: &Arc<dyn Storage>,
806 session: &mut Session,
807) -> std::io::Result<()> {
808 merge_authoritative_metadata_into_stale(storage, session).await;
809 storage.save_session(session).await
810}
811
812#[cfg(test)]
815mod tests {
816 use super::*;
817 use crate::v2::SessionStoreV2;
818 use bamboo_domain::{session::types::Session, PermissionMode};
819 use std::sync::atomic::{AtomicUsize, Ordering};
820
821 struct CountingControlPlaneStorage {
822 inner: Arc<SessionStoreV2>,
823 control_plane_loads: AtomicUsize,
824 }
825
826 #[async_trait::async_trait]
827 impl Storage for CountingControlPlaneStorage {
828 async fn save_session(&self, session: &Session) -> std::io::Result<()> {
829 self.inner.save_session(session).await
830 }
831
832 async fn load_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
833 self.inner.load_session(session_id).await
834 }
835
836 async fn delete_session(&self, session_id: &str) -> std::io::Result<bool> {
837 self.inner.delete_session(session_id).await
838 }
839
840 async fn save_runtime_state(&self, session: &Session) -> std::io::Result<()> {
841 self.inner.save_runtime_state(session).await
842 }
843
844 async fn load_runtime_control_plane(
845 &self,
846 session_id: &str,
847 ) -> std::io::Result<Option<Session>> {
848 self.control_plane_loads.fetch_add(1, Ordering::SeqCst);
849 self.inner.load_runtime_control_plane(session_id).await
850 }
851 }
852
853 async fn make_storage() -> (tempfile::TempDir, Arc<dyn Storage>) {
854 let temp = tempfile::tempdir().unwrap();
855 let storage = SessionStoreV2::new(temp.path().to_path_buf())
856 .await
857 .expect("storage init");
858 (temp, Arc::new(storage) as Arc<dyn Storage>)
859 }
860
861 fn fresh(id: &str) -> Session {
862 Session::new(id.to_string(), "test-model".to_string())
863 }
864
865 fn set_permission_audit(
866 session: &mut Session,
867 requested: bamboo_domain::SessionPermissionMode,
868 policy_revision: u64,
869 mapping: &str,
870 transitioned_at: &str,
871 ) -> u64 {
872 let resolution = bamboo_domain::resolve_permission_mode(requested, PermissionMode::Default);
873 session
874 .agent_runtime_state
875 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
876 .set_permission_mode(requested);
877 bamboo_domain::record_permission_audit(
878 &mut session.metadata,
879 &PermissionAuditSeed::new(policy_revision, resolution, mapping),
880 Some(transitioned_at),
881 )
882 .unwrap()
883 }
884
885 #[tokio::test]
886 async fn same_mode_newer_run_start_audit_survives_every_runtime_save_path() {
887 for path in ["merge", "checkpoint", "control-plane"] {
888 let (_temp, storage) = make_storage().await;
889 let store = LockedSessionStore::new(storage.clone());
890 let session_id = format!("same-mode-newer-{path}");
891 let mut durable = fresh(&session_id);
892 set_permission_audit(
893 &mut durable,
894 bamboo_domain::SessionPermissionMode::Default,
895 1,
896 "bamboo_runtime:old-policy",
897 "2026-07-31T12:00:00Z",
898 );
899 storage.save_session(&durable).await.unwrap();
900
901 let mut run_start = durable.clone();
902 let old_revision = PermissionAuditSnapshot::from_metadata(&durable.metadata)
903 .unwrap()
904 .audit_revision;
905 let new_revision = set_permission_audit(
906 &mut run_start,
907 bamboo_domain::SessionPermissionMode::Default,
908 2,
909 "bamboo_runtime:new-policy",
910 "2026-07-31T12:00:00Z",
911 );
912 assert!(new_revision > old_revision);
913
914 match path {
915 "merge" => store.merge_save_runtime(&mut run_start).await.unwrap(),
916 "checkpoint" => store
917 .checkpoint_runtime_session(&mut run_start)
918 .await
919 .unwrap(),
920 "control-plane" => store.save_runtime_only(&mut run_start).await.unwrap(),
921 _ => unreachable!(),
922 }
923
924 let saved = storage.load_session(&session_id).await.unwrap().unwrap();
925 let audit = PermissionAuditSnapshot::from_metadata(&saved.metadata).unwrap();
926 assert_eq!(audit.audit_revision, new_revision, "path={path}");
927 assert_eq!(audit.policy_revision, 2, "path={path}");
928 assert_eq!(audit.executor_mapping, "bamboo_runtime:new-policy");
929 }
930 }
931
932 #[tokio::test]
933 async fn newer_disk_transition_wins_after_mode_cycles_back() {
934 let (_temp, storage) = make_storage().await;
935 let store = LockedSessionStore::new(storage.clone());
936 let session_id = "permission-cycle-back";
937 let mut baseline = fresh(session_id);
938 let stale_revision = set_permission_audit(
939 &mut baseline,
940 bamboo_domain::SessionPermissionMode::Default,
941 1,
942 "bamboo_runtime:initial",
943 "2026-07-31T12:00:00Z",
944 );
945 storage.save_session(&baseline).await.unwrap();
946 let mut stale_runtime = baseline.clone();
947
948 let mut durable = baseline;
949 set_permission_audit(
950 &mut durable,
951 bamboo_domain::SessionPermissionMode::Auto,
952 2,
953 "bamboo_runtime:auto",
954 "2026-07-31T12:01:00Z",
955 );
956 let durable_revision = set_permission_audit(
957 &mut durable,
958 bamboo_domain::SessionPermissionMode::Default,
959 3,
960 "bamboo_runtime:cycled-default",
961 "2026-07-31T12:02:00Z",
962 );
963 assert!(durable_revision > stale_revision);
964 storage.save_session(&durable).await.unwrap();
965
966 store.merge_save_runtime(&mut stale_runtime).await.unwrap();
967 let saved = storage.load_session(session_id).await.unwrap().unwrap();
968 let audit = PermissionAuditSnapshot::from_metadata(&saved.metadata).unwrap();
969 assert_eq!(audit.audit_revision, durable_revision);
970 assert_eq!(audit.policy_revision, 3);
971 assert_eq!(audit.executor_mapping, "bamboo_runtime:cycled-default");
972 }
973
974 #[tokio::test]
975 async fn authoritative_activation_seed_replaces_every_warm_worker_posture() {
976 let (_temp, storage) = make_storage().await;
977 let store = LockedSessionStore::new(storage.clone());
978 let session_id = "warm-permission-matrix";
979 let cases = [
980 (
981 bamboo_domain::SessionPermissionMode::Auto,
982 PermissionMode::Default,
983 PermissionMode::Auto,
984 ),
985 (
986 bamboo_domain::SessionPermissionMode::Default,
987 PermissionMode::Default,
988 PermissionMode::Default,
989 ),
990 (
991 bamboo_domain::SessionPermissionMode::Auto,
992 PermissionMode::Default,
993 PermissionMode::Auto,
994 ),
995 (
996 bamboo_domain::SessionPermissionMode::Bypass,
997 PermissionMode::Auto,
998 PermissionMode::BypassPermissions,
999 ),
1000 ];
1001 let mut previous_revision = 0;
1002
1003 for (index, (requested, configured, expected_effective)) in cases.into_iter().enumerate() {
1004 let mut activation = fresh(session_id);
1005 activation
1006 .agent_runtime_state
1007 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
1008 .set_permission_mode(requested);
1009 let resolution = bamboo_domain::resolve_permission_mode(requested, configured);
1010 bamboo_domain::record_permission_audit(
1011 &mut activation.metadata,
1012 &PermissionAuditSeed::new(
1013 index as u64 + 1,
1014 resolution,
1015 format!("bamboo_worker:{}", resolution.effective.as_str()),
1016 ),
1017 Some("2026-07-31T12:00:00Z"),
1018 )
1019 .unwrap();
1020
1021 RuntimeSessionPersistence::seed_runtime_activation(&store, &mut activation)
1022 .await
1023 .unwrap();
1024 let durable = storage.load_session(session_id).await.unwrap().unwrap();
1025 assert_eq!(
1026 durable
1027 .agent_runtime_state
1028 .as_ref()
1029 .unwrap()
1030 .effective_permission_mode(),
1031 requested,
1032 "activation {index}"
1033 );
1034 let audit = PermissionAuditSnapshot::from_metadata(&durable.metadata).unwrap();
1035 assert_eq!(audit.resolution.requested, requested);
1036 assert_eq!(audit.resolution.effective, expected_effective);
1037 assert!(audit.audit_revision > previous_revision);
1038 previous_revision = audit.audit_revision;
1039 }
1040 }
1041
1042 #[tokio::test]
1043 async fn resident_reseed_bumps_etag_only_for_typed_transition() {
1044 let (_temp, storage) = make_storage().await;
1045 let store = LockedSessionStore::new(storage.clone());
1046 let session_id = "resident-atomic-permission";
1047 let mut baseline = fresh(session_id);
1048 baseline.metadata_version = 7;
1049 set_permission_audit(
1050 &mut baseline,
1051 bamboo_domain::SessionPermissionMode::Auto,
1052 1,
1053 "bamboo_runtime:auto",
1054 "2026-07-31T12:00:00Z",
1055 );
1056 storage.save_session(&baseline).await.unwrap();
1057 let initial_audit = PermissionAuditSnapshot::from_metadata(&baseline.metadata).unwrap();
1058
1059 let same_mode_seed = PermissionAuditSeed::bamboo_runtime(
1060 2,
1061 bamboo_domain::resolve_permission_mode(
1062 bamboo_domain::SessionPermissionMode::Auto,
1063 PermissionMode::Default,
1064 ),
1065 );
1066 let refreshed = store
1067 .update_authoritative_permission_posture_and_publish(
1068 session_id,
1069 &same_mode_seed,
1070 |session| {
1071 session
1072 .metadata
1073 .insert("resident.marker".to_string(), "same-mode".to_string());
1074 },
1075 |_| {},
1076 )
1077 .await
1078 .unwrap()
1079 .unwrap();
1080 let refreshed_audit = PermissionAuditSnapshot::from_metadata(&refreshed.metadata).unwrap();
1081 assert_eq!(refreshed.metadata_version, 7);
1082 assert!(refreshed_audit.audit_revision > initial_audit.audit_revision);
1083 assert_eq!(refreshed_audit.policy_revision, 2);
1084
1085 let transition_seed = PermissionAuditSeed::bamboo_runtime(
1086 3,
1087 bamboo_domain::resolve_permission_mode(
1088 bamboo_domain::SessionPermissionMode::Default,
1089 PermissionMode::Default,
1090 ),
1091 );
1092 let transitioned = store
1093 .update_authoritative_permission_posture_and_publish(
1094 session_id,
1095 &transition_seed,
1096 |session| {
1097 session
1098 .metadata
1099 .insert("resident.marker".to_string(), "transition".to_string());
1100 },
1101 |_| {},
1102 )
1103 .await
1104 .unwrap()
1105 .unwrap();
1106 let transitioned_audit =
1107 PermissionAuditSnapshot::from_metadata(&transitioned.metadata).unwrap();
1108 assert_eq!(transitioned.metadata_version, 8, "old ETag must be invalid");
1109 assert_eq!(
1110 transitioned
1111 .agent_runtime_state
1112 .as_ref()
1113 .unwrap()
1114 .effective_permission_mode(),
1115 bamboo_domain::SessionPermissionMode::Default
1116 );
1117 assert_eq!(
1118 transitioned_audit.resolution.requested,
1119 bamboo_domain::SessionPermissionMode::Default
1120 );
1121 assert!(transitioned_audit.audit_revision > refreshed_audit.audit_revision);
1122 assert_eq!(
1123 transitioned
1124 .metadata
1125 .get("resident.marker")
1126 .map(String::as_str),
1127 Some("transition")
1128 );
1129 }
1130
1131 #[tokio::test]
1132 async fn worker_activation_cas_cannot_overwrite_concurrent_permission_patch() {
1133 let (_temp, storage) = make_storage().await;
1134 let store = LockedSessionStore::new(storage.clone());
1135 let session_id = "permission-activation-cas";
1136 let mut baseline = fresh(session_id);
1137 set_permission_audit(
1138 &mut baseline,
1139 bamboo_domain::SessionPermissionMode::Default,
1140 1,
1141 "bamboo_runtime:default",
1142 "2026-07-31T12:00:00Z",
1143 );
1144 storage.save_session(&baseline).await.unwrap();
1145 let dispatched_revision = PermissionAuditSnapshot::from_metadata(&baseline.metadata)
1146 .unwrap()
1147 .audit_revision;
1148
1149 let patched_resolution = bamboo_domain::resolve_permission_mode(
1150 bamboo_domain::SessionPermissionMode::Auto,
1151 PermissionMode::Default,
1152 );
1153 let patched = store
1154 .update_authoritative_permission_posture_and_publish(
1155 session_id,
1156 &PermissionAuditSeed::new(2, patched_resolution, "patch:auto"),
1157 |_| {},
1158 |_| {},
1159 )
1160 .await
1161 .unwrap()
1162 .unwrap();
1163 let patched_audit = PermissionAuditSnapshot::from_metadata(&patched.metadata).unwrap();
1164 assert!(patched_audit.audit_revision > dispatched_revision);
1165
1166 let stale_worker_seed = PermissionAuditSeed::new(
1167 1,
1168 bamboo_domain::resolve_permission_mode(
1169 bamboo_domain::SessionPermissionMode::Default,
1170 PermissionMode::Default,
1171 ),
1172 "worker:stale-default",
1173 );
1174 let error = store
1175 .record_permission_posture_activation_and_publish(
1176 session_id,
1177 Some(dispatched_revision),
1178 &stale_worker_seed,
1179 |_| {},
1180 )
1181 .await
1182 .unwrap_err();
1183 assert!(error.to_string().contains("durable audit changed"));
1184
1185 let durable = storage.load_session(session_id).await.unwrap().unwrap();
1186 let durable_audit = PermissionAuditSnapshot::from_metadata(&durable.metadata).unwrap();
1187 assert_eq!(durable_audit, patched_audit);
1188 assert_eq!(durable_audit.executor_mapping, "patch:auto");
1189 }
1190
1191 #[tokio::test]
1194 async fn update_runtime_config_preserves_concurrently_appended_messages() {
1195 use bamboo_domain::session::types::Message;
1196 use bamboo_domain::ReasoningEffort;
1197
1198 let (_temp, storage) = make_storage().await;
1199 let store = LockedSessionStore::new(storage.clone());
1200 let session_id = "cfg-preserve";
1201
1202 let mut initial = fresh(session_id);
1204 initial.add_message(Message::user("hello"));
1205 initial.add_message(Message::assistant("hi", None));
1206 storage.save_session(&initial).await.unwrap();
1207
1208 let mut after_chat = storage.load_session(session_id).await.unwrap().unwrap();
1210 after_chat.add_message(Message::user("second question"));
1211 storage.save_session(&after_chat).await.unwrap();
1212 assert_eq!(after_chat.messages.len(), 3);
1213
1214 let updated = store
1218 .update_runtime_config(session_id, |s| {
1219 s.reasoning_effort = Some(ReasoningEffort::Max);
1220 })
1221 .await
1222 .unwrap()
1223 .expect("session exists");
1224
1225 assert_eq!(updated.reasoning_effort, Some(ReasoningEffort::Max));
1226 assert_eq!(
1227 updated.messages.len(),
1228 3,
1229 "config patch must not revert a concurrently-appended message"
1230 );
1231
1232 let on_disk = storage.load_session(session_id).await.unwrap().unwrap();
1233 assert_eq!(on_disk.messages.len(), 3);
1234 assert_eq!(on_disk.reasoning_effort, Some(ReasoningEffort::Max));
1235 }
1236
1237 #[tokio::test]
1238 async fn update_runtime_config_returns_none_for_missing_session() {
1239 use bamboo_domain::ReasoningEffort;
1240
1241 let (_temp, storage) = make_storage().await;
1242 let store = LockedSessionStore::new(storage);
1243 let result = store
1244 .update_runtime_config("does-not-exist", |s| {
1245 s.reasoning_effort = Some(ReasoningEffort::Low);
1246 })
1247 .await
1248 .unwrap();
1249 assert!(result.is_none());
1250 }
1251
1252 #[tokio::test]
1253 async fn merge_save_runtime_overwrites_messages_from_stale_snapshot() {
1254 use bamboo_domain::session::types::Message;
1259
1260 let (_temp, storage) = make_storage().await;
1261 let store = LockedSessionStore::new(storage.clone());
1262 let session_id = "stale-clobber";
1263
1264 let mut baseline = fresh(session_id);
1266 baseline.add_message(Message::user("hello"));
1267 storage.save_session(&baseline).await.unwrap();
1268 let mut stale_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
1269
1270 let mut after_chat = storage.load_session(session_id).await.unwrap().unwrap();
1272 after_chat.add_message(Message::user("second"));
1273 storage.save_session(&after_chat).await.unwrap();
1274 assert_eq!(
1275 storage
1276 .load_session(session_id)
1277 .await
1278 .unwrap()
1279 .unwrap()
1280 .messages
1281 .len(),
1282 2
1283 );
1284
1285 store.merge_save_runtime(&mut stale_snapshot).await.unwrap();
1287 let after = storage.load_session(session_id).await.unwrap().unwrap();
1288 assert_eq!(
1289 after.messages.len(),
1290 1,
1291 "merge_save_runtime clobbers concurrent appends — this is why config patches must use update_runtime_config"
1292 );
1293 }
1294
1295 #[tokio::test]
1296 async fn stale_runtime_save_cannot_remove_admitted_inbox_transcript() {
1297 use bamboo_domain::session::types::Message;
1298 use bamboo_domain::SessionMessageId;
1299
1300 let (_temp, storage) = make_storage().await;
1301 let store = LockedSessionStore::new(storage.clone());
1302 let session_id = "stale-inbox-preserve";
1303
1304 let mut baseline = fresh(session_id);
1305 let mut base = Message::user("base");
1306 base.id = "base".to_string();
1307 baseline.add_message(base);
1308 storage.save_session(&baseline).await.unwrap();
1309 let mut stale = baseline.clone();
1310 let mut later_assistant = Message::assistant("runner output", None);
1311 later_assistant.id = "later-assistant".to_string();
1312 stale.add_message(later_assistant);
1313
1314 let mut durable = baseline;
1315 let inbox_id = SessionMessageId::parse("durable-inbox-id").unwrap();
1316 let mut admitted = Message::user("durable inbox message");
1317 admitted.id = inbox_id.as_str().to_string();
1318 durable.add_message(admitted);
1319 durable
1320 .session_inbox_admission_mut()
1321 .record(inbox_id.clone(), 7);
1322 storage.save_session(&durable).await.unwrap();
1323
1324 store.merge_save_runtime(&mut stale).await.unwrap();
1325 let saved = storage.load_session(session_id).await.unwrap().unwrap();
1326 let ids = saved
1327 .messages
1328 .iter()
1329 .map(|message| message.id.as_str())
1330 .collect::<Vec<_>>();
1331 assert_eq!(ids, vec!["base", "durable-inbox-id", "later-assistant"]);
1332 assert_eq!(ids.iter().filter(|id| **id == inbox_id.as_str()).count(), 1);
1333 assert!(saved
1334 .session_inbox_admission()
1335 .is_some_and(|state| state.contains(&inbox_id)));
1336 }
1337
1338 #[tokio::test]
1339 async fn stale_runtime_save_preserves_typed_inbox_message_after_cursor_eviction() {
1340 use bamboo_domain::{
1341 SessionMessageEnvelope, SessionMessageId, SESSION_INBOX_ADMITTED_CAPACITY,
1342 };
1343
1344 let (_temp, storage) = make_storage().await;
1345 let store = LockedSessionStore::new(storage.clone());
1346 let session_id = "evicted-inbox-preserve";
1347 let mut durable = fresh(session_id);
1348 let mut envelope = SessionMessageEnvelope::user_input(session_id, "old durable inbox");
1349 envelope.id = SessionMessageId::parse("old-inbox-id").unwrap();
1350 durable.add_message(envelope.to_provider_message().unwrap());
1351 durable
1352 .session_inbox_admission_mut()
1353 .record(envelope.id.clone(), 1);
1354 for sequence in 2..=(SESSION_INBOX_ADMITTED_CAPACITY as u64 + 1) {
1355 durable.session_inbox_admission_mut().record(
1356 SessionMessageId::parse(format!("newer-{sequence}")).unwrap(),
1357 sequence,
1358 );
1359 }
1360 assert!(!durable
1361 .session_inbox_admission()
1362 .unwrap()
1363 .contains(&envelope.id));
1364 storage.save_session(&durable).await.unwrap();
1365
1366 let mut stale = fresh(session_id);
1367 store.merge_save_runtime(&mut stale).await.unwrap();
1368 let saved = storage.load_session(session_id).await.unwrap().unwrap();
1369 assert_eq!(
1370 saved
1371 .messages
1372 .iter()
1373 .filter(|message| message.id == envelope.id.as_str())
1374 .count(),
1375 1
1376 );
1377 }
1378
1379 #[tokio::test]
1380 async fn checkpoint_runtime_session_preserves_disk_suffix_and_appends_live_messages() {
1381 use bamboo_domain::session::types::Message;
1382
1383 let (_temp, storage) = make_storage().await;
1384 let store = LockedSessionStore::new(storage.clone());
1385 let session_id = "checkpoint-no-shrink";
1386
1387 let mut baseline = fresh(session_id);
1388 baseline.add_message(Message::user("base"));
1389 storage.save_session(&baseline).await.unwrap();
1390 let mut runner_snapshot = baseline.clone();
1391
1392 let mut durable = baseline;
1393 let mut disk_only = Message::user("concurrent injected message");
1394 disk_only.id = "disk-only".to_string();
1395 durable.add_message(disk_only);
1396 storage.save_session(&durable).await.unwrap();
1397
1398 let mut live_only = Message::assistant("partial runner output", None);
1399 live_only.id = "live-only".to_string();
1400 runner_snapshot.add_message(live_only);
1401
1402 store
1403 .checkpoint_runtime_session(&mut runner_snapshot)
1404 .await
1405 .unwrap();
1406
1407 let saved = storage.load_session(session_id).await.unwrap().unwrap();
1408 let ids = saved
1409 .messages
1410 .iter()
1411 .map(|message| message.id.as_str())
1412 .collect::<Vec<_>>();
1413 assert_eq!(
1414 ids,
1415 vec![durable.messages[0].id.as_str(), "disk-only", "live-only"]
1416 );
1417 assert_eq!(runner_snapshot.messages.len(), saved.messages.len());
1418 assert_eq!(runner_snapshot.messages[1].id, saved.messages[1].id);
1419 assert_eq!(runner_snapshot.messages[2].id, saved.messages[2].id);
1420 assert_eq!(saved.messages[1].content, "concurrent injected message");
1421 assert_eq!(saved.messages[2].content, "partial runner output");
1422 }
1423
1424 #[tokio::test]
1425 async fn activation_checkpoint_clears_presentation_without_shrinking_concurrent_turn() {
1426 use bamboo_domain::session::runtime_state::{
1427 AgentRuntimeState, AgentStatusState, WaitingForChildrenState,
1428 };
1429 use bamboo_domain::session::types::Message;
1430
1431 let (_temp, storage) = make_storage().await;
1432 let store = LockedSessionStore::new(storage.clone());
1433 let session_id = "activation-no-shrink";
1434 let mut baseline = fresh(session_id);
1435 baseline.add_message(Message::user("base"));
1436 let mut state = AgentRuntimeState::new("activation-run");
1437 state.status = AgentStatusState::Suspended;
1438 state.waiting_for_children = Some(WaitingForChildrenState::for_children(
1439 vec!["child-1".to_string()],
1440 bamboo_domain::session::runtime_state::ChildWaitPolicy::All,
1441 chrono::Utc::now(),
1442 ));
1443 baseline.agent_runtime_state = Some(state);
1444 baseline.metadata.insert(
1445 "runtime.suspend_reason".to_string(),
1446 "waiting_for_children".to_string(),
1447 );
1448 storage.save_session(&baseline).await.unwrap();
1449 let mut activation_snapshot = baseline.clone();
1450
1451 let mut concurrent = baseline;
1452 let mut normal = Message::assistant("normal concurrent answer", None);
1453 normal.id = "normal-concurrent".to_string();
1454 concurrent.add_message(normal);
1455 storage.save_session(&concurrent).await.unwrap();
1456
1457 let state = activation_snapshot.agent_runtime_state.as_mut().unwrap();
1458 state.status = AgentStatusState::Idle;
1459 state.suspension = None;
1460 activation_snapshot
1461 .metadata
1462 .remove("runtime.suspend_reason");
1463 store
1464 .checkpoint_runtime_session(&mut activation_snapshot)
1465 .await
1466 .unwrap();
1467
1468 let saved = storage.load_session(session_id).await.unwrap().unwrap();
1469 assert!(saved
1470 .messages
1471 .iter()
1472 .any(|message| message.id == "normal-concurrent"));
1473 let state = saved.agent_runtime_state.unwrap();
1474 assert_eq!(state.status, AgentStatusState::Idle);
1475 assert!(state.waiting_for_children.is_some());
1476 assert!(!saved.metadata.contains_key("runtime.suspend_reason"));
1477 }
1478
1479 #[tokio::test]
1480 async fn merge_save_runtime_preserves_disk_authoritative_metadata_with_single_load() {
1481 let (_temp, storage) = make_storage().await;
1487 let store = LockedSessionStore::new(storage.clone());
1488 let session_id = "runtime-merge-meta";
1489
1490 let mut baseline = fresh(session_id);
1492 baseline.title = "Auto Title".to_string();
1493 baseline.metadata_version = 0;
1494 storage.save_session(&baseline).await.unwrap();
1495
1496 let mut stale_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
1498
1499 let mut renamed = storage.load_session(session_id).await.unwrap().unwrap();
1501 renamed.title = "User Renamed".to_string();
1502 renamed.title_version = 1;
1503 renamed.pinned = true;
1504 renamed.metadata_version = 1;
1505 store.commit_metadata(&renamed).await.unwrap();
1506
1507 stale_snapshot.title = "Auto Title".to_string();
1509 store.merge_save_runtime(&mut stale_snapshot).await.unwrap();
1510
1511 let after = storage.load_session(session_id).await.unwrap().unwrap();
1512 assert_eq!(after.title, "User Renamed");
1513 assert!(after.pinned);
1514 assert_eq!(after.metadata_version, 1);
1515 assert_eq!(stale_snapshot.title, "User Renamed");
1517 assert_eq!(stale_snapshot.metadata_version, 1);
1518 }
1519
1520 #[tokio::test]
1521 async fn merge_save_runtime_preserves_durable_workflow_run_index_from_stale_runner() {
1522 let (_temp, storage) = make_storage().await;
1523 let store = LockedSessionStore::new(storage.clone());
1524 let session_id = "runtime-workflow-run-index";
1525
1526 let baseline = fresh(session_id);
1527 storage.save_session(&baseline).await.unwrap();
1528 let mut stale_runner = storage.load_session(session_id).await.unwrap().unwrap();
1529
1530 store
1531 .update_runtime_config(session_id, |session| {
1532 session.metadata.insert(
1533 "workflow.run_ids.v1".to_string(),
1534 r#"["http-started-run"]"#.to_string(),
1535 );
1536 })
1537 .await
1538 .unwrap()
1539 .expect("session exists");
1540
1541 store.merge_save_runtime(&mut stale_runner).await.unwrap();
1542
1543 assert_eq!(
1544 stale_runner
1545 .metadata
1546 .get("workflow.run_ids.v1")
1547 .map(String::as_str),
1548 Some(r#"["http-started-run"]"#)
1549 );
1550 let durable = storage.load_session(session_id).await.unwrap().unwrap();
1551 assert_eq!(
1552 durable
1553 .metadata
1554 .get("workflow.run_ids.v1")
1555 .map(String::as_str),
1556 Some(r#"["http-started-run"]"#)
1557 );
1558 }
1559
1560 #[tokio::test]
1564 async fn merge_save_runtime_adopts_disk_bypass_permissions() {
1565 use bamboo_domain::AgentRuntimeState;
1566
1567 let (_temp, storage) = make_storage().await;
1568 let store = LockedSessionStore::new(storage.clone());
1569 let session_id = "runtime-bypass";
1570
1571 let baseline = fresh(session_id);
1573 storage.save_session(&baseline).await.unwrap();
1574
1575 let mut loop_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
1577 loop_snapshot.agent_runtime_state = Some(AgentRuntimeState::default());
1578
1579 store
1581 .update_runtime_config(session_id, |s| {
1582 s.agent_runtime_state
1583 .get_or_insert_with(AgentRuntimeState::default)
1584 .bypass_permissions = true;
1585 })
1586 .await
1587 .unwrap()
1588 .expect("session exists");
1589
1590 store.merge_save_runtime(&mut loop_snapshot).await.unwrap();
1593
1594 let after = storage.load_session(session_id).await.unwrap().unwrap();
1595 assert!(
1596 after
1597 .agent_runtime_state
1598 .as_ref()
1599 .is_some_and(|s| s.bypass_permissions),
1600 "disk bypass=ON must survive a stale runtime save (#540)"
1601 );
1602 assert!(loop_snapshot
1604 .agent_runtime_state
1605 .as_ref()
1606 .is_some_and(|s| s.bypass_permissions));
1607 }
1608
1609 #[tokio::test]
1612 async fn merge_save_runtime_adopts_disk_auto_permission_mode() {
1613 use bamboo_domain::{AgentRuntimeState, SessionPermissionMode};
1614
1615 let (_temp, storage) = make_storage().await;
1616 let store = LockedSessionStore::new(storage.clone());
1617 let session_id = "runtime-auto";
1618
1619 storage.save_session(&fresh(session_id)).await.unwrap();
1620 let mut loop_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
1621 loop_snapshot.agent_runtime_state = Some(AgentRuntimeState::default());
1622 loop_snapshot.metadata.insert(
1623 "permission.requested_mode".to_string(),
1624 "default".to_string(),
1625 );
1626 loop_snapshot.metadata.insert(
1627 "permission.effective_mode".to_string(),
1628 "default".to_string(),
1629 );
1630 loop_snapshot.metadata.insert(
1631 "permission.executor_mapping".to_string(),
1632 "bamboo_runtime:default".to_string(),
1633 );
1634
1635 store
1636 .update_runtime_config(session_id, |session| {
1637 session
1638 .agent_runtime_state
1639 .get_or_insert_with(AgentRuntimeState::default)
1640 .set_permission_mode(SessionPermissionMode::Auto);
1641 session
1642 .metadata
1643 .insert("permission.policy_revision".to_string(), "12".to_string());
1644 session
1645 .metadata
1646 .insert("permission.requested_mode".to_string(), "auto".to_string());
1647 session
1648 .metadata
1649 .insert("permission.effective_mode".to_string(), "auto".to_string());
1650 session.metadata.insert(
1651 "permission.executor_mapping".to_string(),
1652 "bamboo_runtime:auto".to_string(),
1653 );
1654 session.metadata.insert(
1655 "permission.transitioned_at".to_string(),
1656 "2026-07-31T12:00:00Z".to_string(),
1657 );
1658 session.metadata_version = session.metadata_version.saturating_add(1);
1659 })
1660 .await
1661 .unwrap()
1662 .expect("session exists");
1663
1664 store.merge_save_runtime(&mut loop_snapshot).await.unwrap();
1665
1666 let durable = storage.load_session(session_id).await.unwrap().unwrap();
1667 for state in [
1668 durable.agent_runtime_state.as_ref(),
1669 loop_snapshot.agent_runtime_state.as_ref(),
1670 ] {
1671 assert_eq!(
1672 state.map(AgentRuntimeState::effective_permission_mode),
1673 Some(SessionPermissionMode::Auto)
1674 );
1675 }
1676 for session in [&durable, &loop_snapshot] {
1677 assert_eq!(
1678 session.metadata.get("permission.policy_revision"),
1679 Some(&"12".to_string())
1680 );
1681 assert_eq!(
1682 session.metadata.get("permission.requested_mode"),
1683 Some(&"auto".to_string())
1684 );
1685 assert_eq!(
1686 session.metadata.get("permission.effective_mode"),
1687 Some(&"auto".to_string())
1688 );
1689 assert_eq!(
1690 session.metadata.get("permission.executor_mapping"),
1691 Some(&"bamboo_runtime:auto".to_string())
1692 );
1693 assert_eq!(
1694 session.metadata.get("permission.transitioned_at"),
1695 Some(&"2026-07-31T12:00:00Z".to_string())
1696 );
1697 }
1698 }
1699
1700 #[tokio::test]
1703 async fn merge_save_runtime_adopts_disk_bypass_off() {
1704 use bamboo_domain::AgentRuntimeState;
1705
1706 let (_temp, storage) = make_storage().await;
1707 let store = LockedSessionStore::new(storage.clone());
1708 let session_id = "runtime-bypass-off";
1709
1710 let mut baseline = fresh(session_id);
1712 let on_state = AgentRuntimeState {
1713 bypass_permissions: true,
1714 ..AgentRuntimeState::default()
1715 };
1716 baseline.agent_runtime_state = Some(on_state);
1717 storage.save_session(&baseline).await.unwrap();
1718
1719 let mut loop_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
1721
1722 store
1724 .update_runtime_config(session_id, |s| {
1725 s.agent_runtime_state
1726 .get_or_insert_with(AgentRuntimeState::default)
1727 .bypass_permissions = false;
1728 })
1729 .await
1730 .unwrap()
1731 .expect("session exists");
1732
1733 store.merge_save_runtime(&mut loop_snapshot).await.unwrap();
1734
1735 let after = storage.load_session(session_id).await.unwrap().unwrap();
1736 assert!(
1737 !after
1738 .agent_runtime_state
1739 .as_ref()
1740 .is_some_and(|s| s.bypass_permissions),
1741 "disk bypass=OFF must survive a stale runtime save (#540)"
1742 );
1743 }
1744
1745 #[tokio::test]
1748 async fn save_runtime_authoritative_flags_persists_in_memory_posture_and_audit() {
1749 use bamboo_domain::AgentRuntimeState;
1750
1751 let (_temp, storage) = make_storage().await;
1752 let store = LockedSessionStore::new(storage.clone());
1753 let session_id = "child-reseed";
1754
1755 let mut baseline = fresh(session_id);
1757 let on_state = AgentRuntimeState {
1758 bypass_permissions: true,
1759 ..AgentRuntimeState::default()
1760 };
1761 baseline.agent_runtime_state = Some(on_state);
1762 for (key, value) in [
1763 ("permission.policy_revision", "12"),
1764 ("permission.requested_mode", "bypass"),
1765 ("permission.effective_mode", "bypass"),
1766 ("permission.executor_mapping", "bamboo_runtime:bypass"),
1767 ("permission.transitioned_at", "2026-07-31T12:00:00Z"),
1768 ] {
1769 baseline.metadata.insert(key.to_string(), value.to_string());
1770 }
1771 storage.save_session(&baseline).await.unwrap();
1772
1773 let mut child = storage.load_session(session_id).await.unwrap().unwrap();
1776 child
1777 .agent_runtime_state
1778 .get_or_insert_with(AgentRuntimeState::default)
1779 .bypass_permissions = false;
1780 for (key, value) in [
1781 ("permission.policy_revision", "13"),
1782 ("permission.requested_mode", "default"),
1783 ("permission.effective_mode", "default"),
1784 ("permission.executor_mapping", "bamboo_runtime:default"),
1785 ("permission.transitioned_at", "2026-07-31T12:01:00Z"),
1786 ] {
1787 child.metadata.insert(key.to_string(), value.to_string());
1788 }
1789
1790 store
1792 .save_runtime_authoritative_flags(&mut child)
1793 .await
1794 .unwrap();
1795
1796 let after = storage.load_session(session_id).await.unwrap().unwrap();
1797 assert!(
1798 !after
1799 .agent_runtime_state
1800 .as_ref()
1801 .is_some_and(|s| s.bypass_permissions),
1802 "authoritative re-seed of bypass=OFF must persist, not be reverted (#540/#74)"
1803 );
1804 for (key, value) in [
1805 ("permission.policy_revision", "13"),
1806 ("permission.requested_mode", "default"),
1807 ("permission.effective_mode", "default"),
1808 ("permission.executor_mapping", "bamboo_runtime:default"),
1809 ("permission.transitioned_at", "2026-07-31T12:01:00Z"),
1810 ] {
1811 assert_eq!(after.metadata.get(key).map(String::as_str), Some(value));
1812 }
1813 }
1814
1815 #[tokio::test]
1817 async fn merge_save_runtime_leaves_bypass_when_disk_has_no_runtime_state() {
1818 use bamboo_domain::AgentRuntimeState;
1819
1820 let (_temp, storage) = make_storage().await;
1821 let store = LockedSessionStore::new(storage.clone());
1822 let session_id = "no-runtime-state";
1823
1824 let baseline = fresh(session_id);
1826 assert!(baseline.agent_runtime_state.is_none());
1827 storage.save_session(&baseline).await.unwrap();
1828
1829 let mut running = storage.load_session(session_id).await.unwrap().unwrap();
1831 let on_state = AgentRuntimeState {
1832 bypass_permissions: true,
1833 ..AgentRuntimeState::default()
1834 };
1835 running.agent_runtime_state = Some(on_state);
1836
1837 store.merge_save_runtime(&mut running).await.unwrap();
1838
1839 assert!(
1840 running
1841 .agent_runtime_state
1842 .as_ref()
1843 .is_some_and(|s| s.bypass_permissions),
1844 "a runtime-state-less disk copy must not force bypass OFF (#540)"
1845 );
1846 }
1847
1848 #[tokio::test]
1851 async fn merge_preserves_disk_title_when_versions_equal() {
1852 let (_temp, storage) = make_storage().await;
1853 let session_id = "merge-equal";
1854
1855 let mut on_disk = fresh(session_id);
1856 on_disk.title = "User Set This".to_string();
1857 on_disk.title_version = 0;
1858 on_disk.metadata_version = 0;
1859 storage.save_session(&on_disk).await.unwrap();
1860
1861 let mut runtime_copy = fresh(session_id);
1862 runtime_copy.title = "Stale Default".to_string();
1863 runtime_copy.title_version = 0;
1864 runtime_copy.metadata_version = 0;
1865 runtime_copy.messages = vec![];
1866
1867 merge_save_session(&storage, &mut runtime_copy)
1868 .await
1869 .unwrap();
1870
1871 let after = storage.load_session(session_id).await.unwrap().unwrap();
1872 assert_eq!(after.title, "User Set This");
1873 assert_eq!(after.title_version, 0);
1874 assert_eq!(runtime_copy.title, "User Set This");
1875 }
1876
1877 #[tokio::test]
1878 async fn merge_preserves_disk_when_disk_version_higher() {
1879 let (_temp, storage) = make_storage().await;
1880 let session_id = "merge-higher";
1881
1882 let mut on_disk = fresh(session_id);
1883 on_disk.title = "User Title v3".to_string();
1884 on_disk.title_version = 3;
1885 on_disk.metadata_version = 5;
1886 storage.save_session(&on_disk).await.unwrap();
1887
1888 let mut runtime_copy = fresh(session_id);
1889 runtime_copy.title = "Stale".to_string();
1890 runtime_copy.title_version = 1;
1891 runtime_copy.metadata_version = 0;
1892
1893 merge_save_session(&storage, &mut runtime_copy)
1894 .await
1895 .unwrap();
1896
1897 let after = storage.load_session(session_id).await.unwrap().unwrap();
1898 assert_eq!(after.title, "User Title v3");
1899 assert_eq!(after.title_version, 3);
1900 assert_eq!(after.metadata_version, 5);
1901 }
1902
1903 #[tokio::test]
1904 async fn merge_now_preserves_disk_pinned_in_metadata_group() {
1905 let (_temp, storage) = make_storage().await;
1906 let session_id = "pinned-merge";
1907
1908 let mut on_disk = fresh(session_id);
1909 on_disk.pinned = true;
1910 on_disk.metadata_version = 2;
1911 storage.save_session(&on_disk).await.unwrap();
1912
1913 let mut runtime_copy = fresh(session_id);
1914 runtime_copy.pinned = false;
1915 runtime_copy.metadata_version = 0;
1916
1917 merge_save_session(&storage, &mut runtime_copy)
1918 .await
1919 .unwrap();
1920
1921 let after = storage.load_session(session_id).await.unwrap().unwrap();
1922 assert!(
1923 after.pinned,
1924 "disk pinned=true should win over runtime false"
1925 );
1926 assert_eq!(after.metadata_version, 2);
1927 }
1928
1929 #[tokio::test]
1930 async fn merge_keeps_in_memory_when_session_version_higher() {
1931 let (_temp, storage) = make_storage().await;
1932 let session_id = "merge-bumped";
1933
1934 let mut on_disk = fresh(session_id);
1935 on_disk.title = "Old".to_string();
1936 on_disk.title_version = 1;
1937 on_disk.metadata_version = 3;
1938 storage.save_session(&on_disk).await.unwrap();
1939
1940 let mut authoritative_copy = fresh(session_id);
1941 authoritative_copy.title = "New Authoritative".to_string();
1942 authoritative_copy.title_version = 2;
1943 authoritative_copy.metadata_version = 4;
1944 authoritative_copy.pinned = true;
1945
1946 merge_save_session(&storage, &mut authoritative_copy)
1947 .await
1948 .unwrap();
1949
1950 let after = storage.load_session(session_id).await.unwrap().unwrap();
1951 assert_eq!(after.title, "New Authoritative");
1952 assert_eq!(after.title_version, 2);
1953 assert_eq!(after.metadata_version, 4);
1954 assert!(after.pinned);
1955 }
1956
1957 #[tokio::test]
1958 async fn merge_keeps_runtime_messages_when_disk_only_changed_metadata() {
1959 let (_temp, storage) = make_storage().await;
1960 let session_id = "merge-messages";
1961
1962 let mut on_disk = fresh(session_id);
1963 on_disk.title = "Fresh Title".to_string();
1964 on_disk.title_version = 2;
1965 on_disk.metadata_version = 5;
1966 storage.save_session(&on_disk).await.unwrap();
1967
1968 let mut runtime_copy = fresh(session_id);
1969 runtime_copy.title = "Stale".to_string();
1970 runtime_copy.metadata_version = 0;
1971 runtime_copy.messages = vec![bamboo_domain::session::types::Message {
1972 role: bamboo_domain::session::types::Role::User,
1973 content: "keep me".to_string(),
1974 id: "msg-1".to_string(),
1975 created_at: chrono::Utc::now(),
1976 reasoning: None,
1977 reasoning_signature: None,
1978 content_parts: None,
1979 image_ocr: None,
1980 phase: None,
1981 tool_calls: None,
1982 tool_call_id: None,
1983 tool_success: None,
1984 compressed: false,
1985 compressed_by_event_id: None,
1986 never_compress: false,
1987 compression_level: 0,
1988 metadata: None,
1989 }];
1990
1991 merge_save_session(&storage, &mut runtime_copy)
1992 .await
1993 .unwrap();
1994
1995 let after = storage.load_session(session_id).await.unwrap().unwrap();
1996 assert_eq!(after.title, "Fresh Title");
1997 assert_eq!(after.metadata_version, 5);
1998 assert_eq!(after.messages.len(), 1);
1999 assert_eq!(after.messages[0].content, "keep me");
2000 }
2001
2002 #[tokio::test]
2003 async fn runtime_control_plane_port_uses_sidecar_without_rewriting_messages() {
2004 use bamboo_domain::session::types::Message;
2005
2006 let (_temp, storage) = make_storage().await;
2007 let store = LockedSessionStore::new(storage.clone());
2008 let session_id = "runtime-control-plane";
2009
2010 let mut durable = fresh(session_id);
2011 durable.add_message(Message::user("durable transcript"));
2012 storage.save_session(&durable).await.unwrap();
2013
2014 let mut runtime = durable.clone();
2015 runtime.model = "updated-control-plane-model".to_string();
2016 runtime.add_message(Message::assistant("uncheckpointed runtime message", None));
2017 RuntimeSessionPersistence::save_runtime_control_plane(&store, &mut runtime)
2018 .await
2019 .unwrap();
2020
2021 let control_plane =
2022 RuntimeSessionPersistence::load_runtime_control_plane(&store, session_id)
2023 .await
2024 .unwrap()
2025 .expect("control-plane exists");
2026 assert!(
2027 control_plane.messages.is_empty(),
2028 "LockedSessionStore must expose its message-free sidecar"
2029 );
2030 assert_eq!(control_plane.model, "updated-control-plane-model");
2031
2032 let reloaded = storage
2033 .load_session(session_id)
2034 .await
2035 .unwrap()
2036 .expect("session exists");
2037 assert_eq!(reloaded.model, "updated-control-plane-model");
2038 assert_eq!(
2039 reloaded.messages.len(),
2040 1,
2041 "control-plane save must not write the uncheckpointed message"
2042 );
2043 assert_eq!(reloaded.messages[0].content, "durable transcript");
2044 }
2045
2046 #[tokio::test]
2047 async fn atomic_task_patch_loads_inside_lock_and_preserves_interleaved_runtime_state() {
2048 let temp = tempfile::tempdir().unwrap();
2049 let inner = Arc::new(
2050 SessionStoreV2::new(temp.path().to_path_buf())
2051 .await
2052 .expect("storage init"),
2053 );
2054 let session_id = "atomic-task-patch";
2055 inner
2056 .save_session(&fresh(session_id))
2057 .await
2058 .expect("seed session");
2059
2060 let counted = Arc::new(CountingControlPlaneStorage {
2061 inner: inner.clone(),
2062 control_plane_loads: AtomicUsize::new(0),
2063 });
2064 let storage: Arc<dyn Storage> = counted.clone();
2065 let store = Arc::new(LockedSessionStore::new(storage));
2066 let guard = store.acquire_lock(session_id).await;
2067 let now = chrono::Utc::now();
2068 let task_list = bamboo_domain::TaskList {
2069 session_id: session_id.to_string(),
2070 title: "Atomic Task patch".to_string(),
2071 items: Vec::new(),
2072 created_at: now,
2073 updated_at: now,
2074 };
2075 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
2076 let patch_store = store.clone();
2077 let patch = tokio::spawn(async move {
2078 let _ = started_tx.send(());
2079 RuntimeSessionPersistence::update_task_list_control_plane(
2080 patch_store.as_ref(),
2081 session_id,
2082 &task_list,
2083 "9",
2084 )
2085 .await
2086 });
2087 started_rx.await.expect("patch task started");
2088 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2089 assert_eq!(
2090 counted.control_plane_loads.load(Ordering::SeqCst),
2091 0,
2092 "Task patch must acquire the session lock before loading its snapshot"
2093 );
2094
2095 let mut latest = inner
2099 .load_runtime_control_plane(session_id)
2100 .await
2101 .expect("load latest control-plane")
2102 .expect("control-plane exists");
2103 latest.agent_runtime_state = Some(bamboo_domain::AgentRuntimeState::new("latest-run"));
2104 latest
2105 .metadata
2106 .insert("concurrent.runtime".to_string(), "preserve".to_string());
2107 inner
2108 .save_runtime_state(&latest)
2109 .await
2110 .expect("publish concurrent runtime transition");
2111 drop(guard);
2112
2113 assert!(
2114 patch.await.expect("patch join").expect("patch succeeds"),
2115 "existing root must be patched"
2116 );
2117 assert_eq!(counted.control_plane_loads.load(Ordering::SeqCst), 1);
2118 let reloaded = inner
2119 .load_session(session_id)
2120 .await
2121 .expect("reload")
2122 .expect("session exists");
2123 assert_eq!(
2124 reloaded
2125 .agent_runtime_state
2126 .as_ref()
2127 .map(|state| state.run_id.as_str()),
2128 Some("latest-run")
2129 );
2130 assert_eq!(
2131 reloaded
2132 .metadata
2133 .get("concurrent.runtime")
2134 .map(String::as_str),
2135 Some("preserve")
2136 );
2137 assert_eq!(reloaded.task_list_version_meta().as_deref(), Some("9"));
2138 assert_eq!(
2139 reloaded.task_list.as_ref().map(|list| list.title.as_str()),
2140 Some("Atomic Task patch")
2141 );
2142 }
2143
2144 #[tokio::test]
2147 async fn locked_merge_save_runtime_serialises_concurrent_writes() {
2148 let (_temp, storage) = make_storage().await;
2149 let store = Arc::new(LockedSessionStore::new(storage));
2150 let session_id = "lock-serial".to_string();
2151
2152 let base = fresh(&session_id);
2154 store.storage().save_session(&base).await.unwrap();
2155
2156 let store_a = store.clone();
2159 let store_b = store.clone();
2160 let sid_a = session_id.clone();
2161 let sid_b = session_id.clone();
2162
2163 let a = tokio::spawn(async move {
2164 let _guard = store_a.acquire_lock(&sid_a).await;
2165 let mut s = store_a
2166 .storage()
2167 .load_session(&sid_a)
2168 .await
2169 .unwrap()
2170 .unwrap();
2171 s.title = "Writer A".to_string();
2172 s.title_version = s.title_version.saturating_add(1);
2173 s.metadata_version = s.metadata_version.saturating_add(1);
2174 s.updated_at = chrono::Utc::now();
2175 store_a.storage().save_session(&s).await.unwrap();
2176 s.title_version
2177 });
2178
2179 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
2181
2182 let b = tokio::spawn(async move {
2183 let _guard = store_b.acquire_lock(&sid_b).await;
2184 let mut s = store_b
2185 .storage()
2186 .load_session(&sid_b)
2187 .await
2188 .unwrap()
2189 .unwrap();
2190 s.title = "Writer B".to_string();
2191 s.title_version = s.title_version.saturating_add(1);
2192 s.metadata_version = s.metadata_version.saturating_add(1);
2193 s.updated_at = chrono::Utc::now();
2194 store_b.storage().save_session(&s).await.unwrap();
2195 s.title_version
2196 });
2197
2198 let (ver_a, ver_b) = tokio::join!(a, b);
2199 let final_s = store
2200 .storage()
2201 .load_session(&session_id)
2202 .await
2203 .unwrap()
2204 .unwrap();
2205 assert!(
2206 ver_a.unwrap() != ver_b.unwrap(),
2207 "concurrent writers must produce distinct versions"
2208 );
2209 assert_eq!(final_s.metadata_version, 2);
2210 }
2211
2212 #[tokio::test]
2213 async fn commit_metadata_is_plain_save_inside_lock() {
2214 let (_temp, storage) = make_storage().await;
2215 let store = LockedSessionStore::new(storage);
2216 let session_id = "commit-plain";
2217
2218 let mut s = fresh(session_id);
2219 s.title = "Committed".to_string();
2220 s.metadata_version = 1;
2221 s.title_version = 2;
2222
2223 store.commit_metadata(&s).await.unwrap();
2224
2225 let after = store
2226 .storage()
2227 .load_session(session_id)
2228 .await
2229 .unwrap()
2230 .unwrap();
2231 assert_eq!(after.title, "Committed");
2232 assert_eq!(after.metadata_version, 1);
2233 assert_eq!(after.title_version, 2);
2234 }
2235
2236 #[tokio::test]
2239 async fn acquire_lock_self_evicts_when_no_other_holder() {
2240 let (_temp, storage) = make_storage().await;
2241 let store = LockedSessionStore::new(storage);
2242
2243 {
2244 let _guard = store.acquire_lock("solo").await;
2245 assert_eq!(store.locks.len(), 1, "entry present while the lock is held");
2246 }
2247 assert_eq!(
2250 store.locks.len(),
2251 0,
2252 "lock entry must be evicted once released with no other holder"
2253 );
2254 }
2255
2256 #[tokio::test]
2257 async fn acquire_lock_many_distinct_ids_do_not_accumulate() {
2258 let (_temp, storage) = make_storage().await;
2259 let store = LockedSessionStore::new(storage);
2260
2261 for i in 0..100 {
2263 let _guard = store.acquire_lock(&format!("sess-{i}")).await;
2264 }
2265 assert_eq!(
2266 store.locks.len(),
2267 0,
2268 "acquiring locks for many distinct ids must not grow the map"
2269 );
2270 }
2271
2272 #[tokio::test]
2273 async fn acquire_lock_concurrent_waiter_keeps_valid_lock_and_map_drains() {
2274 use std::sync::atomic::{AtomicUsize, Ordering};
2275
2276 let (_temp, storage) = make_storage().await;
2277 let store = Arc::new(LockedSessionStore::new(storage));
2278
2279 let active = Arc::new(AtomicUsize::new(0));
2281 let max_seen = Arc::new(AtomicUsize::new(0));
2282
2283 let mut handles = Vec::new();
2284 for _ in 0..8 {
2285 let store = store.clone();
2286 let active = active.clone();
2287 let max_seen = max_seen.clone();
2288 handles.push(tokio::spawn(async move {
2289 let _guard = store.acquire_lock("contended").await;
2290 let now = active.fetch_add(1, Ordering::SeqCst) + 1;
2291 max_seen.fetch_max(now, Ordering::SeqCst);
2292 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2294 active.fetch_sub(1, Ordering::SeqCst);
2295 }));
2296 }
2297 for h in handles {
2298 h.await.unwrap();
2299 }
2300
2301 assert_eq!(
2306 max_seen.load(Ordering::SeqCst),
2307 1,
2308 "at most one holder of a given session lock at a time"
2309 );
2310 assert_eq!(
2311 store.locks.len(),
2312 0,
2313 "after all holders release, the contended entry must be fully evicted"
2314 );
2315 }
2316}