Skip to main content

bamboo_engine/
session_repository.rs

1//! Canonical session coordinator owned by the framework.
2//!
3//! [`SessionRepository`] bundles the three tiers a Bamboo session lives in — the
4//! in-memory [`SessionCache`], the durable [`Storage`], and the
5//! merge-on-write [`LockedSessionStore`] — and provides the one canonical
6//! load/save coordination (cache → storage → backfill, and dual-write).
7//!
8//! This is a *framework* capability, not a server one: previously the
9//! coordination lived only as inherent methods on the server's `AppState`,
10//! which meant anything outside the HTTP server (the SDK, in-process embedders)
11//! could not load or persist sessions consistently. `SessionRepository` lets any
12//! caller that holds the three tiers share the exact same behaviour; the
13//! server's `AppState` now delegates to it.
14
15use std::sync::Arc;
16
17use bamboo_agent_core::storage::Storage;
18use bamboo_agent_core::Session;
19use bamboo_storage::LockedSessionStore;
20
21use crate::{read_cached_session, SessionCache};
22
23/// Framework-owned coordinator over a session's cache / storage / persistence
24/// tiers. Cheap to clone (all fields are `Arc`).
25#[derive(Clone)]
26pub struct SessionRepository {
27    cache: SessionCache,
28    storage: Arc<dyn Storage>,
29    persistence: Arc<LockedSessionStore>,
30}
31
32impl SessionRepository {
33    pub fn new(
34        cache: SessionCache,
35        storage: Arc<dyn Storage>,
36        persistence: Arc<LockedSessionStore>,
37    ) -> Self {
38        Self {
39            cache,
40            storage,
41            persistence,
42        }
43    }
44
45    pub fn cache(&self) -> &SessionCache {
46        &self.cache
47    }
48
49    pub fn storage(&self) -> &Arc<dyn Storage> {
50        &self.storage
51    }
52
53    pub fn persistence(&self) -> &Arc<LockedSessionStore> {
54        &self.persistence
55    }
56
57    /// Load a session from the memory cache, falling back to durable storage
58    /// (and back-filling the cache on a storage hit). `None` if absent in both.
59    pub async fn load(&self, session_id: &str) -> Option<Session> {
60        if let Some(session) = read_cached_session(&self.cache, session_id) {
61            return Some(session);
62        }
63
64        match self.storage.load_session(session_id).await {
65            Ok(Some(session)) => {
66                self.cache.insert(
67                    session_id.to_string(),
68                    Arc::new(parking_lot::RwLock::new(session.clone())),
69                );
70                Some(session)
71            }
72            _ => None,
73        }
74    }
75
76    /// Like [`load`](Self::load), but surfaces storage errors instead of
77    /// swallowing them to `None`. Cache hit short-circuits; a storage hit
78    /// back-fills the cache.
79    pub async fn try_load(&self, session_id: &str) -> std::io::Result<Option<Session>> {
80        if let Some(session) = read_cached_session(&self.cache, session_id) {
81            return Ok(Some(session));
82        }
83        let loaded = self.storage.load_session(session_id).await?;
84        if let Some(ref session) = loaded {
85            self.cache.insert(
86                session_id.to_string(),
87                Arc::new(parking_lot::RwLock::new(session.clone())),
88            );
89        }
90        Ok(loaded)
91    }
92
93    /// Persist the session (merge-on-write) and refresh the cache, surfacing
94    /// storage errors. Use [`save_and_cache`](Self::save_and_cache) for the
95    /// fire-and-forget variant that logs and continues on failure.
96    pub async fn save(&self, session: &mut Session) -> std::io::Result<()> {
97        self.persistence.merge_save_runtime(session).await?;
98        self.cache.insert(
99            session.id.clone(),
100            Arc::new(parking_lot::RwLock::new(session.clone())),
101        );
102        Ok(())
103    }
104
105    /// Atomically mutate the latest durable runtime session and refresh the
106    /// cache with the saved value. This is the safe path for narrow metadata
107    /// indexes that can be updated concurrently with runner message writes.
108    pub async fn update_runtime_session<F>(
109        &self,
110        session_id: &str,
111        metadata_keys: &[&str],
112        mutate: F,
113    ) -> std::io::Result<Option<Session>>
114    where
115        F: FnOnce(&mut Session),
116    {
117        let saved = self
118            .persistence
119            .update_runtime_config(session_id, mutate)
120            .await?;
121        if let (Some(saved), Some(cached)) = (saved.as_ref(), self.cache.get(session_id)) {
122            let mut cached = cached.write();
123            for key in metadata_keys {
124                if let Some(value) = saved.metadata.get(*key) {
125                    cached.metadata.insert((*key).to_string(), value.clone());
126                } else {
127                    cached.metadata.remove(*key);
128                }
129            }
130        }
131        Ok(saved)
132    }
133
134    /// Load a session, creating a fresh `Session::new(id, model)` if absent.
135    pub async fn load_or_create(&self, session_id: &str, model: &str) -> Session {
136        if let Some(session) = self.load(session_id).await {
137            return session;
138        }
139        Session::new(session_id.to_string(), model.to_string())
140    }
141
142    /// Load a session, reconciling the memory and storage copies via a
143    /// preference heuristic: storage wins when it is strictly newer, or when it
144    /// is the same age but still carries a pending question memory lost. Storage
145    /// is **never** preferred when it is strictly older than memory.
146    ///
147    /// The cache is refreshed cache-aside but with a no-regression guarantee:
148    /// `load_merged` never overwrites a newer cached session with an older
149    /// storage copy, so it is safe to call from hot read paths.
150    pub async fn load_merged(&self, session_id: &str) -> Option<Session> {
151        let memory_session = read_cached_session(&self.cache, session_id);
152        let storage_session = self
153            .storage
154            .load_session(session_id)
155            .await
156            .unwrap_or_default();
157
158        match (memory_session, storage_session) {
159            (Some(memory), Some(storage)) => {
160                let prefer_storage = should_prefer_storage(&memory, &storage);
161                let diverged = prefer_storage || memory.messages.len() != storage.messages.len();
162                let chosen_len = if prefer_storage {
163                    storage.messages.len()
164                } else {
165                    memory.messages.len()
166                };
167                macro_rules! merged_log {
168                    ($level:ident) => {
169                        tracing::$level!(
170                            "[{}] load_session_merged: memory={} msgs (updated_at={}), storage={} msgs (updated_at={}), prefer_storage={} -> chose {} msgs",
171                            session_id,
172                            memory.messages.len(),
173                            memory.updated_at,
174                            storage.messages.len(),
175                            storage.updated_at,
176                            prefer_storage,
177                            chosen_len,
178                        )
179                    };
180                }
181                if diverged {
182                    merged_log!(debug);
183                } else {
184                    merged_log!(trace);
185                }
186                let memory_updated_at = memory.updated_at;
187                let chosen = if prefer_storage { storage } else { memory };
188                // Cache-aside refresh with a hard no-regression invariant: only
189                // write back when we actually reconciled *to storage* (a memory
190                // win is already the cached copy; re-inserting it would needlessly
191                // replace a possibly-live Arc) AND the reconciled copy is not
192                // older than what memory already holds. This is what makes
193                // `load_merged` safe on hot read paths — it can never clobber a
194                // freshly-updated session with a stale storage copy.
195                if prefer_storage && chosen.updated_at >= memory_updated_at {
196                    self.cache.insert(
197                        session_id.to_string(),
198                        Arc::new(parking_lot::RwLock::new(chosen.clone())),
199                    );
200                }
201                Some(chosen)
202            }
203            (Some(memory), None) => Some(memory),
204            (None, Some(storage)) => {
205                self.cache.insert(
206                    session_id.to_string(),
207                    Arc::new(parking_lot::RwLock::new(storage.clone())),
208                );
209                Some(storage)
210            }
211            (None, None) => None,
212        }
213    }
214
215    /// Persist the session (merge-on-write, preserving concurrent UI edits to
216    /// the authoritative metadata group) and refresh the in-memory cache.
217    pub async fn save_and_cache(&self, session: &mut Session) {
218        if let Err(error) = self.persistence.merge_save_runtime(session).await {
219            tracing::warn!("[{}] Failed to save session: {}", session.id, error);
220        }
221        self.cache.insert(
222            session.id.clone(),
223            Arc::new(parking_lot::RwLock::new(session.clone())),
224        );
225    }
226}
227
228fn should_prefer_storage(memory_session: &Session, storage_session: &Session) -> bool {
229    // Never reconcile *backwards* to a strictly-older storage copy: if memory is
230    // newer it is authoritative (e.g. it just answered and cleared a pending
231    // question while storage still holds the stale one). Respecting `updated_at`
232    // here is what stops `load_merged` from returning — and caching — stale data.
233    if storage_session.updated_at < memory_session.updated_at {
234        return false;
235    }
236    // Storage is same-age or newer: prefer it when strictly newer, or when it
237    // still carries a pending question that the (same-age) memory copy lost, so
238    // a genuine clarification is never dropped.
239    storage_session.updated_at > memory_session.updated_at
240        || (memory_session.pending_question.is_none() && storage_session.pending_question.is_some())
241}
242
243/// `SessionRepository` is the canonical `RuntimeSessionPersistence`: the runtime
244/// can persist a session through the same coordinator (merge-on-write + cache
245/// refresh) instead of a bespoke adapter.
246#[async_trait::async_trait]
247impl bamboo_domain::RuntimeSessionPersistence for SessionRepository {
248    async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
249        // Runtime authorization reads through this same cache. Refresh it even
250        // when durable storage fails so a current activation can never observe
251        // a previous run's skill allowlist. The error is still returned to the
252        // caller and durable state remains unchanged.
253        let result = self.persistence.merge_save_runtime(session).await;
254        self.cache.insert(
255            session.id.clone(),
256            Arc::new(parking_lot::RwLock::new(session.clone())),
257        );
258        result
259    }
260
261    async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
262        // The execute-boundary checkpoint uses LockedSessionStore's atomic
263        // append-safe transcript merge, then publishes that reconciled snapshot
264        // to the runtime cache.  On failure, leave the existing cache alone: the
265        // checkpoint may have failed to load the latest durable transcript, and
266        // replacing a fresher cache entry with the stale runner snapshot would
267        // set up a later SHRINK write.
268        let result = self.persistence.checkpoint_runtime_session(session).await;
269        if result.is_ok() {
270            self.cache.insert(
271                session.id.clone(),
272                Arc::new(parking_lot::RwLock::new(session.clone())),
273            );
274        }
275        result
276    }
277
278    async fn load_runtime_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
279        self.try_load(session_id).await
280    }
281
282    async fn clear_legacy_pending_messages(
283        &self,
284        session_id: &str,
285        expected: &[serde_json::Value],
286    ) -> std::io::Result<bool> {
287        // Do not use the trait default here: `load_runtime_session` is
288        // cache-first, so a stale cache could erase a message concurrently
289        // appended to the durable legacy queue. Delegate the compare-and-clear
290        // to LockedSessionStore's single per-session critical section.
291        let cleared = bamboo_domain::RuntimeSessionPersistence::clear_legacy_pending_messages(
292            self.persistence.as_ref(),
293            session_id,
294            expected,
295        )
296        .await?;
297        if !cleared {
298            return Ok(false);
299        }
300
301        // Refresh only after the durable CAS succeeds. A failed comparison or
302        // persistence error leaves the live cache untouched.
303        let latest = self
304            .storage
305            .load_session(session_id)
306            .await?
307            .ok_or_else(|| {
308                std::io::Error::new(
309                    std::io::ErrorKind::NotFound,
310                    format!("session disappeared after clearing legacy inbox: {session_id}"),
311                )
312            })?;
313        self.cache.insert(
314            session_id.to_string(),
315            Arc::new(parking_lot::RwLock::new(latest)),
316        );
317        Ok(true)
318    }
319
320    async fn append_token_usage_record(
321        &self,
322        session_id: &str,
323        json_line: &str,
324    ) -> std::io::Result<()> {
325        self.storage
326            .append_token_usage_record(session_id, json_line)
327            .await
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use bamboo_agent_core::storage::Storage;
335    use chrono::Utc;
336    use std::collections::HashMap;
337    use std::sync::Mutex;
338
339    #[derive(Default)]
340    struct MapStorage {
341        sessions: Mutex<HashMap<String, Session>>,
342    }
343
344    struct FailingSaveStorage {
345        persisted: Mutex<Option<Session>>,
346    }
347
348    #[async_trait::async_trait]
349    impl Storage for MapStorage {
350        async fn save_session(&self, session: &Session) -> std::io::Result<()> {
351            self.sessions
352                .lock()
353                .unwrap()
354                .insert(session.id.clone(), session.clone());
355            Ok(())
356        }
357        async fn load_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
358            Ok(self.sessions.lock().unwrap().get(session_id).cloned())
359        }
360        async fn delete_session(&self, session_id: &str) -> std::io::Result<bool> {
361            Ok(self.sessions.lock().unwrap().remove(session_id).is_some())
362        }
363    }
364
365    #[async_trait::async_trait]
366    impl Storage for FailingSaveStorage {
367        async fn save_session(&self, _session: &Session) -> std::io::Result<()> {
368            Err(std::io::Error::other("injected save failure"))
369        }
370
371        async fn load_session(&self, _session_id: &str) -> std::io::Result<Option<Session>> {
372            Ok(self.persisted.lock().unwrap().clone())
373        }
374
375        async fn delete_session(&self, _session_id: &str) -> std::io::Result<bool> {
376            Ok(false)
377        }
378    }
379
380    fn test_repo(storage: Arc<dyn Storage>) -> SessionRepository {
381        let cache: SessionCache = Arc::new(dashmap::DashMap::new());
382        let persistence = Arc::new(LockedSessionStore::new(storage.clone()));
383        SessionRepository::new(cache, storage, persistence)
384    }
385
386    fn cache_put(repo: &SessionRepository, session: &Session) {
387        repo.cache().insert(
388            session.id.clone(),
389            Arc::new(parking_lot::RwLock::new(session.clone())),
390        );
391    }
392
393    #[tokio::test]
394    async fn narrow_runtime_metadata_transaction_preserves_live_and_durable_non_owned_state() {
395        let storage: Arc<dyn Storage> = Arc::new(MapStorage::default());
396        let repo = test_repo(storage.clone());
397        let id = "narrow-metadata";
398        let mut durable = Session::new(id, "durable-model");
399        durable.add_message(bamboo_agent_core::Message::user("durable user turn"));
400        durable
401            .metadata
402            .insert("external.durable".to_string(), "keep".to_string());
403        storage.save_session(&durable).await.expect("seed durable");
404
405        let mut live = durable.clone();
406        live.add_message(bamboo_agent_core::Message::assistant(
407            "in-flight assistant tool call",
408            None,
409        ));
410        live.model = "live-model".to_string();
411        live.metadata
412            .insert("external.live".to_string(), "keep".to_string());
413        cache_put(&repo, &live);
414
415        repo.update_runtime_session(id, &["workflow.owned"], |latest| {
416            latest
417                .metadata
418                .insert("workflow.owned".to_string(), "active".to_string());
419        })
420        .await
421        .expect("transaction")
422        .expect("session exists");
423
424        let saved = storage
425            .load_session(id)
426            .await
427            .expect("load durable")
428            .expect("durable exists");
429        assert_eq!(
430            saved.messages.len(),
431            1,
432            "transaction never writes stale live messages"
433        );
434        assert_eq!(
435            saved.metadata.get("external.durable").map(String::as_str),
436            Some("keep")
437        );
438        assert_eq!(
439            saved.metadata.get("workflow.owned").map(String::as_str),
440            Some("active")
441        );
442
443        let cached = read_cached_session(repo.cache(), id).expect("live cache");
444        assert_eq!(
445            cached.messages.len(),
446            2,
447            "cache live tool call is not replaced"
448        );
449        assert_eq!(cached.model, "live-model");
450        assert_eq!(
451            cached.metadata.get("external.live").map(String::as_str),
452            Some("keep")
453        );
454        assert_eq!(
455            cached.metadata.get("workflow.owned").map(String::as_str),
456            Some("active")
457        );
458    }
459
460    /// Regression guard: a strictly-newer in-memory session (e.g. one that just
461    /// answered and cleared its pending question) must win over a strictly-older
462    /// storage copy that still carries the pending question — both in the value
463    /// returned AND in the cache (no clobber).
464    #[tokio::test]
465    async fn load_merged_does_not_regress_to_older_storage() {
466        let storage: Arc<dyn Storage> = Arc::new(MapStorage::default());
467        let repo = test_repo(storage.clone());
468        let id = "s1";
469
470        let mut stale = Session::new(id.to_string(), "m");
471        stale.set_pending_question(
472            "tc1".into(),
473            "kind".into(),
474            "q?".into(),
475            vec!["OK".into()],
476            true,
477        );
478        stale.updated_at = Utc::now() - chrono::Duration::seconds(10);
479        storage.save_session(&stale).await.unwrap();
480
481        let mut fresh = Session::new(id.to_string(), "m");
482        fresh.updated_at = Utc::now();
483        cache_put(&repo, &fresh);
484
485        let merged = repo.load_merged(id).await.expect("session exists");
486        assert!(
487            merged.pending_question.is_none(),
488            "must return the newer answered memory copy, not the stale storage one"
489        );
490        let cached = read_cached_session(repo.cache(), id).expect("cached");
491        assert!(
492            cached.pending_question.is_none(),
493            "load_merged must never regress the cache to a stale storage copy"
494        );
495    }
496
497    /// The pending-question recovery still works when storage is the same age:
498    /// if memory lost a pending question that same-age storage retains, prefer
499    /// storage so a genuine clarification is not dropped.
500    #[tokio::test]
501    async fn load_merged_recovers_pending_question_from_same_age_storage() {
502        let storage: Arc<dyn Storage> = Arc::new(MapStorage::default());
503        let repo = test_repo(storage.clone());
504        let id = "s2";
505        let ts = Utc::now();
506
507        let mut with_pending = Session::new(id.to_string(), "m");
508        with_pending.set_pending_question(
509            "tc".into(),
510            "k".into(),
511            "q".into(),
512            vec!["OK".into()],
513            true,
514        );
515        with_pending.updated_at = ts;
516        storage.save_session(&with_pending).await.unwrap();
517
518        let mut lost = with_pending.clone();
519        lost.clear_pending_question();
520        lost.updated_at = ts;
521        cache_put(&repo, &lost);
522
523        let merged = repo.load_merged(id).await.expect("session exists");
524        assert!(
525            merged.pending_question.is_some(),
526            "same-age storage carrying a pending question must still be recovered"
527        );
528    }
529
530    #[tokio::test]
531    async fn runtime_publish_refreshes_cache_even_when_storage_fails() {
532        let id = "runtime-selection";
533        let mut previous = Session::new(id.to_string(), "m");
534        previous.metadata.insert(
535            "skill_runtime_selected_skill_ids".to_string(),
536            "[\"plan\"]".to_string(),
537        );
538        let storage: Arc<dyn Storage> = Arc::new(FailingSaveStorage {
539            persisted: Mutex::new(Some(previous.clone())),
540        });
541        let repo = test_repo(storage.clone());
542        cache_put(&repo, &previous);
543
544        let mut current = previous.clone();
545        current.metadata.insert(
546            "skill_runtime_selected_skill_ids".to_string(),
547            "[\"review\"]".to_string(),
548        );
549        current.updated_at = Utc::now();
550
551        let result =
552            bamboo_domain::RuntimeSessionPersistence::save_runtime_session(&repo, &mut current)
553                .await;
554        assert!(result.is_err(), "durable failure must still be surfaced");
555
556        let cached = repo.load(id).await.expect("cached current session");
557        assert_eq!(
558            cached
559                .metadata
560                .get("skill_runtime_selected_skill_ids")
561                .map(String::as_str),
562            Some("[\"review\"]")
563        );
564        let allowlist = bamboo_skills::access_control::extract_skill_allowlist(&cached.metadata)
565            .expect("runtime authorization allowlist");
566        assert!(allowlist.contains("review"));
567        assert!(!allowlist.contains("plan"));
568        let durable = storage
569            .load_session(id)
570            .await
571            .expect("load durable state")
572            .expect("previous durable session");
573        assert_eq!(
574            durable
575                .metadata
576                .get("skill_runtime_selected_skill_ids")
577                .map(String::as_str),
578            Some("[\"plan\"]")
579        );
580    }
581
582    #[tokio::test]
583    async fn legacy_clear_uses_durable_cas_and_never_erases_concurrent_append_from_stale_cache() {
584        let storage: Arc<dyn Storage> = Arc::new(MapStorage::default());
585        let repo = test_repo(storage.clone());
586        let id = "legacy-cas-race";
587        let expected = vec![serde_json::json!({"content": "first"})];
588
589        let mut stale_cache = Session::new(id, "m");
590        stale_cache.set_pending_injected_messages(expected.clone());
591        cache_put(&repo, &stale_cache);
592
593        let mut durable = stale_cache.clone();
594        durable.set_pending_injected_messages(vec![
595            serde_json::json!({"content": "first"}),
596            serde_json::json!({"content": "concurrent"}),
597        ]);
598        storage.save_session(&durable).await.unwrap();
599
600        let cleared = bamboo_domain::RuntimeSessionPersistence::clear_legacy_pending_messages(
601            &repo, id, &expected,
602        )
603        .await
604        .unwrap();
605        assert!(!cleared, "the durable compare-and-clear must reject drift");
606        assert_eq!(
607            storage
608                .load_session(id)
609                .await
610                .unwrap()
611                .unwrap()
612                .pending_injected_messages()
613                .unwrap(),
614            durable.pending_injected_messages().unwrap(),
615            "the concurrent durable append must remain intact"
616        );
617        assert_eq!(
618            read_cached_session(repo.cache(), id)
619                .unwrap()
620                .pending_injected_messages()
621                .unwrap(),
622            expected,
623            "a failed CAS must not mutate the existing cache"
624        );
625    }
626
627    #[tokio::test]
628    async fn successful_legacy_clear_refreshes_stale_cache_from_durable_state() {
629        let storage: Arc<dyn Storage> = Arc::new(MapStorage::default());
630        let repo = test_repo(storage.clone());
631        let id = "legacy-cas-success";
632        let expected = vec![serde_json::json!({"content": "first"})];
633
634        let mut stale_cache = Session::new(id, "stale-model");
635        stale_cache.set_pending_injected_messages(expected.clone());
636        cache_put(&repo, &stale_cache);
637
638        let mut durable = Session::new(id, "durable-model");
639        durable.set_pending_injected_messages(expected.clone());
640        durable
641            .metadata
642            .insert("durable-only".to_string(), "keep".to_string());
643        storage.save_session(&durable).await.unwrap();
644
645        assert!(
646            bamboo_domain::RuntimeSessionPersistence::clear_legacy_pending_messages(
647                &repo, id, &expected,
648            )
649            .await
650            .unwrap()
651        );
652        let cached = read_cached_session(repo.cache(), id).unwrap();
653        assert!(!cached.has_pending_injected_messages());
654        assert_eq!(cached.model, "durable-model");
655        assert_eq!(
656            cached.metadata.get("durable-only").map(String::as_str),
657            Some("keep")
658        );
659    }
660}