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