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::{PermissionAuditSeed, PermissionAuditSnapshot, RuntimeSessionPersistence};
39use dashmap::DashMap;
40use tokio::sync::{Mutex, OwnedMutexGuard};
41
42const AUTHORITATIVE_METADATA_KEYS: &[&str] = &["gold_config", "workflow.run_ids.v1"];
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        self.save_runtime_only_and_publish(session, |_| {}).await
155    }
156
157    /// Save the runtime control-plane and synchronously publish the committed
158    /// snapshot before releasing this session's serialization lock.
159    ///
160    /// `publish` must remain a short, non-blocking operation. Its synchronous
161    /// shape intentionally prevents callers from holding an in-memory cache
162    /// guard across an await. The callback also runs when the durable save
163    /// fails, preserving [`RuntimeSessionPersistence::save_runtime_control_plane`]
164    /// implementations that publish current runtime authorization state while
165    /// still returning the storage error.
166    pub async fn save_runtime_only_and_publish<F>(
167        &self,
168        session: &mut Session,
169        publish: F,
170    ) -> std::io::Result<()>
171    where
172        F: FnOnce(&Session) + Send,
173    {
174        let _guard = self.acquire_lock(&session.id).await;
175        if let Ok(Some(latest)) = self.storage.load_runtime_control_plane(&session.id).await {
176            apply_authoritative_metadata(session, &latest);
177            // The control-plane sidecar carries `agent_runtime_state`, so a
178            // concurrent mid-run bypass flip is here too — don't revert it. #540.
179            adopt_fresher_disk_permission_posture(session, &latest);
180        }
181        let result = self.storage.save_runtime_state(session).await;
182        publish(session);
183        result
184    }
185
186    /// Atomically patch Task-owned control-plane fields and publish the saved
187    /// value before releasing this session's serialization lock.
188    ///
189    /// This couples durable commit order to cache publication order for
190    /// repository callers. The synchronous callback may take a cache guard but
191    /// cannot hold one across an await.
192    pub async fn update_task_list_control_plane_and_publish<F>(
193        &self,
194        session_id: &str,
195        task_list: &bamboo_domain::TaskList,
196        version: &str,
197        publish: F,
198    ) -> std::io::Result<bool>
199    where
200        F: FnOnce(&Session) + Send,
201    {
202        let _guard = self.acquire_lock(session_id).await;
203        let Some(mut latest) = self.storage.load_runtime_control_plane(session_id).await? else {
204            return Ok(false);
205        };
206        latest.set_task_list(task_list.clone());
207        latest.set_task_list_version_meta(version.to_string());
208        self.storage.save_runtime_state(&latest).await?;
209        publish(&latest);
210        Ok(true)
211    }
212
213    /// Authoritative metadata commit.
214    ///
215    /// The caller must have already loaded the latest session, mutated the
216    /// metadata fields, and bumped `metadata_version` (and `title_version` if
217    /// applicable).  This method simply acquires the per-session lock and
218    /// performs a plain `storage.save_session`.
219    ///
220    /// The lock guarantees that no other write for this session interleaves
221    /// between the caller's load and this save, so merge is unnecessary.
222    pub async fn commit_metadata(&self, session: &Session) -> std::io::Result<()> {
223        let _guard = self.acquire_lock(&session.id).await;
224        self.storage.save_session(session).await
225    }
226
227    /// Runtime / non-authoritative save with per-session lock.
228    ///
229    /// Inside the lock: reload disk, merge the authoritative metadata group
230    /// (`title`, `title_version`, `pinned`, `metadata_version`) from disk into
231    /// the in-memory copy if disk's `metadata_version >= session.metadata_version`,
232    /// then save.
233    ///
234    /// This is the locked equivalent of [`merge_save_session`]; prefer it for
235    /// server-side paths where an authoritative write may race with this save.
236    ///
237    /// Adopts the on-disk typed permission mode so a running loop's save can't
238    /// revert a concurrent `PATCH /sessions` transition (#540/#770). Callers
239    /// that are themselves the authoritative writer of that posture — the
240    /// parent seeding a child's mode (#74) — must use
241    /// [`Self::save_runtime_authoritative_flags`] instead, which persists the
242    /// in-memory mode as-is.
243    pub async fn merge_save_runtime(&self, session: &mut Session) -> std::io::Result<()> {
244        self.merge_save_runtime_and_publish(session, |_, _| {})
245            .await
246    }
247
248    /// Merge-save a runtime session and synchronously publish the resulting
249    /// snapshot before releasing its per-session serialization lock.
250    ///
251    /// The callback receives whether the durable save committed. It always runs
252    /// after the save attempt so repository callers can preserve their existing
253    /// cache-on-failure policy without reopening a durable-to-cache race.
254    pub async fn merge_save_runtime_and_publish<F>(
255        &self,
256        session: &mut Session,
257        publish: F,
258    ) -> std::io::Result<()>
259    where
260        F: FnOnce(&Session, bool) + Send,
261    {
262        self.merge_save_runtime_inner_and_publish(session, true, publish)
263            .await
264    }
265
266    /// Persist an execute-boundary transcript checkpoint without allowing a
267    /// stale runner snapshot to shrink or rewrite the durable message log.
268    ///
269    /// The latest load, append-only message reconciliation, metadata merge and
270    /// save all happen while holding the same per-session lock.  Loading is
271    /// deliberately fail-closed: falling back to a blind full save when the
272    /// latest transcript cannot be read would reintroduce the SHRINK hazard
273    /// this checkpoint exists to prevent.
274    pub async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
275        self.checkpoint_runtime_session_and_publish(session, |_, _| {})
276            .await
277    }
278
279    /// Checkpoint a runtime session and publish its reconciled snapshot before
280    /// releasing the same per-session serialization lock.
281    ///
282    /// The callback runs after the durable save attempt and receives its commit
283    /// status. A load failure returns before publication, matching the
284    /// checkpoint's fail-closed behavior.
285    pub async fn checkpoint_runtime_session_and_publish<F>(
286        &self,
287        session: &mut Session,
288        publish: F,
289    ) -> std::io::Result<()>
290    where
291        F: FnOnce(&Session, bool) + Send,
292    {
293        let _guard = self.acquire_lock(&session.id).await;
294        let latest = self.storage.load_session(&session.id).await?;
295
296        if let Some(latest) = latest.as_ref() {
297            let incoming_count = session.messages.len();
298            let durable_count = latest.messages.len();
299            let appended = bamboo_domain::append_missing_runtime_messages(session, latest);
300            bamboo_domain::merge_session_inbox_admission(session, latest);
301            tracing::debug!(
302                "[{}] append-safe runtime checkpoint: durable={}, incoming={}, appended={}, saved={}",
303                session.id,
304                durable_count,
305                incoming_count,
306                appended,
307                session.messages.len(),
308            );
309            apply_authoritative_metadata(session, latest);
310            adopt_fresher_disk_permission_posture(session, latest);
311        }
312
313        let result = self.storage.save_session(session).await;
314        publish(session, result.is_ok());
315        result
316    }
317
318    /// Like [`Self::merge_save_runtime`] but does NOT adopt the on-disk
319    /// permission mode — the caller's in-memory value is authoritative and
320    /// persists as-is.
321    ///
322    /// For parent-side control writes to a child session (e.g. the #74
323    /// resident-reuse posture re-seed), which set the flag deliberately and must
324    /// not be reverted by the disk-wins protection meant for a running loop's
325    /// own stale saves. Still merges the authoritative metadata group.
326    pub async fn save_runtime_authoritative_flags(
327        &self,
328        session: &mut Session,
329    ) -> std::io::Result<()> {
330        self.merge_save_runtime_inner_and_publish(session, false, |_, _| {})
331            .await
332    }
333
334    async fn merge_save_runtime_inner_and_publish<F>(
335        &self,
336        session: &mut Session,
337        adopt_bypass: bool,
338        publish: F,
339    ) -> std::io::Result<()>
340    where
341        F: FnOnce(&Session, bool) + Send,
342    {
343        let _guard = self.acquire_lock(&session.id).await;
344
345        // Single disk read serves BOTH the SHRINK diagnostic and the
346        // authoritative-metadata merge below. Previously this path loaded the
347        // session twice (once here, once inside the merge helper); on a parent
348        // session carrying the full conversation history that doubled the
349        // deserialization cost of every runtime save, which is the hot path
350        // during sub-agent spawn.
351        let latest = self.storage.load_session(&session.id).await.ok().flatten();
352
353        // DIAGNOSTIC: merge_save_runtime overwrites the whole `messages` array
354        // (it only merges authoritative metadata, not messages). If the incoming
355        // session is stale (fewer messages than what is already on disk), this save
356        // silently reverts a concurrent append (e.g. a just-persisted user message).
357        // Log a SHRINK warning so we can identify the stale writer.
358        let existing_message_count = latest.as_ref().map(|s| s.messages.len());
359        let incoming_message_count = session.messages.len();
360        if existing_message_count.is_some_and(|existing| existing > incoming_message_count) {
361            tracing::warn!(
362                "[{}] merge_save_runtime SHRINK: disk has {:?} messages, saving {} (last_role={:?}, updated_at={}); a stale writer is reverting a concurrent append",
363                session.id,
364                existing_message_count,
365                incoming_message_count,
366                session.messages.last().map(|m| format!("{:?}", m.role)),
367                session.updated_at,
368            );
369        } else {
370            tracing::debug!(
371                "[{}] merge_save_runtime: disk={:?} messages, saving {} (updated_at={})",
372                session.id,
373                existing_message_count,
374                incoming_message_count,
375                session.updated_at,
376            );
377        }
378
379        if let Some(latest) = latest.as_ref() {
380            apply_authoritative_metadata(session, latest);
381            let restored = bamboo_domain::restore_missing_admitted_inbox_messages(session, latest);
382            if restored > 0 {
383                tracing::warn!(
384                    session_id = %session.id,
385                    restored,
386                    "restored durable SessionInbox transcript messages into stale runtime save"
387                );
388            }
389            bamboo_domain::merge_session_inbox_admission(session, latest);
390            // Never let a running loop's save revert a concurrent mid-run
391            // `PATCH /sessions {permission_mode|bypass_permissions}` transition.
392            // #540/#770. Skipped for
393            // authoritative flag writers (`save_runtime_authoritative_flags`).
394            if adopt_bypass {
395                adopt_fresher_disk_permission_posture(session, latest);
396            }
397        }
398        let result = self.storage.save_session(session).await;
399        publish(session, result.is_ok());
400        result
401    }
402
403    /// Persist one validated RunSpec activation as the exact authority for the
404    /// worker's requested posture and complete audit record.
405    ///
406    /// Warm workers reuse a durable session id. An ordinary runtime save is
407    /// intentionally disk-adopting, so using it here would let the previous
408    /// activation's posture stick. This dedicated transaction preserves only
409    /// durable UI metadata and SessionInbox admission/transcript proof, then
410    /// writes the incoming posture with an audit revision above the durable
411    /// floor while holding the same per-session lock.
412    pub async fn seed_runtime_activation_and_publish<F>(
413        &self,
414        session: &mut Session,
415        publish: F,
416    ) -> std::io::Result<()>
417    where
418        F: FnOnce(&Session, bool) + Send,
419    {
420        let _guard = self.acquire_lock(&session.id).await;
421        let mut incoming_audit = PermissionAuditSnapshot::from_metadata(&session.metadata)
422            .ok_or_else(|| {
423                std::io::Error::new(
424                    std::io::ErrorKind::InvalidInput,
425                    "activation seed requires a complete permission audit record",
426                )
427            })?;
428
429        if let Some(latest) = self.storage.load_session(&session.id).await? {
430            apply_authoritative_metadata(session, &latest);
431            bamboo_domain::restore_missing_admitted_inbox_messages(session, &latest);
432            bamboo_domain::merge_session_inbox_admission(session, &latest);
433
434            let durable_audit = PermissionAuditSnapshot::from_metadata(&latest.metadata);
435            let durable_floor = durable_audit
436                .as_ref()
437                .map(|snapshot| snapshot.audit_revision)
438                .unwrap_or_default();
439            if let Some(durable_audit) = durable_audit {
440                if durable_audit.resolution == incoming_audit.resolution {
441                    incoming_audit.transitioned_at = durable_audit.transitioned_at;
442                }
443            }
444            incoming_audit.audit_revision = bamboo_domain::next_permission_audit_revision_after(
445                durable_floor.max(incoming_audit.audit_revision),
446            )
447            .map_err(|error| {
448                std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string())
449            })?;
450        }
451
452        session
453            .agent_runtime_state
454            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
455            .set_permission_mode(incoming_audit.resolution.requested);
456        incoming_audit.write_to(&mut session.metadata);
457
458        let result = self.storage.save_session(session).await;
459        publish(session, result.is_ok());
460        result
461    }
462
463    /// Atomically re-seed a resident child from its parent posture.
464    ///
465    /// The latest session load, typed-mode comparison, complete audit refresh,
466    /// metadata CAS bump (only for a true typed transition), narrow companion
467    /// mutation, save, and cache publication share one session lock.
468    pub async fn update_authoritative_permission_posture_and_publish<M, P>(
469        &self,
470        session_id: &str,
471        seed: &PermissionAuditSeed,
472        mutate: M,
473        publish: P,
474    ) -> std::io::Result<Option<Session>>
475    where
476        M: FnOnce(&mut Session),
477        P: FnOnce(&Session),
478    {
479        let _guard = self.acquire_lock(session_id).await;
480        let Some(mut latest) = self.storage.load_session(session_id).await? else {
481            return Ok(None);
482        };
483        let previous_mode = latest
484            .agent_runtime_state
485            .as_ref()
486            .map(|state| state.effective_permission_mode())
487            .unwrap_or_default();
488        let previous_resolution = PermissionAuditSnapshot::from_metadata(&latest.metadata)
489            .map(|snapshot| snapshot.resolution);
490        mutate(&mut latest);
491        latest
492            .agent_runtime_state
493            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
494            .set_permission_mode(seed.resolution.requested);
495        let mode_changed = previous_mode != seed.resolution.requested;
496        let posture_changed = previous_resolution != Some(seed.resolution);
497        let transitioned_at = posture_changed.then(|| chrono::Utc::now().to_rfc3339());
498        bamboo_domain::record_permission_audit(
499            &mut latest.metadata,
500            seed,
501            transitioned_at.as_deref(),
502        )
503        .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string()))?;
504        if mode_changed {
505            latest.metadata_version = latest.metadata_version.saturating_add(1);
506        }
507        self.storage.save_session(&latest).await?;
508        publish(&latest);
509        Ok(Some(latest))
510    }
511
512    /// Persist a worker's bounded executor mapping only when the exact host
513    /// posture observed before dispatch is still current. The remote event does
514    /// not contribute an audit revision or transition timestamp: both are
515    /// allocated from the latest durable record while this session lock is held.
516    pub async fn record_permission_posture_activation_and_publish<P>(
517        &self,
518        session_id: &str,
519        expected_audit_revision: Option<u64>,
520        seed: &PermissionAuditSeed,
521        publish: P,
522    ) -> std::io::Result<Option<Session>>
523    where
524        P: FnOnce(&Session),
525    {
526        let _guard = self.acquire_lock(session_id).await;
527        let Some(mut latest) = self.storage.load_session(session_id).await? else {
528            return Ok(None);
529        };
530        let durable_audit = PermissionAuditSnapshot::from_metadata(&latest.metadata);
531        let durable_revision = durable_audit
532            .as_ref()
533            .map(|snapshot| snapshot.audit_revision);
534        if durable_revision != expected_audit_revision {
535            return Err(std::io::Error::new(
536                std::io::ErrorKind::InvalidData,
537                "stale permission posture activation: durable audit changed after dispatch",
538            ));
539        }
540        let durable_requested = latest
541            .agent_runtime_state
542            .as_ref()
543            .map(|state| state.effective_permission_mode())
544            .unwrap_or_default();
545        if durable_requested != seed.resolution.requested || !seed.resolution.is_consistent() {
546            return Err(std::io::Error::new(
547                std::io::ErrorKind::InvalidData,
548                "stale or inconsistent permission posture activation",
549            ));
550        }
551        bamboo_domain::record_permission_audit(&mut latest.metadata, seed, None).map_err(
552            |error| std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string()),
553        )?;
554        self.storage.save_session(&latest).await?;
555        publish(&latest);
556        Ok(Some(latest))
557    }
558
559    /// Apply a config-only mutation to a session without ever clobbering its
560    /// `messages` (or other concurrently-written state).
561    ///
562    /// Unlike [`Self::merge_save_runtime`], the caller does NOT pass a session
563    /// snapshot. Instead this loads the **latest** session from storage *inside*
564    /// the per-session lock, applies `mutate` (intended for small config fields
565    /// like `model_ref` / `reasoning_effort`), and saves. Because the load and
566    /// save both happen under the lock, a concurrent append (e.g. `POST /chat`
567    /// adding a user message) can never be reverted by this write.
568    ///
569    /// Returns the saved session, or `None` if it does not exist.
570    pub async fn update_runtime_config<F>(
571        &self,
572        session_id: &str,
573        mutate: F,
574    ) -> std::io::Result<Option<Session>>
575    where
576        F: FnOnce(&mut Session),
577    {
578        self.update_runtime_config_and_publish(session_id, mutate, |_| {})
579            .await
580    }
581
582    /// Apply a config-only mutation and synchronously publish the saved
583    /// snapshot before releasing the session lock.
584    pub async fn update_runtime_config_and_publish<M, P>(
585        &self,
586        session_id: &str,
587        mutate: M,
588        publish: P,
589    ) -> std::io::Result<Option<Session>>
590    where
591        M: FnOnce(&mut Session),
592        P: FnOnce(&Session),
593    {
594        let _guard = self.acquire_lock(session_id).await;
595        let Some(mut session) = self.storage.load_session(session_id).await? else {
596            return Ok(None);
597        };
598        mutate(&mut session);
599        self.storage.save_session(&session).await?;
600        publish(&session);
601        Ok(Some(session))
602    }
603
604    /// Clear the legacy compatibility queue using durable CAS and publish the
605    /// saved full snapshot before releasing the same session lock.
606    pub async fn clear_legacy_pending_messages_and_publish<F>(
607        &self,
608        session_id: &str,
609        expected: &[serde_json::Value],
610        publish: F,
611    ) -> std::io::Result<bool>
612    where
613        F: FnOnce(&Session) + Send,
614    {
615        let _guard = self.acquire_lock(session_id).await;
616        let Some(mut latest) = self.storage.load_session(session_id).await? else {
617            return Ok(false);
618        };
619        if latest.pending_injected_messages().as_deref() != Some(expected) {
620            return Ok(false);
621        }
622        latest.clear_pending_injected_messages();
623        self.storage.save_runtime_state(&latest).await?;
624        publish(&latest);
625        Ok(true)
626    }
627}
628
629/// Infrastructure implementation of the domain runtime-persistence port.
630/// Server should assemble this as `Arc<dyn RuntimeSessionPersistence>` and must
631/// not define a separate adapter layer for the same behavior.
632#[async_trait::async_trait]
633impl RuntimeSessionPersistence for LockedSessionStore {
634    async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
635        self.merge_save_runtime(session).await
636    }
637
638    async fn seed_runtime_activation(&self, session: &mut Session) -> std::io::Result<()> {
639        self.seed_runtime_activation_and_publish(session, |_, _| {})
640            .await
641    }
642
643    async fn record_permission_posture_activation(
644        &self,
645        session_id: &str,
646        expected_audit_revision: Option<u64>,
647        seed: &PermissionAuditSeed,
648    ) -> std::io::Result<Option<Session>> {
649        self.record_permission_posture_activation_and_publish(
650            session_id,
651            expected_audit_revision,
652            seed,
653            |_| {},
654        )
655        .await
656    }
657
658    async fn save_runtime_control_plane(&self, session: &mut Session) -> std::io::Result<()> {
659        self.save_runtime_only(session).await
660    }
661
662    async fn load_runtime_control_plane(
663        &self,
664        session_id: &str,
665    ) -> std::io::Result<Option<Session>> {
666        self.storage.load_runtime_control_plane(session_id).await
667    }
668
669    async fn update_task_list_control_plane(
670        &self,
671        session_id: &str,
672        task_list: &bamboo_domain::TaskList,
673        version: &str,
674    ) -> std::io::Result<bool> {
675        self.update_task_list_control_plane_and_publish(session_id, task_list, version, |_| {})
676            .await
677    }
678
679    async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
680        LockedSessionStore::checkpoint_runtime_session(self, session).await
681    }
682
683    async fn load_runtime_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
684        self.storage.load_session(session_id).await
685    }
686
687    async fn clear_legacy_pending_messages(
688        &self,
689        session_id: &str,
690        expected: &[serde_json::Value],
691    ) -> std::io::Result<bool> {
692        self.clear_legacy_pending_messages_and_publish(session_id, expected, |_| {})
693            .await
694    }
695}
696
697// ── Internal merge helper ─────────────────────────────────────────────
698
699/// Re-read the on-disk session and, when the disk copy carries a
700/// `metadata_version >= session.metadata_version`, overwrite the in-memory
701/// authoritative metadata fields with the disk values.
702///
703/// This is the core staleness-correction: non-authoritative writers call it
704/// before saving so they don't accidentally revert a concurrent UI edit.
705async fn merge_authoritative_metadata_into_stale(
706    storage: &Arc<dyn Storage>,
707    session: &mut Session,
708) {
709    if let Ok(Some(latest)) = storage.load_session(&session.id).await {
710        apply_authoritative_metadata(session, &latest);
711        bamboo_domain::restore_missing_admitted_inbox_messages(session, &latest);
712        bamboo_domain::merge_session_inbox_admission(session, &latest);
713        adopt_fresher_disk_permission_posture(session, &latest);
714    }
715}
716
717/// Adopt the on-disk typed permission posture into the session about to be
718/// saved when the durable posture is semantically fresher.
719///
720/// `PATCH /sessions {permission_mode|bypass_permissions}` is the authoritative
721/// writer of this posture (a running loop only carries it forward from run
722/// start). Without this, a runtime save from an in-flight run — which holds the
723/// run-start value — silently reverts a concurrent mid-run transition on disk.
724/// A true typed-mode difference always represents an authoritative durable
725/// transition. When the modes are equal, the complete audit revision is the
726/// ordering fence: an older/missing disk audit must never delete a newer
727/// run-start policy/mapping refresh. #540/#770.
728fn adopt_fresher_disk_permission_posture(session: &mut Session, latest: &Session) {
729    // A disk copy with NO runtime state at all carries no authoritative mode
730    // value — treat it as "unknown" and leave the in-memory flag untouched,
731    // rather than forcing it OFF (which would silently disable a legitimately
732    // bypassed run on any backend/path that doesn't round-trip the field). #540.
733    let Some(disk_mode) = latest
734        .agent_runtime_state
735        .as_ref()
736        .map(|state| state.effective_permission_mode())
737    else {
738        return;
739    };
740    let current_mode = session
741        .agent_runtime_state
742        .as_ref()
743        .map(|state| state.effective_permission_mode())
744        .unwrap_or_default();
745    let Some(disk_audit) = bamboo_domain::fresher_disk_permission_audit(
746        current_mode,
747        &session.metadata,
748        disk_mode,
749        &latest.metadata,
750    ) else {
751        return;
752    };
753
754    match session.agent_runtime_state.as_mut() {
755        Some(state) => state.set_permission_mode(disk_mode),
756        // No runtime state in memory and disk says "off" → nothing to adopt;
757        // avoid allocating a default state just to store `false`.
758        None if disk_mode != bamboo_domain::SessionPermissionMode::Default => {
759            let state = session
760                .agent_runtime_state
761                .get_or_insert_with(bamboo_domain::AgentRuntimeState::default);
762            state.set_permission_mode(disk_mode);
763        }
764        None => {}
765    }
766
767    // The typed posture and its complete bounded audit record move together.
768    disk_audit.write_to(&mut session.metadata);
769}
770
771/// Pure merge step: given a freshly-loaded on-disk copy, overwrite the
772/// in-memory authoritative metadata group when disk's `metadata_version` is at
773/// least the in-memory one. Split out so callers that have already loaded the
774/// disk copy (e.g. [`LockedSessionStore::merge_save_runtime`]) don't pay for a
775/// second read.
776fn apply_authoritative_metadata(session: &mut Session, latest: &Session) {
777    if latest.metadata_version >= session.metadata_version {
778        session.title = latest.title.clone();
779        session.title_version = latest.title_version;
780        session.pinned = latest.pinned;
781        for key in AUTHORITATIVE_METADATA_KEYS {
782            if let Some(value) = latest.metadata.get(*key) {
783                session.metadata.insert((*key).to_string(), value.clone());
784            } else {
785                session.metadata.remove(*key);
786            }
787        }
788        session.metadata_version = latest.metadata_version;
789    }
790}
791
792// ── Free merge-save function ──────────────────────────────────────────
793
794/// Save a session while preserving any concurrent UI edits to the
795/// authoritative metadata group.
796///
797/// Behaviour: if the on-disk session has `metadata_version >=
798/// session.metadata_version`, the on-disk `title`, `title_version`, `pinned`
799/// and `metadata_version` overwrite the in-memory values before writing.
800///
801/// This is the stateless variant (no per-session lock). Prefer
802/// [`LockedSessionStore::merge_save_runtime`] for server-side paths where an
803/// authoritative writer may race with this save.
804pub async fn merge_save_session(
805    storage: &Arc<dyn Storage>,
806    session: &mut Session,
807) -> std::io::Result<()> {
808    merge_authoritative_metadata_into_stale(storage, session).await;
809    storage.save_session(session).await
810}
811
812// ── Tests ─────────────────────────────────────────────────────────────
813
814#[cfg(test)]
815mod tests {
816    use super::*;
817    use crate::v2::SessionStoreV2;
818    use bamboo_domain::{session::types::Session, PermissionMode};
819    use std::sync::atomic::{AtomicUsize, Ordering};
820
821    struct CountingControlPlaneStorage {
822        inner: Arc<SessionStoreV2>,
823        control_plane_loads: AtomicUsize,
824    }
825
826    #[async_trait::async_trait]
827    impl Storage for CountingControlPlaneStorage {
828        async fn save_session(&self, session: &Session) -> std::io::Result<()> {
829            self.inner.save_session(session).await
830        }
831
832        async fn load_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
833            self.inner.load_session(session_id).await
834        }
835
836        async fn delete_session(&self, session_id: &str) -> std::io::Result<bool> {
837            self.inner.delete_session(session_id).await
838        }
839
840        async fn save_runtime_state(&self, session: &Session) -> std::io::Result<()> {
841            self.inner.save_runtime_state(session).await
842        }
843
844        async fn load_runtime_control_plane(
845            &self,
846            session_id: &str,
847        ) -> std::io::Result<Option<Session>> {
848            self.control_plane_loads.fetch_add(1, Ordering::SeqCst);
849            self.inner.load_runtime_control_plane(session_id).await
850        }
851    }
852
853    async fn make_storage() -> (tempfile::TempDir, Arc<dyn Storage>) {
854        let temp = tempfile::tempdir().unwrap();
855        let storage = SessionStoreV2::new(temp.path().to_path_buf())
856            .await
857            .expect("storage init");
858        (temp, Arc::new(storage) as Arc<dyn Storage>)
859    }
860
861    fn fresh(id: &str) -> Session {
862        Session::new(id.to_string(), "test-model".to_string())
863    }
864
865    fn set_permission_audit(
866        session: &mut Session,
867        requested: bamboo_domain::SessionPermissionMode,
868        policy_revision: u64,
869        mapping: &str,
870        transitioned_at: &str,
871    ) -> u64 {
872        let resolution = bamboo_domain::resolve_permission_mode(requested, PermissionMode::Default);
873        session
874            .agent_runtime_state
875            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
876            .set_permission_mode(requested);
877        bamboo_domain::record_permission_audit(
878            &mut session.metadata,
879            &PermissionAuditSeed::new(policy_revision, resolution, mapping),
880            Some(transitioned_at),
881        )
882        .unwrap()
883    }
884
885    #[tokio::test]
886    async fn same_mode_newer_run_start_audit_survives_every_runtime_save_path() {
887        for path in ["merge", "checkpoint", "control-plane"] {
888            let (_temp, storage) = make_storage().await;
889            let store = LockedSessionStore::new(storage.clone());
890            let session_id = format!("same-mode-newer-{path}");
891            let mut durable = fresh(&session_id);
892            set_permission_audit(
893                &mut durable,
894                bamboo_domain::SessionPermissionMode::Default,
895                1,
896                "bamboo_runtime:old-policy",
897                "2026-07-31T12:00:00Z",
898            );
899            storage.save_session(&durable).await.unwrap();
900
901            let mut run_start = durable.clone();
902            let old_revision = PermissionAuditSnapshot::from_metadata(&durable.metadata)
903                .unwrap()
904                .audit_revision;
905            let new_revision = set_permission_audit(
906                &mut run_start,
907                bamboo_domain::SessionPermissionMode::Default,
908                2,
909                "bamboo_runtime:new-policy",
910                "2026-07-31T12:00:00Z",
911            );
912            assert!(new_revision > old_revision);
913
914            match path {
915                "merge" => store.merge_save_runtime(&mut run_start).await.unwrap(),
916                "checkpoint" => store
917                    .checkpoint_runtime_session(&mut run_start)
918                    .await
919                    .unwrap(),
920                "control-plane" => store.save_runtime_only(&mut run_start).await.unwrap(),
921                _ => unreachable!(),
922            }
923
924            let saved = storage.load_session(&session_id).await.unwrap().unwrap();
925            let audit = PermissionAuditSnapshot::from_metadata(&saved.metadata).unwrap();
926            assert_eq!(audit.audit_revision, new_revision, "path={path}");
927            assert_eq!(audit.policy_revision, 2, "path={path}");
928            assert_eq!(audit.executor_mapping, "bamboo_runtime:new-policy");
929        }
930    }
931
932    #[tokio::test]
933    async fn newer_disk_transition_wins_after_mode_cycles_back() {
934        let (_temp, storage) = make_storage().await;
935        let store = LockedSessionStore::new(storage.clone());
936        let session_id = "permission-cycle-back";
937        let mut baseline = fresh(session_id);
938        let stale_revision = set_permission_audit(
939            &mut baseline,
940            bamboo_domain::SessionPermissionMode::Default,
941            1,
942            "bamboo_runtime:initial",
943            "2026-07-31T12:00:00Z",
944        );
945        storage.save_session(&baseline).await.unwrap();
946        let mut stale_runtime = baseline.clone();
947
948        let mut durable = baseline;
949        set_permission_audit(
950            &mut durable,
951            bamboo_domain::SessionPermissionMode::Auto,
952            2,
953            "bamboo_runtime:auto",
954            "2026-07-31T12:01:00Z",
955        );
956        let durable_revision = set_permission_audit(
957            &mut durable,
958            bamboo_domain::SessionPermissionMode::Default,
959            3,
960            "bamboo_runtime:cycled-default",
961            "2026-07-31T12:02:00Z",
962        );
963        assert!(durable_revision > stale_revision);
964        storage.save_session(&durable).await.unwrap();
965
966        store.merge_save_runtime(&mut stale_runtime).await.unwrap();
967        let saved = storage.load_session(session_id).await.unwrap().unwrap();
968        let audit = PermissionAuditSnapshot::from_metadata(&saved.metadata).unwrap();
969        assert_eq!(audit.audit_revision, durable_revision);
970        assert_eq!(audit.policy_revision, 3);
971        assert_eq!(audit.executor_mapping, "bamboo_runtime:cycled-default");
972    }
973
974    #[tokio::test]
975    async fn authoritative_activation_seed_replaces_every_warm_worker_posture() {
976        let (_temp, storage) = make_storage().await;
977        let store = LockedSessionStore::new(storage.clone());
978        let session_id = "warm-permission-matrix";
979        let cases = [
980            (
981                bamboo_domain::SessionPermissionMode::Auto,
982                PermissionMode::Default,
983                PermissionMode::Auto,
984            ),
985            (
986                bamboo_domain::SessionPermissionMode::Default,
987                PermissionMode::Default,
988                PermissionMode::Default,
989            ),
990            (
991                bamboo_domain::SessionPermissionMode::Auto,
992                PermissionMode::Default,
993                PermissionMode::Auto,
994            ),
995            (
996                bamboo_domain::SessionPermissionMode::Bypass,
997                PermissionMode::Auto,
998                PermissionMode::BypassPermissions,
999            ),
1000        ];
1001        let mut previous_revision = 0;
1002
1003        for (index, (requested, configured, expected_effective)) in cases.into_iter().enumerate() {
1004            let mut activation = fresh(session_id);
1005            activation
1006                .agent_runtime_state
1007                .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
1008                .set_permission_mode(requested);
1009            let resolution = bamboo_domain::resolve_permission_mode(requested, configured);
1010            bamboo_domain::record_permission_audit(
1011                &mut activation.metadata,
1012                &PermissionAuditSeed::new(
1013                    index as u64 + 1,
1014                    resolution,
1015                    format!("bamboo_worker:{}", resolution.effective.as_str()),
1016                ),
1017                Some("2026-07-31T12:00:00Z"),
1018            )
1019            .unwrap();
1020
1021            RuntimeSessionPersistence::seed_runtime_activation(&store, &mut activation)
1022                .await
1023                .unwrap();
1024            let durable = storage.load_session(session_id).await.unwrap().unwrap();
1025            assert_eq!(
1026                durable
1027                    .agent_runtime_state
1028                    .as_ref()
1029                    .unwrap()
1030                    .effective_permission_mode(),
1031                requested,
1032                "activation {index}"
1033            );
1034            let audit = PermissionAuditSnapshot::from_metadata(&durable.metadata).unwrap();
1035            assert_eq!(audit.resolution.requested, requested);
1036            assert_eq!(audit.resolution.effective, expected_effective);
1037            assert!(audit.audit_revision > previous_revision);
1038            previous_revision = audit.audit_revision;
1039        }
1040    }
1041
1042    #[tokio::test]
1043    async fn resident_reseed_bumps_etag_only_for_typed_transition() {
1044        let (_temp, storage) = make_storage().await;
1045        let store = LockedSessionStore::new(storage.clone());
1046        let session_id = "resident-atomic-permission";
1047        let mut baseline = fresh(session_id);
1048        baseline.metadata_version = 7;
1049        set_permission_audit(
1050            &mut baseline,
1051            bamboo_domain::SessionPermissionMode::Auto,
1052            1,
1053            "bamboo_runtime:auto",
1054            "2026-07-31T12:00:00Z",
1055        );
1056        storage.save_session(&baseline).await.unwrap();
1057        let initial_audit = PermissionAuditSnapshot::from_metadata(&baseline.metadata).unwrap();
1058
1059        let same_mode_seed = PermissionAuditSeed::bamboo_runtime(
1060            2,
1061            bamboo_domain::resolve_permission_mode(
1062                bamboo_domain::SessionPermissionMode::Auto,
1063                PermissionMode::Default,
1064            ),
1065        );
1066        let refreshed = store
1067            .update_authoritative_permission_posture_and_publish(
1068                session_id,
1069                &same_mode_seed,
1070                |session| {
1071                    session
1072                        .metadata
1073                        .insert("resident.marker".to_string(), "same-mode".to_string());
1074                },
1075                |_| {},
1076            )
1077            .await
1078            .unwrap()
1079            .unwrap();
1080        let refreshed_audit = PermissionAuditSnapshot::from_metadata(&refreshed.metadata).unwrap();
1081        assert_eq!(refreshed.metadata_version, 7);
1082        assert!(refreshed_audit.audit_revision > initial_audit.audit_revision);
1083        assert_eq!(refreshed_audit.policy_revision, 2);
1084
1085        let transition_seed = PermissionAuditSeed::bamboo_runtime(
1086            3,
1087            bamboo_domain::resolve_permission_mode(
1088                bamboo_domain::SessionPermissionMode::Default,
1089                PermissionMode::Default,
1090            ),
1091        );
1092        let transitioned = store
1093            .update_authoritative_permission_posture_and_publish(
1094                session_id,
1095                &transition_seed,
1096                |session| {
1097                    session
1098                        .metadata
1099                        .insert("resident.marker".to_string(), "transition".to_string());
1100                },
1101                |_| {},
1102            )
1103            .await
1104            .unwrap()
1105            .unwrap();
1106        let transitioned_audit =
1107            PermissionAuditSnapshot::from_metadata(&transitioned.metadata).unwrap();
1108        assert_eq!(transitioned.metadata_version, 8, "old ETag must be invalid");
1109        assert_eq!(
1110            transitioned
1111                .agent_runtime_state
1112                .as_ref()
1113                .unwrap()
1114                .effective_permission_mode(),
1115            bamboo_domain::SessionPermissionMode::Default
1116        );
1117        assert_eq!(
1118            transitioned_audit.resolution.requested,
1119            bamboo_domain::SessionPermissionMode::Default
1120        );
1121        assert!(transitioned_audit.audit_revision > refreshed_audit.audit_revision);
1122        assert_eq!(
1123            transitioned
1124                .metadata
1125                .get("resident.marker")
1126                .map(String::as_str),
1127            Some("transition")
1128        );
1129    }
1130
1131    #[tokio::test]
1132    async fn worker_activation_cas_cannot_overwrite_concurrent_permission_patch() {
1133        let (_temp, storage) = make_storage().await;
1134        let store = LockedSessionStore::new(storage.clone());
1135        let session_id = "permission-activation-cas";
1136        let mut baseline = fresh(session_id);
1137        set_permission_audit(
1138            &mut baseline,
1139            bamboo_domain::SessionPermissionMode::Default,
1140            1,
1141            "bamboo_runtime:default",
1142            "2026-07-31T12:00:00Z",
1143        );
1144        storage.save_session(&baseline).await.unwrap();
1145        let dispatched_revision = PermissionAuditSnapshot::from_metadata(&baseline.metadata)
1146            .unwrap()
1147            .audit_revision;
1148
1149        let patched_resolution = bamboo_domain::resolve_permission_mode(
1150            bamboo_domain::SessionPermissionMode::Auto,
1151            PermissionMode::Default,
1152        );
1153        let patched = store
1154            .update_authoritative_permission_posture_and_publish(
1155                session_id,
1156                &PermissionAuditSeed::new(2, patched_resolution, "patch:auto"),
1157                |_| {},
1158                |_| {},
1159            )
1160            .await
1161            .unwrap()
1162            .unwrap();
1163        let patched_audit = PermissionAuditSnapshot::from_metadata(&patched.metadata).unwrap();
1164        assert!(patched_audit.audit_revision > dispatched_revision);
1165
1166        let stale_worker_seed = PermissionAuditSeed::new(
1167            1,
1168            bamboo_domain::resolve_permission_mode(
1169                bamboo_domain::SessionPermissionMode::Default,
1170                PermissionMode::Default,
1171            ),
1172            "worker:stale-default",
1173        );
1174        let error = store
1175            .record_permission_posture_activation_and_publish(
1176                session_id,
1177                Some(dispatched_revision),
1178                &stale_worker_seed,
1179                |_| {},
1180            )
1181            .await
1182            .unwrap_err();
1183        assert!(error.to_string().contains("durable audit changed"));
1184
1185        let durable = storage.load_session(session_id).await.unwrap().unwrap();
1186        let durable_audit = PermissionAuditSnapshot::from_metadata(&durable.metadata).unwrap();
1187        assert_eq!(durable_audit, patched_audit);
1188        assert_eq!(durable_audit.executor_mapping, "patch:auto");
1189    }
1190
1191    // ── update_runtime_config: config patches must never clobber messages ──
1192
1193    #[tokio::test]
1194    async fn update_runtime_config_preserves_concurrently_appended_messages() {
1195        use bamboo_domain::session::types::Message;
1196        use bamboo_domain::ReasoningEffort;
1197
1198        let (_temp, storage) = make_storage().await;
1199        let store = LockedSessionStore::new(storage.clone());
1200        let session_id = "cfg-preserve";
1201
1202        // Persisted baseline: one user + one assistant turn.
1203        let mut initial = fresh(session_id);
1204        initial.add_message(Message::user("hello"));
1205        initial.add_message(Message::assistant("hi", None));
1206        storage.save_session(&initial).await.unwrap();
1207
1208        // Simulate `POST /chat` appending a new user message to disk.
1209        let mut after_chat = storage.load_session(session_id).await.unwrap().unwrap();
1210        after_chat.add_message(Message::user("second question"));
1211        storage.save_session(&after_chat).await.unwrap();
1212        assert_eq!(after_chat.messages.len(), 3);
1213
1214        // A config-only patch must load the freshest session and preserve the
1215        // appended message (this is the regression that broke message sending on
1216        // existing sessions).
1217        let updated = store
1218            .update_runtime_config(session_id, |s| {
1219                s.reasoning_effort = Some(ReasoningEffort::Max);
1220            })
1221            .await
1222            .unwrap()
1223            .expect("session exists");
1224
1225        assert_eq!(updated.reasoning_effort, Some(ReasoningEffort::Max));
1226        assert_eq!(
1227            updated.messages.len(),
1228            3,
1229            "config patch must not revert a concurrently-appended message"
1230        );
1231
1232        let on_disk = storage.load_session(session_id).await.unwrap().unwrap();
1233        assert_eq!(on_disk.messages.len(), 3);
1234        assert_eq!(on_disk.reasoning_effort, Some(ReasoningEffort::Max));
1235    }
1236
1237    #[tokio::test]
1238    async fn update_runtime_config_returns_none_for_missing_session() {
1239        use bamboo_domain::ReasoningEffort;
1240
1241        let (_temp, storage) = make_storage().await;
1242        let store = LockedSessionStore::new(storage);
1243        let result = store
1244            .update_runtime_config("does-not-exist", |s| {
1245                s.reasoning_effort = Some(ReasoningEffort::Low);
1246            })
1247            .await
1248            .unwrap();
1249        assert!(result.is_none());
1250    }
1251
1252    #[tokio::test]
1253    async fn merge_save_runtime_overwrites_messages_from_stale_snapshot() {
1254        // Characterization of the bug that motivated `update_runtime_config`:
1255        // `merge_save_runtime` writes the caller's `messages` verbatim, so a
1256        // stale snapshot reverts a concurrent append. Config-only writers must
1257        // therefore use `update_runtime_config`, never `merge_save_runtime`.
1258        use bamboo_domain::session::types::Message;
1259
1260        let (_temp, storage) = make_storage().await;
1261        let store = LockedSessionStore::new(storage.clone());
1262        let session_id = "stale-clobber";
1263
1264        // A handler loads the session (1 message) …
1265        let mut baseline = fresh(session_id);
1266        baseline.add_message(Message::user("hello"));
1267        storage.save_session(&baseline).await.unwrap();
1268        let mut stale_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
1269
1270        // … then `POST /chat` appends a second message to disk …
1271        let mut after_chat = storage.load_session(session_id).await.unwrap().unwrap();
1272        after_chat.add_message(Message::user("second"));
1273        storage.save_session(&after_chat).await.unwrap();
1274        assert_eq!(
1275            storage
1276                .load_session(session_id)
1277                .await
1278                .unwrap()
1279                .unwrap()
1280                .messages
1281                .len(),
1282            2
1283        );
1284
1285        // … and the stale handler saves via merge_save_runtime -> append reverted.
1286        store.merge_save_runtime(&mut stale_snapshot).await.unwrap();
1287        let after = storage.load_session(session_id).await.unwrap().unwrap();
1288        assert_eq!(
1289            after.messages.len(),
1290            1,
1291            "merge_save_runtime clobbers concurrent appends — this is why config patches must use update_runtime_config"
1292        );
1293    }
1294
1295    #[tokio::test]
1296    async fn stale_runtime_save_cannot_remove_admitted_inbox_transcript() {
1297        use bamboo_domain::session::types::Message;
1298        use bamboo_domain::SessionMessageId;
1299
1300        let (_temp, storage) = make_storage().await;
1301        let store = LockedSessionStore::new(storage.clone());
1302        let session_id = "stale-inbox-preserve";
1303
1304        let mut baseline = fresh(session_id);
1305        let mut base = Message::user("base");
1306        base.id = "base".to_string();
1307        baseline.add_message(base);
1308        storage.save_session(&baseline).await.unwrap();
1309        let mut stale = baseline.clone();
1310        let mut later_assistant = Message::assistant("runner output", None);
1311        later_assistant.id = "later-assistant".to_string();
1312        stale.add_message(later_assistant);
1313
1314        let mut durable = baseline;
1315        let inbox_id = SessionMessageId::parse("durable-inbox-id").unwrap();
1316        let mut admitted = Message::user("durable inbox message");
1317        admitted.id = inbox_id.as_str().to_string();
1318        durable.add_message(admitted);
1319        durable
1320            .session_inbox_admission_mut()
1321            .record(inbox_id.clone(), 7);
1322        storage.save_session(&durable).await.unwrap();
1323
1324        store.merge_save_runtime(&mut stale).await.unwrap();
1325        let saved = storage.load_session(session_id).await.unwrap().unwrap();
1326        let ids = saved
1327            .messages
1328            .iter()
1329            .map(|message| message.id.as_str())
1330            .collect::<Vec<_>>();
1331        assert_eq!(ids, vec!["base", "durable-inbox-id", "later-assistant"]);
1332        assert_eq!(ids.iter().filter(|id| **id == inbox_id.as_str()).count(), 1);
1333        assert!(saved
1334            .session_inbox_admission()
1335            .is_some_and(|state| state.contains(&inbox_id)));
1336    }
1337
1338    #[tokio::test]
1339    async fn stale_runtime_save_preserves_typed_inbox_message_after_cursor_eviction() {
1340        use bamboo_domain::{
1341            SessionMessageEnvelope, SessionMessageId, SESSION_INBOX_ADMITTED_CAPACITY,
1342        };
1343
1344        let (_temp, storage) = make_storage().await;
1345        let store = LockedSessionStore::new(storage.clone());
1346        let session_id = "evicted-inbox-preserve";
1347        let mut durable = fresh(session_id);
1348        let mut envelope = SessionMessageEnvelope::user_input(session_id, "old durable inbox");
1349        envelope.id = SessionMessageId::parse("old-inbox-id").unwrap();
1350        durable.add_message(envelope.to_provider_message().unwrap());
1351        durable
1352            .session_inbox_admission_mut()
1353            .record(envelope.id.clone(), 1);
1354        for sequence in 2..=(SESSION_INBOX_ADMITTED_CAPACITY as u64 + 1) {
1355            durable.session_inbox_admission_mut().record(
1356                SessionMessageId::parse(format!("newer-{sequence}")).unwrap(),
1357                sequence,
1358            );
1359        }
1360        assert!(!durable
1361            .session_inbox_admission()
1362            .unwrap()
1363            .contains(&envelope.id));
1364        storage.save_session(&durable).await.unwrap();
1365
1366        let mut stale = fresh(session_id);
1367        store.merge_save_runtime(&mut stale).await.unwrap();
1368        let saved = storage.load_session(session_id).await.unwrap().unwrap();
1369        assert_eq!(
1370            saved
1371                .messages
1372                .iter()
1373                .filter(|message| message.id == envelope.id.as_str())
1374                .count(),
1375            1
1376        );
1377    }
1378
1379    #[tokio::test]
1380    async fn checkpoint_runtime_session_preserves_disk_suffix_and_appends_live_messages() {
1381        use bamboo_domain::session::types::Message;
1382
1383        let (_temp, storage) = make_storage().await;
1384        let store = LockedSessionStore::new(storage.clone());
1385        let session_id = "checkpoint-no-shrink";
1386
1387        let mut baseline = fresh(session_id);
1388        baseline.add_message(Message::user("base"));
1389        storage.save_session(&baseline).await.unwrap();
1390        let mut runner_snapshot = baseline.clone();
1391
1392        let mut durable = baseline;
1393        let mut disk_only = Message::user("concurrent injected message");
1394        disk_only.id = "disk-only".to_string();
1395        durable.add_message(disk_only);
1396        storage.save_session(&durable).await.unwrap();
1397
1398        let mut live_only = Message::assistant("partial runner output", None);
1399        live_only.id = "live-only".to_string();
1400        runner_snapshot.add_message(live_only);
1401
1402        store
1403            .checkpoint_runtime_session(&mut runner_snapshot)
1404            .await
1405            .unwrap();
1406
1407        let saved = storage.load_session(session_id).await.unwrap().unwrap();
1408        let ids = saved
1409            .messages
1410            .iter()
1411            .map(|message| message.id.as_str())
1412            .collect::<Vec<_>>();
1413        assert_eq!(
1414            ids,
1415            vec![durable.messages[0].id.as_str(), "disk-only", "live-only"]
1416        );
1417        assert_eq!(runner_snapshot.messages.len(), saved.messages.len());
1418        assert_eq!(runner_snapshot.messages[1].id, saved.messages[1].id);
1419        assert_eq!(runner_snapshot.messages[2].id, saved.messages[2].id);
1420        assert_eq!(saved.messages[1].content, "concurrent injected message");
1421        assert_eq!(saved.messages[2].content, "partial runner output");
1422    }
1423
1424    #[tokio::test]
1425    async fn activation_checkpoint_clears_presentation_without_shrinking_concurrent_turn() {
1426        use bamboo_domain::session::runtime_state::{
1427            AgentRuntimeState, AgentStatusState, WaitingForChildrenState,
1428        };
1429        use bamboo_domain::session::types::Message;
1430
1431        let (_temp, storage) = make_storage().await;
1432        let store = LockedSessionStore::new(storage.clone());
1433        let session_id = "activation-no-shrink";
1434        let mut baseline = fresh(session_id);
1435        baseline.add_message(Message::user("base"));
1436        let mut state = AgentRuntimeState::new("activation-run");
1437        state.status = AgentStatusState::Suspended;
1438        state.waiting_for_children = Some(WaitingForChildrenState::for_children(
1439            vec!["child-1".to_string()],
1440            bamboo_domain::session::runtime_state::ChildWaitPolicy::All,
1441            chrono::Utc::now(),
1442        ));
1443        baseline.agent_runtime_state = Some(state);
1444        baseline.metadata.insert(
1445            "runtime.suspend_reason".to_string(),
1446            "waiting_for_children".to_string(),
1447        );
1448        storage.save_session(&baseline).await.unwrap();
1449        let mut activation_snapshot = baseline.clone();
1450
1451        let mut concurrent = baseline;
1452        let mut normal = Message::assistant("normal concurrent answer", None);
1453        normal.id = "normal-concurrent".to_string();
1454        concurrent.add_message(normal);
1455        storage.save_session(&concurrent).await.unwrap();
1456
1457        let state = activation_snapshot.agent_runtime_state.as_mut().unwrap();
1458        state.status = AgentStatusState::Idle;
1459        state.suspension = None;
1460        activation_snapshot
1461            .metadata
1462            .remove("runtime.suspend_reason");
1463        store
1464            .checkpoint_runtime_session(&mut activation_snapshot)
1465            .await
1466            .unwrap();
1467
1468        let saved = storage.load_session(session_id).await.unwrap().unwrap();
1469        assert!(saved
1470            .messages
1471            .iter()
1472            .any(|message| message.id == "normal-concurrent"));
1473        let state = saved.agent_runtime_state.unwrap();
1474        assert_eq!(state.status, AgentStatusState::Idle);
1475        assert!(state.waiting_for_children.is_some());
1476        assert!(!saved.metadata.contains_key("runtime.suspend_reason"));
1477    }
1478
1479    #[tokio::test]
1480    async fn merge_save_runtime_preserves_disk_authoritative_metadata_with_single_load() {
1481        // Regression guard for the single-load refactor of `merge_save_runtime`:
1482        // it must STILL pull the authoritative metadata group (title / pinned /
1483        // metadata_version) from the freshest on-disk copy when disk's
1484        // metadata_version >= the in-memory one, even though it now reads disk
1485        // only once.
1486        let (_temp, storage) = make_storage().await;
1487        let store = LockedSessionStore::new(storage.clone());
1488        let session_id = "runtime-merge-meta";
1489
1490        // Baseline persisted by a runtime writer (metadata_version 0).
1491        let mut baseline = fresh(session_id);
1492        baseline.title = "Auto Title".to_string();
1493        baseline.metadata_version = 0;
1494        storage.save_session(&baseline).await.unwrap();
1495
1496        // A stale runtime snapshot (still metadata_version 0, old title).
1497        let mut stale_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
1498
1499        // An authoritative UI rename bumps metadata_version on disk.
1500        let mut renamed = storage.load_session(session_id).await.unwrap().unwrap();
1501        renamed.title = "User Renamed".to_string();
1502        renamed.title_version = 1;
1503        renamed.pinned = true;
1504        renamed.metadata_version = 1;
1505        store.commit_metadata(&renamed).await.unwrap();
1506
1507        // The stale runtime writer saves: it must adopt the disk title/pinned.
1508        stale_snapshot.title = "Auto Title".to_string();
1509        store.merge_save_runtime(&mut stale_snapshot).await.unwrap();
1510
1511        let after = storage.load_session(session_id).await.unwrap().unwrap();
1512        assert_eq!(after.title, "User Renamed");
1513        assert!(after.pinned);
1514        assert_eq!(after.metadata_version, 1);
1515        // And the in-memory copy was corrected by the merge too.
1516        assert_eq!(stale_snapshot.title, "User Renamed");
1517        assert_eq!(stale_snapshot.metadata_version, 1);
1518    }
1519
1520    #[tokio::test]
1521    async fn merge_save_runtime_preserves_durable_workflow_run_index_from_stale_runner() {
1522        let (_temp, storage) = make_storage().await;
1523        let store = LockedSessionStore::new(storage.clone());
1524        let session_id = "runtime-workflow-run-index";
1525
1526        let baseline = fresh(session_id);
1527        storage.save_session(&baseline).await.unwrap();
1528        let mut stale_runner = storage.load_session(session_id).await.unwrap().unwrap();
1529
1530        store
1531            .update_runtime_config(session_id, |session| {
1532                session.metadata.insert(
1533                    "workflow.run_ids.v1".to_string(),
1534                    r#"["http-started-run"]"#.to_string(),
1535                );
1536            })
1537            .await
1538            .unwrap()
1539            .expect("session exists");
1540
1541        store.merge_save_runtime(&mut stale_runner).await.unwrap();
1542
1543        assert_eq!(
1544            stale_runner
1545                .metadata
1546                .get("workflow.run_ids.v1")
1547                .map(String::as_str),
1548            Some(r#"["http-started-run"]"#)
1549        );
1550        let durable = storage.load_session(session_id).await.unwrap().unwrap();
1551        assert_eq!(
1552            durable
1553                .metadata
1554                .get("workflow.run_ids.v1")
1555                .map(String::as_str),
1556            Some(r#"["http-started-run"]"#)
1557        );
1558    }
1559
1560    // #540: a running loop's `merge_save_runtime` (carrying the run-start bypass
1561    // value) must NOT revert a concurrent mid-run `PATCH /sessions
1562    // {bypass_permissions}` write on disk — disk is the authoritative writer.
1563    #[tokio::test]
1564    async fn merge_save_runtime_adopts_disk_bypass_permissions() {
1565        use bamboo_domain::AgentRuntimeState;
1566
1567        let (_temp, storage) = make_storage().await;
1568        let store = LockedSessionStore::new(storage.clone());
1569        let session_id = "runtime-bypass";
1570
1571        // Baseline persisted with bypass OFF.
1572        let baseline = fresh(session_id);
1573        storage.save_session(&baseline).await.unwrap();
1574
1575        // The running loop holds a snapshot with bypass OFF (run-start value).
1576        let mut loop_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
1577        loop_snapshot.agent_runtime_state = Some(AgentRuntimeState::default());
1578
1579        // A concurrent PATCH flips bypass ON on disk (via update_runtime_config).
1580        store
1581            .update_runtime_config(session_id, |s| {
1582                s.agent_runtime_state
1583                    .get_or_insert_with(AgentRuntimeState::default)
1584                    .bypass_permissions = true;
1585            })
1586            .await
1587            .unwrap()
1588            .expect("session exists");
1589
1590        // The loop saves its stale snapshot: it must adopt disk's ON value, not
1591        // revert to OFF.
1592        store.merge_save_runtime(&mut loop_snapshot).await.unwrap();
1593
1594        let after = storage.load_session(session_id).await.unwrap().unwrap();
1595        assert!(
1596            after
1597                .agent_runtime_state
1598                .as_ref()
1599                .is_some_and(|s| s.bypass_permissions),
1600            "disk bypass=ON must survive a stale runtime save (#540)"
1601        );
1602        // The in-memory copy is corrected too.
1603        assert!(loop_snapshot
1604            .agent_runtime_state
1605            .as_ref()
1606            .is_some_and(|s| s.bypass_permissions));
1607    }
1608
1609    // #770: the generalized disk-wins path must preserve Auto as a distinct
1610    // typed mode rather than collapsing it into the legacy bypass boolean.
1611    #[tokio::test]
1612    async fn merge_save_runtime_adopts_disk_auto_permission_mode() {
1613        use bamboo_domain::{AgentRuntimeState, SessionPermissionMode};
1614
1615        let (_temp, storage) = make_storage().await;
1616        let store = LockedSessionStore::new(storage.clone());
1617        let session_id = "runtime-auto";
1618
1619        storage.save_session(&fresh(session_id)).await.unwrap();
1620        let mut loop_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
1621        loop_snapshot.agent_runtime_state = Some(AgentRuntimeState::default());
1622        loop_snapshot.metadata.insert(
1623            "permission.requested_mode".to_string(),
1624            "default".to_string(),
1625        );
1626        loop_snapshot.metadata.insert(
1627            "permission.effective_mode".to_string(),
1628            "default".to_string(),
1629        );
1630        loop_snapshot.metadata.insert(
1631            "permission.executor_mapping".to_string(),
1632            "bamboo_runtime:default".to_string(),
1633        );
1634
1635        store
1636            .update_runtime_config(session_id, |session| {
1637                session
1638                    .agent_runtime_state
1639                    .get_or_insert_with(AgentRuntimeState::default)
1640                    .set_permission_mode(SessionPermissionMode::Auto);
1641                session
1642                    .metadata
1643                    .insert("permission.policy_revision".to_string(), "12".to_string());
1644                session
1645                    .metadata
1646                    .insert("permission.requested_mode".to_string(), "auto".to_string());
1647                session
1648                    .metadata
1649                    .insert("permission.effective_mode".to_string(), "auto".to_string());
1650                session.metadata.insert(
1651                    "permission.executor_mapping".to_string(),
1652                    "bamboo_runtime:auto".to_string(),
1653                );
1654                session.metadata.insert(
1655                    "permission.transitioned_at".to_string(),
1656                    "2026-07-31T12:00:00Z".to_string(),
1657                );
1658                session.metadata_version = session.metadata_version.saturating_add(1);
1659            })
1660            .await
1661            .unwrap()
1662            .expect("session exists");
1663
1664        store.merge_save_runtime(&mut loop_snapshot).await.unwrap();
1665
1666        let durable = storage.load_session(session_id).await.unwrap().unwrap();
1667        for state in [
1668            durable.agent_runtime_state.as_ref(),
1669            loop_snapshot.agent_runtime_state.as_ref(),
1670        ] {
1671            assert_eq!(
1672                state.map(AgentRuntimeState::effective_permission_mode),
1673                Some(SessionPermissionMode::Auto)
1674            );
1675        }
1676        for session in [&durable, &loop_snapshot] {
1677            assert_eq!(
1678                session.metadata.get("permission.policy_revision"),
1679                Some(&"12".to_string())
1680            );
1681            assert_eq!(
1682                session.metadata.get("permission.requested_mode"),
1683                Some(&"auto".to_string())
1684            );
1685            assert_eq!(
1686                session.metadata.get("permission.effective_mode"),
1687                Some(&"auto".to_string())
1688            );
1689            assert_eq!(
1690                session.metadata.get("permission.executor_mapping"),
1691                Some(&"bamboo_runtime:auto".to_string())
1692            );
1693            assert_eq!(
1694                session.metadata.get("permission.transitioned_at"),
1695                Some(&"2026-07-31T12:00:00Z".to_string())
1696            );
1697        }
1698    }
1699
1700    // The reverse direction: a PATCH turning bypass OFF must also stick against
1701    // a stale loop snapshot that still has it ON.
1702    #[tokio::test]
1703    async fn merge_save_runtime_adopts_disk_bypass_off() {
1704        use bamboo_domain::AgentRuntimeState;
1705
1706        let (_temp, storage) = make_storage().await;
1707        let store = LockedSessionStore::new(storage.clone());
1708        let session_id = "runtime-bypass-off";
1709
1710        // Baseline persisted with bypass ON.
1711        let mut baseline = fresh(session_id);
1712        let on_state = AgentRuntimeState {
1713            bypass_permissions: true,
1714            ..AgentRuntimeState::default()
1715        };
1716        baseline.agent_runtime_state = Some(on_state);
1717        storage.save_session(&baseline).await.unwrap();
1718
1719        // Loop snapshot still ON.
1720        let mut loop_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
1721
1722        // PATCH flips OFF on disk.
1723        store
1724            .update_runtime_config(session_id, |s| {
1725                s.agent_runtime_state
1726                    .get_or_insert_with(AgentRuntimeState::default)
1727                    .bypass_permissions = false;
1728            })
1729            .await
1730            .unwrap()
1731            .expect("session exists");
1732
1733        store.merge_save_runtime(&mut loop_snapshot).await.unwrap();
1734
1735        let after = storage.load_session(session_id).await.unwrap().unwrap();
1736        assert!(
1737            !after
1738                .agent_runtime_state
1739                .as_ref()
1740                .is_some_and(|s| s.bypass_permissions),
1741            "disk bypass=OFF must survive a stale runtime save (#540)"
1742        );
1743    }
1744
1745    // #540 review: the authoritative flag writer (#74 child-reseed) must NOT be
1746    // reverted by the disk-wins protection — its in-memory value persists as-is.
1747    #[tokio::test]
1748    async fn save_runtime_authoritative_flags_persists_in_memory_posture_and_audit() {
1749        use bamboo_domain::AgentRuntimeState;
1750
1751        let (_temp, storage) = make_storage().await;
1752        let store = LockedSessionStore::new(storage.clone());
1753        let session_id = "child-reseed";
1754
1755        // Child on disk has bypass ON (created under a bypassed parent).
1756        let mut baseline = fresh(session_id);
1757        let on_state = AgentRuntimeState {
1758            bypass_permissions: true,
1759            ..AgentRuntimeState::default()
1760        };
1761        baseline.agent_runtime_state = Some(on_state);
1762        for (key, value) in [
1763            ("permission.policy_revision", "12"),
1764            ("permission.requested_mode", "bypass"),
1765            ("permission.effective_mode", "bypass"),
1766            ("permission.executor_mapping", "bamboo_runtime:bypass"),
1767            ("permission.transitioned_at", "2026-07-31T12:00:00Z"),
1768        ] {
1769            baseline.metadata.insert(key.to_string(), value.to_string());
1770        }
1771        storage.save_session(&baseline).await.unwrap();
1772
1773        // Parent re-seeds the reused child to OFF (parent flipped bypass off),
1774        // loading the child then setting the flag in memory.
1775        let mut child = storage.load_session(session_id).await.unwrap().unwrap();
1776        child
1777            .agent_runtime_state
1778            .get_or_insert_with(AgentRuntimeState::default)
1779            .bypass_permissions = false;
1780        for (key, value) in [
1781            ("permission.policy_revision", "13"),
1782            ("permission.requested_mode", "default"),
1783            ("permission.effective_mode", "default"),
1784            ("permission.executor_mapping", "bamboo_runtime:default"),
1785            ("permission.transitioned_at", "2026-07-31T12:01:00Z"),
1786        ] {
1787            child.metadata.insert(key.to_string(), value.to_string());
1788        }
1789
1790        // Authoritative write must persist OFF, not adopt the disk's stale ON.
1791        store
1792            .save_runtime_authoritative_flags(&mut child)
1793            .await
1794            .unwrap();
1795
1796        let after = storage.load_session(session_id).await.unwrap().unwrap();
1797        assert!(
1798            !after
1799                .agent_runtime_state
1800                .as_ref()
1801                .is_some_and(|s| s.bypass_permissions),
1802            "authoritative re-seed of bypass=OFF must persist, not be reverted (#540/#74)"
1803        );
1804        for (key, value) in [
1805            ("permission.policy_revision", "13"),
1806            ("permission.requested_mode", "default"),
1807            ("permission.effective_mode", "default"),
1808            ("permission.executor_mapping", "bamboo_runtime:default"),
1809            ("permission.transitioned_at", "2026-07-31T12:01:00Z"),
1810        ] {
1811            assert_eq!(after.metadata.get(key).map(String::as_str), Some(value));
1812        }
1813    }
1814
1815    // A disk copy lacking runtime state must not force the in-memory bypass OFF.
1816    #[tokio::test]
1817    async fn merge_save_runtime_leaves_bypass_when_disk_has_no_runtime_state() {
1818        use bamboo_domain::AgentRuntimeState;
1819
1820        let (_temp, storage) = make_storage().await;
1821        let store = LockedSessionStore::new(storage.clone());
1822        let session_id = "no-runtime-state";
1823
1824        // Disk copy with NO agent_runtime_state.
1825        let baseline = fresh(session_id);
1826        assert!(baseline.agent_runtime_state.is_none());
1827        storage.save_session(&baseline).await.unwrap();
1828
1829        // A running loop legitimately carries bypass ON in memory.
1830        let mut running = storage.load_session(session_id).await.unwrap().unwrap();
1831        let on_state = AgentRuntimeState {
1832            bypass_permissions: true,
1833            ..AgentRuntimeState::default()
1834        };
1835        running.agent_runtime_state = Some(on_state);
1836
1837        store.merge_save_runtime(&mut running).await.unwrap();
1838
1839        assert!(
1840            running
1841                .agent_runtime_state
1842                .as_ref()
1843                .is_some_and(|s| s.bypass_permissions),
1844            "a runtime-state-less disk copy must not force bypass OFF (#540)"
1845        );
1846    }
1847
1848    // ── Free-function merge tests (updated for metadata-group) ──────
1849
1850    #[tokio::test]
1851    async fn merge_preserves_disk_title_when_versions_equal() {
1852        let (_temp, storage) = make_storage().await;
1853        let session_id = "merge-equal";
1854
1855        let mut on_disk = fresh(session_id);
1856        on_disk.title = "User Set This".to_string();
1857        on_disk.title_version = 0;
1858        on_disk.metadata_version = 0;
1859        storage.save_session(&on_disk).await.unwrap();
1860
1861        let mut runtime_copy = fresh(session_id);
1862        runtime_copy.title = "Stale Default".to_string();
1863        runtime_copy.title_version = 0;
1864        runtime_copy.metadata_version = 0;
1865        runtime_copy.messages = vec![];
1866
1867        merge_save_session(&storage, &mut runtime_copy)
1868            .await
1869            .unwrap();
1870
1871        let after = storage.load_session(session_id).await.unwrap().unwrap();
1872        assert_eq!(after.title, "User Set This");
1873        assert_eq!(after.title_version, 0);
1874        assert_eq!(runtime_copy.title, "User Set This");
1875    }
1876
1877    #[tokio::test]
1878    async fn merge_preserves_disk_when_disk_version_higher() {
1879        let (_temp, storage) = make_storage().await;
1880        let session_id = "merge-higher";
1881
1882        let mut on_disk = fresh(session_id);
1883        on_disk.title = "User Title v3".to_string();
1884        on_disk.title_version = 3;
1885        on_disk.metadata_version = 5;
1886        storage.save_session(&on_disk).await.unwrap();
1887
1888        let mut runtime_copy = fresh(session_id);
1889        runtime_copy.title = "Stale".to_string();
1890        runtime_copy.title_version = 1;
1891        runtime_copy.metadata_version = 0;
1892
1893        merge_save_session(&storage, &mut runtime_copy)
1894            .await
1895            .unwrap();
1896
1897        let after = storage.load_session(session_id).await.unwrap().unwrap();
1898        assert_eq!(after.title, "User Title v3");
1899        assert_eq!(after.title_version, 3);
1900        assert_eq!(after.metadata_version, 5);
1901    }
1902
1903    #[tokio::test]
1904    async fn merge_now_preserves_disk_pinned_in_metadata_group() {
1905        let (_temp, storage) = make_storage().await;
1906        let session_id = "pinned-merge";
1907
1908        let mut on_disk = fresh(session_id);
1909        on_disk.pinned = true;
1910        on_disk.metadata_version = 2;
1911        storage.save_session(&on_disk).await.unwrap();
1912
1913        let mut runtime_copy = fresh(session_id);
1914        runtime_copy.pinned = false;
1915        runtime_copy.metadata_version = 0;
1916
1917        merge_save_session(&storage, &mut runtime_copy)
1918            .await
1919            .unwrap();
1920
1921        let after = storage.load_session(session_id).await.unwrap().unwrap();
1922        assert!(
1923            after.pinned,
1924            "disk pinned=true should win over runtime false"
1925        );
1926        assert_eq!(after.metadata_version, 2);
1927    }
1928
1929    #[tokio::test]
1930    async fn merge_keeps_in_memory_when_session_version_higher() {
1931        let (_temp, storage) = make_storage().await;
1932        let session_id = "merge-bumped";
1933
1934        let mut on_disk = fresh(session_id);
1935        on_disk.title = "Old".to_string();
1936        on_disk.title_version = 1;
1937        on_disk.metadata_version = 3;
1938        storage.save_session(&on_disk).await.unwrap();
1939
1940        let mut authoritative_copy = fresh(session_id);
1941        authoritative_copy.title = "New Authoritative".to_string();
1942        authoritative_copy.title_version = 2;
1943        authoritative_copy.metadata_version = 4;
1944        authoritative_copy.pinned = true;
1945
1946        merge_save_session(&storage, &mut authoritative_copy)
1947            .await
1948            .unwrap();
1949
1950        let after = storage.load_session(session_id).await.unwrap().unwrap();
1951        assert_eq!(after.title, "New Authoritative");
1952        assert_eq!(after.title_version, 2);
1953        assert_eq!(after.metadata_version, 4);
1954        assert!(after.pinned);
1955    }
1956
1957    #[tokio::test]
1958    async fn merge_keeps_runtime_messages_when_disk_only_changed_metadata() {
1959        let (_temp, storage) = make_storage().await;
1960        let session_id = "merge-messages";
1961
1962        let mut on_disk = fresh(session_id);
1963        on_disk.title = "Fresh Title".to_string();
1964        on_disk.title_version = 2;
1965        on_disk.metadata_version = 5;
1966        storage.save_session(&on_disk).await.unwrap();
1967
1968        let mut runtime_copy = fresh(session_id);
1969        runtime_copy.title = "Stale".to_string();
1970        runtime_copy.metadata_version = 0;
1971        runtime_copy.messages = vec![bamboo_domain::session::types::Message {
1972            role: bamboo_domain::session::types::Role::User,
1973            content: "keep me".to_string(),
1974            id: "msg-1".to_string(),
1975            created_at: chrono::Utc::now(),
1976            reasoning: None,
1977            reasoning_signature: None,
1978            content_parts: None,
1979            image_ocr: None,
1980            phase: None,
1981            tool_calls: None,
1982            tool_call_id: None,
1983            tool_success: None,
1984            compressed: false,
1985            compressed_by_event_id: None,
1986            never_compress: false,
1987            compression_level: 0,
1988            metadata: None,
1989        }];
1990
1991        merge_save_session(&storage, &mut runtime_copy)
1992            .await
1993            .unwrap();
1994
1995        let after = storage.load_session(session_id).await.unwrap().unwrap();
1996        assert_eq!(after.title, "Fresh Title");
1997        assert_eq!(after.metadata_version, 5);
1998        assert_eq!(after.messages.len(), 1);
1999        assert_eq!(after.messages[0].content, "keep me");
2000    }
2001
2002    #[tokio::test]
2003    async fn runtime_control_plane_port_uses_sidecar_without_rewriting_messages() {
2004        use bamboo_domain::session::types::Message;
2005
2006        let (_temp, storage) = make_storage().await;
2007        let store = LockedSessionStore::new(storage.clone());
2008        let session_id = "runtime-control-plane";
2009
2010        let mut durable = fresh(session_id);
2011        durable.add_message(Message::user("durable transcript"));
2012        storage.save_session(&durable).await.unwrap();
2013
2014        let mut runtime = durable.clone();
2015        runtime.model = "updated-control-plane-model".to_string();
2016        runtime.add_message(Message::assistant("uncheckpointed runtime message", None));
2017        RuntimeSessionPersistence::save_runtime_control_plane(&store, &mut runtime)
2018            .await
2019            .unwrap();
2020
2021        let control_plane =
2022            RuntimeSessionPersistence::load_runtime_control_plane(&store, session_id)
2023                .await
2024                .unwrap()
2025                .expect("control-plane exists");
2026        assert!(
2027            control_plane.messages.is_empty(),
2028            "LockedSessionStore must expose its message-free sidecar"
2029        );
2030        assert_eq!(control_plane.model, "updated-control-plane-model");
2031
2032        let reloaded = storage
2033            .load_session(session_id)
2034            .await
2035            .unwrap()
2036            .expect("session exists");
2037        assert_eq!(reloaded.model, "updated-control-plane-model");
2038        assert_eq!(
2039            reloaded.messages.len(),
2040            1,
2041            "control-plane save must not write the uncheckpointed message"
2042        );
2043        assert_eq!(reloaded.messages[0].content, "durable transcript");
2044    }
2045
2046    #[tokio::test]
2047    async fn atomic_task_patch_loads_inside_lock_and_preserves_interleaved_runtime_state() {
2048        let temp = tempfile::tempdir().unwrap();
2049        let inner = Arc::new(
2050            SessionStoreV2::new(temp.path().to_path_buf())
2051                .await
2052                .expect("storage init"),
2053        );
2054        let session_id = "atomic-task-patch";
2055        inner
2056            .save_session(&fresh(session_id))
2057            .await
2058            .expect("seed session");
2059
2060        let counted = Arc::new(CountingControlPlaneStorage {
2061            inner: inner.clone(),
2062            control_plane_loads: AtomicUsize::new(0),
2063        });
2064        let storage: Arc<dyn Storage> = counted.clone();
2065        let store = Arc::new(LockedSessionStore::new(storage));
2066        let guard = store.acquire_lock(session_id).await;
2067        let now = chrono::Utc::now();
2068        let task_list = bamboo_domain::TaskList {
2069            session_id: session_id.to_string(),
2070            title: "Atomic Task patch".to_string(),
2071            items: Vec::new(),
2072            created_at: now,
2073            updated_at: now,
2074        };
2075        let (started_tx, started_rx) = tokio::sync::oneshot::channel();
2076        let patch_store = store.clone();
2077        let patch = tokio::spawn(async move {
2078            let _ = started_tx.send(());
2079            RuntimeSessionPersistence::update_task_list_control_plane(
2080                patch_store.as_ref(),
2081                session_id,
2082                &task_list,
2083                "9",
2084            )
2085            .await
2086        });
2087        started_rx.await.expect("patch task started");
2088        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2089        assert_eq!(
2090            counted.control_plane_loads.load(Ordering::SeqCst),
2091            0,
2092            "Task patch must acquire the session lock before loading its snapshot"
2093        );
2094
2095        // Publish a newer unrelated runtime transition while the Task patch is
2096        // queued on the same session lock. Once the guard releases, the patch
2097        // must load this latest snapshot and change only Task-owned fields.
2098        let mut latest = inner
2099            .load_runtime_control_plane(session_id)
2100            .await
2101            .expect("load latest control-plane")
2102            .expect("control-plane exists");
2103        latest.agent_runtime_state = Some(bamboo_domain::AgentRuntimeState::new("latest-run"));
2104        latest
2105            .metadata
2106            .insert("concurrent.runtime".to_string(), "preserve".to_string());
2107        inner
2108            .save_runtime_state(&latest)
2109            .await
2110            .expect("publish concurrent runtime transition");
2111        drop(guard);
2112
2113        assert!(
2114            patch.await.expect("patch join").expect("patch succeeds"),
2115            "existing root must be patched"
2116        );
2117        assert_eq!(counted.control_plane_loads.load(Ordering::SeqCst), 1);
2118        let reloaded = inner
2119            .load_session(session_id)
2120            .await
2121            .expect("reload")
2122            .expect("session exists");
2123        assert_eq!(
2124            reloaded
2125                .agent_runtime_state
2126                .as_ref()
2127                .map(|state| state.run_id.as_str()),
2128            Some("latest-run")
2129        );
2130        assert_eq!(
2131            reloaded
2132                .metadata
2133                .get("concurrent.runtime")
2134                .map(String::as_str),
2135            Some("preserve")
2136        );
2137        assert_eq!(reloaded.task_list_version_meta().as_deref(), Some("9"));
2138        assert_eq!(
2139            reloaded.task_list.as_ref().map(|list| list.title.as_str()),
2140            Some("Atomic Task patch")
2141        );
2142    }
2143
2144    // ── LockedSessionStore tests ────────────────────────────────────
2145
2146    #[tokio::test]
2147    async fn locked_merge_save_runtime_serialises_concurrent_writes() {
2148        let (_temp, storage) = make_storage().await;
2149        let store = Arc::new(LockedSessionStore::new(storage));
2150        let session_id = "lock-serial".to_string();
2151
2152        // Seed with base version.
2153        let base = fresh(&session_id);
2154        store.storage().save_session(&base).await.unwrap();
2155
2156        // Two concurrent authorised writers each bump and commit.
2157        // We'll simulate via clone-and-bump-then-commit.
2158        let store_a = store.clone();
2159        let store_b = store.clone();
2160        let sid_a = session_id.clone();
2161        let sid_b = session_id.clone();
2162
2163        let a = tokio::spawn(async move {
2164            let _guard = store_a.acquire_lock(&sid_a).await;
2165            let mut s = store_a
2166                .storage()
2167                .load_session(&sid_a)
2168                .await
2169                .unwrap()
2170                .unwrap();
2171            s.title = "Writer A".to_string();
2172            s.title_version = s.title_version.saturating_add(1);
2173            s.metadata_version = s.metadata_version.saturating_add(1);
2174            s.updated_at = chrono::Utc::now();
2175            store_a.storage().save_session(&s).await.unwrap();
2176            s.title_version
2177        });
2178
2179        // Tiny yield so A goes first.
2180        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
2181
2182        let b = tokio::spawn(async move {
2183            let _guard = store_b.acquire_lock(&sid_b).await;
2184            let mut s = store_b
2185                .storage()
2186                .load_session(&sid_b)
2187                .await
2188                .unwrap()
2189                .unwrap();
2190            s.title = "Writer B".to_string();
2191            s.title_version = s.title_version.saturating_add(1);
2192            s.metadata_version = s.metadata_version.saturating_add(1);
2193            s.updated_at = chrono::Utc::now();
2194            store_b.storage().save_session(&s).await.unwrap();
2195            s.title_version
2196        });
2197
2198        let (ver_a, ver_b) = tokio::join!(a, b);
2199        let final_s = store
2200            .storage()
2201            .load_session(&session_id)
2202            .await
2203            .unwrap()
2204            .unwrap();
2205        assert!(
2206            ver_a.unwrap() != ver_b.unwrap(),
2207            "concurrent writers must produce distinct versions"
2208        );
2209        assert_eq!(final_s.metadata_version, 2);
2210    }
2211
2212    #[tokio::test]
2213    async fn commit_metadata_is_plain_save_inside_lock() {
2214        let (_temp, storage) = make_storage().await;
2215        let store = LockedSessionStore::new(storage);
2216        let session_id = "commit-plain";
2217
2218        let mut s = fresh(session_id);
2219        s.title = "Committed".to_string();
2220        s.metadata_version = 1;
2221        s.title_version = 2;
2222
2223        store.commit_metadata(&s).await.unwrap();
2224
2225        let after = store
2226            .storage()
2227            .load_session(session_id)
2228            .await
2229            .unwrap()
2230            .unwrap();
2231        assert_eq!(after.title, "Committed");
2232        assert_eq!(after.metadata_version, 1);
2233        assert_eq!(after.title_version, 2);
2234    }
2235
2236    // ── Self-cleaning per-session lock (issue #346) ─────────────────
2237
2238    #[tokio::test]
2239    async fn acquire_lock_self_evicts_when_no_other_holder() {
2240        let (_temp, storage) = make_storage().await;
2241        let store = LockedSessionStore::new(storage);
2242
2243        {
2244            let _guard = store.acquire_lock("solo").await;
2245            assert_eq!(store.locks.len(), 1, "entry present while the lock is held");
2246        }
2247        // Dropping the guard runs the self-cleaning `remove_if`. Without the
2248        // eviction logic this stays at 1 forever (the pre-#346 leak).
2249        assert_eq!(
2250            store.locks.len(),
2251            0,
2252            "lock entry must be evicted once released with no other holder"
2253        );
2254    }
2255
2256    #[tokio::test]
2257    async fn acquire_lock_many_distinct_ids_do_not_accumulate() {
2258        let (_temp, storage) = make_storage().await;
2259        let store = LockedSessionStore::new(storage);
2260
2261        // Serially acquire+release for 100 distinct session ids.
2262        for i in 0..100 {
2263            let _guard = store.acquire_lock(&format!("sess-{i}")).await;
2264        }
2265        assert_eq!(
2266            store.locks.len(),
2267            0,
2268            "acquiring locks for many distinct ids must not grow the map"
2269        );
2270    }
2271
2272    #[tokio::test]
2273    async fn acquire_lock_concurrent_waiter_keeps_valid_lock_and_map_drains() {
2274        use std::sync::atomic::{AtomicUsize, Ordering};
2275
2276        let (_temp, storage) = make_storage().await;
2277        let store = Arc::new(LockedSessionStore::new(storage));
2278
2279        // Tracks concurrent holders of the SAME session lock; must never exceed 1.
2280        let active = Arc::new(AtomicUsize::new(0));
2281        let max_seen = Arc::new(AtomicUsize::new(0));
2282
2283        let mut handles = Vec::new();
2284        for _ in 0..8 {
2285            let store = store.clone();
2286            let active = active.clone();
2287            let max_seen = max_seen.clone();
2288            handles.push(tokio::spawn(async move {
2289                let _guard = store.acquire_lock("contended").await;
2290                let now = active.fetch_add(1, Ordering::SeqCst) + 1;
2291                max_seen.fetch_max(now, Ordering::SeqCst);
2292                // Hold briefly so the other tasks actually queue on the mutex.
2293                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2294                active.fetch_sub(1, Ordering::SeqCst);
2295            }));
2296        }
2297        for h in handles {
2298            h.await.unwrap();
2299        }
2300
2301        // Mutual exclusion must hold: a self-cleaning removal that raced (removed
2302        // the entry a waiter had already cloned, letting a later task create and
2303        // lock a *second* mutex for the same id) would show 2 concurrent holders.
2304        // `remove_if`'s atomic strong-count check under the shard lock prevents it.
2305        assert_eq!(
2306            max_seen.load(Ordering::SeqCst),
2307            1,
2308            "at most one holder of a given session lock at a time"
2309        );
2310        assert_eq!(
2311            store.locks.len(),
2312            0,
2313            "after all holders release, the contended entry must be fully evicted"
2314        );
2315    }
2316}