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            // The control-plane sidecar carries `agent_runtime_state`, so a
158            // concurrent mid-run bypass flip is here too — don't revert it. #540.
159            adopt_disk_bypass_permissions(session, &latest);
160        }
161        self.storage.save_runtime_state(session).await
162    }
163
164    /// Authoritative metadata commit.
165    ///
166    /// The caller must have already loaded the latest session, mutated the
167    /// metadata fields, and bumped `metadata_version` (and `title_version` if
168    /// applicable).  This method simply acquires the per-session lock and
169    /// performs a plain `storage.save_session`.
170    ///
171    /// The lock guarantees that no other write for this session interleaves
172    /// between the caller's load and this save, so merge is unnecessary.
173    pub async fn commit_metadata(&self, session: &Session) -> std::io::Result<()> {
174        let _guard = self.acquire_lock(&session.id).await;
175        self.storage.save_session(session).await
176    }
177
178    /// Runtime / non-authoritative save with per-session lock.
179    ///
180    /// Inside the lock: reload disk, merge the authoritative metadata group
181    /// (`title`, `title_version`, `pinned`, `metadata_version`) from disk into
182    /// the in-memory copy if disk's `metadata_version >= session.metadata_version`,
183    /// then save.
184    ///
185    /// This is the locked equivalent of [`merge_save_session`]; prefer it for
186    /// server-side paths where an authoritative write may race with this save.
187    ///
188    /// Adopts the on-disk `bypass_permissions` so a running loop's save can't
189    /// revert a concurrent `PATCH /sessions` flip (#540). Callers that are
190    /// themselves the authoritative writer of that flag — the parent seeding a
191    /// child's posture (#74) — must use
192    /// [`Self::save_runtime_authoritative_flags`] instead, which persists the
193    /// in-memory flag as-is.
194    pub async fn merge_save_runtime(&self, session: &mut Session) -> std::io::Result<()> {
195        self.merge_save_runtime_inner(session, true).await
196    }
197
198    /// Like [`Self::merge_save_runtime`] but does NOT adopt the on-disk
199    /// `bypass_permissions` — the caller's in-memory value is authoritative and
200    /// persists as-is.
201    ///
202    /// For parent-side control writes to a child session (e.g. the #74
203    /// resident-reuse posture re-seed), which set the flag deliberately and must
204    /// not be reverted by the disk-wins protection meant for a running loop's
205    /// own stale saves. Still merges the authoritative metadata group.
206    pub async fn save_runtime_authoritative_flags(
207        &self,
208        session: &mut Session,
209    ) -> std::io::Result<()> {
210        self.merge_save_runtime_inner(session, false).await
211    }
212
213    async fn merge_save_runtime_inner(
214        &self,
215        session: &mut Session,
216        adopt_bypass: bool,
217    ) -> std::io::Result<()> {
218        let _guard = self.acquire_lock(&session.id).await;
219
220        // Single disk read serves BOTH the SHRINK diagnostic and the
221        // authoritative-metadata merge below. Previously this path loaded the
222        // session twice (once here, once inside the merge helper); on a parent
223        // session carrying the full conversation history that doubled the
224        // deserialization cost of every runtime save, which is the hot path
225        // during sub-agent spawn.
226        let latest = self.storage.load_session(&session.id).await.ok().flatten();
227
228        // DIAGNOSTIC: merge_save_runtime overwrites the whole `messages` array
229        // (it only merges authoritative metadata, not messages). If the incoming
230        // session is stale (fewer messages than what is already on disk), this save
231        // silently reverts a concurrent append (e.g. a just-persisted user message).
232        // Log a SHRINK warning so we can identify the stale writer.
233        let existing_message_count = latest.as_ref().map(|s| s.messages.len());
234        let incoming_message_count = session.messages.len();
235        if existing_message_count.is_some_and(|existing| existing > incoming_message_count) {
236            tracing::warn!(
237                "[{}] merge_save_runtime SHRINK: disk has {:?} messages, saving {} (last_role={:?}, updated_at={}); a stale writer is reverting a concurrent append",
238                session.id,
239                existing_message_count,
240                incoming_message_count,
241                session.messages.last().map(|m| format!("{:?}", m.role)),
242                session.updated_at,
243            );
244        } else {
245            tracing::debug!(
246                "[{}] merge_save_runtime: disk={:?} messages, saving {} (updated_at={})",
247                session.id,
248                existing_message_count,
249                incoming_message_count,
250                session.updated_at,
251            );
252        }
253
254        if let Some(latest) = latest.as_ref() {
255            apply_authoritative_metadata(session, latest);
256            // Never let a running loop's save revert a concurrent mid-run
257            // `PATCH /sessions {bypass_permissions}` flip. #540. Skipped for
258            // authoritative flag writers (`save_runtime_authoritative_flags`).
259            if adopt_bypass {
260                adopt_disk_bypass_permissions(session, latest);
261            }
262        }
263        self.storage.save_session(session).await
264    }
265
266    /// Apply a config-only mutation to a session without ever clobbering its
267    /// `messages` (or other concurrently-written state).
268    ///
269    /// Unlike [`Self::merge_save_runtime`], the caller does NOT pass a session
270    /// snapshot. Instead this loads the **latest** session from storage *inside*
271    /// the per-session lock, applies `mutate` (intended for small config fields
272    /// like `model_ref` / `reasoning_effort`), and saves. Because the load and
273    /// save both happen under the lock, a concurrent append (e.g. `POST /chat`
274    /// adding a user message) can never be reverted by this write.
275    ///
276    /// Returns the saved session, or `None` if it does not exist.
277    pub async fn update_runtime_config<F>(
278        &self,
279        session_id: &str,
280        mutate: F,
281    ) -> std::io::Result<Option<Session>>
282    where
283        F: FnOnce(&mut Session),
284    {
285        let _guard = self.acquire_lock(session_id).await;
286        let Some(mut session) = self.storage.load_session(session_id).await? else {
287            return Ok(None);
288        };
289        mutate(&mut session);
290        self.storage.save_session(&session).await?;
291        Ok(Some(session))
292    }
293}
294
295/// Infrastructure implementation of the domain runtime-persistence port.
296/// Server should assemble this as `Arc<dyn RuntimeSessionPersistence>` and must
297/// not define a separate adapter layer for the same behavior.
298#[async_trait::async_trait]
299impl RuntimeSessionPersistence for LockedSessionStore {
300    async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
301        self.merge_save_runtime(session).await
302    }
303}
304
305// ── Internal merge helper ─────────────────────────────────────────────
306
307/// Re-read the on-disk session and, when the disk copy carries a
308/// `metadata_version >= session.metadata_version`, overwrite the in-memory
309/// authoritative metadata fields with the disk values.
310///
311/// This is the core staleness-correction: non-authoritative writers call it
312/// before saving so they don't accidentally revert a concurrent UI edit.
313async fn merge_authoritative_metadata_into_stale(
314    storage: &Arc<dyn Storage>,
315    session: &mut Session,
316) {
317    if let Ok(Some(latest)) = storage.load_session(&session.id).await {
318        apply_authoritative_metadata(session, &latest);
319        adopt_disk_bypass_permissions(session, &latest);
320    }
321}
322
323/// Adopt the on-disk `agent_runtime_state.bypass_permissions` into the session
324/// about to be saved.
325///
326/// `PATCH /sessions {bypass_permissions}` is the SOLE authoritative writer of
327/// this flag (a running loop only carries it forward from run start). Without
328/// this, a runtime save from an in-flight run — which holds the run-start value
329/// — silently reverts a concurrent mid-run flip on disk. Unlike the metadata
330/// group this is NOT version-gated: the PATCH writes via `update_runtime_config`,
331/// which does not bump `metadata_version`. #540.
332fn adopt_disk_bypass_permissions(session: &mut Session, latest: &Session) {
333    // A disk copy with NO runtime state at all carries no authoritative bypass
334    // value — treat it as "unknown" and leave the in-memory flag untouched,
335    // rather than forcing it OFF (which would silently disable a legitimately
336    // bypassed run on any backend/path that doesn't round-trip the field). #540.
337    let Some(disk_bypass) = latest
338        .agent_runtime_state
339        .as_ref()
340        .map(|state| state.bypass_permissions)
341    else {
342        return;
343    };
344    match session.agent_runtime_state.as_mut() {
345        Some(state) => state.bypass_permissions = disk_bypass,
346        // No runtime state in memory and disk says "off" → nothing to adopt;
347        // avoid allocating a default state just to store `false`.
348        None if disk_bypass => {
349            session
350                .agent_runtime_state
351                .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
352                .bypass_permissions = true;
353        }
354        None => {}
355    }
356}
357
358/// Pure merge step: given a freshly-loaded on-disk copy, overwrite the
359/// in-memory authoritative metadata group when disk's `metadata_version` is at
360/// least the in-memory one. Split out so callers that have already loaded the
361/// disk copy (e.g. [`LockedSessionStore::merge_save_runtime`]) don't pay for a
362/// second read.
363fn apply_authoritative_metadata(session: &mut Session, latest: &Session) {
364    if latest.metadata_version >= session.metadata_version {
365        session.title = latest.title.clone();
366        session.title_version = latest.title_version;
367        session.pinned = latest.pinned;
368        for key in AUTHORITATIVE_METADATA_KEYS {
369            if let Some(value) = latest.metadata.get(*key) {
370                session.metadata.insert((*key).to_string(), value.clone());
371            } else {
372                session.metadata.remove(*key);
373            }
374        }
375        session.metadata_version = latest.metadata_version;
376    }
377}
378
379// ── Free merge-save function ──────────────────────────────────────────
380
381/// Save a session while preserving any concurrent UI edits to the
382/// authoritative metadata group.
383///
384/// Behaviour: if the on-disk session has `metadata_version >=
385/// session.metadata_version`, the on-disk `title`, `title_version`, `pinned`
386/// and `metadata_version` overwrite the in-memory values before writing.
387///
388/// This is the stateless variant (no per-session lock). Prefer
389/// [`LockedSessionStore::merge_save_runtime`] for server-side paths where an
390/// authoritative writer may race with this save.
391pub async fn merge_save_session(
392    storage: &Arc<dyn Storage>,
393    session: &mut Session,
394) -> std::io::Result<()> {
395    merge_authoritative_metadata_into_stale(storage, session).await;
396    storage.save_session(session).await
397}
398
399// ── Tests ─────────────────────────────────────────────────────────────
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use crate::v2::SessionStoreV2;
405    use bamboo_domain::session::types::Session;
406
407    async fn make_storage() -> (tempfile::TempDir, Arc<dyn Storage>) {
408        let temp = tempfile::tempdir().unwrap();
409        let storage = SessionStoreV2::new(temp.path().to_path_buf())
410            .await
411            .expect("storage init");
412        (temp, Arc::new(storage) as Arc<dyn Storage>)
413    }
414
415    fn fresh(id: &str) -> Session {
416        Session::new(id.to_string(), "test-model".to_string())
417    }
418
419    // ── update_runtime_config: config patches must never clobber messages ──
420
421    #[tokio::test]
422    async fn update_runtime_config_preserves_concurrently_appended_messages() {
423        use bamboo_domain::session::types::Message;
424        use bamboo_domain::ReasoningEffort;
425
426        let (_temp, storage) = make_storage().await;
427        let store = LockedSessionStore::new(storage.clone());
428        let session_id = "cfg-preserve";
429
430        // Persisted baseline: one user + one assistant turn.
431        let mut initial = fresh(session_id);
432        initial.add_message(Message::user("hello"));
433        initial.add_message(Message::assistant("hi", None));
434        storage.save_session(&initial).await.unwrap();
435
436        // Simulate `POST /chat` appending a new user message to disk.
437        let mut after_chat = storage.load_session(session_id).await.unwrap().unwrap();
438        after_chat.add_message(Message::user("second question"));
439        storage.save_session(&after_chat).await.unwrap();
440        assert_eq!(after_chat.messages.len(), 3);
441
442        // A config-only patch must load the freshest session and preserve the
443        // appended message (this is the regression that broke message sending on
444        // existing sessions).
445        let updated = store
446            .update_runtime_config(session_id, |s| {
447                s.reasoning_effort = Some(ReasoningEffort::Max);
448            })
449            .await
450            .unwrap()
451            .expect("session exists");
452
453        assert_eq!(updated.reasoning_effort, Some(ReasoningEffort::Max));
454        assert_eq!(
455            updated.messages.len(),
456            3,
457            "config patch must not revert a concurrently-appended message"
458        );
459
460        let on_disk = storage.load_session(session_id).await.unwrap().unwrap();
461        assert_eq!(on_disk.messages.len(), 3);
462        assert_eq!(on_disk.reasoning_effort, Some(ReasoningEffort::Max));
463    }
464
465    #[tokio::test]
466    async fn update_runtime_config_returns_none_for_missing_session() {
467        use bamboo_domain::ReasoningEffort;
468
469        let (_temp, storage) = make_storage().await;
470        let store = LockedSessionStore::new(storage);
471        let result = store
472            .update_runtime_config("does-not-exist", |s| {
473                s.reasoning_effort = Some(ReasoningEffort::Low);
474            })
475            .await
476            .unwrap();
477        assert!(result.is_none());
478    }
479
480    #[tokio::test]
481    async fn merge_save_runtime_overwrites_messages_from_stale_snapshot() {
482        // Characterization of the bug that motivated `update_runtime_config`:
483        // `merge_save_runtime` writes the caller's `messages` verbatim, so a
484        // stale snapshot reverts a concurrent append. Config-only writers must
485        // therefore use `update_runtime_config`, never `merge_save_runtime`.
486        use bamboo_domain::session::types::Message;
487
488        let (_temp, storage) = make_storage().await;
489        let store = LockedSessionStore::new(storage.clone());
490        let session_id = "stale-clobber";
491
492        // A handler loads the session (1 message) …
493        let mut baseline = fresh(session_id);
494        baseline.add_message(Message::user("hello"));
495        storage.save_session(&baseline).await.unwrap();
496        let mut stale_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
497
498        // … then `POST /chat` appends a second message to disk …
499        let mut after_chat = storage.load_session(session_id).await.unwrap().unwrap();
500        after_chat.add_message(Message::user("second"));
501        storage.save_session(&after_chat).await.unwrap();
502        assert_eq!(
503            storage
504                .load_session(session_id)
505                .await
506                .unwrap()
507                .unwrap()
508                .messages
509                .len(),
510            2
511        );
512
513        // … and the stale handler saves via merge_save_runtime -> append reverted.
514        store.merge_save_runtime(&mut stale_snapshot).await.unwrap();
515        let after = storage.load_session(session_id).await.unwrap().unwrap();
516        assert_eq!(
517            after.messages.len(),
518            1,
519            "merge_save_runtime clobbers concurrent appends — this is why config patches must use update_runtime_config"
520        );
521    }
522
523    #[tokio::test]
524    async fn merge_save_runtime_preserves_disk_authoritative_metadata_with_single_load() {
525        // Regression guard for the single-load refactor of `merge_save_runtime`:
526        // it must STILL pull the authoritative metadata group (title / pinned /
527        // metadata_version) from the freshest on-disk copy when disk's
528        // metadata_version >= the in-memory one, even though it now reads disk
529        // only once.
530        let (_temp, storage) = make_storage().await;
531        let store = LockedSessionStore::new(storage.clone());
532        let session_id = "runtime-merge-meta";
533
534        // Baseline persisted by a runtime writer (metadata_version 0).
535        let mut baseline = fresh(session_id);
536        baseline.title = "Auto Title".to_string();
537        baseline.metadata_version = 0;
538        storage.save_session(&baseline).await.unwrap();
539
540        // A stale runtime snapshot (still metadata_version 0, old title).
541        let mut stale_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
542
543        // An authoritative UI rename bumps metadata_version on disk.
544        let mut renamed = storage.load_session(session_id).await.unwrap().unwrap();
545        renamed.title = "User Renamed".to_string();
546        renamed.title_version = 1;
547        renamed.pinned = true;
548        renamed.metadata_version = 1;
549        store.commit_metadata(&renamed).await.unwrap();
550
551        // The stale runtime writer saves: it must adopt the disk title/pinned.
552        stale_snapshot.title = "Auto Title".to_string();
553        store.merge_save_runtime(&mut stale_snapshot).await.unwrap();
554
555        let after = storage.load_session(session_id).await.unwrap().unwrap();
556        assert_eq!(after.title, "User Renamed");
557        assert!(after.pinned);
558        assert_eq!(after.metadata_version, 1);
559        // And the in-memory copy was corrected by the merge too.
560        assert_eq!(stale_snapshot.title, "User Renamed");
561        assert_eq!(stale_snapshot.metadata_version, 1);
562    }
563
564    // #540: a running loop's `merge_save_runtime` (carrying the run-start bypass
565    // value) must NOT revert a concurrent mid-run `PATCH /sessions
566    // {bypass_permissions}` write on disk — disk is the authoritative writer.
567    #[tokio::test]
568    async fn merge_save_runtime_adopts_disk_bypass_permissions() {
569        use bamboo_domain::AgentRuntimeState;
570
571        let (_temp, storage) = make_storage().await;
572        let store = LockedSessionStore::new(storage.clone());
573        let session_id = "runtime-bypass";
574
575        // Baseline persisted with bypass OFF.
576        let baseline = fresh(session_id);
577        storage.save_session(&baseline).await.unwrap();
578
579        // The running loop holds a snapshot with bypass OFF (run-start value).
580        let mut loop_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
581        loop_snapshot.agent_runtime_state = Some(AgentRuntimeState::default());
582
583        // A concurrent PATCH flips bypass ON on disk (via update_runtime_config).
584        store
585            .update_runtime_config(session_id, |s| {
586                s.agent_runtime_state
587                    .get_or_insert_with(AgentRuntimeState::default)
588                    .bypass_permissions = true;
589            })
590            .await
591            .unwrap()
592            .expect("session exists");
593
594        // The loop saves its stale snapshot: it must adopt disk's ON value, not
595        // revert to OFF.
596        store.merge_save_runtime(&mut loop_snapshot).await.unwrap();
597
598        let after = storage.load_session(session_id).await.unwrap().unwrap();
599        assert!(
600            after
601                .agent_runtime_state
602                .as_ref()
603                .is_some_and(|s| s.bypass_permissions),
604            "disk bypass=ON must survive a stale runtime save (#540)"
605        );
606        // The in-memory copy is corrected too.
607        assert!(loop_snapshot
608            .agent_runtime_state
609            .as_ref()
610            .is_some_and(|s| s.bypass_permissions));
611    }
612
613    // The reverse direction: a PATCH turning bypass OFF must also stick against
614    // a stale loop snapshot that still has it ON.
615    #[tokio::test]
616    async fn merge_save_runtime_adopts_disk_bypass_off() {
617        use bamboo_domain::AgentRuntimeState;
618
619        let (_temp, storage) = make_storage().await;
620        let store = LockedSessionStore::new(storage.clone());
621        let session_id = "runtime-bypass-off";
622
623        // Baseline persisted with bypass ON.
624        let mut baseline = fresh(session_id);
625        let mut on_state = AgentRuntimeState::default();
626        on_state.bypass_permissions = true;
627        baseline.agent_runtime_state = Some(on_state);
628        storage.save_session(&baseline).await.unwrap();
629
630        // Loop snapshot still ON.
631        let mut loop_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
632
633        // PATCH flips OFF on disk.
634        store
635            .update_runtime_config(session_id, |s| {
636                s.agent_runtime_state
637                    .get_or_insert_with(AgentRuntimeState::default)
638                    .bypass_permissions = false;
639            })
640            .await
641            .unwrap()
642            .expect("session exists");
643
644        store.merge_save_runtime(&mut loop_snapshot).await.unwrap();
645
646        let after = storage.load_session(session_id).await.unwrap().unwrap();
647        assert!(
648            !after
649                .agent_runtime_state
650                .as_ref()
651                .is_some_and(|s| s.bypass_permissions),
652            "disk bypass=OFF must survive a stale runtime save (#540)"
653        );
654    }
655
656    // #540 review: the authoritative flag writer (#74 child-reseed) must NOT be
657    // reverted by the disk-wins protection — its in-memory value persists as-is.
658    #[tokio::test]
659    async fn save_runtime_authoritative_flags_persists_in_memory_bypass() {
660        use bamboo_domain::AgentRuntimeState;
661
662        let (_temp, storage) = make_storage().await;
663        let store = LockedSessionStore::new(storage.clone());
664        let session_id = "child-reseed";
665
666        // Child on disk has bypass ON (created under a bypassed parent).
667        let mut baseline = fresh(session_id);
668        let mut on_state = AgentRuntimeState::default();
669        on_state.bypass_permissions = true;
670        baseline.agent_runtime_state = Some(on_state);
671        storage.save_session(&baseline).await.unwrap();
672
673        // Parent re-seeds the reused child to OFF (parent flipped bypass off),
674        // loading the child then setting the flag in memory.
675        let mut child = storage.load_session(session_id).await.unwrap().unwrap();
676        child
677            .agent_runtime_state
678            .get_or_insert_with(AgentRuntimeState::default)
679            .bypass_permissions = false;
680
681        // Authoritative write must persist OFF, not adopt the disk's stale ON.
682        store
683            .save_runtime_authoritative_flags(&mut child)
684            .await
685            .unwrap();
686
687        let after = storage.load_session(session_id).await.unwrap().unwrap();
688        assert!(
689            !after
690                .agent_runtime_state
691                .as_ref()
692                .is_some_and(|s| s.bypass_permissions),
693            "authoritative re-seed of bypass=OFF must persist, not be reverted (#540/#74)"
694        );
695    }
696
697    // A disk copy lacking runtime state must not force the in-memory bypass OFF.
698    #[tokio::test]
699    async fn merge_save_runtime_leaves_bypass_when_disk_has_no_runtime_state() {
700        use bamboo_domain::AgentRuntimeState;
701
702        let (_temp, storage) = make_storage().await;
703        let store = LockedSessionStore::new(storage.clone());
704        let session_id = "no-runtime-state";
705
706        // Disk copy with NO agent_runtime_state.
707        let baseline = fresh(session_id);
708        assert!(baseline.agent_runtime_state.is_none());
709        storage.save_session(&baseline).await.unwrap();
710
711        // A running loop legitimately carries bypass ON in memory.
712        let mut running = storage.load_session(session_id).await.unwrap().unwrap();
713        let mut on_state = AgentRuntimeState::default();
714        on_state.bypass_permissions = true;
715        running.agent_runtime_state = Some(on_state);
716
717        store.merge_save_runtime(&mut running).await.unwrap();
718
719        assert!(
720            running
721                .agent_runtime_state
722                .as_ref()
723                .is_some_and(|s| s.bypass_permissions),
724            "a runtime-state-less disk copy must not force bypass OFF (#540)"
725        );
726    }
727
728    // ── Free-function merge tests (updated for metadata-group) ──────
729
730    #[tokio::test]
731    async fn merge_preserves_disk_title_when_versions_equal() {
732        let (_temp, storage) = make_storage().await;
733        let session_id = "merge-equal";
734
735        let mut on_disk = fresh(session_id);
736        on_disk.title = "User Set This".to_string();
737        on_disk.title_version = 0;
738        on_disk.metadata_version = 0;
739        storage.save_session(&on_disk).await.unwrap();
740
741        let mut runtime_copy = fresh(session_id);
742        runtime_copy.title = "Stale Default".to_string();
743        runtime_copy.title_version = 0;
744        runtime_copy.metadata_version = 0;
745        runtime_copy.messages = vec![];
746
747        merge_save_session(&storage, &mut runtime_copy)
748            .await
749            .unwrap();
750
751        let after = storage.load_session(session_id).await.unwrap().unwrap();
752        assert_eq!(after.title, "User Set This");
753        assert_eq!(after.title_version, 0);
754        assert_eq!(runtime_copy.title, "User Set This");
755    }
756
757    #[tokio::test]
758    async fn merge_preserves_disk_when_disk_version_higher() {
759        let (_temp, storage) = make_storage().await;
760        let session_id = "merge-higher";
761
762        let mut on_disk = fresh(session_id);
763        on_disk.title = "User Title v3".to_string();
764        on_disk.title_version = 3;
765        on_disk.metadata_version = 5;
766        storage.save_session(&on_disk).await.unwrap();
767
768        let mut runtime_copy = fresh(session_id);
769        runtime_copy.title = "Stale".to_string();
770        runtime_copy.title_version = 1;
771        runtime_copy.metadata_version = 0;
772
773        merge_save_session(&storage, &mut runtime_copy)
774            .await
775            .unwrap();
776
777        let after = storage.load_session(session_id).await.unwrap().unwrap();
778        assert_eq!(after.title, "User Title v3");
779        assert_eq!(after.title_version, 3);
780        assert_eq!(after.metadata_version, 5);
781    }
782
783    #[tokio::test]
784    async fn merge_now_preserves_disk_pinned_in_metadata_group() {
785        let (_temp, storage) = make_storage().await;
786        let session_id = "pinned-merge";
787
788        let mut on_disk = fresh(session_id);
789        on_disk.pinned = true;
790        on_disk.metadata_version = 2;
791        storage.save_session(&on_disk).await.unwrap();
792
793        let mut runtime_copy = fresh(session_id);
794        runtime_copy.pinned = false;
795        runtime_copy.metadata_version = 0;
796
797        merge_save_session(&storage, &mut runtime_copy)
798            .await
799            .unwrap();
800
801        let after = storage.load_session(session_id).await.unwrap().unwrap();
802        assert!(
803            after.pinned,
804            "disk pinned=true should win over runtime false"
805        );
806        assert_eq!(after.metadata_version, 2);
807    }
808
809    #[tokio::test]
810    async fn merge_keeps_in_memory_when_session_version_higher() {
811        let (_temp, storage) = make_storage().await;
812        let session_id = "merge-bumped";
813
814        let mut on_disk = fresh(session_id);
815        on_disk.title = "Old".to_string();
816        on_disk.title_version = 1;
817        on_disk.metadata_version = 3;
818        storage.save_session(&on_disk).await.unwrap();
819
820        let mut authoritative_copy = fresh(session_id);
821        authoritative_copy.title = "New Authoritative".to_string();
822        authoritative_copy.title_version = 2;
823        authoritative_copy.metadata_version = 4;
824        authoritative_copy.pinned = true;
825
826        merge_save_session(&storage, &mut authoritative_copy)
827            .await
828            .unwrap();
829
830        let after = storage.load_session(session_id).await.unwrap().unwrap();
831        assert_eq!(after.title, "New Authoritative");
832        assert_eq!(after.title_version, 2);
833        assert_eq!(after.metadata_version, 4);
834        assert!(after.pinned);
835    }
836
837    #[tokio::test]
838    async fn merge_keeps_runtime_messages_when_disk_only_changed_metadata() {
839        let (_temp, storage) = make_storage().await;
840        let session_id = "merge-messages";
841
842        let mut on_disk = fresh(session_id);
843        on_disk.title = "Fresh Title".to_string();
844        on_disk.title_version = 2;
845        on_disk.metadata_version = 5;
846        storage.save_session(&on_disk).await.unwrap();
847
848        let mut runtime_copy = fresh(session_id);
849        runtime_copy.title = "Stale".to_string();
850        runtime_copy.metadata_version = 0;
851        runtime_copy.messages = vec![bamboo_domain::session::types::Message {
852            role: bamboo_domain::session::types::Role::User,
853            content: "keep me".to_string(),
854            id: "msg-1".to_string(),
855            created_at: chrono::Utc::now(),
856            reasoning: None,
857            reasoning_signature: None,
858            content_parts: None,
859            image_ocr: None,
860            phase: None,
861            tool_calls: None,
862            tool_call_id: None,
863            tool_success: None,
864            compressed: false,
865            compressed_by_event_id: None,
866            never_compress: false,
867            compression_level: 0,
868            metadata: None,
869        }];
870
871        merge_save_session(&storage, &mut runtime_copy)
872            .await
873            .unwrap();
874
875        let after = storage.load_session(session_id).await.unwrap().unwrap();
876        assert_eq!(after.title, "Fresh Title");
877        assert_eq!(after.metadata_version, 5);
878        assert_eq!(after.messages.len(), 1);
879        assert_eq!(after.messages[0].content, "keep me");
880    }
881
882    // ── LockedSessionStore tests ────────────────────────────────────
883
884    #[tokio::test]
885    async fn locked_merge_save_runtime_serialises_concurrent_writes() {
886        let (_temp, storage) = make_storage().await;
887        let store = Arc::new(LockedSessionStore::new(storage));
888        let session_id = "lock-serial".to_string();
889
890        // Seed with base version.
891        let base = fresh(&session_id);
892        store.storage().save_session(&base).await.unwrap();
893
894        // Two concurrent authorised writers each bump and commit.
895        // We'll simulate via clone-and-bump-then-commit.
896        let store_a = store.clone();
897        let store_b = store.clone();
898        let sid_a = session_id.clone();
899        let sid_b = session_id.clone();
900
901        let a = tokio::spawn(async move {
902            let _guard = store_a.acquire_lock(&sid_a).await;
903            let mut s = store_a
904                .storage()
905                .load_session(&sid_a)
906                .await
907                .unwrap()
908                .unwrap();
909            s.title = "Writer A".to_string();
910            s.title_version = s.title_version.saturating_add(1);
911            s.metadata_version = s.metadata_version.saturating_add(1);
912            s.updated_at = chrono::Utc::now();
913            store_a.storage().save_session(&s).await.unwrap();
914            s.title_version
915        });
916
917        // Tiny yield so A goes first.
918        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
919
920        let b = tokio::spawn(async move {
921            let _guard = store_b.acquire_lock(&sid_b).await;
922            let mut s = store_b
923                .storage()
924                .load_session(&sid_b)
925                .await
926                .unwrap()
927                .unwrap();
928            s.title = "Writer B".to_string();
929            s.title_version = s.title_version.saturating_add(1);
930            s.metadata_version = s.metadata_version.saturating_add(1);
931            s.updated_at = chrono::Utc::now();
932            store_b.storage().save_session(&s).await.unwrap();
933            s.title_version
934        });
935
936        let (ver_a, ver_b) = tokio::join!(a, b);
937        let final_s = store
938            .storage()
939            .load_session(&session_id)
940            .await
941            .unwrap()
942            .unwrap();
943        assert!(
944            ver_a.unwrap() != ver_b.unwrap(),
945            "concurrent writers must produce distinct versions"
946        );
947        assert_eq!(final_s.metadata_version, 2);
948    }
949
950    #[tokio::test]
951    async fn commit_metadata_is_plain_save_inside_lock() {
952        let (_temp, storage) = make_storage().await;
953        let store = LockedSessionStore::new(storage);
954        let session_id = "commit-plain";
955
956        let mut s = fresh(session_id);
957        s.title = "Committed".to_string();
958        s.metadata_version = 1;
959        s.title_version = 2;
960
961        store.commit_metadata(&s).await.unwrap();
962
963        let after = store
964            .storage()
965            .load_session(session_id)
966            .await
967            .unwrap()
968            .unwrap();
969        assert_eq!(after.title, "Committed");
970        assert_eq!(after.metadata_version, 1);
971        assert_eq!(after.title_version, 2);
972    }
973
974    // ── Self-cleaning per-session lock (issue #346) ─────────────────
975
976    #[tokio::test]
977    async fn acquire_lock_self_evicts_when_no_other_holder() {
978        let (_temp, storage) = make_storage().await;
979        let store = LockedSessionStore::new(storage);
980
981        {
982            let _guard = store.acquire_lock("solo").await;
983            assert_eq!(store.locks.len(), 1, "entry present while the lock is held");
984        }
985        // Dropping the guard runs the self-cleaning `remove_if`. Without the
986        // eviction logic this stays at 1 forever (the pre-#346 leak).
987        assert_eq!(
988            store.locks.len(),
989            0,
990            "lock entry must be evicted once released with no other holder"
991        );
992    }
993
994    #[tokio::test]
995    async fn acquire_lock_many_distinct_ids_do_not_accumulate() {
996        let (_temp, storage) = make_storage().await;
997        let store = LockedSessionStore::new(storage);
998
999        // Serially acquire+release for 100 distinct session ids.
1000        for i in 0..100 {
1001            let _guard = store.acquire_lock(&format!("sess-{i}")).await;
1002        }
1003        assert_eq!(
1004            store.locks.len(),
1005            0,
1006            "acquiring locks for many distinct ids must not grow the map"
1007        );
1008    }
1009
1010    #[tokio::test]
1011    async fn acquire_lock_concurrent_waiter_keeps_valid_lock_and_map_drains() {
1012        use std::sync::atomic::{AtomicUsize, Ordering};
1013
1014        let (_temp, storage) = make_storage().await;
1015        let store = Arc::new(LockedSessionStore::new(storage));
1016
1017        // Tracks concurrent holders of the SAME session lock; must never exceed 1.
1018        let active = Arc::new(AtomicUsize::new(0));
1019        let max_seen = Arc::new(AtomicUsize::new(0));
1020
1021        let mut handles = Vec::new();
1022        for _ in 0..8 {
1023            let store = store.clone();
1024            let active = active.clone();
1025            let max_seen = max_seen.clone();
1026            handles.push(tokio::spawn(async move {
1027                let _guard = store.acquire_lock("contended").await;
1028                let now = active.fetch_add(1, Ordering::SeqCst) + 1;
1029                max_seen.fetch_max(now, Ordering::SeqCst);
1030                // Hold briefly so the other tasks actually queue on the mutex.
1031                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1032                active.fetch_sub(1, Ordering::SeqCst);
1033            }));
1034        }
1035        for h in handles {
1036            h.await.unwrap();
1037        }
1038
1039        // Mutual exclusion must hold: a self-cleaning removal that raced (removed
1040        // the entry a waiter had already cloned, letting a later task create and
1041        // lock a *second* mutex for the same id) would show 2 concurrent holders.
1042        // `remove_if`'s atomic strong-count check under the shard lock prevents it.
1043        assert_eq!(
1044            max_seen.load(Ordering::SeqCst),
1045            1,
1046            "at most one holder of a given session lock at a time"
1047        );
1048        assert_eq!(
1049            store.locks.len(),
1050            0,
1051            "after all holders release, the contended entry must be fully evicted"
1052        );
1053    }
1054}