Skip to main content

bamboo_storage/
session_merge.rs

1//! Merge-aware session save helper.
2//!
3//! Provides [`merge_save_session`], which preserves any concurrent UI edits to
4//! the authoritative metadata group (`title`, `title_version`, `pinned`,
5//! `metadata_version`) before writing the runtime-modified session to storage.
6//! Re-reads the latest persisted copy and only takes in-memory values when the
7//! caller's `metadata_version` strictly exceeds disk's.
8//!
9//! ## Field-by-field merge policy
10//!
11//! All authoritative metadata fields are grouped under `metadata_version`:
12//! when `disk.metadata_version >= session.metadata_version`, the on-disk
13//! `title`, `title_version`, `pinned`, and `metadata_version` overwrite the
14//! in-memory values before writing. Authoritative writers bump
15//! `metadata_version` (and `title_version` for title edits) before calling so
16//! their values survive the merge; non-authoritative writers don't bump and so
17//! are overwritten by any later disk changes.
18//!
19//! ## Two save primitives
20//!
21//! - **`merge_save_session`** — stateless merge+save. Still works for
22//!   non-authoritative writers that hold `Arc<dyn Storage>` directly.
23//! - **`LockedSessionStore::merge_save_runtime`** — per-session-locked variant
24//!   that additionally serializes writes for the same session. Prefer this for
25//!   server-side paths where an authoritative writer may race with a runtime
26//!   save.
27//! - **`LockedSessionStore::commit_metadata`** — plain save inside a per-session
28//!   lock. For authoritative writers that have already performed
29//!   load→mutate→bump inside the lock; no merge needed (they hold the latest).
30//!
31//! Bare [`Storage::save_session`] is reserved for first-write paths (e.g. new
32//! session creation) where there is no prior on-disk copy to merge against.
33
34use std::sync::Arc;
35
36use bamboo_domain::session::types::Session;
37use bamboo_domain::storage::Storage;
38use bamboo_domain::RuntimeSessionPersistence;
39use dashmap::DashMap;
40use tokio::sync::{Mutex, OwnedMutexGuard};
41
42const AUTHORITATIVE_METADATA_KEYS: &[&str] = &["gold_config"];
43
44// ── LockedSessionStore ────────────────────────────────────────────────
45
46/// Wraps a [`Storage`] implementation with per-session write serialization.
47///
48/// Under the hood it maintains a `DashMap<String, Arc<Mutex<()>>>` so that
49/// only writes targeting the *same* session are serialised; different
50/// sessions proceed concurrently.
51pub struct LockedSessionStore {
52    storage: Arc<dyn Storage>,
53    locks: Arc<DashMap<String, Arc<Mutex<()>>>>,
54}
55
56/// Self-cleaning guard returned by [`LockedSessionStore::acquire_lock`].
57///
58/// Holds the `OwnedMutexGuard` for the session's serialization mutex. On drop it
59/// releases the mutex **first** (so this guard's `Arc` clone is gone before the
60/// count is read) and then removes the map entry iff `Arc::strong_count == 1` —
61/// i.e. only the map's own reference remains, no other task holds or is waiting
62/// on this session's lock.
63///
64/// ## Race freedom
65///
66/// The strong-count check and the removal execute atomically under DashMap's
67/// per-shard lock via [`DashMap::remove_if`]. A waiter that clones the `Arc`
68/// (through `acquire_lock`'s `entry()`) does so under the same shard lock, so it
69/// either:
70/// - clones **before** our `remove_if` → `strong_count >= 2` → we skip removal,
71///   the waiter keeps a live, map-resident lock; or
72/// - clones **after** our `remove_if` → the entry is gone → it inserts a fresh
73///   `Arc<Mutex<()>>`; since our guard had already been released, the two tasks
74///   never overlapped and needed no mutual exclusion.
75///
76/// There is therefore no interleaving in which a waiter observes a lock that we
77/// then delete out from under it.
78pub struct SessionLockGuard {
79    /// `Option` so `Drop` can release the mutex before evaluating strong-count.
80    guard: Option<OwnedMutexGuard<()>>,
81    locks: Arc<DashMap<String, Arc<Mutex<()>>>>,
82    session_id: String,
83}
84
85impl Drop for SessionLockGuard {
86    fn drop(&mut self) {
87        // Release the mutex (drops this guard's `Arc` clone) BEFORE reading the
88        // strong count, otherwise the count can never reach 1.
89        self.guard.take();
90        self.locks
91            .remove_if(&self.session_id, |_, arc| Arc::strong_count(arc) == 1);
92    }
93}
94
95impl LockedSessionStore {
96    /// Wrap an existing storage backend.
97    pub fn new(storage: Arc<dyn Storage>) -> Self {
98        Self {
99            storage,
100            locks: Arc::new(DashMap::new()),
101        }
102    }
103
104    /// Borrow the inner storage for read-only access.
105    pub fn storage(&self) -> &Arc<dyn Storage> {
106        &self.storage
107    }
108
109    /// Acquire a per-session serialization guard.
110    ///
111    /// Only writes for the **same** session are serialised; writes for
112    /// different sessions can proceed concurrently.
113    ///
114    /// The returned [`SessionLockGuard`] is **self-cleaning**: when it drops it
115    /// releases the mutex and then removes the map entry iff no other holder
116    /// remains. Without this the `locks` map grew by one entry for every session
117    /// id ever written and never shrank (issue #346), so a long-lived server
118    /// leaked one `Arc<Mutex<()>>` per session-ever-persisted. See
119    /// [`SessionLockGuard`] for the race-freedom argument.
120    pub async fn acquire_lock(&self, session_id: &str) -> SessionLockGuard {
121        // `entry().or_insert_with().clone()` releases the DashMap shard lock at
122        // the end of THIS statement, before the `.await` below — never hold a
123        // shard lock across the async lock acquisition (it would deadlock the
124        // self-cleaning `remove_if` on drop, which also takes the shard lock).
125        let lock = self
126            .locks
127            .entry(session_id.to_string())
128            .or_insert_with(|| Arc::new(Mutex::new(())))
129            .clone();
130        let guard = lock.lock_owned().await;
131        SessionLockGuard {
132            guard: Some(guard),
133            locks: self.locks.clone(),
134            session_id: session_id.to_string(),
135        }
136    }
137
138    /// Runtime-only save: persist the control-plane (`agent_runtime_state`,
139    /// metadata, …) without rewriting the message history.
140    ///
141    /// This is the fast path for runtime-state mutations that do NOT change
142    /// `messages` — e.g. registering a parent's wait for spawned children. It
143    /// delegates to [`Storage::save_runtime_state`], which writes a small
144    /// sidecar (or falls back to a full save on backends without one).
145    ///
146    /// Like [`Self::merge_save_runtime`], it merges newer authoritative metadata
147    /// from disk so a concurrent UI title/pin edit is never clobbered — but it
148    /// reads only the lightweight control-plane snapshot (no message history) to
149    /// do so.
150    ///
151    /// Callers MUST NOT use this when they have appended messages: the in-memory
152    /// `messages` are ignored by the sidecar and would not be persisted.
153    pub async fn save_runtime_only(&self, session: &mut Session) -> std::io::Result<()> {
154        let _guard = self.acquire_lock(&session.id).await;
155        if let Ok(Some(latest)) = self.storage.load_runtime_control_plane(&session.id).await {
156            apply_authoritative_metadata(session, &latest);
157        }
158        self.storage.save_runtime_state(session).await
159    }
160
161    /// Authoritative metadata commit.
162    ///
163    /// The caller must have already loaded the latest session, mutated the
164    /// metadata fields, and bumped `metadata_version` (and `title_version` if
165    /// applicable).  This method simply acquires the per-session lock and
166    /// performs a plain `storage.save_session`.
167    ///
168    /// The lock guarantees that no other write for this session interleaves
169    /// between the caller's load and this save, so merge is unnecessary.
170    pub async fn commit_metadata(&self, session: &Session) -> std::io::Result<()> {
171        let _guard = self.acquire_lock(&session.id).await;
172        self.storage.save_session(session).await
173    }
174
175    /// Runtime / non-authoritative save with per-session lock.
176    ///
177    /// Inside the lock: reload disk, merge the authoritative metadata group
178    /// (`title`, `title_version`, `pinned`, `metadata_version`) from disk into
179    /// the in-memory copy if disk's `metadata_version >= session.metadata_version`,
180    /// then save.
181    ///
182    /// This is the locked equivalent of [`merge_save_session`]; prefer it for
183    /// server-side paths where an authoritative write may race with this save.
184    pub async fn merge_save_runtime(&self, session: &mut Session) -> std::io::Result<()> {
185        let _guard = self.acquire_lock(&session.id).await;
186
187        // Single disk read serves BOTH the SHRINK diagnostic and the
188        // authoritative-metadata merge below. Previously this path loaded the
189        // session twice (once here, once inside the merge helper); on a parent
190        // session carrying the full conversation history that doubled the
191        // deserialization cost of every runtime save, which is the hot path
192        // during sub-agent spawn.
193        let latest = self.storage.load_session(&session.id).await.ok().flatten();
194
195        // DIAGNOSTIC: merge_save_runtime overwrites the whole `messages` array
196        // (it only merges authoritative metadata, not messages). If the incoming
197        // session is stale (fewer messages than what is already on disk), this save
198        // silently reverts a concurrent append (e.g. a just-persisted user message).
199        // Log a SHRINK warning so we can identify the stale writer.
200        let existing_message_count = latest.as_ref().map(|s| s.messages.len());
201        let incoming_message_count = session.messages.len();
202        if existing_message_count.is_some_and(|existing| existing > incoming_message_count) {
203            tracing::warn!(
204                "[{}] merge_save_runtime SHRINK: disk has {:?} messages, saving {} (last_role={:?}, updated_at={}); a stale writer is reverting a concurrent append",
205                session.id,
206                existing_message_count,
207                incoming_message_count,
208                session.messages.last().map(|m| format!("{:?}", m.role)),
209                session.updated_at,
210            );
211        } else {
212            tracing::debug!(
213                "[{}] merge_save_runtime: disk={:?} messages, saving {} (updated_at={})",
214                session.id,
215                existing_message_count,
216                incoming_message_count,
217                session.updated_at,
218            );
219        }
220
221        if let Some(latest) = latest.as_ref() {
222            apply_authoritative_metadata(session, latest);
223        }
224        self.storage.save_session(session).await
225    }
226
227    /// Apply a config-only mutation to a session without ever clobbering its
228    /// `messages` (or other concurrently-written state).
229    ///
230    /// Unlike [`Self::merge_save_runtime`], the caller does NOT pass a session
231    /// snapshot. Instead this loads the **latest** session from storage *inside*
232    /// the per-session lock, applies `mutate` (intended for small config fields
233    /// like `model_ref` / `reasoning_effort`), and saves. Because the load and
234    /// save both happen under the lock, a concurrent append (e.g. `POST /chat`
235    /// adding a user message) can never be reverted by this write.
236    ///
237    /// Returns the saved session, or `None` if it does not exist.
238    pub async fn update_runtime_config<F>(
239        &self,
240        session_id: &str,
241        mutate: F,
242    ) -> std::io::Result<Option<Session>>
243    where
244        F: FnOnce(&mut Session),
245    {
246        let _guard = self.acquire_lock(session_id).await;
247        let Some(mut session) = self.storage.load_session(session_id).await? else {
248            return Ok(None);
249        };
250        mutate(&mut session);
251        self.storage.save_session(&session).await?;
252        Ok(Some(session))
253    }
254}
255
256/// Infrastructure implementation of the domain runtime-persistence port.
257/// Server should assemble this as `Arc<dyn RuntimeSessionPersistence>` and must
258/// not define a separate adapter layer for the same behavior.
259#[async_trait::async_trait]
260impl RuntimeSessionPersistence for LockedSessionStore {
261    async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
262        self.merge_save_runtime(session).await
263    }
264}
265
266// ── Internal merge helper ─────────────────────────────────────────────
267
268/// Re-read the on-disk session and, when the disk copy carries a
269/// `metadata_version >= session.metadata_version`, overwrite the in-memory
270/// authoritative metadata fields with the disk values.
271///
272/// This is the core staleness-correction: non-authoritative writers call it
273/// before saving so they don't accidentally revert a concurrent UI edit.
274async fn merge_authoritative_metadata_into_stale(
275    storage: &Arc<dyn Storage>,
276    session: &mut Session,
277) {
278    if let Ok(Some(latest)) = storage.load_session(&session.id).await {
279        apply_authoritative_metadata(session, &latest);
280    }
281}
282
283/// Pure merge step: given a freshly-loaded on-disk copy, overwrite the
284/// in-memory authoritative metadata group when disk's `metadata_version` is at
285/// least the in-memory one. Split out so callers that have already loaded the
286/// disk copy (e.g. [`LockedSessionStore::merge_save_runtime`]) don't pay for a
287/// second read.
288fn apply_authoritative_metadata(session: &mut Session, latest: &Session) {
289    if latest.metadata_version >= session.metadata_version {
290        session.title = latest.title.clone();
291        session.title_version = latest.title_version;
292        session.pinned = latest.pinned;
293        for key in AUTHORITATIVE_METADATA_KEYS {
294            if let Some(value) = latest.metadata.get(*key) {
295                session.metadata.insert((*key).to_string(), value.clone());
296            } else {
297                session.metadata.remove(*key);
298            }
299        }
300        session.metadata_version = latest.metadata_version;
301    }
302}
303
304// ── Free merge-save function ──────────────────────────────────────────
305
306/// Save a session while preserving any concurrent UI edits to the
307/// authoritative metadata group.
308///
309/// Behaviour: if the on-disk session has `metadata_version >=
310/// session.metadata_version`, the on-disk `title`, `title_version`, `pinned`
311/// and `metadata_version` overwrite the in-memory values before writing.
312///
313/// This is the stateless variant (no per-session lock). Prefer
314/// [`LockedSessionStore::merge_save_runtime`] for server-side paths where an
315/// authoritative writer may race with this save.
316pub async fn merge_save_session(
317    storage: &Arc<dyn Storage>,
318    session: &mut Session,
319) -> std::io::Result<()> {
320    merge_authoritative_metadata_into_stale(storage, session).await;
321    storage.save_session(session).await
322}
323
324// ── Tests ─────────────────────────────────────────────────────────────
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use crate::v2::SessionStoreV2;
330    use bamboo_domain::session::types::Session;
331
332    async fn make_storage() -> (tempfile::TempDir, Arc<dyn Storage>) {
333        let temp = tempfile::tempdir().unwrap();
334        let storage = SessionStoreV2::new(temp.path().to_path_buf())
335            .await
336            .expect("storage init");
337        (temp, Arc::new(storage) as Arc<dyn Storage>)
338    }
339
340    fn fresh(id: &str) -> Session {
341        Session::new(id.to_string(), "test-model".to_string())
342    }
343
344    // ── update_runtime_config: config patches must never clobber messages ──
345
346    #[tokio::test]
347    async fn update_runtime_config_preserves_concurrently_appended_messages() {
348        use bamboo_domain::session::types::Message;
349        use bamboo_domain::ReasoningEffort;
350
351        let (_temp, storage) = make_storage().await;
352        let store = LockedSessionStore::new(storage.clone());
353        let session_id = "cfg-preserve";
354
355        // Persisted baseline: one user + one assistant turn.
356        let mut initial = fresh(session_id);
357        initial.add_message(Message::user("hello"));
358        initial.add_message(Message::assistant("hi", None));
359        storage.save_session(&initial).await.unwrap();
360
361        // Simulate `POST /chat` appending a new user message to disk.
362        let mut after_chat = storage.load_session(session_id).await.unwrap().unwrap();
363        after_chat.add_message(Message::user("second question"));
364        storage.save_session(&after_chat).await.unwrap();
365        assert_eq!(after_chat.messages.len(), 3);
366
367        // A config-only patch must load the freshest session and preserve the
368        // appended message (this is the regression that broke message sending on
369        // existing sessions).
370        let updated = store
371            .update_runtime_config(session_id, |s| {
372                s.reasoning_effort = Some(ReasoningEffort::Max);
373            })
374            .await
375            .unwrap()
376            .expect("session exists");
377
378        assert_eq!(updated.reasoning_effort, Some(ReasoningEffort::Max));
379        assert_eq!(
380            updated.messages.len(),
381            3,
382            "config patch must not revert a concurrently-appended message"
383        );
384
385        let on_disk = storage.load_session(session_id).await.unwrap().unwrap();
386        assert_eq!(on_disk.messages.len(), 3);
387        assert_eq!(on_disk.reasoning_effort, Some(ReasoningEffort::Max));
388    }
389
390    #[tokio::test]
391    async fn update_runtime_config_returns_none_for_missing_session() {
392        use bamboo_domain::ReasoningEffort;
393
394        let (_temp, storage) = make_storage().await;
395        let store = LockedSessionStore::new(storage);
396        let result = store
397            .update_runtime_config("does-not-exist", |s| {
398                s.reasoning_effort = Some(ReasoningEffort::Low);
399            })
400            .await
401            .unwrap();
402        assert!(result.is_none());
403    }
404
405    #[tokio::test]
406    async fn merge_save_runtime_overwrites_messages_from_stale_snapshot() {
407        // Characterization of the bug that motivated `update_runtime_config`:
408        // `merge_save_runtime` writes the caller's `messages` verbatim, so a
409        // stale snapshot reverts a concurrent append. Config-only writers must
410        // therefore use `update_runtime_config`, never `merge_save_runtime`.
411        use bamboo_domain::session::types::Message;
412
413        let (_temp, storage) = make_storage().await;
414        let store = LockedSessionStore::new(storage.clone());
415        let session_id = "stale-clobber";
416
417        // A handler loads the session (1 message) …
418        let mut baseline = fresh(session_id);
419        baseline.add_message(Message::user("hello"));
420        storage.save_session(&baseline).await.unwrap();
421        let mut stale_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
422
423        // … then `POST /chat` appends a second message to disk …
424        let mut after_chat = storage.load_session(session_id).await.unwrap().unwrap();
425        after_chat.add_message(Message::user("second"));
426        storage.save_session(&after_chat).await.unwrap();
427        assert_eq!(
428            storage
429                .load_session(session_id)
430                .await
431                .unwrap()
432                .unwrap()
433                .messages
434                .len(),
435            2
436        );
437
438        // … and the stale handler saves via merge_save_runtime -> append reverted.
439        store.merge_save_runtime(&mut stale_snapshot).await.unwrap();
440        let after = storage.load_session(session_id).await.unwrap().unwrap();
441        assert_eq!(
442            after.messages.len(),
443            1,
444            "merge_save_runtime clobbers concurrent appends — this is why config patches must use update_runtime_config"
445        );
446    }
447
448    #[tokio::test]
449    async fn merge_save_runtime_preserves_disk_authoritative_metadata_with_single_load() {
450        // Regression guard for the single-load refactor of `merge_save_runtime`:
451        // it must STILL pull the authoritative metadata group (title / pinned /
452        // metadata_version) from the freshest on-disk copy when disk's
453        // metadata_version >= the in-memory one, even though it now reads disk
454        // only once.
455        let (_temp, storage) = make_storage().await;
456        let store = LockedSessionStore::new(storage.clone());
457        let session_id = "runtime-merge-meta";
458
459        // Baseline persisted by a runtime writer (metadata_version 0).
460        let mut baseline = fresh(session_id);
461        baseline.title = "Auto Title".to_string();
462        baseline.metadata_version = 0;
463        storage.save_session(&baseline).await.unwrap();
464
465        // A stale runtime snapshot (still metadata_version 0, old title).
466        let mut stale_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
467
468        // An authoritative UI rename bumps metadata_version on disk.
469        let mut renamed = storage.load_session(session_id).await.unwrap().unwrap();
470        renamed.title = "User Renamed".to_string();
471        renamed.title_version = 1;
472        renamed.pinned = true;
473        renamed.metadata_version = 1;
474        store.commit_metadata(&renamed).await.unwrap();
475
476        // The stale runtime writer saves: it must adopt the disk title/pinned.
477        stale_snapshot.title = "Auto Title".to_string();
478        store.merge_save_runtime(&mut stale_snapshot).await.unwrap();
479
480        let after = storage.load_session(session_id).await.unwrap().unwrap();
481        assert_eq!(after.title, "User Renamed");
482        assert!(after.pinned);
483        assert_eq!(after.metadata_version, 1);
484        // And the in-memory copy was corrected by the merge too.
485        assert_eq!(stale_snapshot.title, "User Renamed");
486        assert_eq!(stale_snapshot.metadata_version, 1);
487    }
488
489    // ── Free-function merge tests (updated for metadata-group) ──────
490
491    #[tokio::test]
492    async fn merge_preserves_disk_title_when_versions_equal() {
493        let (_temp, storage) = make_storage().await;
494        let session_id = "merge-equal";
495
496        let mut on_disk = fresh(session_id);
497        on_disk.title = "User Set This".to_string();
498        on_disk.title_version = 0;
499        on_disk.metadata_version = 0;
500        storage.save_session(&on_disk).await.unwrap();
501
502        let mut runtime_copy = fresh(session_id);
503        runtime_copy.title = "Stale Default".to_string();
504        runtime_copy.title_version = 0;
505        runtime_copy.metadata_version = 0;
506        runtime_copy.messages = vec![];
507
508        merge_save_session(&storage, &mut runtime_copy)
509            .await
510            .unwrap();
511
512        let after = storage.load_session(session_id).await.unwrap().unwrap();
513        assert_eq!(after.title, "User Set This");
514        assert_eq!(after.title_version, 0);
515        assert_eq!(runtime_copy.title, "User Set This");
516    }
517
518    #[tokio::test]
519    async fn merge_preserves_disk_when_disk_version_higher() {
520        let (_temp, storage) = make_storage().await;
521        let session_id = "merge-higher";
522
523        let mut on_disk = fresh(session_id);
524        on_disk.title = "User Title v3".to_string();
525        on_disk.title_version = 3;
526        on_disk.metadata_version = 5;
527        storage.save_session(&on_disk).await.unwrap();
528
529        let mut runtime_copy = fresh(session_id);
530        runtime_copy.title = "Stale".to_string();
531        runtime_copy.title_version = 1;
532        runtime_copy.metadata_version = 0;
533
534        merge_save_session(&storage, &mut runtime_copy)
535            .await
536            .unwrap();
537
538        let after = storage.load_session(session_id).await.unwrap().unwrap();
539        assert_eq!(after.title, "User Title v3");
540        assert_eq!(after.title_version, 3);
541        assert_eq!(after.metadata_version, 5);
542    }
543
544    #[tokio::test]
545    async fn merge_now_preserves_disk_pinned_in_metadata_group() {
546        let (_temp, storage) = make_storage().await;
547        let session_id = "pinned-merge";
548
549        let mut on_disk = fresh(session_id);
550        on_disk.pinned = true;
551        on_disk.metadata_version = 2;
552        storage.save_session(&on_disk).await.unwrap();
553
554        let mut runtime_copy = fresh(session_id);
555        runtime_copy.pinned = false;
556        runtime_copy.metadata_version = 0;
557
558        merge_save_session(&storage, &mut runtime_copy)
559            .await
560            .unwrap();
561
562        let after = storage.load_session(session_id).await.unwrap().unwrap();
563        assert!(
564            after.pinned,
565            "disk pinned=true should win over runtime false"
566        );
567        assert_eq!(after.metadata_version, 2);
568    }
569
570    #[tokio::test]
571    async fn merge_keeps_in_memory_when_session_version_higher() {
572        let (_temp, storage) = make_storage().await;
573        let session_id = "merge-bumped";
574
575        let mut on_disk = fresh(session_id);
576        on_disk.title = "Old".to_string();
577        on_disk.title_version = 1;
578        on_disk.metadata_version = 3;
579        storage.save_session(&on_disk).await.unwrap();
580
581        let mut authoritative_copy = fresh(session_id);
582        authoritative_copy.title = "New Authoritative".to_string();
583        authoritative_copy.title_version = 2;
584        authoritative_copy.metadata_version = 4;
585        authoritative_copy.pinned = true;
586
587        merge_save_session(&storage, &mut authoritative_copy)
588            .await
589            .unwrap();
590
591        let after = storage.load_session(session_id).await.unwrap().unwrap();
592        assert_eq!(after.title, "New Authoritative");
593        assert_eq!(after.title_version, 2);
594        assert_eq!(after.metadata_version, 4);
595        assert!(after.pinned);
596    }
597
598    #[tokio::test]
599    async fn merge_keeps_runtime_messages_when_disk_only_changed_metadata() {
600        let (_temp, storage) = make_storage().await;
601        let session_id = "merge-messages";
602
603        let mut on_disk = fresh(session_id);
604        on_disk.title = "Fresh Title".to_string();
605        on_disk.title_version = 2;
606        on_disk.metadata_version = 5;
607        storage.save_session(&on_disk).await.unwrap();
608
609        let mut runtime_copy = fresh(session_id);
610        runtime_copy.title = "Stale".to_string();
611        runtime_copy.metadata_version = 0;
612        runtime_copy.messages = vec![bamboo_domain::session::types::Message {
613            role: bamboo_domain::session::types::Role::User,
614            content: "keep me".to_string(),
615            id: "msg-1".to_string(),
616            created_at: chrono::Utc::now(),
617            reasoning: None,
618            content_parts: None,
619            image_ocr: None,
620            phase: None,
621            tool_calls: None,
622            tool_call_id: None,
623            tool_success: None,
624            compressed: false,
625            compressed_by_event_id: None,
626            never_compress: false,
627            compression_level: 0,
628            metadata: None,
629        }];
630
631        merge_save_session(&storage, &mut runtime_copy)
632            .await
633            .unwrap();
634
635        let after = storage.load_session(session_id).await.unwrap().unwrap();
636        assert_eq!(after.title, "Fresh Title");
637        assert_eq!(after.metadata_version, 5);
638        assert_eq!(after.messages.len(), 1);
639        assert_eq!(after.messages[0].content, "keep me");
640    }
641
642    // ── LockedSessionStore tests ────────────────────────────────────
643
644    #[tokio::test]
645    async fn locked_merge_save_runtime_serialises_concurrent_writes() {
646        let (_temp, storage) = make_storage().await;
647        let store = Arc::new(LockedSessionStore::new(storage));
648        let session_id = "lock-serial".to_string();
649
650        // Seed with base version.
651        let base = fresh(&session_id);
652        store.storage().save_session(&base).await.unwrap();
653
654        // Two concurrent authorised writers each bump and commit.
655        // We'll simulate via clone-and-bump-then-commit.
656        let store_a = store.clone();
657        let store_b = store.clone();
658        let sid_a = session_id.clone();
659        let sid_b = session_id.clone();
660
661        let a = tokio::spawn(async move {
662            let _guard = store_a.acquire_lock(&sid_a).await;
663            let mut s = store_a
664                .storage()
665                .load_session(&sid_a)
666                .await
667                .unwrap()
668                .unwrap();
669            s.title = "Writer A".to_string();
670            s.title_version = s.title_version.saturating_add(1);
671            s.metadata_version = s.metadata_version.saturating_add(1);
672            s.updated_at = chrono::Utc::now();
673            store_a.storage().save_session(&s).await.unwrap();
674            s.title_version
675        });
676
677        // Tiny yield so A goes first.
678        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
679
680        let b = tokio::spawn(async move {
681            let _guard = store_b.acquire_lock(&sid_b).await;
682            let mut s = store_b
683                .storage()
684                .load_session(&sid_b)
685                .await
686                .unwrap()
687                .unwrap();
688            s.title = "Writer B".to_string();
689            s.title_version = s.title_version.saturating_add(1);
690            s.metadata_version = s.metadata_version.saturating_add(1);
691            s.updated_at = chrono::Utc::now();
692            store_b.storage().save_session(&s).await.unwrap();
693            s.title_version
694        });
695
696        let (ver_a, ver_b) = tokio::join!(a, b);
697        let final_s = store
698            .storage()
699            .load_session(&session_id)
700            .await
701            .unwrap()
702            .unwrap();
703        assert!(
704            ver_a.unwrap() != ver_b.unwrap(),
705            "concurrent writers must produce distinct versions"
706        );
707        assert_eq!(final_s.metadata_version, 2);
708    }
709
710    #[tokio::test]
711    async fn commit_metadata_is_plain_save_inside_lock() {
712        let (_temp, storage) = make_storage().await;
713        let store = LockedSessionStore::new(storage);
714        let session_id = "commit-plain";
715
716        let mut s = fresh(session_id);
717        s.title = "Committed".to_string();
718        s.metadata_version = 1;
719        s.title_version = 2;
720
721        store.commit_metadata(&s).await.unwrap();
722
723        let after = store
724            .storage()
725            .load_session(session_id)
726            .await
727            .unwrap()
728            .unwrap();
729        assert_eq!(after.title, "Committed");
730        assert_eq!(after.metadata_version, 1);
731        assert_eq!(after.title_version, 2);
732    }
733
734    // ── Self-cleaning per-session lock (issue #346) ─────────────────
735
736    #[tokio::test]
737    async fn acquire_lock_self_evicts_when_no_other_holder() {
738        let (_temp, storage) = make_storage().await;
739        let store = LockedSessionStore::new(storage);
740
741        {
742            let _guard = store.acquire_lock("solo").await;
743            assert_eq!(store.locks.len(), 1, "entry present while the lock is held");
744        }
745        // Dropping the guard runs the self-cleaning `remove_if`. Without the
746        // eviction logic this stays at 1 forever (the pre-#346 leak).
747        assert_eq!(
748            store.locks.len(),
749            0,
750            "lock entry must be evicted once released with no other holder"
751        );
752    }
753
754    #[tokio::test]
755    async fn acquire_lock_many_distinct_ids_do_not_accumulate() {
756        let (_temp, storage) = make_storage().await;
757        let store = LockedSessionStore::new(storage);
758
759        // Serially acquire+release for 100 distinct session ids.
760        for i in 0..100 {
761            let _guard = store.acquire_lock(&format!("sess-{i}")).await;
762        }
763        assert_eq!(
764            store.locks.len(),
765            0,
766            "acquiring locks for many distinct ids must not grow the map"
767        );
768    }
769
770    #[tokio::test]
771    async fn acquire_lock_concurrent_waiter_keeps_valid_lock_and_map_drains() {
772        use std::sync::atomic::{AtomicUsize, Ordering};
773
774        let (_temp, storage) = make_storage().await;
775        let store = Arc::new(LockedSessionStore::new(storage));
776
777        // Tracks concurrent holders of the SAME session lock; must never exceed 1.
778        let active = Arc::new(AtomicUsize::new(0));
779        let max_seen = Arc::new(AtomicUsize::new(0));
780
781        let mut handles = Vec::new();
782        for _ in 0..8 {
783            let store = store.clone();
784            let active = active.clone();
785            let max_seen = max_seen.clone();
786            handles.push(tokio::spawn(async move {
787                let _guard = store.acquire_lock("contended").await;
788                let now = active.fetch_add(1, Ordering::SeqCst) + 1;
789                max_seen.fetch_max(now, Ordering::SeqCst);
790                // Hold briefly so the other tasks actually queue on the mutex.
791                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
792                active.fetch_sub(1, Ordering::SeqCst);
793            }));
794        }
795        for h in handles {
796            h.await.unwrap();
797        }
798
799        // Mutual exclusion must hold: a self-cleaning removal that raced (removed
800        // the entry a waiter had already cloned, letting a later task create and
801        // lock a *second* mutex for the same id) would show 2 concurrent holders.
802        // `remove_if`'s atomic strong-count check under the shard lock prevents it.
803        assert_eq!(
804            max_seen.load(Ordering::SeqCst),
805            1,
806            "at most one holder of a given session lock at a time"
807        );
808        assert_eq!(
809            store.locks.len(),
810            0,
811            "after all holders release, the contended entry must be fully evicted"
812        );
813    }
814}