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`,
5//! `title_generated`, `pinned`, `metadata_version`) before writing the
6//! runtime-modified session to storage.
7//! Re-reads the latest persisted copy and only takes in-memory values when the
8//! caller's `metadata_version` strictly exceeds disk's.
9//!
10//! ## Field-by-field merge policy
11//!
12//! All authoritative metadata fields are grouped under `metadata_version`:
13//! when `disk.metadata_version >= session.metadata_version`, the on-disk
14//! `title`, `title_version`, `title_generated`, `pinned`, and
15//! `metadata_version` overwrite the in-memory values before writing.
16//! Authoritative writers bump
17//! `metadata_version` (and `title_version` for title edits) before calling so
18//! their values survive the merge; non-authoritative writers don't bump and so
19//! are overwritten by any later disk changes.
20//!
21//! ## Two save primitives
22//!
23//! - **`merge_save_session`** — stateless merge+save. Still works for
24//!   non-authoritative writers that hold `Arc<dyn Storage>` directly.
25//! - **`LockedSessionStore::merge_save_runtime`** — per-session-locked variant
26//!   that additionally serializes writes for the same session. Prefer this for
27//!   server-side paths where an authoritative writer may race with a runtime
28//!   save.
29//! - **`LockedSessionStore::commit_metadata`** — plain save inside a per-session
30//!   lock. For authoritative writers that have already performed
31//!   load→mutate→bump inside the lock; no merge needed (they hold the latest).
32//!
33//! Bare [`Storage::save_session`] is reserved for first-write paths (e.g. new
34//! session creation) where there is no prior on-disk copy to merge against.
35
36use std::sync::Arc;
37
38use bamboo_domain::session::types::Session;
39use bamboo_domain::storage::Storage;
40use bamboo_domain::{
41    latest_response_occurrence, PermissionAuditSeed, PermissionAuditSnapshot, ResponseOccurrence,
42    RetrievalWindowCheckpointOutcome, RuntimeSessionPersistence, CONSUMED_CLARIFICATION_IDS_KEY,
43    CONSUMED_RESPONSE_OCCURRENCES_KEY,
44};
45use dashmap::DashMap;
46use tokio::sync::{Mutex, OwnedMutexGuard};
47
48const AUTHORITATIVE_METADATA_KEYS: &[&str] = &[
49    "gold_config",
50    "workflow.run_ids.v1",
51    "ticket.runtime.dispatch.v1",
52    "ticket.runtime.owned_stop.v1",
53];
54const ROOT_PROJECT_CONTEXT_KEYS: &[&str] = &[
55    "workspace_source",
56    "workspace_binding_status",
57    "project_context_rendered",
58    "project_resources_rendered",
59    "runtime_prompt_snapshot",
60];
61const RESPONSE_CONTROL_METADATA_KEYS: &[&str] = &[
62    CONSUMED_CLARIFICATION_IDS_KEY,
63    CONSUMED_RESPONSE_OCCURRENCES_KEY,
64    "runtime.suspend_reason",
65    "clarification_resume_pending",
66    "conclusion_with_options_resume_pending",
67    "execute.startup_handoff_at",
68    "permission.reexecute_tool_call_id",
69    "permission.reexecute_request_generation",
70    "retry_resume_pending",
71    "retry_resume_reason",
72    "provider_name",
73    "runtime.child_completion_source_v1",
74];
75const TASK_CONTROL_PLANE_CONFLICT_PREFIX: &str = "Task control-plane changed while saving session ";
76const MAX_TASK_CONTROL_PLANE_REBASE_RETRIES: usize = 3;
77const LAST_MANUAL_ARCHIVE_OCCURRENCE_KEY: &str =
78    "context_management.last_manual_archive_occurrence.v1";
79const MANUAL_ARCHIVE_REJECTIONS_KEY: &str = "context_management.manual_archive_rejections.v1";
80const MAX_MANUAL_ARCHIVE_REJECTIONS: usize = 64;
81const RESPONSES_PREVIOUS_RESPONSE_ID_KEY: &str = "responses.previous_response_id";
82const ACTOR_PARENT_QUESTION_HANDOFF_KEY: &str = "runtime.actor_parent_question_handoff";
83
84/// A worker clarification was already checkpointed by the Host while this
85/// runner still held an older in-memory Child snapshot. The Host checkpoint,
86/// and a direct-parent answer that may have followed it, own the transcript
87/// and question control plane. Reconcile under the same lock as the final save
88/// so an old runner cannot erase the question or undo a fast answer.
89fn adopt_durable_actor_parent_question_handoff(
90    session: &mut Session,
91    durable: &Session,
92) -> std::io::Result<()> {
93    if session
94        .metadata
95        .get(ACTOR_PARENT_QUESTION_HANDOFF_KEY)
96        .map(String::as_str)
97        != Some("true")
98    {
99        return Ok(());
100    }
101    let request = durable
102        .metadata
103        .get(bamboo_domain::PARENT_QUESTION_REQUEST_KEY)
104        .and_then(|value| serde_json::from_str::<bamboo_domain::ParentQuestion>(value).ok());
105    let pending_question = request.as_ref().is_some_and(|question| {
106        durable.pending_question.as_ref().is_some_and(|pending| {
107            pending.source == bamboo_domain::PendingQuestionSource::DirectParent
108                && pending.tool_call_id == question.tool_call_id
109        }) && durable
110            .metadata
111            .get("runtime.suspend_reason")
112            .is_some_and(|reason| reason == "awaiting_clarification")
113    });
114    let resolved_question = request.as_ref().is_some_and(|question| {
115        durable
116            .metadata
117            .get(bamboo_domain::PARENT_QUESTION_RESOLUTION_KEY)
118            .and_then(|value| {
119                serde_json::from_str::<bamboo_domain::ParentQuestionResolution>(value).ok()
120            })
121            .is_some_and(|resolution| resolution.request == *question)
122    });
123    if session.kind != bamboo_domain::SessionKind::Child
124        || session.id != durable.id
125        || session.created_at != durable.created_at
126        || session.parent_session_id != durable.parent_session_id
127        || session.root_session_id != durable.root_session_id
128        || session.spawn_depth != durable.spawn_depth
129        || session.project_id_meta() != durable.project_id_meta()
130        || !request.as_ref().is_some_and(|question| {
131            question.child.session_id == durable.id
132                && question.child.created_at == durable.created_at
133                && question.root_session_id == durable.root_session_id
134                && question.project_id == durable.project_id_meta()
135        })
136        || !(pending_question || resolved_question)
137    {
138        return Err(std::io::Error::new(
139            std::io::ErrorKind::InvalidData,
140            "canonical direct-parent question handoff is missing or changed",
141        ));
142    }
143
144    // A suspended question has no legitimate runner-only messages after its
145    // Host checkpoint. In particular, a terminal Worker text is not a new
146    // assistant reply. Durable ordering also includes any fast parent answer.
147    session.messages.clone_from(&durable.messages);
148    session
149        .provider_transcript
150        .clone_from(&durable.provider_transcript);
151    session
152        .pending_question
153        .clone_from(&durable.pending_question);
154    for key in RESPONSE_CONTROL_METADATA_KEYS.iter().copied().chain([
155        bamboo_domain::PARENT_QUESTION_REQUEST_KEY,
156        bamboo_domain::PARENT_QUESTION_RESOLUTION_KEY,
157    ]) {
158        match durable.metadata.get(key) {
159            Some(value) => {
160                session.metadata.insert(key.to_string(), value.clone());
161            }
162            None => {
163                session.metadata.remove(key);
164            }
165        }
166    }
167    session.model.clone_from(&durable.model);
168    session.model_ref.clone_from(&durable.model_ref);
169    session.reasoning_effort = durable.reasoning_effort;
170    session
171        .agent_runtime_state
172        .clone_from(&durable.agent_runtime_state);
173    session.updated_at = session.updated_at.max(durable.updated_at);
174    session.metadata.remove(ACTOR_PARENT_QUESTION_HANDOFF_KEY);
175    Ok(())
176}
177
178fn may_publish_runtime_result(result: &std::io::Result<()>) -> bool {
179    !result.as_ref().err().is_some_and(|error| {
180        error.get_ref().is_some_and(|cause| {
181            cause.is::<bamboo_domain::SessionAuthorityConflict>()
182                || cause.is::<crate::v2::DirectParentTerminalConflict>()
183                || cause.is::<crate::v2::DirectParentTerminalProofError>()
184        })
185    })
186}
187
188fn is_direct_parent_terminal_conflict(error: &std::io::Error) -> bool {
189    error.kind() == std::io::ErrorKind::WouldBlock
190        && error
191            .get_ref()
192            .is_some_and(|cause| cause.is::<crate::v2::DirectParentTerminalConflict>())
193}
194
195/// Ordinary runner writes do not author tagged tool waits. Their first arm is
196/// a runtime-only control-plane write; untagged safety-net waits remain owned
197/// by the runner. Adopt the latest wait under the same lock as this save,
198/// without reverting the new run's Initializing/Running status to Suspended.
199fn adopt_durable_tagged_child_wait(
200    session: &mut Session,
201    latest: &Session,
202) -> Option<chrono::DateTime<chrono::Utc>> {
203    if session.id != latest.id || session.created_at != latest.created_at {
204        return None;
205    }
206    let incoming = session.agent_runtime_state.as_mut()?;
207    let wait = incoming.waiting_for_children.as_ref()?;
208    wait.registered_by_tool_call_id.as_ref()?;
209    let registered_at = wait.registered_at;
210    incoming.waiting_for_children = latest
211        .agent_runtime_state
212        .as_ref()
213        .and_then(|state| state.waiting_for_children.clone());
214    if incoming.waiting_for_children.is_some() {
215        return None;
216    }
217    if session
218        .metadata
219        .get("runtime.suspend_reason")
220        .map(String::as_str)
221        == Some("waiting_for_children")
222    {
223        session.metadata.remove("runtime.suspend_reason");
224    }
225    if incoming
226        .suspension
227        .as_ref()
228        .is_some_and(|s| s.reason == "waiting_for_children")
229    {
230        incoming.suspension = None;
231        if incoming.status == bamboo_domain::AgentStatusState::Suspended {
232            incoming.status = bamboo_domain::AgentStatusState::Idle;
233        }
234    }
235    Some(registered_at)
236}
237
238/// A synchronous SubAgent tool has already persisted its tagged wait before
239/// launching the child. At runner finalization, the durable copy therefore
240/// owns later completion/rollback changes to that wait. Reconcile inside the
241/// same lock as the final save so completion cannot clear the wait between a
242/// read and a stale write. Untagged runner-created waits stay caller-owned:
243/// their first persistence attempt may have failed and this may be the retry.
244pub(crate) fn adopt_finalized_child_wait(
245    session: &mut Session,
246    latest: &Session,
247    inherited: bool,
248) -> Option<chrono::DateTime<chrono::Utc>> {
249    let incoming = session.agent_runtime_state.as_mut()?;
250    if !inherited
251        && (session
252            .metadata
253            .get("runtime.suspend_reason")
254            .map(String::as_str)
255            != Some("waiting_for_children")
256            || incoming
257                .waiting_for_children
258                .as_ref()
259                .is_none_or(|wait| wait.registered_by_tool_call_id.is_none()))
260    {
261        return None;
262    }
263    let durable = latest.agent_runtime_state.as_ref();
264    if !inherited && durable.is_none() {
265        return None;
266    }
267
268    let registered_at = incoming
269        .waiting_for_children
270        .as_ref()
271        .map(|wait| wait.registered_at);
272    incoming.waiting_for_children = durable.and_then(|state| state.waiting_for_children.clone());
273    let cleared = incoming.waiting_for_children.is_none();
274    let cleared_without_other_suspension =
275        cleared && !latest.metadata.contains_key("runtime.suspend_reason");
276    if cleared {
277        if let Some(durable) = durable {
278            incoming.status = durable.status;
279            incoming.suspension = durable.suspension.clone();
280        }
281        match latest.metadata.get("runtime.suspend_reason") {
282            Some(reason) => {
283                session
284                    .metadata
285                    .insert("runtime.suspend_reason".to_string(), reason.clone());
286            }
287            None => {
288                session.metadata.remove("runtime.suspend_reason");
289            }
290        }
291    }
292    // The child-session adapter still writes the legacy mirror when it arms
293    // a wait. Keep an existing mirror coherent with the reconciled typed
294    // state; do not create new mirrors for modern sessions.
295    if session.metadata.contains_key("agent.runtime.state") {
296        match serde_json::to_string(incoming) {
297            Ok(serialized) => {
298                session
299                    .metadata
300                    .insert("agent.runtime.state".to_string(), serialized);
301            }
302            Err(_) => {
303                session.metadata.remove("agent.runtime.state");
304            }
305        }
306    }
307    if cleared_without_other_suspension && session.last_run_status().as_deref() == Some("suspended")
308    {
309        session.set_last_run_status("completed");
310    }
311    if cleared {
312        registered_at
313    } else {
314        None
315    }
316}
317
318/// Intermediate persistence of a carried untagged wait owns neither its
319/// durable completion nor replacement. Only that exact captured wait is
320/// reconciled; a new wait armed by this run remains caller-owned.
321pub(crate) fn reconcile_inherited_child_wait(
322    session: &mut Session,
323    latest: &Session,
324    inherited: &bamboo_domain::InheritedChildWait,
325) -> std::io::Result<()> {
326    inherited.validate_session(session)?;
327    inherited.validate_session(latest)?;
328    let Some(incoming) = session.agent_runtime_state.as_mut() else {
329        return Ok(());
330    };
331    if incoming.waiting_for_children.as_ref() != Some(inherited.wait()) {
332        return Ok(());
333    }
334    incoming.waiting_for_children = latest
335        .agent_runtime_state
336        .as_ref()
337        .and_then(|state| state.waiting_for_children.clone());
338    let cleared = incoming.waiting_for_children.is_none();
339    if cleared {
340        if session
341            .metadata
342            .get("runtime.suspend_reason")
343            .map(String::as_str)
344            == Some("waiting_for_children")
345        {
346            session.metadata.remove("runtime.suspend_reason");
347        }
348        if incoming
349            .suspension
350            .as_ref()
351            .is_some_and(|s| s.reason == "waiting_for_children")
352        {
353            incoming.suspension = None;
354            if incoming.status == bamboo_domain::AgentStatusState::Suspended {
355                incoming.status = bamboo_domain::AgentStatusState::Idle;
356            }
357        }
358    }
359    // Preserve the legacy mirror only where an adapter already installed it.
360    if session.metadata.contains_key("agent.runtime.state") {
361        session.metadata.insert(
362            "agent.runtime.state".into(),
363            serde_json::to_string(incoming).map_err(std::io::Error::other)?,
364        );
365    }
366    if cleared {
367        preserve_finalized_hidden_child_resumes(session, latest, inherited.wait().registered_at);
368    }
369    Ok(())
370}
371
372fn preserve_finalized_hidden_child_resumes(
373    session: &mut Session,
374    latest: &Session,
375    registered_at: chrono::DateTime<chrono::Utc>,
376) {
377    let existing: std::collections::HashSet<_> = session
378        .messages
379        .iter()
380        .map(|message| message.id.as_str())
381        .collect();
382    let missing = latest
383        .messages
384        .iter()
385        .filter(|message| {
386            !existing.contains(message.id.as_str())
387                && message.created_at >= registered_at
388                && message
389                    .metadata
390                    .as_ref()
391                    .and_then(|metadata| metadata.get("runtime_kind"))
392                    .and_then(|value| value.as_str())
393                    .is_some_and(|kind| {
394                        matches!(kind, "child_completion_resume" | "guardian_review_resume")
395                    })
396        })
397        .cloned()
398        .collect::<Vec<_>>();
399    session.messages.extend(missing);
400}
401
402/// A pending response is an authoritative compare-and-consume transaction.
403/// When a stale runner still carries the consumed ask, its terminal save must
404/// adopt the durable response control plane instead of resurrecting the ask or
405/// erasing the resume handoff. The bounded consumed-id ledger distinguishes a
406/// genuinely new pending question from an old snapshot after `pending_question`
407/// itself has been cleared. New sessions bind that ledger to the concrete
408/// tool-result message and permission generation; the id-only key is a
409/// compatibility fallback only when no occurrence ledger exists.
410fn adopt_durable_consumed_clarification(session: &mut Session, durable: &Session) -> bool {
411    let Some(incoming_tool_call_id) = session
412        .pending_question
413        .as_ref()
414        .map(|pending| pending.tool_call_id.clone())
415    else {
416        return false;
417    };
418    let occurrence_ledger = durable
419        .metadata
420        .get(CONSUMED_RESPONSE_OCCURRENCES_KEY)
421        .map(|value| serde_json::from_str::<Vec<ResponseOccurrence>>(value));
422    let was_consumed = match occurrence_ledger {
423        Some(Ok(consumed)) => latest_response_occurrence(session, &incoming_tool_call_id)
424            .is_some_and(|incoming| consumed.iter().any(|entry| entry == &incoming)),
425        // A present but malformed v1 ledger is never widened to the legacy
426        // id-only matcher: doing so could consume a later reused provider id.
427        Some(Err(_)) => false,
428        None => {
429            let legacy_consumed = durable
430                .metadata
431                .get(CONSUMED_CLARIFICATION_IDS_KEY)
432                .and_then(|value| serde_json::from_str::<Vec<String>>(value).ok())
433                .unwrap_or_default()
434                .iter()
435                .any(|tool_call_id| tool_call_id == &incoming_tool_call_id);
436            // Upgrade compatibility must still bind the old id-only ledger to
437            // its concrete durable tool-result. Otherwise a provider-reused id
438            // in the next round would be mistaken for the consumed occurrence.
439            legacy_consumed
440                && latest_response_occurrence(durable, &incoming_tool_call_id).is_some_and(
441                    |durable_occurrence| {
442                        latest_response_occurrence(session, &incoming_tool_call_id)
443                            .is_some_and(|incoming| incoming == durable_occurrence)
444                    },
445                )
446        }
447    };
448    if !was_consumed {
449        return false;
450    }
451
452    // Durable ordering/content wins (including the selected-response rewrite),
453    // while any truly new runner-only suffix remains append-only.
454    bamboo_domain::append_missing_runtime_messages(session, durable);
455    session
456        .pending_question
457        .clone_from(&durable.pending_question);
458    for key in RESPONSE_CONTROL_METADATA_KEYS {
459        if let Some(value) = durable.metadata.get(*key) {
460            session.metadata.insert((*key).to_string(), value.clone());
461        } else {
462            session.metadata.remove(*key);
463        }
464    }
465    session.model.clone_from(&durable.model);
466    session.model_ref.clone_from(&durable.model_ref);
467    session.reasoning_effort = durable.reasoning_effort;
468    session
469        .agent_runtime_state
470        .clone_from(&durable.agent_runtime_state);
471    if let Some(runtime_metadata) = session.runtime_metadata.as_mut() {
472        runtime_metadata.provider_name = durable
473            .runtime_metadata
474            .as_ref()
475            .and_then(|metadata| metadata.provider_name.clone());
476    } else if durable
477        .runtime_metadata
478        .as_ref()
479        .is_some_and(|metadata| metadata.provider_name.is_some())
480    {
481        session.runtime_metadata = durable.runtime_metadata.as_ref().map(|metadata| {
482            let mut response_metadata = bamboo_domain::SessionRuntimeMetadata::default();
483            response_metadata
484                .provider_name
485                .clone_from(&metadata.provider_name);
486            response_metadata
487        });
488    }
489    true
490}
491
492fn is_task_control_plane_save_conflict(error: &std::io::Error) -> bool {
493    error.kind() == std::io::ErrorKind::WouldBlock
494        && error
495            .to_string()
496            .starts_with(TASK_CONTROL_PLANE_CONFLICT_PREFIX)
497}
498
499fn adopt_durable_task_control_plane(session: &mut Session, durable: &Session) {
500    session.task_list = durable.task_list.clone();
501    session
502        .metadata
503        .remove(bamboo_domain::session::runtime_metadata::keys::TASK_LIST_VERSION);
504    if let Some(runtime_metadata) = session.runtime_metadata.as_mut() {
505        runtime_metadata.task_list_version = None;
506    }
507    if session
508        .runtime_metadata
509        .as_ref()
510        .is_some_and(bamboo_domain::session::SessionRuntimeMetadata::is_empty)
511    {
512        session.runtime_metadata = None;
513    }
514    if let Some(version) = durable.task_list_version_meta() {
515        session.set_task_list_version_meta(version);
516    }
517}
518
519fn task_list_snapshot_matches(
520    session: &Session,
521    expected_task_list: &bamboo_domain::TaskList,
522) -> std::io::Result<bool> {
523    Ok(serde_json::to_value(&session.task_list)
524        .map_err(|error| std::io::Error::other(error.to_string()))?
525        == serde_json::to_value(Some(expected_task_list))
526            .map_err(|error| std::io::Error::other(error.to_string()))?)
527}
528
529fn unconditional_task_patch_would_regress(
530    durable: &Session,
531    incoming_task_list: &bamboo_domain::TaskList,
532    incoming_version: &str,
533) -> std::io::Result<bool> {
534    let same_list = task_list_snapshot_matches(durable, incoming_task_list)?;
535    let Some(durable_version) = durable.task_list_version_meta() else {
536        return Ok(false);
537    };
538    match (
539        incoming_version.parse::<u64>(),
540        durable_version.parse::<u64>(),
541    ) {
542        (Ok(incoming), Ok(durable)) => {
543            Ok(incoming < durable || (incoming == durable && !same_list))
544        }
545        _ => Ok(incoming_version != durable_version || !same_list),
546    }
547}
548
549// ── LockedSessionStore ────────────────────────────────────────────────
550
551/// Wraps a [`Storage`] implementation with per-session write serialization.
552///
553/// Under the hood it maintains a `DashMap<String, Arc<Mutex<()>>>` so that
554/// only writes targeting the *same* session are serialised; different
555/// sessions proceed concurrently.
556pub struct LockedSessionStore {
557    storage: Arc<dyn Storage>,
558    inherited_child_wait: Option<bamboo_domain::InheritedChildWait>,
559    root_actor_writer: Option<(
560        bamboo_domain::RootActorRuntimeWrite,
561        bamboo_domain::RootActorRuntimePublisher,
562    )>,
563    locks: Arc<DashMap<String, Arc<Mutex<()>>>>,
564    /// Serializes recoverable child/root Task transactions. Per-session locks
565    /// still provide the data isolation; this gate ensures a retained recovery
566    /// journal is resolved before another pair attempts to commit.
567    task_pair_transaction_lock: Arc<Mutex<()>>,
568}
569
570/// Self-cleaning guard returned by [`LockedSessionStore::acquire_lock`].
571///
572/// Holds the `OwnedMutexGuard` for the session's serialization mutex. On drop it
573/// releases the mutex **first** (so this guard's `Arc` clone is gone before the
574/// count is read) and then removes the map entry iff `Arc::strong_count == 1` —
575/// i.e. only the map's own reference remains, no other task holds or is waiting
576/// on this session's lock.
577///
578/// ## Race freedom
579///
580/// The strong-count check and the removal execute atomically under DashMap's
581/// per-shard lock via [`DashMap::remove_if`]. A waiter that clones the `Arc`
582/// (through `acquire_lock`'s `entry()`) does so under the same shard lock, so it
583/// either:
584/// - clones **before** our `remove_if` → `strong_count >= 2` → we skip removal,
585///   the waiter keeps a live, map-resident lock; or
586/// - clones **after** our `remove_if` → the entry is gone → it inserts a fresh
587///   `Arc<Mutex<()>>`; since our guard had already been released, the two tasks
588///   never overlapped and needed no mutual exclusion.
589///
590/// There is therefore no interleaving in which a waiter observes a lock that we
591/// then delete out from under it.
592pub struct SessionLockGuard {
593    /// `Option` so `Drop` can release the mutex before evaluating strong-count.
594    guard: Option<OwnedMutexGuard<()>>,
595    locks: Arc<DashMap<String, Arc<Mutex<()>>>>,
596    session_id: String,
597}
598
599impl Drop for SessionLockGuard {
600    fn drop(&mut self) {
601        // Release the mutex (drops this guard's `Arc` clone) BEFORE reading the
602        // strong count, otherwise the count can never reach 1.
603        self.guard.take();
604        self.locks
605            .remove_if(&self.session_id, |_, arc| Arc::strong_count(arc) == 1);
606    }
607}
608
609impl LockedSessionStore {
610    /// Wrap an existing storage backend.
611    pub fn new(storage: Arc<dyn Storage>) -> Self {
612        Self {
613            storage,
614            inherited_child_wait: None,
615            root_actor_writer: None,
616            locks: Arc::new(DashMap::new()),
617            task_pair_transaction_lock: Arc::new(Mutex::new(())),
618        }
619    }
620
621    /// Immutable per-execution binding; shares the existing process locks and
622    /// never changes the default writer's authority.
623    pub fn bind_root_actor_writer(
624        &self,
625        owner: bamboo_domain::RootActorRuntimeWrite,
626        publish: bamboo_domain::RootActorRuntimePublisher,
627    ) -> std::io::Result<Self> {
628        if !self.storage.supports_root_actor_runtime_write() {
629            return Err(std::io::Error::new(
630                std::io::ErrorKind::Unsupported,
631                "storage does not support a Root Actor writer",
632            ));
633        }
634        Ok(Self {
635            storage: self.storage.clone(),
636            inherited_child_wait: self.inherited_child_wait.clone(),
637            root_actor_writer: Some((owner, publish)),
638            locks: self.locks.clone(),
639            task_pair_transaction_lock: self.task_pair_transaction_lock.clone(),
640        })
641    }
642
643    /// Keep the exact captured birth/wait in a private execution view, sharing
644    /// process serialization and any already-bound ordinary Root capability.
645    pub fn bind_inherited_child_wait(&self, inherited: bamboo_domain::InheritedChildWait) -> Self {
646        Self {
647            storage: self.storage.clone(),
648            inherited_child_wait: Some(inherited),
649            root_actor_writer: self.root_actor_writer.clone(),
650            locks: self.locks.clone(),
651            task_pair_transaction_lock: self.task_pair_transaction_lock.clone(),
652        }
653    }
654
655    fn inherited_for_session(
656        &self,
657        session: &Session,
658    ) -> Option<&bamboo_domain::InheritedChildWait> {
659        self.inherited_child_wait
660            .as_ref()
661            .filter(|wait| wait.session_id() == session.id)
662    }
663
664    async fn save_runtime_snapshot(
665        &self,
666        session: &mut Session,
667        runtime_only: bool,
668    ) -> std::io::Result<()> {
669        if let Some(inherited) = self.inherited_for_session(session) {
670            self.storage
671                .save_runtime_with_inherited_child_wait(
672                    session,
673                    inherited,
674                    runtime_only,
675                    self.root_actor_writer.clone(),
676                    None,
677                )
678                .await
679        } else if let Some((owner, publish)) = self.root_actor_writer.as_ref() {
680            self.storage
681                .save_root_actor_runtime(owner, session, runtime_only, publish.clone())
682                .await
683        } else if runtime_only {
684            self.storage.save_runtime_state(session).await
685        } else {
686            self.storage.save_session(session).await
687        }
688    }
689
690    /// Borrow the inner storage for read-only access.
691    pub fn storage(&self) -> &Arc<dyn Storage> {
692        &self.storage
693    }
694
695    /// Acquire a per-session serialization guard.
696    ///
697    /// Only writes for the **same** session are serialised; writes for
698    /// different sessions can proceed concurrently.
699    ///
700    /// The returned [`SessionLockGuard`] is **self-cleaning**: when it drops it
701    /// releases the mutex and then removes the map entry iff no other holder
702    /// remains. Without this the `locks` map grew by one entry for every session
703    /// id ever written and never shrank (issue #346), so a long-lived server
704    /// leaked one `Arc<Mutex<()>>` per session-ever-persisted. See
705    /// [`SessionLockGuard`] for the race-freedom argument.
706    pub async fn acquire_lock(&self, session_id: &str) -> SessionLockGuard {
707        // `entry().or_insert_with().clone()` releases the DashMap shard lock at
708        // the end of THIS statement, before the `.await` below — never hold a
709        // shard lock across the async lock acquisition (it would deadlock the
710        // self-cleaning `remove_if` on drop, which also takes the shard lock).
711        let lock = self
712            .locks
713            .entry(session_id.to_string())
714            .or_insert_with(|| Arc::new(Mutex::new(())))
715            .clone();
716        // Arm cleanup before the cancellable wait. The previous holder may
717        // drop while this waiter owns the last extra Arc; cancelling that waiter
718        // must still reclaim the map entry without needing another acquisition.
719        let mut guard = SessionLockGuard {
720            guard: None,
721            locks: self.locks.clone(),
722            session_id: session_id.to_string(),
723        };
724        guard.guard = Some(lock.lock_owned().await);
725        guard
726    }
727
728    /// Save a full snapshot while preserving Task generations advanced by an
729    /// independent store instance. V2 rejects such a stale write before any
730    /// mutation; reloading and adopting only Task-owned fields makes the retry,
731    /// caller snapshot, and subsequent cache publication agree exactly.
732    async fn save_session_rebasing_task_conflicts(
733        &self,
734        session: &mut Session,
735    ) -> std::io::Result<()> {
736        self.save_session_rebasing_task_conflicts_with_input(session, None, None)
737            .await
738    }
739
740    async fn save_session_rebasing_task_conflicts_with_input(
741        &self,
742        session: &mut Session,
743        input: Option<&(
744            Arc<dyn bamboo_domain::SessionInboxPort>,
745            bamboo_domain::SessionInboxOwnedClaim,
746        )>,
747        inherited: Option<&bamboo_domain::session::runtime_state::WaitingForChildrenState>,
748    ) -> std::io::Result<()> {
749        for attempt in 0..=MAX_TASK_CONTROL_PLANE_REBASE_RETRIES {
750            if let Some(provenance) = self.inherited_for_session(session) {
751                provenance.validate_session(session)?;
752            }
753            let result = if inherited.is_none() && self.inherited_for_session(session).is_some() {
754                self.storage
755                    .save_runtime_with_inherited_child_wait(
756                        session,
757                        self.inherited_for_session(session)
758                            .expect("bound provenance"),
759                        false,
760                        self.root_actor_writer.clone(),
761                        input.cloned(),
762                    )
763                    .await
764            } else if let Some((inbox, claim)) = input {
765                let (owner, publish) = self.root_actor_writer.as_ref().ok_or_else(|| {
766                    std::io::Error::other("Root input checkpoint requires a bound writer")
767                })?;
768                self.storage
769                    .save_root_actor_input(owner, session, inbox.clone(), claim, publish.clone())
770                    .await
771            } else if let Some(inherited) = inherited {
772                self.storage
773                    .save_inherited_child_wait_finalized(
774                        session,
775                        inherited,
776                        self.root_actor_writer.clone(),
777                    )
778                    .await
779            } else {
780                self.save_runtime_snapshot(session, false).await
781            };
782            match result {
783                Ok(()) => return Ok(()),
784                Err(error)
785                    if is_task_control_plane_save_conflict(&error)
786                        && attempt < MAX_TASK_CONTROL_PLANE_REBASE_RETRIES =>
787                {
788                    let Some(durable) =
789                        self.storage.load_runtime_control_plane(&session.id).await?
790                    else {
791                        return Err(error);
792                    };
793                    adopt_durable_task_control_plane(session, &durable);
794                }
795                Err(error) => {
796                    if is_task_control_plane_save_conflict(&error) {
797                        if let Some(durable) =
798                            self.storage.load_runtime_control_plane(&session.id).await?
799                        {
800                            adopt_durable_task_control_plane(session, &durable);
801                        }
802                    }
803                    return Err(error);
804                }
805            }
806        }
807        unreachable!("bounded Task conflict retry loop always returns")
808    }
809
810    /// Sidecar-only counterpart of
811    /// [`Self::save_session_rebasing_task_conflicts`].
812    async fn save_runtime_state_rebasing_task_conflicts(
813        &self,
814        session: &mut Session,
815    ) -> std::io::Result<()> {
816        for attempt in 0..=MAX_TASK_CONTROL_PLANE_REBASE_RETRIES {
817            match self.save_runtime_snapshot(session, true).await {
818                Ok(()) => return Ok(()),
819                Err(error)
820                    if is_task_control_plane_save_conflict(&error)
821                        && attempt < MAX_TASK_CONTROL_PLANE_REBASE_RETRIES =>
822                {
823                    let Some(durable) =
824                        self.storage.load_runtime_control_plane(&session.id).await?
825                    else {
826                        return Err(error);
827                    };
828                    adopt_durable_task_control_plane(session, &durable);
829                }
830                Err(error) => {
831                    if is_task_control_plane_save_conflict(&error) {
832                        if let Some(durable) =
833                            self.storage.load_runtime_control_plane(&session.id).await?
834                        {
835                            adopt_durable_task_control_plane(session, &durable);
836                        }
837                    }
838                    return Err(error);
839                }
840            }
841        }
842        unreachable!("bounded Task conflict retry loop always returns")
843    }
844
845    /// Runtime-only save: persist the control-plane (`agent_runtime_state`,
846    /// metadata, …) without rewriting the message history.
847    ///
848    /// This is the fast path for runtime-state mutations that do NOT change
849    /// `messages` — e.g. registering a parent's wait for spawned children. It
850    /// delegates to [`Storage::save_runtime_state`], which writes a small
851    /// sidecar (or falls back to a full save on backends without one).
852    ///
853    /// Like [`Self::merge_save_runtime`], it merges newer authoritative metadata
854    /// from disk so a concurrent UI title/pin edit is never clobbered — but it
855    /// reads only the lightweight control-plane snapshot (no message history) to
856    /// do so.
857    ///
858    /// Callers MUST NOT use this when they have appended messages: the in-memory
859    /// `messages` are ignored by the sidecar and would not be persisted. They
860    /// also MUST NOT use it to author `model_context_state`; the durable ledger
861    /// loaded under the lock always wins this narrow save.
862    pub async fn save_runtime_only(&self, session: &mut Session) -> std::io::Result<()> {
863        self.save_runtime_only_and_publish(session, |_| {}).await
864    }
865
866    /// Commit an auto-run promise for the exact child generation the caller
867    /// prepared. The adapter uses this for launch paths (such as Plan) that
868    /// first saved a draft and only later chose to enqueue it. Read and write
869    /// the lightweight control plane under one per-session lock, so a stale
870    /// enqueue cannot give an older generation a new launch promise.
871    pub async fn ensure_child_auto_run_launch_intent(
872        &self,
873        expected: &Session,
874    ) -> std::io::Result<Option<Session>> {
875        let _guard = self.acquire_lock(&expected.id).await;
876        let Some(mut latest) = self
877            .storage
878            .load_runtime_control_plane(&expected.id)
879            .await?
880        else {
881            return Ok(None);
882        };
883        let generation = expected.child_launch_generation();
884        if latest.kind != bamboo_domain::SessionKind::Child
885            || latest.created_at != expected.created_at
886            || latest.parent_session_id != expected.parent_session_id
887            || latest.root_session_id != expected.root_session_id
888            || latest.child_launch_generation() != generation
889            || latest.is_child_launch_cancelled(generation)
890        {
891            return Ok(None);
892        }
893        if latest.last_run_status().as_deref() != Some("pending") {
894            // Keep the scheduler's existing AlreadyCommitted behavior for a
895            // duplicate delivery after this same promised run has started.
896            // Recovery itself still filters strictly to pending sessions.
897            let already_promised = latest
898                .runtime_metadata
899                .as_ref()
900                .and_then(|metadata| metadata.child_auto_run_launch_intent_generation)
901                == Some(generation);
902            let already_started = matches!(
903                latest.last_run_status().as_deref(),
904                Some("running" | "completed" | "error" | "timeout" | "skipped" | "suspended")
905            );
906            return Ok((already_promised && already_started).then_some(latest));
907        }
908        if latest.recoverable_child_launch_generation() != Some(generation) {
909            latest.mark_child_auto_run_launch_intent();
910            self.save_runtime_state_rebasing_task_conflicts(&mut latest)
911                .await?;
912        }
913        Ok(Some(latest))
914    }
915
916    /// Save the runtime control-plane and synchronously publish the committed
917    /// snapshot before releasing this session's serialization lock.
918    ///
919    /// `publish` must remain a short, non-blocking operation. Its synchronous
920    /// shape intentionally prevents callers from holding an in-memory cache
921    /// guard across an await. The callback also runs when the durable save
922    /// fails, preserving [`RuntimeSessionPersistence::save_runtime_control_plane`]
923    /// implementations that publish current runtime authorization state while
924    /// still returning the storage error. Authority conflicts are excluded:
925    /// publishing a rejected identity would disagree with durable authority.
926    pub async fn save_runtime_only_and_publish<F>(
927        &self,
928        session: &mut Session,
929        publish: F,
930    ) -> std::io::Result<()>
931    where
932        F: FnOnce(&Session) + Send,
933    {
934        self.save_runtime_only_and_publish_inner(session, publish, true)
935            .await
936    }
937
938    /// Save a control-plane transition and publish it only after a successful
939    /// durable sidecar write. Callers that use the callback to release an Inbox
940    /// wait must not expose an uncommitted transition after a save error.
941    pub async fn save_runtime_only_and_publish_on_success<F>(
942        &self,
943        session: &mut Session,
944        publish: F,
945    ) -> std::io::Result<()>
946    where
947        F: FnOnce(&Session) + Send,
948    {
949        self.save_runtime_only_and_publish_inner(session, publish, false)
950            .await
951    }
952
953    async fn save_runtime_only_and_publish_inner<F>(
954        &self,
955        session: &mut Session,
956        publish: F,
957        publish_on_failure: bool,
958    ) -> std::io::Result<()>
959    where
960        F: FnOnce(&Session) + Send,
961    {
962        let _guard = self.acquire_lock(&session.id).await;
963        if let Some(latest) = self.storage.load_runtime_control_plane(&session.id).await? {
964            apply_authoritative_metadata(session, &latest);
965            // The control-plane sidecar carries `agent_runtime_state`, so a
966            // concurrent mid-run bypass flip is here too — don't revert it. #540.
967            adopt_fresher_disk_permission_posture(session, &latest);
968            // Runtime-only callers own narrow control-plane fields (Task list,
969            // parent wait state, …), never the model-context ledger. The sidecar
970            // load and save share this lock, so disk is unconditionally
971            // authoritative for the ledger and the published snapshot.
972            adopt_durable_model_context_state(session, &latest);
973        }
974        let result = self
975            .save_runtime_state_rebasing_task_conflicts(session)
976            .await;
977        if result.is_ok() || (publish_on_failure && may_publish_runtime_result(&result)) {
978            publish(session);
979        }
980        result
981    }
982
983    /// Forward child-wait mutations to the final physical writer. The local
984    /// lock orders this instance's callers; V2 owns cross-instance atomicity.
985    pub async fn register_child_wait_and_publish(
986        &self,
987        expected: &Session,
988        batch: &[(String, Option<String>)],
989        policy: bamboo_domain::ChildWaitPolicy,
990        check_terminal: bool,
991        publish: bamboo_domain::RootActorRuntimePublisher,
992    ) -> std::io::Result<(Session, usize)> {
993        let _guard = self.acquire_lock(&expected.id).await;
994        if self.storage.supports_atomic_child_wait_control_plane() {
995            return self
996                .storage
997                .register_child_wait_control_plane(expected, batch, policy, check_terminal, publish)
998                .await;
999        }
1000        // Preserve legacy/custom backend support under this instance's existing
1001        // serialization lock. Only capable backends claim physical atomicity.
1002        use crate::v2::child_wait_control_plane::{
1003            register_pending, terminal, validate_incarnation,
1004        };
1005        let mut latest = self
1006            .storage
1007            .load_session(&expected.id)
1008            .await?
1009            .ok_or_else(|| {
1010                std::io::Error::new(
1011                    std::io::ErrorKind::NotFound,
1012                    "child wait parent disappeared",
1013                )
1014            })?;
1015        validate_incarnation(expected, &latest)?;
1016        let mut pending = Vec::new();
1017        let mut satisfied = false;
1018        for entry in batch {
1019            if !check_terminal {
1020                pending.push(entry.clone());
1021                continue;
1022            }
1023            let child = self.storage.load_runtime_control_plane(&entry.0).await?;
1024            let status = child
1025                .filter(|child| child.parent_session_id.as_deref() == Some(&expected.id))
1026                .and_then(|child| child.last_run_status())
1027                .filter(|status| terminal(status));
1028            if let Some(status) = status {
1029                satisfied |= policy == bamboo_domain::ChildWaitPolicy::Any
1030                    || (policy == bamboo_domain::ChildWaitPolicy::FirstError
1031                        && matches!(status.as_str(), "error" | "timeout" | "cancelled"));
1032            } else {
1033                pending.push(entry.clone());
1034            }
1035        }
1036        if satisfied || pending.is_empty() {
1037            publish(&latest);
1038            return Ok((latest, 0));
1039        }
1040        let count = pending.len();
1041        register_pending(&mut latest, pending, policy)?;
1042        self.save_runtime_state_rebasing_task_conflicts(&mut latest)
1043            .await?;
1044        publish(&latest);
1045        Ok((latest, count))
1046    }
1047
1048    pub async fn compare_exchange_child_wait_and_publish(
1049        &self,
1050        expected: &Session,
1051        updated: &mut Session,
1052        runtime_only: bool,
1053        publish: bamboo_domain::RootActorRuntimePublisher,
1054    ) -> std::io::Result<bool> {
1055        let _guard = self.acquire_lock(&expected.id).await;
1056        if self.storage.supports_atomic_child_wait_control_plane() {
1057            return self
1058                .storage
1059                .compare_exchange_child_wait_control_plane(expected, updated, runtime_only, publish)
1060                .await;
1061        }
1062        use crate::v2::child_wait_control_plane::{
1063            apply_transition, is_observation, runtime, validate_incarnation,
1064        };
1065        let mut latest = self
1066            .storage
1067            .load_session(&expected.id)
1068            .await?
1069            .ok_or_else(|| {
1070                std::io::Error::new(
1071                    std::io::ErrorKind::NotFound,
1072                    "child wait parent disappeared",
1073                )
1074            })?;
1075        validate_incarnation(expected, &latest)?;
1076        validate_incarnation(expected, updated)?;
1077        if runtime(&latest).waiting_for_children != runtime(expected).waiting_for_children {
1078            return Ok(false);
1079        }
1080        if is_observation(expected, updated) {
1081            *updated = latest;
1082            return Ok(true);
1083        }
1084        apply_transition(&mut latest, expected, updated, runtime_only)?;
1085        if runtime_only {
1086            self.save_runtime_state_rebasing_task_conflicts(&mut latest)
1087                .await?;
1088        } else {
1089            self.save_session_rebasing_task_conflicts(&mut latest)
1090                .await?;
1091        }
1092        publish(&latest);
1093        *updated = latest;
1094        Ok(true)
1095    }
1096
1097    /// Atomically patch Task-owned control-plane fields and publish the saved
1098    /// value before releasing this session's serialization lock.
1099    ///
1100    /// This couples durable commit order to cache publication order for
1101    /// repository callers. The synchronous callback may take a cache guard but
1102    /// cannot hold one across an await.
1103    pub async fn update_task_list_control_plane_and_publish<F>(
1104        &self,
1105        session_id: &str,
1106        task_list: &bamboo_domain::TaskList,
1107        version: &str,
1108        publish: F,
1109    ) -> std::io::Result<bool>
1110    where
1111        F: FnOnce(&Session) + Send,
1112    {
1113        let _guard = self.acquire_lock(session_id).await;
1114        let Some(mut latest) = self.storage.load_runtime_control_plane(session_id).await? else {
1115            return Ok(false);
1116        };
1117        if unconditional_task_patch_would_regress(&latest, task_list, version)? {
1118            return Err(std::io::Error::new(
1119                std::io::ErrorKind::WouldBlock,
1120                format!("Task control-plane changed while patching session {session_id}"),
1121            ));
1122        }
1123        let original = latest.clone();
1124        latest.task_list = Some(task_list.clone());
1125        latest.set_task_list_version_meta(version.to_string());
1126        if !self
1127            .storage
1128            .save_task_control_plane_if_matches(&original, &latest)
1129            .await?
1130        {
1131            return Err(std::io::Error::new(
1132                std::io::ErrorKind::WouldBlock,
1133                format!("Task control-plane changed while patching session {session_id}"),
1134            ));
1135        }
1136        publish(&latest);
1137        Ok(true)
1138    }
1139
1140    /// Compare-and-patch variant used by asynchronous evaluators. The durable
1141    /// generation check, narrow Task mutation, save, and cache publication all
1142    /// occur under the same per-session lock.
1143    pub async fn update_task_list_control_plane_if_version_and_publish<F>(
1144        &self,
1145        session_id: &str,
1146        expected_version: &str,
1147        expected_task_list: &bamboo_domain::TaskList,
1148        task_list: &bamboo_domain::TaskList,
1149        version: &str,
1150        publish: F,
1151    ) -> std::io::Result<bool>
1152    where
1153        F: FnOnce(&Session) + Send,
1154    {
1155        let _guard = self.acquire_lock(session_id).await;
1156        let Some(mut latest) = self.storage.load_runtime_control_plane(session_id).await? else {
1157            return Ok(false);
1158        };
1159        if latest.task_list_version_meta().as_deref() != Some(expected_version)
1160            || !task_list_snapshot_matches(&latest, expected_task_list)?
1161        {
1162            return Ok(false);
1163        }
1164        let original = latest.clone();
1165        latest.task_list = Some(task_list.clone());
1166        latest.set_task_list_version_meta(version.to_string());
1167        if !self
1168            .storage
1169            .save_task_control_plane_if_matches(&original, &latest)
1170            .await?
1171        {
1172            return Ok(false);
1173        }
1174        publish(&latest);
1175        Ok(true)
1176    }
1177
1178    /// Recoverably validate and narrowly patch both the executing session and
1179    /// its shared root. Locks are acquired in lexical id order to prevent two
1180    /// child evaluators from deadlocking while sharing a root. The underlying
1181    /// storage transaction must either commit both Task generations or retain
1182    /// an undo journal and fail closed until both originals are restored.
1183    pub async fn update_task_list_control_planes_if_version_and_publish<F>(
1184        &self,
1185        session_id: &str,
1186        shared_session_id: &str,
1187        expected_version: &str,
1188        expected_task_list: &bamboo_domain::TaskList,
1189        task_list: &bamboo_domain::TaskList,
1190        version: &str,
1191        publish: F,
1192    ) -> std::io::Result<bool>
1193    where
1194        F: FnOnce(&Session, &Session) + Send,
1195    {
1196        if session_id == shared_session_id {
1197            return self
1198                .update_task_list_control_plane_if_version_and_publish(
1199                    session_id,
1200                    expected_version,
1201                    expected_task_list,
1202                    task_list,
1203                    version,
1204                    |session| publish(session, session),
1205                )
1206                .await;
1207        }
1208
1209        let (first_id, second_id) = if session_id < shared_session_id {
1210            (session_id, shared_session_id)
1211        } else {
1212            (shared_session_id, session_id)
1213        };
1214        let _transaction_guard = self.task_pair_transaction_lock.lock().await;
1215        let _first_guard = self.acquire_lock(first_id).await;
1216        let _second_guard = self.acquire_lock(second_id).await;
1217
1218        // A prior rollback may have failed after one sidecar was published.
1219        // Recover under the same lexical pair locks before observing either
1220        // generation; an unresolved journal fails this access closed.
1221        self.storage
1222            .recover_task_control_plane_transaction(first_id, second_id)
1223            .await?;
1224
1225        let Some(mut local) = self.storage.load_runtime_control_plane(session_id).await? else {
1226            return Ok(false);
1227        };
1228        let Some(mut shared) = self
1229            .storage
1230            .load_runtime_control_plane(shared_session_id)
1231            .await?
1232        else {
1233            return Ok(false);
1234        };
1235        if local.task_list_version_meta().as_deref() != Some(expected_version)
1236            || shared.task_list_version_meta().as_deref() != Some(expected_version)
1237            || !task_list_snapshot_matches(&local, expected_task_list)?
1238            || !task_list_snapshot_matches(&shared, expected_task_list)?
1239        {
1240            return Ok(false);
1241        }
1242
1243        let local_original = local.clone();
1244        let shared_original = shared.clone();
1245        // The paired persistence port owns only Task list/generation. Keep the
1246        // surrounding session snapshot byte-stable so its minimal undo journal
1247        // never needs transcript or unrelated metadata.
1248        local.task_list = Some(task_list.clone());
1249        local.set_task_list_version_meta(version.to_string());
1250        shared.task_list = Some(task_list.clone());
1251        shared.set_task_list_version_meta(version.to_string());
1252        let (first_original, first_updated, second_original, second_updated) =
1253            if session_id < shared_session_id {
1254                (&local_original, &local, &shared_original, &shared)
1255            } else {
1256                (&shared_original, &shared, &local_original, &local)
1257            };
1258        let committed = self
1259            .storage
1260            .save_task_control_planes_atomically(
1261                first_original,
1262                first_updated,
1263                second_original,
1264                second_updated,
1265            )
1266            .await?;
1267        if !committed {
1268            return Ok(false);
1269        }
1270        publish(&local, &shared);
1271        Ok(true)
1272    }
1273
1274    /// Authoritative metadata commit.
1275    ///
1276    /// The caller must have already loaded the latest session, mutated the
1277    /// metadata fields, and bumped `metadata_version` (and `title_version` if
1278    /// applicable).  This method simply acquires the per-session lock and
1279    /// performs a plain `storage.save_session`.
1280    ///
1281    /// The lock guarantees that no other write for this session interleaves
1282    /// between the caller's load and this save, so merge is unnecessary.
1283    pub async fn commit_metadata(&self, session: &Session) -> std::io::Result<()> {
1284        let _guard = self.acquire_lock(&session.id).await;
1285        let mut committed = session.clone();
1286        if let Some(latest) = self.storage.load_runtime_control_plane(&session.id).await? {
1287            adopt_durable_model_context_state(&mut committed, &latest);
1288        }
1289        self.save_session_rebasing_task_conflicts(&mut committed)
1290            .await
1291    }
1292
1293    /// Runtime / non-authoritative save with per-session lock.
1294    ///
1295    /// Inside the lock: reload disk, merge the authoritative metadata group
1296    /// (`title`, `title_version`, `title_generated`, `pinned`, `metadata_version`) from disk into
1297    /// the in-memory copy if disk's `metadata_version >= session.metadata_version`,
1298    /// then save.
1299    ///
1300    /// This is the locked equivalent of [`merge_save_session`]; prefer it for
1301    /// server-side paths where an authoritative write may race with this save.
1302    ///
1303    /// Adopts the on-disk typed permission mode so a running loop's save can't
1304    /// revert a concurrent `PATCH /sessions` transition (#540/#770). Callers
1305    /// that are themselves the authoritative writer of that posture — the
1306    /// parent seeding a child's mode (#74) — must use
1307    /// [`Self::save_runtime_authoritative_flags`] instead, which persists the
1308    /// in-memory mode as-is.
1309    pub async fn merge_save_runtime(&self, session: &mut Session) -> std::io::Result<()> {
1310        self.merge_save_runtime_and_publish(session, |_, _| {})
1311            .await
1312    }
1313
1314    /// Merge-save a runtime session and synchronously publish the resulting
1315    /// snapshot before releasing its per-session serialization lock.
1316    ///
1317    /// The callback receives whether the durable save committed. It runs
1318    /// after the save attempt so repository callers can preserve their existing
1319    /// cache-on-failure policy without reopening a durable-to-cache race,
1320    /// except when authority validation rejects the snapshot.
1321    pub async fn merge_save_runtime_and_publish<F>(
1322        &self,
1323        session: &mut Session,
1324        publish: F,
1325    ) -> std::io::Result<()>
1326    where
1327        F: FnOnce(&Session, bool) + Send,
1328    {
1329        self.merge_save_runtime_inner_and_publish(session, true, false, None, publish)
1330            .await
1331    }
1332
1333    /// Final runner save with a lock-scoped check for a synchronous child
1334    /// completion that raced the pipeline's earlier wait-state read.
1335    pub async fn merge_save_finalized_runtime(&self, session: &mut Session) -> std::io::Result<()> {
1336        self.merge_save_finalized_runtime_and_publish(session, |_, _| {})
1337            .await
1338    }
1339
1340    /// Publish the reconciled final snapshot under the same serialization
1341    /// lock, with the ordinary runtime save's cache-on-failure semantics.
1342    pub async fn merge_save_finalized_runtime_and_publish<F>(
1343        &self,
1344        session: &mut Session,
1345        publish: F,
1346    ) -> std::io::Result<()>
1347    where
1348        F: FnOnce(&Session, bool) + Send,
1349    {
1350        self.merge_save_runtime_inner_and_publish(session, true, true, None, publish)
1351            .await
1352    }
1353
1354    pub async fn merge_save_inherited_child_wait_and_publish<F>(
1355        &self,
1356        session: &mut Session,
1357        inherited: &bamboo_domain::session::runtime_state::WaitingForChildrenState,
1358        publish: F,
1359    ) -> std::io::Result<()>
1360    where
1361        F: FnOnce(&Session, bool) + Send,
1362    {
1363        self.merge_save_runtime_inner_and_publish(session, true, true, Some(inherited), publish)
1364            .await
1365    }
1366
1367    /// Persist an execute-boundary transcript checkpoint without allowing a
1368    /// stale runner snapshot to shrink or rewrite the durable message log.
1369    ///
1370    /// The latest load, append-only message reconciliation, metadata merge and
1371    /// save all happen while holding the same per-session lock.  Loading is
1372    /// deliberately fail-closed: falling back to a blind full save when the
1373    /// latest transcript cannot be read would reintroduce the SHRINK hazard
1374    /// this checkpoint exists to prevent.
1375    pub async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1376        self.checkpoint_runtime_session_and_publish(session, |_, _| {})
1377            .await
1378    }
1379
1380    /// Checkpoint a runtime session and publish its reconciled snapshot before
1381    /// releasing the same per-session serialization lock.
1382    ///
1383    /// The callback runs after the durable save attempt and receives its commit
1384    /// status. A load failure returns before publication, matching the
1385    /// checkpoint's fail-closed behavior.
1386    pub async fn checkpoint_runtime_session_and_publish<F>(
1387        &self,
1388        session: &mut Session,
1389        publish: F,
1390    ) -> std::io::Result<()>
1391    where
1392        F: FnOnce(&Session, bool) + Send,
1393    {
1394        self.checkpoint_runtime_session_with_input(session, None, publish)
1395            .await
1396    }
1397
1398    pub async fn checkpoint_root_input(
1399        &self,
1400        session: &mut Session,
1401        inbox: Arc<dyn bamboo_domain::SessionInboxPort>,
1402        claim: &bamboo_domain::SessionInboxOwnedClaim,
1403    ) -> std::io::Result<()> {
1404        self.checkpoint_runtime_session_with_input(session, Some((inbox, claim.clone())), |_, _| {})
1405            .await
1406    }
1407
1408    async fn checkpoint_runtime_session_with_input<F>(
1409        &self,
1410        session: &mut Session,
1411        input: Option<(
1412            Arc<dyn bamboo_domain::SessionInboxPort>,
1413            bamboo_domain::SessionInboxOwnedClaim,
1414        )>,
1415        publish: F,
1416    ) -> std::io::Result<()>
1417    where
1418        F: FnOnce(&Session, bool) + Send,
1419    {
1420        let _guard = self.acquire_lock(&session.id).await;
1421        let latest = self.storage.load_session(&session.id).await?;
1422
1423        // This context update belongs to the exact input, not the ordinary
1424        // append-only runner snapshot. A durable typed turn is already proof
1425        // of its first checkpoint: reconciling its lost ACK must not replay an
1426        // old prompt over a subsequent committed prompt.
1427        let input_prompt = match input.as_ref() {
1428            Some((_, claim))
1429                if !latest.as_ref().is_some_and(|durable| {
1430                    durable.messages.iter().any(|message| {
1431                        bamboo_domain::is_matching_session_message(message, &claim.claim.envelope)
1432                    })
1433                }) =>
1434            {
1435                claim
1436                    .claim
1437                    .envelope
1438                    .root_chat_prompt()
1439                    .map_err(std::io::Error::other)?
1440                    .map(str::to_owned)
1441            }
1442            _ => None,
1443        };
1444
1445        if let Some(latest) = latest.as_ref() {
1446            ensure_model_context_checkpoint_is_current(session, latest)?;
1447            let incoming_count = session.messages.len();
1448            let durable_count = latest.messages.len();
1449            let appended = bamboo_domain::append_missing_runtime_messages(session, latest);
1450            bamboo_domain::merge_session_inbox_admission(session, latest);
1451            let adopted_response = adopt_durable_consumed_clarification(session, latest);
1452            tracing::debug!(
1453                "[{}] append-safe runtime checkpoint: durable={}, incoming={}, appended={}, adopted_response={}, saved={}",
1454                session.id,
1455                durable_count,
1456                incoming_count,
1457                appended,
1458                adopted_response,
1459                session.messages.len(),
1460            );
1461            apply_authoritative_metadata(session, latest);
1462            adopt_fresher_disk_permission_posture(session, latest);
1463            let _ = adopt_durable_tagged_child_wait(session, latest);
1464        }
1465
1466        if let Some(prompt) = input_prompt.as_deref() {
1467            session
1468                .messages
1469                .retain(|message| message.role != bamboo_domain::Role::System);
1470            session
1471                .messages
1472                .insert(0, bamboo_domain::Message::system(prompt));
1473        }
1474        let mut result = self
1475            .save_session_rebasing_task_conflicts_with_input(session, input.as_ref(), None)
1476            .await;
1477        for _ in 0..MAX_TASK_CONTROL_PLANE_REBASE_RETRIES {
1478            if !result
1479                .as_ref()
1480                .err()
1481                .is_some_and(is_direct_parent_terminal_conflict)
1482            {
1483                break;
1484            }
1485            // A second Store can commit the terminal after the first load but
1486            // before V2 acquires its cross-process file lock. Rebase this
1487            // checkpoint against that durable suffix, then let V2 validate the
1488            // retry under the lock again.
1489            let Some(durable) = self.storage.load_session(&session.id).await? else {
1490                break;
1491            };
1492            ensure_model_context_checkpoint_is_current(session, &durable)?;
1493            bamboo_domain::append_missing_runtime_messages(session, &durable);
1494            bamboo_domain::merge_session_inbox_admission(session, &durable);
1495            adopt_durable_consumed_clarification(session, &durable);
1496            apply_authoritative_metadata(session, &durable);
1497            adopt_fresher_disk_permission_posture(session, &durable);
1498            let _ = adopt_durable_tagged_child_wait(session, &durable);
1499            if let Some(prompt) = input_prompt.as_deref() {
1500                session
1501                    .messages
1502                    .retain(|message| message.role != bamboo_domain::Role::System);
1503                session
1504                    .messages
1505                    .insert(0, bamboo_domain::Message::system(prompt));
1506            }
1507            result = self
1508                .save_session_rebasing_task_conflicts_with_input(session, input.as_ref(), None)
1509                .await;
1510        }
1511        if may_publish_runtime_result(&result) {
1512            publish(session, result.is_ok());
1513        }
1514        result
1515    }
1516
1517    /// Commit a retrieval-window rewrite without passing its message mutations
1518    /// through the ordinary append-only reconciliation path.
1519    ///
1520    /// A concurrent durable transcript change returns a clean rebased Session
1521    /// without writing. The engine must replan from that value before retrying.
1522    pub async fn checkpoint_retrieval_window_and_publish<F>(
1523        &self,
1524        expected_base: &Session,
1525        staged: &mut Session,
1526        publish: F,
1527    ) -> std::io::Result<RetrievalWindowCheckpointOutcome>
1528    where
1529        F: FnOnce(&Session) + Send,
1530    {
1531        validate_staged_retrieval_window_transition(expected_base, staged)?;
1532        let _guard = self.acquire_lock(&staged.id).await;
1533        let latest = self.storage.load_session(&staged.id).await?;
1534
1535        if let Some(latest) = latest.as_ref() {
1536            if !retrieval_window_base_matches(expected_base, latest)? {
1537                *staged = rebase_retrieval_window_base(expected_base, latest);
1538                apply_authoritative_metadata(staged, latest);
1539                adopt_fresher_disk_permission_posture(staged, latest);
1540                return Ok(RetrievalWindowCheckpointOutcome::Rebased);
1541            }
1542
1543            ensure_model_context_checkpoint_is_current(staged, latest)?;
1544            bamboo_domain::merge_session_inbox_admission(staged, latest);
1545            apply_authoritative_metadata(staged, latest);
1546            adopt_fresher_disk_permission_posture(staged, latest);
1547        }
1548
1549        self.save_session_rebasing_task_conflicts(staged).await?;
1550        publish(staged);
1551        Ok(RetrievalWindowCheckpointOutcome::Committed)
1552    }
1553
1554    /// Commit a bounded System-prompt rewrite without routing it through the
1555    /// append-safe checkpoint or pretending that an archive event occurred.
1556    pub async fn checkpoint_prompt_rewrite_and_publish<F>(
1557        &self,
1558        expected_base: &Session,
1559        staged: &mut Session,
1560        publish: F,
1561    ) -> std::io::Result<RetrievalWindowCheckpointOutcome>
1562    where
1563        F: FnOnce(&Session) + Send,
1564    {
1565        validate_staged_prompt_rewrite_transition(expected_base, staged)?;
1566        let _guard = self.acquire_lock(&staged.id).await;
1567        let latest = self.storage.load_session(&staged.id).await?;
1568
1569        if let Some(latest) = latest.as_ref() {
1570            if !retrieval_window_base_matches(expected_base, latest)? {
1571                *staged = rebase_retrieval_window_base(expected_base, latest);
1572                apply_authoritative_metadata(staged, latest);
1573                adopt_fresher_disk_permission_posture(staged, latest);
1574                return Ok(RetrievalWindowCheckpointOutcome::Rebased);
1575            }
1576
1577            ensure_model_context_checkpoint_is_current(staged, latest)?;
1578            bamboo_domain::merge_session_inbox_admission(staged, latest);
1579            apply_authoritative_metadata(staged, latest);
1580            adopt_fresher_disk_permission_posture(staged, latest);
1581        }
1582
1583        self.save_session_rebasing_task_conflicts(staged).await?;
1584        publish(staged);
1585        Ok(RetrievalWindowCheckpointOutcome::Committed)
1586    }
1587
1588    /// Commit one bounded `archive_context` rejection rewrite.
1589    ///
1590    /// Unlike the append-safe runtime checkpoint, this preserves the staged
1591    /// Tool result content while fencing the write with the exact durable base.
1592    pub async fn checkpoint_manual_archive_rejection_and_publish<F>(
1593        &self,
1594        expected_base: &Session,
1595        staged: &mut Session,
1596        publish: F,
1597    ) -> std::io::Result<RetrievalWindowCheckpointOutcome>
1598    where
1599        F: FnOnce(&Session) + Send,
1600    {
1601        validate_staged_manual_archive_rejection_transition(expected_base, staged)?;
1602        let _guard = self.acquire_lock(&staged.id).await;
1603        let latest = self.storage.load_session(&staged.id).await?;
1604
1605        if let Some(latest) = latest.as_ref() {
1606            if !retrieval_window_base_matches(expected_base, latest)? {
1607                *staged = latest.clone();
1608                apply_authoritative_metadata(staged, latest);
1609                adopt_fresher_disk_permission_posture(staged, latest);
1610                return Ok(RetrievalWindowCheckpointOutcome::Rebased);
1611            }
1612
1613            ensure_model_context_checkpoint_is_current(staged, latest)?;
1614            bamboo_domain::merge_session_inbox_admission(staged, latest);
1615            apply_authoritative_metadata(staged, latest);
1616            adopt_fresher_disk_permission_posture(staged, latest);
1617        }
1618
1619        self.save_session_rebasing_task_conflicts(staged).await?;
1620        publish(staged);
1621        Ok(RetrievalWindowCheckpointOutcome::Committed)
1622    }
1623
1624    /// Commit the consumed marker for a successful no-op `archive_context`.
1625    ///
1626    /// The marker is a metadata rewrite, so the append-safe runtime checkpoint
1627    /// is insufficient: it could full-save stale open metadata after a
1628    /// concurrent runtime writer. Fence the complete retrieval base and let the
1629    /// engine restage from the latest durable snapshot on any conflict.
1630    pub async fn checkpoint_manual_archive_consumption_and_publish<F>(
1631        &self,
1632        expected_base: &Session,
1633        staged: &mut Session,
1634        publish: F,
1635    ) -> std::io::Result<RetrievalWindowCheckpointOutcome>
1636    where
1637        F: FnOnce(&Session) + Send,
1638    {
1639        validate_staged_manual_archive_consumption_transition(expected_base, staged)?;
1640        let _guard = self.acquire_lock(&staged.id).await;
1641        let latest = self.storage.load_session(&staged.id).await?;
1642
1643        if let Some(latest) = latest.as_ref() {
1644            if !retrieval_window_base_matches(expected_base, latest)? {
1645                *staged = latest.clone();
1646                apply_authoritative_metadata(staged, latest);
1647                adopt_fresher_disk_permission_posture(staged, latest);
1648                return Ok(RetrievalWindowCheckpointOutcome::Rebased);
1649            }
1650
1651            ensure_model_context_checkpoint_is_current(staged, latest)?;
1652            bamboo_domain::merge_session_inbox_admission(staged, latest);
1653            apply_authoritative_metadata(staged, latest);
1654            adopt_fresher_disk_permission_posture(staged, latest);
1655        }
1656
1657        self.save_session_rebasing_task_conflicts(staged).await?;
1658        publish(staged);
1659        Ok(RetrievalWindowCheckpointOutcome::Committed)
1660    }
1661
1662    /// Like [`Self::merge_save_runtime`] but does NOT adopt the on-disk
1663    /// permission mode — the caller's in-memory value is authoritative and
1664    /// persists as-is.
1665    ///
1666    /// For parent-side control writes to a child session (e.g. the #74
1667    /// resident-reuse posture re-seed), which set the flag deliberately and must
1668    /// not be reverted by the disk-wins protection meant for a running loop's
1669    /// own stale saves. Still merges the authoritative metadata group.
1670    pub async fn save_runtime_authoritative_flags(
1671        &self,
1672        session: &mut Session,
1673    ) -> std::io::Result<()> {
1674        self.merge_save_runtime_inner_and_publish(session, false, false, None, |_, _| {})
1675            .await
1676    }
1677
1678    async fn merge_save_runtime_inner_and_publish<F>(
1679        &self,
1680        session: &mut Session,
1681        adopt_bypass: bool,
1682        finalize_child_wait: bool,
1683        inherited: Option<&bamboo_domain::session::runtime_state::WaitingForChildrenState>,
1684        publish: F,
1685    ) -> std::io::Result<()>
1686    where
1687        F: FnOnce(&Session, bool) + Send,
1688    {
1689        let _guard = self.acquire_lock(&session.id).await;
1690
1691        // Single disk read serves BOTH the SHRINK diagnostic and the
1692        // authoritative-metadata merge below. Previously this path loaded the
1693        // session twice (once here, once inside the merge helper); on a parent
1694        // session carrying the full conversation history that doubled the
1695        // deserialization cost of every runtime save, which is the hot path
1696        // during sub-agent spawn.
1697        let latest = self.storage.load_session(&session.id).await?;
1698
1699        // DIAGNOSTIC: merge_save_runtime overwrites the whole `messages` array
1700        // (it only merges authoritative metadata, not messages). If the incoming
1701        // session is stale (fewer messages than what is already on disk), this save
1702        // silently reverts a concurrent append (e.g. a just-persisted user message).
1703        // Log a SHRINK warning so we can identify the stale writer.
1704        let existing_message_count = latest.as_ref().map(|s| s.messages.len());
1705        let incoming_message_count = session.messages.len();
1706        if existing_message_count.is_some_and(|existing| existing > incoming_message_count) {
1707            tracing::warn!(
1708                "[{}] merge_save_runtime SHRINK: disk has {:?} messages, saving {} (last_role={:?}, updated_at={}); a stale writer is reverting a concurrent append",
1709                session.id,
1710                existing_message_count,
1711                incoming_message_count,
1712                session.messages.last().map(|m| format!("{:?}", m.role)),
1713                session.updated_at,
1714            );
1715        } else {
1716            tracing::debug!(
1717                "[{}] merge_save_runtime: disk={:?} messages, saving {} (updated_at={})",
1718                session.id,
1719                existing_message_count,
1720                incoming_message_count,
1721                session.updated_at,
1722            );
1723        }
1724
1725        if let Some(latest) = latest.as_ref() {
1726            adopt_durable_actor_parent_question_handoff(session, latest)?;
1727            if finalize_child_wait {
1728                if let Some(registered_at) = adopt_finalized_child_wait(session, latest, false) {
1729                    preserve_finalized_hidden_child_resumes(session, latest, registered_at);
1730                }
1731            }
1732            if let Some(registered_at) = adopt_durable_tagged_child_wait(session, latest) {
1733                preserve_finalized_hidden_child_resumes(session, latest, registered_at);
1734            }
1735            adopt_durable_consumed_clarification(session, latest);
1736            apply_authoritative_metadata(session, latest);
1737            let restored = bamboo_domain::restore_missing_admitted_inbox_messages(session, latest);
1738            if restored > 0 {
1739                tracing::warn!(
1740                    session_id = %session.id,
1741                    restored,
1742                    "restored durable SessionInbox transcript messages into stale runtime save"
1743                );
1744            }
1745            bamboo_domain::merge_session_inbox_admission(session, latest);
1746            // Never let a running loop's save revert a concurrent mid-run
1747            // `PATCH /sessions {permission_mode|bypass_permissions}` transition.
1748            // #540/#770. Skipped for
1749            // authoritative flag writers (`save_runtime_authoritative_flags`).
1750            if adopt_bypass {
1751                adopt_fresher_disk_permission_posture(session, latest);
1752            }
1753            adopt_fresher_durable_model_context_state(session, latest);
1754        }
1755        let result = self
1756            .save_session_rebasing_task_conflicts_with_input(session, None, inherited)
1757            .await;
1758        if may_publish_runtime_result(&result) {
1759            publish(session, result.is_ok());
1760        }
1761        result
1762    }
1763
1764    /// Persist one validated RunSpec activation as the exact authority for the
1765    /// worker's requested posture and complete audit record.
1766    ///
1767    /// Warm workers reuse a durable session id. An ordinary runtime save is
1768    /// intentionally disk-adopting, so using it here would let the previous
1769    /// activation's posture stick. This dedicated transaction preserves only
1770    /// durable UI metadata and SessionInbox admission/transcript proof, then
1771    /// writes the incoming posture with an audit revision above the durable
1772    /// floor while holding the same per-session lock.
1773    pub async fn seed_runtime_activation_and_publish<F>(
1774        &self,
1775        session: &mut Session,
1776        publish: F,
1777    ) -> std::io::Result<()>
1778    where
1779        F: FnOnce(&Session, bool) + Send,
1780    {
1781        let _guard = self.acquire_lock(&session.id).await;
1782        let mut incoming_audit = PermissionAuditSnapshot::from_metadata(&session.metadata)
1783            .ok_or_else(|| {
1784                std::io::Error::new(
1785                    std::io::ErrorKind::InvalidInput,
1786                    "activation seed requires a complete permission audit record",
1787                )
1788            })?;
1789
1790        if let Some(latest) = self.storage.load_session(&session.id).await? {
1791            // A warm worker constructs a fresh in-memory Session for each RunSpec.
1792            // Preserve the durable Child birth only after the new activation has
1793            // matched the immutable identity and Project under this write lock.
1794            if session.kind == bamboo_domain::SessionKind::Child {
1795                if latest.kind != bamboo_domain::SessionKind::Child
1796                    || latest.id != session.id
1797                    || latest.root_session_id != session.root_session_id
1798                    || latest.parent_session_id != session.parent_session_id
1799                    || latest.spawn_depth != session.spawn_depth
1800                    || latest.project_id_meta() != session.project_id_meta()
1801                {
1802                    return Err(std::io::Error::new(
1803                        std::io::ErrorKind::WouldBlock,
1804                        bamboo_domain::SessionAuthorityConflict(
1805                            "warm Child activation disagrees with durable creation identity or Project"
1806                                .to_string(),
1807                        ),
1808                    ));
1809                }
1810                session.created_at = latest.created_at;
1811            }
1812            apply_authoritative_metadata(session, &latest);
1813            bamboo_domain::restore_missing_admitted_inbox_messages(session, &latest);
1814            bamboo_domain::merge_session_inbox_admission(session, &latest);
1815            adopt_fresher_durable_model_context_state(session, &latest);
1816
1817            let durable_audit = PermissionAuditSnapshot::from_metadata(&latest.metadata);
1818            let durable_floor = durable_audit
1819                .as_ref()
1820                .map(|snapshot| snapshot.audit_revision)
1821                .unwrap_or_default();
1822            if let Some(durable_audit) = durable_audit {
1823                if durable_audit.resolution == incoming_audit.resolution {
1824                    incoming_audit.transitioned_at = durable_audit.transitioned_at;
1825                }
1826            }
1827            incoming_audit.audit_revision = bamboo_domain::next_permission_audit_revision_after(
1828                durable_floor.max(incoming_audit.audit_revision),
1829            )
1830            .map_err(|error| {
1831                std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string())
1832            })?;
1833        }
1834
1835        session
1836            .agent_runtime_state
1837            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
1838            .set_permission_mode(incoming_audit.resolution.requested);
1839        incoming_audit.write_to(&mut session.metadata);
1840
1841        let result = self.save_session_rebasing_task_conflicts(session).await;
1842        if may_publish_runtime_result(&result) {
1843            publish(session, result.is_ok());
1844        }
1845        result
1846    }
1847
1848    /// Atomically re-seed a resident child from its parent posture.
1849    ///
1850    /// The latest session load, typed-mode comparison, complete audit refresh,
1851    /// metadata CAS bump (only for a true typed transition), narrow companion
1852    /// mutation, save, and cache publication share one session lock.
1853    pub async fn update_authoritative_permission_posture_and_publish<M, P>(
1854        &self,
1855        session_id: &str,
1856        seed: &PermissionAuditSeed,
1857        mutate: M,
1858        publish: P,
1859    ) -> std::io::Result<Option<Session>>
1860    where
1861        M: FnOnce(&mut Session),
1862        P: FnOnce(&Session),
1863    {
1864        let _guard = self.acquire_lock(session_id).await;
1865        let Some(mut latest) = self.storage.load_session(session_id).await? else {
1866            return Ok(None);
1867        };
1868        let previous_mode = latest
1869            .agent_runtime_state
1870            .as_ref()
1871            .map(|state| state.effective_permission_mode())
1872            .unwrap_or_default();
1873        let previous_resolution = PermissionAuditSnapshot::from_metadata(&latest.metadata)
1874            .map(|snapshot| snapshot.resolution);
1875        mutate(&mut latest);
1876        latest
1877            .agent_runtime_state
1878            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
1879            .set_permission_mode(seed.resolution.requested);
1880        let mode_changed = previous_mode != seed.resolution.requested;
1881        let posture_changed = previous_resolution != Some(seed.resolution);
1882        let transitioned_at = posture_changed.then(|| chrono::Utc::now().to_rfc3339());
1883        bamboo_domain::record_permission_audit(
1884            &mut latest.metadata,
1885            seed,
1886            transitioned_at.as_deref(),
1887        )
1888        .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string()))?;
1889        if mode_changed {
1890            latest.metadata_version = latest.metadata_version.saturating_add(1);
1891        }
1892        self.save_session_rebasing_task_conflicts(&mut latest)
1893            .await?;
1894        publish(&latest);
1895        Ok(Some(latest))
1896    }
1897
1898    /// Persist a worker's bounded executor mapping only when the exact host
1899    /// posture observed before dispatch is still current. The remote event does
1900    /// not contribute an audit revision or transition timestamp: both are
1901    /// allocated from the latest durable record while this session lock is held.
1902    pub async fn record_permission_posture_activation_and_publish<P>(
1903        &self,
1904        session_id: &str,
1905        expected_audit_revision: Option<u64>,
1906        seed: &PermissionAuditSeed,
1907        publish: P,
1908    ) -> std::io::Result<Option<Session>>
1909    where
1910        P: FnOnce(&Session),
1911    {
1912        let _guard = self.acquire_lock(session_id).await;
1913        let Some(mut latest) = self.storage.load_session(session_id).await? else {
1914            return Ok(None);
1915        };
1916        let durable_audit = PermissionAuditSnapshot::from_metadata(&latest.metadata);
1917        let durable_revision = durable_audit
1918            .as_ref()
1919            .map(|snapshot| snapshot.audit_revision);
1920        if durable_revision != expected_audit_revision {
1921            return Err(std::io::Error::new(
1922                std::io::ErrorKind::InvalidData,
1923                "stale permission posture activation: durable audit changed after dispatch",
1924            ));
1925        }
1926        let durable_requested = latest
1927            .agent_runtime_state
1928            .as_ref()
1929            .map(|state| state.effective_permission_mode())
1930            .unwrap_or_default();
1931        if durable_requested != seed.resolution.requested || !seed.resolution.is_consistent() {
1932            return Err(std::io::Error::new(
1933                std::io::ErrorKind::InvalidData,
1934                "stale or inconsistent permission posture activation",
1935            ));
1936        }
1937        bamboo_domain::record_permission_audit(&mut latest.metadata, seed, None).map_err(
1938            |error| std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string()),
1939        )?;
1940        self.save_session_rebasing_task_conflicts(&mut latest)
1941            .await?;
1942        publish(&latest);
1943        Ok(Some(latest))
1944    }
1945
1946    /// Apply a config-only mutation to a session without ever clobbering its
1947    /// `messages` (or other concurrently-written state).
1948    ///
1949    /// Unlike [`Self::merge_save_runtime`], the caller does NOT pass a session
1950    /// snapshot. Instead this loads the **latest** session from storage *inside*
1951    /// the per-session lock, applies `mutate` (intended for small config fields
1952    /// like `model_ref` / `reasoning_effort`), and saves. Because the load and
1953    /// save both happen under the lock, a concurrent append (e.g. `POST /chat`
1954    /// adding a user message) can never be reverted by this write.
1955    ///
1956    /// Returns the saved session, or `None` if it does not exist.
1957    pub async fn update_runtime_config<F>(
1958        &self,
1959        session_id: &str,
1960        mutate: F,
1961    ) -> std::io::Result<Option<Session>>
1962    where
1963        F: FnOnce(&mut Session),
1964    {
1965        self.update_runtime_config_and_publish(session_id, mutate, |_| {})
1966            .await
1967    }
1968
1969    /// Apply a config-only mutation and synchronously publish the saved
1970    /// snapshot before releasing the session lock.
1971    pub async fn update_runtime_config_and_publish<M, P>(
1972        &self,
1973        session_id: &str,
1974        mutate: M,
1975        publish: P,
1976    ) -> std::io::Result<Option<Session>>
1977    where
1978        M: FnOnce(&mut Session),
1979        P: FnOnce(&Session),
1980    {
1981        let _guard = self.acquire_lock(session_id).await;
1982        let Some(mut session) = self.storage.load_session(session_id).await? else {
1983            return Ok(None);
1984        };
1985        mutate(&mut session);
1986        self.save_session_rebasing_task_conflicts(&mut session)
1987            .await?;
1988        publish(&session);
1989        Ok(Some(session))
1990    }
1991
1992    /// Load the authoritative response transaction candidate while the caller
1993    /// holds this store's per-session lock. The merge rules intentionally
1994    /// match [`Self::mutate_runtime_session_and_publish`], including adoption
1995    /// of a clarification that durable storage already consumed.
1996    async fn load_response_candidate<C>(
1997        &self,
1998        session_id: &str,
1999        load_cached: C,
2000    ) -> std::io::Result<Option<Session>>
2001    where
2002        C: FnOnce() -> Option<Session> + Send,
2003    {
2004        let cached_candidate = load_cached();
2005        let durable = self.storage.load_session(session_id).await?;
2006        let Some(mut session) = (match (cached_candidate, durable.as_ref()) {
2007            (Some(cached), Some(durable)) => {
2008                let prefer_durable = durable.updated_at > cached.updated_at
2009                    || (durable.updated_at == cached.updated_at
2010                        && cached.pending_question.is_none()
2011                        && durable.pending_question.is_some());
2012                Some(if prefer_durable {
2013                    durable.clone()
2014                } else {
2015                    cached
2016                })
2017            }
2018            (Some(cached), None) => Some(cached),
2019            (None, durable) => durable.cloned(),
2020        }) else {
2021            return Ok(None);
2022        };
2023        if let Some(latest) = durable.as_ref() {
2024            // A response transaction starts from an append-safe transcript.
2025            // In particular, a cache snapshot that is timestamp-newer but
2026            // still carries an already-consumed ask must not resurrect that
2027            // ask or discard ordinary messages committed with the answer.
2028            adopt_durable_consumed_clarification(&mut session, latest);
2029            bamboo_domain::append_missing_runtime_messages(&mut session, latest);
2030            apply_authoritative_metadata(&mut session, latest);
2031            let restored =
2032                bamboo_domain::restore_missing_admitted_inbox_messages(&mut session, latest);
2033            if restored > 0 {
2034                tracing::warn!(
2035                    session_id,
2036                    restored,
2037                    "restored durable SessionInbox transcript messages into response transaction"
2038                );
2039            }
2040            bamboo_domain::merge_session_inbox_admission(&mut session, latest);
2041            adopt_fresher_disk_permission_posture(&mut session, latest);
2042            adopt_fresher_durable_model_context_state(&mut session, latest);
2043        }
2044        Ok(Some(session))
2045    }
2046
2047    /// Inspect the same authoritative snapshot a response mutation would use,
2048    /// without persisting or publishing it. Callers use this immediately before
2049    /// reserving a successor so an already-consumed response cannot allocate a
2050    /// replacement runner.
2051    pub async fn inspect_runtime_session_for_response<C>(
2052        &self,
2053        session_id: &str,
2054        load_cached: C,
2055    ) -> std::io::Result<Option<Session>>
2056    where
2057        C: FnOnce() -> Option<Session> + Send,
2058    {
2059        let _guard = self.acquire_lock(session_id).await;
2060        self.load_response_candidate(session_id, load_cached).await
2061    }
2062
2063    /// Atomically load, validate/mutate, persist, and publish one runtime
2064    /// session under its canonical per-session lock.
2065    ///
2066    /// The nested result keeps validation errors distinct from storage errors:
2067    /// `Ok(Err(error))` means `mutate` rejected the latest snapshot and no save
2068    /// occurred. This is suitable for compare-and-consume operations such as a
2069    /// typed pending-question response, where separate load/save calls would
2070    /// allow another writer to replace the question between validation and
2071    /// persistence.
2072    pub async fn mutate_runtime_session_and_publish<C, M, P, E>(
2073        &self,
2074        session_id: &str,
2075        load_cached: C,
2076        mutate: M,
2077        publish: P,
2078    ) -> std::io::Result<Result<Option<Session>, E>>
2079    where
2080        C: FnOnce() -> Option<Session> + Send,
2081        M: FnOnce(&mut Session) -> Result<(), E> + Send,
2082        P: FnOnce(&Session) + Send,
2083        E: Send,
2084    {
2085        let _guard = self.acquire_lock(session_id).await;
2086        let Some(mut session) = self
2087            .load_response_candidate(session_id, load_cached)
2088            .await?
2089        else {
2090            return Ok(Ok(None));
2091        };
2092        if let Err(error) = mutate(&mut session) {
2093            return Ok(Err(error));
2094        }
2095        self.save_session_rebasing_task_conflicts(&mut session)
2096            .await?;
2097        publish(&session);
2098        Ok(Ok(Some(session)))
2099    }
2100
2101    /// Clear the legacy compatibility queue using durable CAS and publish the
2102    /// saved full snapshot before releasing the same session lock.
2103    pub async fn clear_legacy_pending_messages_and_publish<F>(
2104        &self,
2105        session_id: &str,
2106        expected: &[serde_json::Value],
2107        publish: F,
2108    ) -> std::io::Result<bool>
2109    where
2110        F: FnOnce(&Session) + Send,
2111    {
2112        let _guard = self.acquire_lock(session_id).await;
2113        let Some(mut latest) = self.storage.load_session(session_id).await? else {
2114            return Ok(false);
2115        };
2116        if latest.pending_injected_messages().as_deref() != Some(expected) {
2117            return Ok(false);
2118        }
2119        latest.clear_pending_injected_messages();
2120        self.save_runtime_state_rebasing_task_conflicts(&mut latest)
2121            .await?;
2122        publish(&latest);
2123        Ok(true)
2124    }
2125}
2126
2127/// Infrastructure implementation of the domain runtime-persistence port.
2128/// Server should assemble this as `Arc<dyn RuntimeSessionPersistence>` and must
2129/// not define a separate adapter layer for the same behavior.
2130#[async_trait::async_trait]
2131impl RuntimeSessionPersistence for LockedSessionStore {
2132    fn inherited_child_wait(&self) -> Option<bamboo_domain::InheritedChildWait> {
2133        self.inherited_child_wait.clone()
2134    }
2135
2136    fn bind_inherited_child_wait(
2137        &self,
2138        inherited: bamboo_domain::InheritedChildWait,
2139    ) -> std::io::Result<Arc<dyn RuntimeSessionPersistence>> {
2140        Ok(Arc::new(self.bind_inherited_child_wait(inherited)))
2141    }
2142
2143    async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
2144        self.merge_save_runtime(session).await
2145    }
2146
2147    async fn save_finalized_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
2148        self.merge_save_finalized_runtime(session).await
2149    }
2150
2151    async fn save_finalized_runtime_with_inherited_child_wait(
2152        &self,
2153        session: &mut Session,
2154        inherited: &bamboo_domain::session::runtime_state::WaitingForChildrenState,
2155    ) -> std::io::Result<()> {
2156        self.merge_save_inherited_child_wait_and_publish(session, inherited, |_, _| {})
2157            .await
2158    }
2159
2160    async fn seed_runtime_activation(&self, session: &mut Session) -> std::io::Result<()> {
2161        self.seed_runtime_activation_and_publish(session, |_, _| {})
2162            .await
2163    }
2164
2165    async fn record_permission_posture_activation(
2166        &self,
2167        session_id: &str,
2168        expected_audit_revision: Option<u64>,
2169        seed: &PermissionAuditSeed,
2170    ) -> std::io::Result<Option<Session>> {
2171        self.record_permission_posture_activation_and_publish(
2172            session_id,
2173            expected_audit_revision,
2174            seed,
2175            |_| {},
2176        )
2177        .await
2178    }
2179
2180    async fn save_runtime_control_plane(&self, session: &mut Session) -> std::io::Result<()> {
2181        self.save_runtime_only(session).await
2182    }
2183
2184    async fn load_runtime_control_plane(
2185        &self,
2186        session_id: &str,
2187    ) -> std::io::Result<Option<Session>> {
2188        self.storage.load_runtime_control_plane(session_id).await
2189    }
2190
2191    async fn update_task_list_control_plane(
2192        &self,
2193        session_id: &str,
2194        task_list: &bamboo_domain::TaskList,
2195        version: &str,
2196    ) -> std::io::Result<bool> {
2197        self.update_task_list_control_plane_and_publish(session_id, task_list, version, |_| {})
2198            .await
2199    }
2200
2201    async fn update_task_list_control_plane_if_version(
2202        &self,
2203        session_id: &str,
2204        expected_version: &str,
2205        expected_task_list: &bamboo_domain::TaskList,
2206        task_list: &bamboo_domain::TaskList,
2207        version: &str,
2208    ) -> std::io::Result<bool> {
2209        self.update_task_list_control_plane_if_version_and_publish(
2210            session_id,
2211            expected_version,
2212            expected_task_list,
2213            task_list,
2214            version,
2215            |_| {},
2216        )
2217        .await
2218    }
2219
2220    async fn update_task_list_control_planes_if_version(
2221        &self,
2222        session_id: &str,
2223        shared_session_id: &str,
2224        expected_version: &str,
2225        expected_task_list: &bamboo_domain::TaskList,
2226        task_list: &bamboo_domain::TaskList,
2227        version: &str,
2228    ) -> std::io::Result<bool> {
2229        self.update_task_list_control_planes_if_version_and_publish(
2230            session_id,
2231            shared_session_id,
2232            expected_version,
2233            expected_task_list,
2234            task_list,
2235            version,
2236            |_, _| {},
2237        )
2238        .await
2239    }
2240
2241    async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
2242        LockedSessionStore::checkpoint_runtime_session(self, session).await
2243    }
2244
2245    async fn checkpoint_retrieval_window(
2246        &self,
2247        expected_base: &Session,
2248        staged: &mut Session,
2249    ) -> std::io::Result<RetrievalWindowCheckpointOutcome> {
2250        self.checkpoint_retrieval_window_and_publish(expected_base, staged, |_| {})
2251            .await
2252    }
2253
2254    async fn checkpoint_prompt_rewrite(
2255        &self,
2256        expected_base: &Session,
2257        staged: &mut Session,
2258    ) -> std::io::Result<RetrievalWindowCheckpointOutcome> {
2259        self.checkpoint_prompt_rewrite_and_publish(expected_base, staged, |_| {})
2260            .await
2261    }
2262
2263    async fn checkpoint_manual_archive_rejection(
2264        &self,
2265        expected_base: &Session,
2266        staged: &mut Session,
2267    ) -> std::io::Result<RetrievalWindowCheckpointOutcome> {
2268        self.checkpoint_manual_archive_rejection_and_publish(expected_base, staged, |_| {})
2269            .await
2270    }
2271
2272    async fn checkpoint_manual_archive_consumption(
2273        &self,
2274        expected_base: &Session,
2275        staged: &mut Session,
2276    ) -> std::io::Result<RetrievalWindowCheckpointOutcome> {
2277        self.checkpoint_manual_archive_consumption_and_publish(expected_base, staged, |_| {})
2278            .await
2279    }
2280
2281    async fn load_runtime_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
2282        self.storage.load_session(session_id).await
2283    }
2284
2285    async fn clear_legacy_pending_messages(
2286        &self,
2287        session_id: &str,
2288        expected: &[serde_json::Value],
2289    ) -> std::io::Result<bool> {
2290        self.clear_legacy_pending_messages_and_publish(session_id, expected, |_| {})
2291            .await
2292    }
2293}
2294
2295// ── Internal merge helper ─────────────────────────────────────────────
2296
2297/// Re-read the on-disk session and, when the disk copy carries a
2298/// `metadata_version >= session.metadata_version`, overwrite the in-memory
2299/// authoritative metadata fields with the disk values.
2300///
2301/// This is the core staleness-correction: non-authoritative writers call it
2302/// before saving so they don't accidentally revert a concurrent UI edit.
2303async fn merge_authoritative_metadata_into_stale(
2304    storage: &Arc<dyn Storage>,
2305    session: &mut Session,
2306) -> std::io::Result<()> {
2307    if let Some(latest) = storage.load_session(&session.id).await? {
2308        adopt_durable_consumed_clarification(session, &latest);
2309        apply_authoritative_metadata(session, &latest);
2310        bamboo_domain::restore_missing_admitted_inbox_messages(session, &latest);
2311        bamboo_domain::merge_session_inbox_admission(session, &latest);
2312        adopt_fresher_disk_permission_posture(session, &latest);
2313        adopt_fresher_durable_model_context_state(session, &latest);
2314    }
2315    Ok(())
2316}
2317
2318/// Runtime-only writes never own the ledger, so the snapshot loaded under the
2319/// session lock is authoritative even if the caller happens to carry a larger
2320/// revision. This keeps both the sidecar and synchronous cache publication from
2321/// regressing after a concurrent engine checkpoint.
2322fn adopt_durable_model_context_state(session: &mut Session, latest: &Session) {
2323    session
2324        .model_context_state
2325        .clone_from(&latest.model_context_state);
2326}
2327
2328/// Merge policy for ordinary full runtime saves. Legitimate ledger writers
2329/// (compression/rollback/reconciliation) advance `state_revision`; stale or
2330/// conflicting non-ledger writers adopt the already-committed state. Equal
2331/// revisions with different bytes are concurrent children of the same base, so
2332/// durable state wins instead of allowing last-writer-wins corruption.
2333fn adopt_fresher_durable_model_context_state(session: &mut Session, latest: &Session) {
2334    let adopt = match (
2335        session.model_context_state.as_ref(),
2336        latest.model_context_state.as_ref(),
2337    ) {
2338        (None, Some(_)) => true,
2339        (Some(incoming), Some(durable)) => {
2340            durable.state_revision > incoming.state_revision
2341                || (durable.state_revision == incoming.state_revision && durable != incoming)
2342        }
2343        _ => false,
2344    };
2345    if adopt {
2346        adopt_durable_model_context_state(session, latest);
2347    }
2348}
2349
2350/// A provider-bound ledger checkpoint must never silently substitute a newer or
2351/// conflicting disk ledger after the request body was prepared. Reject it so
2352/// the engine can roll back the in-memory candidate and retry from a fresh
2353/// session; only a strictly newer incoming revision may replace disk.
2354fn ensure_model_context_checkpoint_is_current(
2355    session: &Session,
2356    latest: &Session,
2357) -> std::io::Result<()> {
2358    let stale_or_conflicting = match (
2359        session.model_context_state.as_ref(),
2360        latest.model_context_state.as_ref(),
2361    ) {
2362        (None, Some(_)) => true,
2363        (Some(incoming), Some(durable)) => {
2364            durable.state_revision > incoming.state_revision
2365                || (durable.state_revision == incoming.state_revision && durable != incoming)
2366        }
2367        _ => false,
2368    };
2369    if stale_or_conflicting {
2370        return Err(std::io::Error::new(
2371            std::io::ErrorKind::WouldBlock,
2372            "stale or conflicting model-context ledger checkpoint",
2373        ));
2374    }
2375    Ok(())
2376}
2377
2378fn message_matches_retrieval_window_base(
2379    expected: &bamboo_domain::Message,
2380    durable: &bamboo_domain::Message,
2381) -> std::io::Result<bool> {
2382    if durable.image_ocr.is_some() && durable.image_ocr != expected.image_ocr {
2383        return Ok(false);
2384    }
2385    let mut expected = expected.clone();
2386    let mut durable = durable.clone();
2387    // A staged OCR cache may legitimately be newer than disk and is committed
2388    // with the archive. Every other message field must still describe the exact
2389    // durable prefix used for planning.
2390    expected.image_ocr = None;
2391    durable.image_ocr = None;
2392    let expected = serde_json::to_vec(&expected)
2393        .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
2394    let durable = serde_json::to_vec(&durable)
2395        .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
2396    Ok(expected == durable)
2397}
2398
2399fn invalid_retrieval_window_checkpoint(message: impl Into<String>) -> std::io::Error {
2400    std::io::Error::new(std::io::ErrorKind::InvalidInput, message.into())
2401}
2402
2403fn validate_staged_manual_archive_consumption_transition(
2404    expected: &Session,
2405    staged: &Session,
2406) -> std::io::Result<()> {
2407    if expected.id != staged.id || expected.messages.len() != staged.messages.len() {
2408        return Err(invalid_retrieval_window_checkpoint(
2409            "manual archive-consumption checkpoint must preserve the Session ID and message array length",
2410        ));
2411    }
2412
2413    let occurrence = staged
2414        .metadata
2415        .get(LAST_MANUAL_ARCHIVE_OCCURRENCE_KEY)
2416        .ok_or_else(|| {
2417            invalid_retrieval_window_checkpoint(
2418                "manual archive-consumption checkpoint is missing its consumed occurrence",
2419            )
2420        })
2421        .and_then(|value| {
2422            serde_json::from_str::<ResponseOccurrence>(value).map_err(|error| {
2423                invalid_retrieval_window_checkpoint(format!(
2424                    "manual archive-consumption checkpoint has an invalid consumed occurrence: {error}"
2425                ))
2426            })
2427        })?;
2428    let result_index = expected
2429        .messages
2430        .iter()
2431        .position(|message| {
2432            message.id == occurrence.tool_result_message_id
2433                && message.tool_call_id.as_deref() == Some(occurrence.tool_call_id.as_str())
2434                && matches!(message.role, bamboo_domain::Role::Tool)
2435        })
2436        .ok_or_else(|| {
2437            invalid_retrieval_window_checkpoint(
2438                "manual archive-consumption checkpoint occurrence does not identify a Tool result",
2439            )
2440        })?;
2441    let correlated_archive_call = expected.messages[..result_index]
2442        .iter()
2443        .rev()
2444        .find(|message| !matches!(message.role, bamboo_domain::Role::Tool))
2445        .filter(|message| matches!(message.role, bamboo_domain::Role::Assistant))
2446        .and_then(|message| message.tool_calls.as_ref())
2447        .into_iter()
2448        .flatten()
2449        .any(|call| {
2450            call.id == occurrence.tool_call_id
2451                && bamboo_domain::canonical_tool_name(&call.function.name) == "archive_context"
2452        });
2453    if !correlated_archive_call {
2454        return Err(invalid_retrieval_window_checkpoint(
2455            "manual archive-consumption checkpoint is not correlated to the current archive_context batch",
2456        ));
2457    }
2458
2459    let mut canonical = expected.clone();
2460    canonical.metadata.insert(
2461        LAST_MANUAL_ARCHIVE_OCCURRENCE_KEY.to_string(),
2462        staged
2463            .metadata
2464            .get(LAST_MANUAL_ARCHIVE_OCCURRENCE_KEY)
2465            .expect("validated occurrence metadata")
2466            .clone(),
2467    );
2468    let canonical = serde_json::to_vec(&canonical)
2469        .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
2470    let staged = serde_json::to_vec(staged)
2471        .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
2472    if canonical != staged {
2473        return Err(invalid_retrieval_window_checkpoint(
2474            "manual archive-consumption checkpoint contains mutations outside the correlated consumed marker",
2475        ));
2476    }
2477    Ok(())
2478}
2479
2480fn validate_staged_manual_archive_rejection_transition(
2481    expected: &Session,
2482    staged: &Session,
2483) -> std::io::Result<()> {
2484    if expected.id != staged.id || expected.messages.len() != staged.messages.len() {
2485        return Err(invalid_retrieval_window_checkpoint(
2486            "manual archive-rejection checkpoint must preserve the Session ID and message array length",
2487        ));
2488    }
2489
2490    let occurrence = staged
2491        .metadata
2492        .get(LAST_MANUAL_ARCHIVE_OCCURRENCE_KEY)
2493        .ok_or_else(|| {
2494            invalid_retrieval_window_checkpoint(
2495                "manual archive-rejection checkpoint is missing its consumed occurrence",
2496            )
2497        })
2498        .and_then(|value| {
2499            serde_json::from_str::<ResponseOccurrence>(value).map_err(|error| {
2500                invalid_retrieval_window_checkpoint(format!(
2501                    "manual archive-rejection checkpoint has an invalid consumed occurrence: {error}"
2502                ))
2503            })
2504        })?;
2505    let result_index = expected
2506        .messages
2507        .iter()
2508        .position(|message| {
2509            message.id == occurrence.tool_result_message_id
2510                && message.tool_call_id.as_deref() == Some(occurrence.tool_call_id.as_str())
2511                && matches!(message.role, bamboo_domain::Role::Tool)
2512        })
2513        .ok_or_else(|| {
2514            invalid_retrieval_window_checkpoint(
2515                "manual archive-rejection checkpoint occurrence does not identify a Tool result",
2516            )
2517        })?;
2518    let before = &expected.messages[result_index];
2519    let after = &staged.messages[result_index];
2520    let rejection_reason = after
2521        .content
2522        .strip_prefix("archive_context rejected: ")
2523        .filter(|reason| !reason.trim().is_empty())
2524        .ok_or_else(|| {
2525            invalid_retrieval_window_checkpoint(
2526                "manual archive-rejection checkpoint has an invalid Tool result message",
2527            )
2528        })?;
2529    if after.tool_success != Some(false)
2530        || (before.content == after.content && before.tool_success == after.tool_success)
2531    {
2532        return Err(invalid_retrieval_window_checkpoint(
2533            "manual archive-rejection checkpoint did not reject the Tool result",
2534        ));
2535    }
2536
2537    let correlated_archive_call = expected.messages[..result_index]
2538        .iter()
2539        .rev()
2540        .find(|message| !matches!(message.role, bamboo_domain::Role::Tool))
2541        .filter(|message| matches!(message.role, bamboo_domain::Role::Assistant))
2542        .and_then(|message| message.tool_calls.as_ref())
2543        .into_iter()
2544        .flatten()
2545        .any(|call| {
2546            call.id == occurrence.tool_call_id
2547                && bamboo_domain::canonical_tool_name(&call.function.name) == "archive_context"
2548        });
2549    if !correlated_archive_call {
2550        return Err(invalid_retrieval_window_checkpoint(
2551            "manual archive-rejection checkpoint is not correlated to the current archive_context batch",
2552        ));
2553    }
2554
2555    let staged_rejections = staged
2556        .metadata
2557        .get(MANUAL_ARCHIVE_REJECTIONS_KEY)
2558        .ok_or_else(|| {
2559            invalid_retrieval_window_checkpoint(
2560                "manual archive-rejection checkpoint is missing its rejection ledger",
2561            )
2562        })
2563        .and_then(|value| {
2564            serde_json::from_str::<Vec<serde_json::Value>>(value).map_err(|error| {
2565                invalid_retrieval_window_checkpoint(format!(
2566                    "manual archive-rejection checkpoint has an invalid rejection ledger: {error}"
2567                ))
2568            })
2569        })?;
2570    let occurrence_value = serde_json::to_value(&occurrence)
2571        .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
2572    let Some(last_rejection) = staged_rejections.last() else {
2573        return Err(invalid_retrieval_window_checkpoint(
2574            "manual archive-rejection checkpoint has an empty rejection ledger",
2575        ));
2576    };
2577    if last_rejection.get("occurrence") != Some(&occurrence_value)
2578        || last_rejection
2579            .get("reason")
2580            .and_then(serde_json::Value::as_str)
2581            != Some(rejection_reason)
2582    {
2583        return Err(invalid_retrieval_window_checkpoint(
2584            "manual archive-rejection checkpoint ledger does not match its Tool result",
2585        ));
2586    }
2587    let mut expected_rejections = expected
2588        .metadata
2589        .get(MANUAL_ARCHIVE_REJECTIONS_KEY)
2590        .and_then(|value| serde_json::from_str::<Vec<serde_json::Value>>(value).ok())
2591        .unwrap_or_default();
2592    expected_rejections.retain(|rejection| rejection.get("occurrence") != Some(&occurrence_value));
2593    expected_rejections.push(last_rejection.clone());
2594    if expected_rejections.len() > MAX_MANUAL_ARCHIVE_REJECTIONS {
2595        expected_rejections.drain(..expected_rejections.len() - MAX_MANUAL_ARCHIVE_REJECTIONS);
2596    }
2597    if expected_rejections != staged_rejections {
2598        return Err(invalid_retrieval_window_checkpoint(
2599            "manual archive-rejection checkpoint rewrote unrelated rejection-ledger entries",
2600        ));
2601    }
2602
2603    let mut canonical = expected.clone();
2604    canonical.messages[result_index]
2605        .content
2606        .clone_from(&after.content);
2607    canonical.messages[result_index].tool_success = Some(false);
2608    canonical.metadata.insert(
2609        LAST_MANUAL_ARCHIVE_OCCURRENCE_KEY.to_string(),
2610        staged
2611            .metadata
2612            .get(LAST_MANUAL_ARCHIVE_OCCURRENCE_KEY)
2613            .expect("validated occurrence metadata")
2614            .clone(),
2615    );
2616    canonical.metadata.insert(
2617        MANUAL_ARCHIVE_REJECTIONS_KEY.to_string(),
2618        staged
2619            .metadata
2620            .get(MANUAL_ARCHIVE_REJECTIONS_KEY)
2621            .expect("validated rejection metadata")
2622            .clone(),
2623    );
2624    canonical
2625        .metadata
2626        .remove(RESPONSES_PREVIOUS_RESPONSE_ID_KEY);
2627    canonical
2628        .reset_model_context_epoch(bamboo_domain::ModelContextResetReason::ExplicitHistoryRewrite);
2629    let canonical = serde_json::to_vec(&canonical)
2630        .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
2631    let staged = serde_json::to_vec(staged)
2632        .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
2633    if canonical != staged {
2634        return Err(invalid_retrieval_window_checkpoint(
2635            "manual archive-rejection checkpoint contains mutations outside the correlated Tool result, bounded metadata, and provider reset",
2636        ));
2637    }
2638    Ok(())
2639}
2640
2641fn validate_staged_prompt_rewrite_transition(
2642    expected: &Session,
2643    staged: &Session,
2644) -> std::io::Result<()> {
2645    if expected.id != staged.id || expected.messages.len() != staged.messages.len() {
2646        return Err(invalid_retrieval_window_checkpoint(
2647            "prompt-rewrite checkpoint must preserve the Session ID and message array length",
2648        ));
2649    }
2650
2651    let mut canonical = expected.clone();
2652    let mut rewrote_system_prompt = false;
2653    for ((before, after), canonical_message) in expected
2654        .messages
2655        .iter()
2656        .zip(&staged.messages)
2657        .zip(&mut canonical.messages)
2658    {
2659        if before.id != after.id {
2660            return Err(invalid_retrieval_window_checkpoint(
2661                "prompt-rewrite checkpoint reordered or replaced a message",
2662            ));
2663        }
2664        if before.content != after.content {
2665            if !matches!(before.role, bamboo_domain::Role::System)
2666                || !matches!(after.role, bamboo_domain::Role::System)
2667            {
2668                return Err(invalid_retrieval_window_checkpoint(
2669                    "prompt-rewrite checkpoint changed non-System message content",
2670                ));
2671            }
2672            canonical_message.content.clone_from(&after.content);
2673            rewrote_system_prompt = true;
2674        }
2675    }
2676    if !rewrote_system_prompt {
2677        return Err(invalid_retrieval_window_checkpoint(
2678            "prompt-rewrite checkpoint did not change System message content",
2679        ));
2680    }
2681
2682    canonical.metadata.remove("responses.previous_response_id");
2683    canonical
2684        .reset_model_context_epoch(bamboo_domain::ModelContextResetReason::ExplicitHistoryRewrite);
2685    let canonical = serde_json::to_vec(&canonical)
2686        .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
2687    let staged = serde_json::to_vec(staged)
2688        .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
2689    if canonical != staged {
2690        return Err(invalid_retrieval_window_checkpoint(
2691            "prompt-rewrite checkpoint contains mutations outside the System prompt and provider reset",
2692        ));
2693    }
2694    Ok(())
2695}
2696
2697fn validate_staged_retrieval_window_transition(
2698    expected: &Session,
2699    staged: &Session,
2700) -> std::io::Result<()> {
2701    if expected.id != staged.id {
2702        return Err(invalid_retrieval_window_checkpoint(
2703            "retrieval-window checkpoint Session IDs differ",
2704        ));
2705    }
2706    if expected.conversation_summary.is_some() || staged.conversation_summary.is_some() {
2707        return Err(invalid_retrieval_window_checkpoint(
2708            "retrieval-window checkpoint cannot contain a conversation summary",
2709        ));
2710    }
2711    if expected.messages.len() != staged.messages.len() {
2712        return Err(invalid_retrieval_window_checkpoint(
2713            "retrieval-window checkpoint must preserve the message array",
2714        ));
2715    }
2716    if staged.compression_events.len() != expected.compression_events.len().saturating_add(1) {
2717        return Err(invalid_retrieval_window_checkpoint(
2718            "retrieval-window checkpoint must append exactly one compression event",
2719        ));
2720    }
2721
2722    let expected_events = serde_json::to_vec(&expected.compression_events)
2723        .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
2724    let staged_prefix =
2725        serde_json::to_vec(&staged.compression_events[..expected.compression_events.len()])
2726            .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
2727    if expected_events != staged_prefix {
2728        return Err(invalid_retrieval_window_checkpoint(
2729            "retrieval-window checkpoint rewrote an existing compression event",
2730        ));
2731    }
2732
2733    let event = staged
2734        .compression_events
2735        .last()
2736        .expect("length check guarantees a staged compression event");
2737    if event.kind != bamboo_domain::CompressionEventKind::RetrievalWindow
2738        || event.id.is_empty()
2739        || expected
2740            .compression_events
2741            .iter()
2742            .any(|existing| existing.id == event.id)
2743    {
2744        return Err(invalid_retrieval_window_checkpoint(
2745            "retrieval-window checkpoint has an invalid archive event",
2746        ));
2747    }
2748
2749    let mut newly_archived = 0usize;
2750    for (before, after) in expected.messages.iter().zip(&staged.messages) {
2751        if before.id != after.id {
2752            return Err(invalid_retrieval_window_checkpoint(
2753                "retrieval-window checkpoint reordered or replaced a message",
2754            ));
2755        }
2756
2757        let is_new_archive = !before.compressed && after.compressed;
2758        if is_new_archive {
2759            if before.compressed_by_event_id.is_some()
2760                || after.compressed_by_event_id.as_deref() != Some(event.id.as_str())
2761            {
2762                return Err(invalid_retrieval_window_checkpoint(
2763                    "retrieval-window checkpoint has an invalid message correlation",
2764                ));
2765            }
2766            newly_archived = newly_archived.saturating_add(1);
2767        } else if before.compressed != after.compressed
2768            || before.compressed_by_event_id != after.compressed_by_event_id
2769        {
2770            return Err(invalid_retrieval_window_checkpoint(
2771                "retrieval-window checkpoint contains an unsupported archive mutation",
2772            ));
2773        }
2774
2775        let mut normalized_after = after.clone();
2776        normalized_after.compressed = before.compressed;
2777        normalized_after
2778            .compressed_by_event_id
2779            .clone_from(&before.compressed_by_event_id);
2780        let before = serde_json::to_vec(before)
2781            .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
2782        let after = serde_json::to_vec(&normalized_after)
2783            .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
2784        if before != after {
2785            return Err(invalid_retrieval_window_checkpoint(
2786                "retrieval-window checkpoint mutated message content",
2787            ));
2788        }
2789    }
2790
2791    if newly_archived == 0 || newly_archived != event.messages_compressed {
2792        return Err(invalid_retrieval_window_checkpoint(
2793            "retrieval-window checkpoint event count does not match message correlations",
2794        ));
2795    }
2796    if staged
2797        .model_context_state
2798        .as_ref()
2799        .and_then(|state| state.last_reset_reason)
2800        != Some(bamboo_domain::ModelContextResetReason::Compression)
2801    {
2802        return Err(invalid_retrieval_window_checkpoint(
2803            "retrieval-window checkpoint is missing its model-context reset",
2804        ));
2805    }
2806
2807    Ok(())
2808}
2809
2810fn retrieval_window_base_matches(expected: &Session, durable: &Session) -> std::io::Result<bool> {
2811    if expected.id != durable.id || durable.messages.len() > expected.messages.len() {
2812        return Ok(false);
2813    }
2814    for (durable_message, expected_message) in durable.messages.iter().zip(expected.messages.iter())
2815    {
2816        if !message_matches_retrieval_window_base(expected_message, durable_message)? {
2817            return Ok(false);
2818        }
2819    }
2820
2821    if durable.compression_events.len() > expected.compression_events.len() {
2822        return Ok(false);
2823    }
2824    for (durable_event, expected_event) in durable
2825        .compression_events
2826        .iter()
2827        .zip(expected.compression_events.iter())
2828    {
2829        let durable_event = serde_json::to_vec(durable_event)
2830            .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
2831        let expected_event = serde_json::to_vec(expected_event)
2832            .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
2833        if durable_event != expected_event {
2834            return Ok(false);
2835        }
2836    }
2837
2838    let summary_matches = serde_json::to_vec(&expected.conversation_summary)
2839        .and_then(|expected| {
2840            serde_json::to_vec(&durable.conversation_summary).map(|durable| expected == durable)
2841        })
2842        .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
2843    if !summary_matches {
2844        return Ok(false);
2845    }
2846
2847    // Narrow runtime writers commit arbitrary metadata keys under the same
2848    // per-session lock (for example pending background-completion injections,
2849    // workflow indexes, or skill activation state). Authoritative PATCHes also
2850    // change execution-profile fields under `metadata_version`. A
2851    // prompt/archive rewrite planned before either commit must rebase instead
2852    // of full-saving its stale metadata or model configuration snapshot.
2853    if expected.metadata != durable.metadata
2854        || expected.runtime_metadata != durable.runtime_metadata
2855        || expected.metadata_version != durable.metadata_version
2856        || expected.model != durable.model
2857        || expected.model_ref != durable.model_ref
2858        || expected.reasoning_effort != durable.reasoning_effort
2859    {
2860        return Ok(false);
2861    }
2862
2863    Ok(ensure_model_context_checkpoint_is_current(expected, durable).is_ok())
2864}
2865
2866fn rebase_retrieval_window_base(expected: &Session, durable: &Session) -> Session {
2867    let mut rebased = expected.clone();
2868    bamboo_domain::append_missing_runtime_messages(&mut rebased, durable);
2869    rebased.metadata.clone_from(&durable.metadata);
2870    rebased
2871        .runtime_metadata
2872        .clone_from(&durable.runtime_metadata);
2873    rebased.model.clone_from(&durable.model);
2874    rebased.model_ref.clone_from(&durable.model_ref);
2875    rebased.reasoning_effort = durable.reasoning_effort;
2876    bamboo_domain::merge_session_inbox_admission(&mut rebased, durable);
2877    rebased
2878        .conversation_summary
2879        .clone_from(&durable.conversation_summary);
2880    rebased
2881        .compression_events
2882        .clone_from(&durable.compression_events);
2883    rebased.token_usage.clone_from(&durable.token_usage);
2884    rebased
2885        .model_context_state
2886        .clone_from(&durable.model_context_state);
2887    if durable.updated_at > rebased.updated_at {
2888        rebased.updated_at = durable.updated_at;
2889    }
2890    rebased
2891}
2892
2893/// Adopt the on-disk typed permission posture into the session about to be
2894/// saved when the durable posture is semantically fresher.
2895///
2896/// `PATCH /sessions {permission_mode|bypass_permissions}` is the authoritative
2897/// writer of this posture (a running loop only carries it forward from run
2898/// start). Without this, a runtime save from an in-flight run — which holds the
2899/// run-start value — silently reverts a concurrent mid-run transition on disk.
2900/// A true typed-mode difference always represents an authoritative durable
2901/// transition. When the modes are equal, the complete audit revision is the
2902/// ordering fence: an older/missing disk audit must never delete a newer
2903/// run-start policy/mapping refresh. #540/#770.
2904fn adopt_fresher_disk_permission_posture(session: &mut Session, latest: &Session) {
2905    // A disk copy with NO runtime state at all carries no authoritative mode
2906    // value — treat it as "unknown" and leave the in-memory flag untouched,
2907    // rather than forcing it OFF (which would silently disable a legitimately
2908    // bypassed run on any backend/path that doesn't round-trip the field). #540.
2909    let Some(disk_mode) = latest
2910        .agent_runtime_state
2911        .as_ref()
2912        .map(|state| state.effective_permission_mode())
2913    else {
2914        return;
2915    };
2916    let current_mode = session
2917        .agent_runtime_state
2918        .as_ref()
2919        .map(|state| state.effective_permission_mode())
2920        .unwrap_or_default();
2921    let Some(disk_audit) = bamboo_domain::fresher_disk_permission_audit(
2922        current_mode,
2923        &session.metadata,
2924        disk_mode,
2925        &latest.metadata,
2926    ) else {
2927        return;
2928    };
2929
2930    match session.agent_runtime_state.as_mut() {
2931        Some(state) => state.set_permission_mode(disk_mode),
2932        // No runtime state in memory and disk says "off" → nothing to adopt;
2933        // avoid allocating a default state just to store `false`.
2934        None if disk_mode != bamboo_domain::SessionPermissionMode::Default => {
2935            let state = session
2936                .agent_runtime_state
2937                .get_or_insert_with(bamboo_domain::AgentRuntimeState::default);
2938            state.set_permission_mode(disk_mode);
2939        }
2940        None => {}
2941    }
2942
2943    // The typed posture and its complete bounded audit record move together.
2944    disk_audit.write_to(&mut session.metadata);
2945}
2946
2947/// Pure merge step: given a freshly-loaded on-disk copy, overwrite the
2948/// in-memory authoritative metadata group when disk's `metadata_version` is at
2949/// least the in-memory one. Split out so callers that have already loaded the
2950/// disk copy (e.g. [`LockedSessionStore::merge_save_runtime`]) don't pay for a
2951/// second read.
2952fn apply_authoritative_metadata(session: &mut Session, latest: &Session) {
2953    if session.kind == bamboo_domain::SessionKind::Child
2954        && latest.kind == bamboo_domain::SessionKind::Child
2955        && session.created_at == latest.created_at
2956    {
2957        // A runner's earlier snapshot must not erase a parent's durable
2958        // cancellation fence during a later transcript checkpoint.
2959        let latest_launch = latest
2960            .runtime_metadata
2961            .as_ref()
2962            .and_then(|metadata| metadata.child_launch_generation);
2963        let incoming_launch = session
2964            .runtime_metadata
2965            .as_ref()
2966            .and_then(|metadata| metadata.child_launch_generation);
2967        if latest_launch > incoming_launch {
2968            session
2969                .runtime_metadata
2970                .get_or_insert_with(Default::default)
2971                .child_launch_generation = latest_launch;
2972        }
2973        let latest_cancelled = latest
2974            .runtime_metadata
2975            .as_ref()
2976            .and_then(|metadata| metadata.child_cancelled_generation);
2977        let incoming_cancelled = session
2978            .runtime_metadata
2979            .as_ref()
2980            .and_then(|metadata| metadata.child_cancelled_generation);
2981        if latest_cancelled > incoming_cancelled {
2982            session
2983                .runtime_metadata
2984                .get_or_insert_with(Default::default)
2985                .child_cancelled_generation = latest_cancelled;
2986        }
2987        let latest_intent = latest
2988            .runtime_metadata
2989            .as_ref()
2990            .and_then(|metadata| metadata.child_auto_run_launch_intent_generation);
2991        let incoming_intent = session
2992            .runtime_metadata
2993            .as_ref()
2994            .and_then(|metadata| metadata.child_auto_run_launch_intent_generation);
2995        if latest_intent > incoming_intent {
2996            session
2997                .runtime_metadata
2998                .get_or_insert_with(Default::default)
2999                .child_auto_run_launch_intent_generation = latest_intent;
3000        }
3001    }
3002    // Identity is independent of the UI metadata revision. Preserve it in the
3003    // caller snapshot too, so a successful merge-save cannot downgrade the cache.
3004    // Never replace an explicit Supervisor incarnation: the final storage guard
3005    // must reject stale identities after deletion/recreation rather than hiding them.
3006    // A newly constructed or previously deleted Ordinary session with this ID
3007    // is not a snapshot of the current Root and must not be rebound to it.
3008    if session.authority_identity.is_ordinary() && session.created_at == latest.created_at {
3009        session.authority_identity = latest.authority_identity.clone();
3010    }
3011    if session.kind == bamboo_domain::SessionKind::Root
3012        && latest.kind == bamboo_domain::SessionKind::Root
3013    {
3014        // Runtime writes own transcript/runtime changes, never Root mode
3015        // authority. Adopt the coherent durable tuple, including a recovery
3016        // epoch that changed no tool policy. Invalid birth/revision pairs stay
3017        // untouched so the final V2 writer rejects them instead of rebinding.
3018        let _ = session.adopt_root_tool_authority_from(latest);
3019    }
3020    // Relationships are a separate monotonic authority, independent of UI
3021    // metadata. Adopt the canonical state into the actual caller snapshot only
3022    // for the same Root lifetime/incarnation; never rebind stale identities.
3023    if session.kind == bamboo_domain::SessionKind::Root
3024        && latest.kind == bamboo_domain::SessionKind::Root
3025        && session.created_at == latest.created_at
3026        && session.authority_identity == latest.authority_identity
3027    {
3028        session.supervisor_management = latest.supervisor_management.clone();
3029    }
3030    // Project and its revision are one fence. Never stamp a newer disk revision
3031    // onto the caller's old Project; that would manufacture a fresh-looking
3032    // stale assignment. Equal-revision runtime workspace refreshes within the
3033    // same Project remain valid, while an actual reassignment adopts its whole
3034    // workspace context before the caller can be published to a cache.
3035    if session.kind == bamboo_domain::SessionKind::Root
3036        && latest.kind == bamboo_domain::SessionKind::Root
3037        && session.created_at == latest.created_at
3038        && latest.metadata_version >= session.metadata_version
3039        && (latest.metadata_version > session.metadata_version
3040            || latest.project_id_meta() != session.project_id_meta())
3041    {
3042        match latest.project_id_meta() {
3043            Some(project) => session.set_project_id_meta(project),
3044            None => session.clear_project_id_meta(),
3045        }
3046        match latest.workspace_path_meta() {
3047            Some(workspace) => session.set_workspace_path_meta(workspace),
3048            None => {
3049                session.metadata.remove("workspace_path");
3050                if let Some(metadata) = session.runtime_metadata.as_mut() {
3051                    metadata.workspace_path = None;
3052                }
3053            }
3054        }
3055        session.workspace.clone_from(&latest.workspace);
3056        for key in ROOT_PROJECT_CONTEXT_KEYS {
3057            match latest.metadata.get(*key) {
3058                Some(value) => {
3059                    session.metadata.insert((*key).to_string(), value.clone());
3060                }
3061                None => {
3062                    session.metadata.remove(*key);
3063                }
3064            }
3065        }
3066        session.prompt_snapshot.clone_from(&latest.prompt_snapshot);
3067    }
3068    if latest.metadata_version >= session.metadata_version {
3069        session.title = latest.title.clone();
3070        session.title_version = latest.title_version;
3071        session.title_generated = latest.title_generated;
3072        session.pinned = latest.pinned;
3073        for key in AUTHORITATIVE_METADATA_KEYS {
3074            if let Some(value) = latest.metadata.get(*key) {
3075                session.metadata.insert((*key).to_string(), value.clone());
3076            } else {
3077                session.metadata.remove(*key);
3078            }
3079        }
3080        session.metadata_version = latest.metadata_version;
3081    }
3082}
3083
3084// ── Free merge-save function ──────────────────────────────────────────
3085
3086/// Save a session while preserving any concurrent UI edits to the
3087/// authoritative metadata group.
3088///
3089/// Behaviour: if the on-disk session has `metadata_version >=
3090/// session.metadata_version`, the on-disk `title`, `title_version`, `title_generated`, `pinned`
3091/// and `metadata_version` overwrite the in-memory values before writing.
3092///
3093/// This is the stateless variant (no per-session lock). Prefer
3094/// [`LockedSessionStore::merge_save_runtime`] for server-side paths where an
3095/// authoritative writer may race with this save.
3096pub async fn merge_save_session(
3097    storage: &Arc<dyn Storage>,
3098    session: &mut Session,
3099) -> std::io::Result<()> {
3100    merge_authoritative_metadata_into_stale(storage, session).await?;
3101    storage.save_session(session).await
3102}
3103
3104// ── Tests ─────────────────────────────────────────────────────────────
3105
3106#[cfg(test)]
3107mod tests {
3108    use super::*;
3109    use crate::v2::{RuntimeTaskTransactionFault, SessionStoreV2};
3110    use bamboo_domain::{session::types::Session, PermissionMode};
3111    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
3112
3113    #[test]
3114    fn authority_merge_updates_ordinary_cache_but_does_not_hide_stale_incarnation() {
3115        let mut latest = Session::new(bamboo_domain::DEFAULT_SUPERVISOR_SESSION_ID, "model");
3116        latest.authority_identity = bamboo_domain::SessionAuthorityIdentity::Supervisor {
3117            incarnation_id: uuid::Uuid::new_v4(),
3118        };
3119        let mut stale = latest.clone();
3120        stale.authority_identity = bamboo_domain::SessionAuthorityIdentity::Ordinary;
3121        stale.metadata_version = 100;
3122        apply_authoritative_metadata(&mut stale, &latest);
3123        assert_eq!(stale.authority_identity, latest.authority_identity);
3124        let old_identity = bamboo_domain::SessionAuthorityIdentity::Supervisor {
3125            incarnation_id: uuid::Uuid::new_v4(),
3126        };
3127        stale.authority_identity = old_identity.clone();
3128        apply_authoritative_metadata(&mut stale, &latest);
3129        assert_eq!(stale.authority_identity, old_identity);
3130    }
3131
3132    struct AuthoritySavePauseStorage {
3133        inner: Arc<SessionStoreV2>,
3134        calls: std::sync::atomic::AtomicUsize,
3135        reached: tokio::sync::Barrier,
3136        release: tokio::sync::Barrier,
3137    }
3138
3139    #[tokio::test]
3140    async fn root_project_merge_publishes_project_revision_and_workspace_together() {
3141        for runtime_only in [false, true] {
3142            for equal_revision in [false, true] {
3143                let temp = tempfile::tempdir().unwrap();
3144                let storage = Arc::new(SessionStoreV2::new(temp.path().into()).await.unwrap());
3145                let mut stale = Session::new("root-project-merge", "model");
3146                stale.set_project_id_meta("project-a");
3147                stale.set_workspace_path_meta("/project-a");
3148                stale.metadata.insert(
3149                    "runtime_prompt_snapshot".into(),
3150                    "old Project A prompt".into(),
3151                );
3152                stale.add_message(bamboo_domain::Message::user("Keep transcript"));
3153                storage.save_session(&stale).await.unwrap();
3154                let mut current = stale.clone();
3155                current.metadata_version += 1;
3156                current.set_project_id_meta("project-b");
3157                current.set_workspace_path_meta("/project-b");
3158                current.metadata.remove("runtime_prompt_snapshot");
3159                current
3160                    .metadata
3161                    .insert("workspace_source".into(), "project_default".into());
3162                current
3163                    .metadata
3164                    .insert("project_context_rendered".into(), "Project B".into());
3165                storage.save_session(&current).await.unwrap();
3166                if equal_revision {
3167                    // The pre-fix merge could already have copied only the
3168                    // version. Reconcile this equal-version divergent Project.
3169                    stale.metadata_version = current.metadata_version;
3170                }
3171                let locked = LockedSessionStore::new(storage.clone());
3172                let published = AtomicBool::new(false);
3173                let publish = |saved: &Session| {
3174                    assert!(!saved.metadata.contains_key("runtime_prompt_snapshot"));
3175                    assert_eq!(saved.project_id_meta().as_deref(), Some("project-b"));
3176                    assert_eq!(saved.workspace_path_meta().as_deref(), Some("/project-b"));
3177                    assert_eq!(saved.metadata_version, current.metadata_version);
3178                    assert_eq!(
3179                        saved.metadata.get("workspace_source").map(String::as_str),
3180                        Some("project_default")
3181                    );
3182                    assert_eq!(
3183                        saved
3184                            .metadata
3185                            .get("project_context_rendered")
3186                            .map(String::as_str),
3187                        Some("Project B")
3188                    );
3189                    published.store(true, Ordering::SeqCst);
3190                };
3191                if runtime_only {
3192                    locked
3193                        .save_runtime_only_and_publish(&mut stale, publish)
3194                        .await
3195                        .unwrap();
3196                } else {
3197                    locked
3198                        .merge_save_runtime_and_publish(&mut stale, |saved, committed| {
3199                            assert!(committed);
3200                            publish(saved);
3201                        })
3202                        .await
3203                        .unwrap();
3204                }
3205                assert!(published.load(Ordering::SeqCst));
3206                assert_eq!(stale.project_id_meta(), current.project_id_meta());
3207                let loaded = storage.load_session(&stale.id).await.unwrap().unwrap();
3208                assert_eq!(loaded.project_id_meta(), current.project_id_meta());
3209                assert_eq!(loaded.messages.len(), 1);
3210                storage.flush_search_index().await;
3211            }
3212        }
3213    }
3214
3215    #[tokio::test]
3216    async fn root_project_change_after_merge_read_rejects_without_cache_publication() {
3217        for runtime_only in [false, true] {
3218            let temp = tempfile::tempdir().unwrap();
3219            let first = Arc::new(SessionStoreV2::new(temp.path().into()).await.unwrap());
3220            let mut stale = Session::new("root-project-race", "model");
3221            stale.set_project_id_meta("project-a");
3222            stale.add_message(bamboo_domain::Message::user("Keep transcript"));
3223            first.save_session(&stale).await.unwrap();
3224            let second = SessionStoreV2::new(temp.path().into()).await.unwrap();
3225            let mut current = stale.clone();
3226            current.metadata_version += 1;
3227            current.set_project_id_meta("project-b");
3228            let paused = Arc::new(AuthoritySavePauseStorage {
3229                inner: first.clone(),
3230                calls: std::sync::atomic::AtomicUsize::new(0),
3231                reached: tokio::sync::Barrier::new(2),
3232                release: tokio::sync::Barrier::new(2),
3233            });
3234            let locked = LockedSessionStore::new(paused.clone());
3235            let published = AtomicBool::new(false);
3236            let save = async {
3237                if runtime_only {
3238                    locked
3239                        .save_runtime_only_and_publish(&mut stale, |_| {
3240                            published.store(true, Ordering::SeqCst);
3241                        })
3242                        .await
3243                } else {
3244                    locked
3245                        .merge_save_runtime_and_publish(&mut stale, |_, _| {
3246                            published.store(true, Ordering::SeqCst);
3247                        })
3248                        .await
3249                }
3250            };
3251            let update = async {
3252                paused.reached.wait().await;
3253                second.save_session(&current).await.unwrap();
3254                paused.release.wait().await;
3255            };
3256            let (result, ()) = tokio::time::timeout(std::time::Duration::from_secs(10), async {
3257                tokio::join!(save, update)
3258            })
3259            .await
3260            .expect("deterministic Project/save race completes");
3261            assert!(!may_publish_runtime_result(&Err(result.unwrap_err())));
3262            assert!(!published.load(Ordering::SeqCst));
3263            let loaded = first.load_session(&current.id).await.unwrap().unwrap();
3264            assert_eq!(loaded.project_id_meta(), current.project_id_meta());
3265            assert_eq!(loaded.metadata_version, current.metadata_version);
3266            assert_eq!(loaded.messages.len(), 1);
3267            first.flush_search_index().await;
3268            second.flush_search_index().await;
3269        }
3270    }
3271
3272    #[async_trait::async_trait]
3273    impl Storage for AuthoritySavePauseStorage {
3274        async fn load_session(&self, id: &str) -> std::io::Result<Option<Session>> {
3275            self.inner.load_session(id).await
3276        }
3277        async fn load_runtime_control_plane(&self, id: &str) -> std::io::Result<Option<Session>> {
3278            self.inner.load_runtime_control_plane(id).await
3279        }
3280        async fn delete_session(&self, id: &str) -> std::io::Result<bool> {
3281            self.inner.delete_session(id).await
3282        }
3283        async fn save_session(&self, session: &Session) -> std::io::Result<()> {
3284            self.calls.fetch_add(1, Ordering::SeqCst);
3285            self.reached.wait().await;
3286            self.release.wait().await;
3287            self.inner.save_session(session).await
3288        }
3289        async fn save_runtime_state(&self, session: &Session) -> std::io::Result<()> {
3290            self.calls.fetch_add(1, Ordering::SeqCst);
3291            self.reached.wait().await;
3292            self.release.wait().await;
3293            self.inner.save_runtime_state(session).await
3294        }
3295    }
3296
3297    #[tokio::test]
3298    async fn actor_claim_after_merge_read_rejects_once_without_cache_publication() {
3299        use bamboo_domain::{ActorActivationClaim, ActorDirectoryPort};
3300        for runtime_only in [false, true] {
3301            let home = tempfile::tempdir().unwrap();
3302            let first = Arc::new(SessionStoreV2::new(home.path().into()).await.unwrap());
3303            let mut stale = Session::new("actor-cache-race", "model");
3304            stale.add_message(bamboo_domain::Message::user("durable"));
3305            first.save_session(&stale).await.unwrap();
3306            let second = SessionStoreV2::new(home.path().into()).await.unwrap();
3307            let paused = Arc::new(AuthoritySavePauseStorage {
3308                inner: first.clone(),
3309                calls: std::sync::atomic::AtomicUsize::new(0),
3310                reached: tokio::sync::Barrier::new(2),
3311                release: tokio::sync::Barrier::new(2),
3312            });
3313            let locked = LockedSessionStore::new(paused.clone());
3314            let id = stale.id.clone();
3315            let cached = Arc::new(std::sync::Mutex::new(stale.clone()));
3316            let publications = std::sync::atomic::AtomicUsize::new(0);
3317            stale.conversation_summary =
3318                Some(bamboo_domain::ConversationSummary::new("reject", 1, 1));
3319            let save = async {
3320                if runtime_only {
3321                    locked
3322                        .save_runtime_only_and_publish(&mut stale, |saved| {
3323                            publications.fetch_add(1, Ordering::SeqCst);
3324                            *cached.lock().unwrap() = saved.clone();
3325                        })
3326                        .await
3327                } else {
3328                    locked
3329                        .merge_save_runtime_and_publish(&mut stale, |saved, _| {
3330                            publications.fetch_add(1, Ordering::SeqCst);
3331                            *cached.lock().unwrap() = saved.clone();
3332                        })
3333                        .await
3334                }
3335            };
3336            let activate = async {
3337                paused.reached.wait().await;
3338                let now = chrono::Utc::now();
3339                second
3340                    .claim_activation(&ActorActivationClaim {
3341                        actor_id: id.clone(),
3342                        run_id: "claim".into(),
3343                        lease_owner: "owner".into(),
3344                        lease_expires_at: now + chrono::Duration::minutes(5),
3345                        inbox_generation: 0,
3346                        placement_ref: None,
3347                        now,
3348                    })
3349                    .await
3350                    .unwrap();
3351                paused.release.wait().await;
3352            };
3353            let (result, ()) = tokio::time::timeout(std::time::Duration::from_secs(10), async {
3354                tokio::join!(save, activate)
3355            })
3356            .await
3357            .expect("real final save/activation race terminates");
3358            let error = result.unwrap_err();
3359            assert_eq!(error.kind(), std::io::ErrorKind::Unsupported);
3360            assert!(!is_task_control_plane_save_conflict(&error));
3361            assert!(!may_publish_runtime_result(&Err(error)));
3362            assert_eq!(paused.calls.load(Ordering::SeqCst), 1);
3363            assert_eq!(publications.load(Ordering::SeqCst), 0);
3364            assert!(cached.lock().unwrap().conversation_summary.is_none());
3365            assert!(first
3366                .load_session(&id)
3367                .await
3368                .unwrap()
3369                .unwrap()
3370                .conversation_summary
3371                .is_none());
3372        }
3373    }
3374
3375    #[tokio::test]
3376    async fn root_deleted_after_merge_read_rejects_without_cache_or_event_publication() {
3377        for runtime_only in [false, true] {
3378            let temp = tempfile::tempdir().unwrap();
3379            let first = Arc::new(SessionStoreV2::new(temp.path().into()).await.unwrap());
3380            let mut stale = Session::new("root-delete-race", "model");
3381            stale.set_project_id_meta("project-a");
3382            stale.add_message(bamboo_domain::Message::user("Old lifetime"));
3383            first.save_session(&stale).await.unwrap();
3384            let id = stale.id.clone();
3385            let second = SessionStoreV2::new(temp.path().into()).await.unwrap();
3386            let paused = Arc::new(AuthoritySavePauseStorage {
3387                inner: first.clone(),
3388                calls: std::sync::atomic::AtomicUsize::new(0),
3389                reached: tokio::sync::Barrier::new(2),
3390                release: tokio::sync::Barrier::new(2),
3391            });
3392            let locked = LockedSessionStore::new(paused.clone());
3393            let published = AtomicBool::new(false);
3394            let save = async {
3395                if runtime_only {
3396                    locked
3397                        .save_runtime_only_and_publish(&mut stale, |_| {
3398                            published.store(true, Ordering::SeqCst);
3399                        })
3400                        .await
3401                } else {
3402                    locked
3403                        .merge_save_runtime_and_publish(&mut stale, |_, _| {
3404                            published.store(true, Ordering::SeqCst);
3405                        })
3406                        .await
3407                }
3408            };
3409            let delete = async {
3410                paused.reached.wait().await;
3411                assert!(second.delete_session(&id).await.unwrap());
3412                paused.release.wait().await;
3413            };
3414            let (result, ()) = tokio::time::timeout(std::time::Duration::from_secs(10), async {
3415                tokio::join!(save, delete)
3416            })
3417            .await
3418            .expect("deterministic delete/save race completes");
3419            assert!(!may_publish_runtime_result(&Err(result.unwrap_err())));
3420            assert!(!published.load(Ordering::SeqCst));
3421            assert!(first.load_root_authority(&id).await.unwrap().is_none());
3422            assert!(!first.sessions_root_dir().join(&id).exists());
3423            first.flush_search_index().await;
3424            second.flush_search_index().await;
3425        }
3426    }
3427
3428    #[tokio::test]
3429    async fn supervisor_bootstrap_between_merge_read_and_save_rejects_without_publishing() {
3430        for runtime_only in [false, true] {
3431            let temp = tempfile::tempdir().unwrap();
3432            let first = Arc::new(SessionStoreV2::new(temp.path().into()).await.unwrap());
3433            let second = SessionStoreV2::new(temp.path().into()).await.unwrap();
3434            let paused = Arc::new(AuthoritySavePauseStorage {
3435                inner: first.clone(),
3436                calls: std::sync::atomic::AtomicUsize::new(0),
3437                reached: tokio::sync::Barrier::new(2),
3438                release: tokio::sync::Barrier::new(2),
3439            });
3440            let store = LockedSessionStore::new(paused.clone());
3441            let mut stale = Session::new(bamboo_domain::DEFAULT_SUPERVISOR_SESSION_ID, "stale");
3442            stale.add_message(bamboo_domain::Message::user(
3443                "must not enter new Supervisor",
3444            ));
3445            let published = AtomicBool::new(false);
3446            let save = async {
3447                if runtime_only {
3448                    store
3449                        .save_runtime_only_and_publish(&mut stale, |_| {
3450                            published.store(true, Ordering::SeqCst);
3451                        })
3452                        .await
3453                } else {
3454                    store
3455                        .merge_save_runtime_and_publish(&mut stale, |_, _| {
3456                            published.store(true, Ordering::SeqCst);
3457                        })
3458                        .await
3459                }
3460            };
3461            let bootstrap = async {
3462                // The merge read saw None. Publish from an independent V2 store
3463                // before allowing the loser to take its final filesystem lock.
3464                paused.reached.wait().await;
3465                let receipt = second
3466                    .get_or_create_default_supervisor("supervisor")
3467                    .await
3468                    .unwrap();
3469                paused.release.wait().await;
3470                receipt
3471            };
3472            let (result, receipt) =
3473                tokio::time::timeout(std::time::Duration::from_secs(10), async {
3474                    tokio::join!(save, bootstrap)
3475                })
3476                .await
3477                .expect("deterministic bootstrap/save race completes");
3478            let error = result.unwrap_err();
3479            assert!(!may_publish_runtime_result(&Err(error)));
3480            assert!(!published.load(Ordering::SeqCst));
3481            assert!(stale.authority_identity.is_ordinary());
3482            let observed = first
3483                .load_root_authority(&receipt.session_id)
3484                .await
3485                .unwrap()
3486                .unwrap();
3487            // The independent store's ordinary lookup index can remain stale;
3488            // strict authority must observe canonical publication without it.
3489            let durable = second
3490                .load_session(&receipt.session_id)
3491                .await
3492                .unwrap()
3493                .unwrap();
3494            assert_eq!(durable.model, "supervisor");
3495            assert_eq!(observed.authority_identity, durable.authority_identity);
3496            assert!(durable.messages.is_empty());
3497            assert_eq!(
3498                durable.authority_identity,
3499                bamboo_domain::SessionAuthorityIdentity::Supervisor {
3500                    incarnation_id: receipt.incarnation_id,
3501                }
3502            );
3503        }
3504    }
3505
3506    #[tokio::test]
3507    async fn supervisor_merge_publishes_adopted_identity_but_never_a_rejected_incarnation() {
3508        for runtime_only in [false, true] {
3509            let temp = tempfile::tempdir().unwrap();
3510            let storage = Arc::new(SessionStoreV2::new(temp.path().into()).await.unwrap());
3511            let receipt = storage
3512                .get_or_create_default_supervisor("model")
3513                .await
3514                .unwrap();
3515            let baseline = storage
3516                .load_session(&receipt.session_id)
3517                .await
3518                .unwrap()
3519                .unwrap();
3520            let expected = baseline.authority_identity.clone();
3521            let store = LockedSessionStore::new(storage.clone());
3522            for case in 0..3 {
3523                let rejected = case != 0;
3524                let mut snapshot = baseline.clone();
3525                snapshot.authority_identity = if case == 1 {
3526                    bamboo_domain::SessionAuthorityIdentity::Supervisor {
3527                        incarnation_id: uuid::Uuid::new_v4(),
3528                    }
3529                } else {
3530                    bamboo_domain::SessionAuthorityIdentity::Ordinary
3531                };
3532                if case == 2 {
3533                    snapshot.created_at -= chrono::Duration::seconds(1);
3534                    snapshot.model = "stale Ordinary instance".into();
3535                    snapshot.add_message(bamboo_domain::Message::user("must not be rebound"));
3536                }
3537                let published = AtomicBool::new(false);
3538                let callback = |saved: &Session| {
3539                    assert_eq!(saved.authority_identity, expected);
3540                    published.store(true, Ordering::SeqCst);
3541                };
3542                let result = if runtime_only {
3543                    store
3544                        .save_runtime_only_and_publish(&mut snapshot, callback)
3545                        .await
3546                } else {
3547                    store
3548                        .merge_save_runtime_and_publish(&mut snapshot, |saved, committed| {
3549                            assert!(committed);
3550                            callback(saved);
3551                        })
3552                        .await
3553                };
3554                assert_eq!(result.is_err(), rejected);
3555                assert_eq!(published.load(Ordering::SeqCst), !rejected);
3556                if !rejected {
3557                    assert_eq!(snapshot.authority_identity, expected);
3558                }
3559                let durable = storage
3560                    .load_session(&receipt.session_id)
3561                    .await
3562                    .unwrap()
3563                    .unwrap();
3564                assert_eq!(durable.authority_identity, expected);
3565                assert_eq!(durable.created_at, baseline.created_at);
3566                assert_eq!(durable.model, baseline.model);
3567                assert!(durable.messages.is_empty());
3568            }
3569        }
3570    }
3571
3572    struct CountingControlPlaneStorage {
3573        inner: Arc<SessionStoreV2>,
3574        control_plane_loads: AtomicUsize,
3575        full_saves: AtomicUsize,
3576        runtime_state_saves: AtomicUsize,
3577    }
3578
3579    struct PairCommitBarrierStorage {
3580        inner: Arc<SessionStoreV2>,
3581        before_commit: Arc<tokio::sync::Barrier>,
3582    }
3583
3584    struct SingleCommitBarrierStorage {
3585        inner: Arc<SessionStoreV2>,
3586        before_commit: Arc<tokio::sync::Barrier>,
3587    }
3588
3589    struct SingleCommitPauseStorage {
3590        inner: Arc<SessionStoreV2>,
3591        commit_reached: Arc<tokio::sync::Barrier>,
3592        release_commit: Arc<tokio::sync::Barrier>,
3593    }
3594
3595    #[async_trait::async_trait]
3596    impl Storage for SingleCommitPauseStorage {
3597        async fn save_session(&self, session: &Session) -> std::io::Result<()> {
3598            self.inner.save_session(session).await
3599        }
3600
3601        async fn load_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
3602            self.inner.load_session(session_id).await
3603        }
3604
3605        async fn delete_session(&self, session_id: &str) -> std::io::Result<bool> {
3606            self.inner.delete_session(session_id).await
3607        }
3608
3609        async fn load_runtime_control_plane(
3610            &self,
3611            session_id: &str,
3612        ) -> std::io::Result<Option<Session>> {
3613            self.inner.load_runtime_control_plane(session_id).await
3614        }
3615
3616        async fn save_task_control_plane_if_matches(
3617            &self,
3618            original: &Session,
3619            updated: &Session,
3620        ) -> std::io::Result<bool> {
3621            self.commit_reached.wait().await;
3622            self.release_commit.wait().await;
3623            self.inner
3624                .save_task_control_plane_if_matches(original, updated)
3625                .await
3626        }
3627
3628        async fn save_task_control_planes_atomically(
3629            &self,
3630            first_original: &Session,
3631            first_updated: &Session,
3632            second_original: &Session,
3633            second_updated: &Session,
3634        ) -> std::io::Result<bool> {
3635            self.commit_reached.wait().await;
3636            self.release_commit.wait().await;
3637            self.inner
3638                .save_task_control_planes_atomically(
3639                    first_original,
3640                    first_updated,
3641                    second_original,
3642                    second_updated,
3643                )
3644                .await
3645        }
3646    }
3647
3648    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3649    async fn supervisor_management_race_rejects_staged_task_callbacks_and_fresh_invocation_succeeds(
3650    ) {
3651        use bamboo_domain::{
3652            SupervisorManagementMutation, SupervisorManagementRequest, SupervisorReference,
3653        };
3654
3655        for mode in 0..4 {
3656            let home = tempfile::tempdir().unwrap();
3657            let inner = Arc::new(SessionStoreV2::new(home.path().into()).await.unwrap());
3658            let receipt = inner
3659                .get_or_create_default_supervisor("model")
3660                .await
3661                .unwrap();
3662            let reference = SupervisorReference::from(&receipt);
3663            let mut root = inner
3664                .load_session(&reference.session_id)
3665                .await
3666                .unwrap()
3667                .unwrap();
3668            let list = bamboo_domain::TaskList {
3669                session_id: root.id.clone(),
3670                title: "original".into(),
3671                items: vec![],
3672                created_at: root.created_at,
3673                updated_at: root.created_at,
3674            };
3675            let updated = bamboo_domain::TaskList {
3676                title: "updated".into(),
3677                ..list.clone()
3678            };
3679            root.task_list = Some(list.clone());
3680            root.set_task_list_version_meta("1");
3681            inner.save_session(&root).await.unwrap();
3682            let child_id = if mode == 2 { "aaa-child" } else { "zzz-child" };
3683            let mut child = Session::new_child_of(child_id, &root, "model", "child");
3684            child.task_list = Some(list.clone());
3685            child.set_task_list_version_meta("1");
3686            inner.save_session(&child).await.unwrap();
3687            let independent = SessionStoreV2::new(home.path().into()).await.unwrap();
3688            let commit_reached = Arc::new(tokio::sync::Barrier::new(2));
3689            let release_commit = Arc::new(tokio::sync::Barrier::new(2));
3690            let paused = LockedSessionStore::new(Arc::new(SingleCommitPauseStorage {
3691                inner: inner.clone(),
3692                commit_reached: commit_reached.clone(),
3693                release_commit: release_commit.clone(),
3694            }));
3695            let published = AtomicBool::new(false);
3696            let loser = async {
3697                if mode == 0 {
3698                    paused
3699                        .update_task_list_control_plane_and_publish(&root.id, &updated, "2", |_| {
3700                            published.store(true, Ordering::SeqCst)
3701                        })
3702                        .await
3703                } else if mode == 1 {
3704                    paused
3705                        .update_task_list_control_plane_if_version_and_publish(
3706                            &root.id,
3707                            "1",
3708                            &list,
3709                            &updated,
3710                            "2",
3711                            |_| published.store(true, Ordering::SeqCst),
3712                        )
3713                        .await
3714                } else {
3715                    paused
3716                        .update_task_list_control_planes_if_version_and_publish(
3717                            child_id,
3718                            &root.id,
3719                            "1",
3720                            &list,
3721                            &updated,
3722                            "2",
3723                            |_, _| published.store(true, Ordering::SeqCst),
3724                        )
3725                        .await
3726                }
3727            };
3728            let winner = async {
3729                commit_reached.wait().await;
3730                independent
3731                    .mutate_supervisor_management(&SupervisorManagementRequest {
3732                        supervisor: reference.clone(),
3733                        expected_state_revision: 0,
3734                        mutation: SupervisorManagementMutation::ConfigureProjectScope {
3735                            allowed_projects: ["project-a".parse().unwrap()].into(),
3736                        },
3737                    })
3738                    .await
3739                    .unwrap();
3740                release_commit.wait().await;
3741            };
3742            let (result, ()) = tokio::join!(loser, winner);
3743            if mode == 0 {
3744                assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::WouldBlock);
3745            } else {
3746                assert!(!result.unwrap());
3747            }
3748            assert!(!published.load(Ordering::SeqCst));
3749            for id in [&root.id, &child.id] {
3750                assert_eq!(
3751                    inner
3752                        .load_runtime_control_plane(id)
3753                        .await
3754                        .unwrap()
3755                        .unwrap()
3756                        .task_list_version_meta()
3757                        .as_deref(),
3758                    Some("1")
3759                );
3760            }
3761            let fresh = LockedSessionStore::new(inner.clone());
3762            let check = |saved: &Session| {
3763                assert_eq!(saved.supervisor_management.as_ref().unwrap().revision, 1);
3764                published.store(true, Ordering::SeqCst);
3765            };
3766            let retried = if mode == 0 {
3767                fresh
3768                    .update_task_list_control_plane_and_publish(&root.id, &updated, "2", check)
3769                    .await
3770            } else if mode == 1 {
3771                fresh
3772                    .update_task_list_control_plane_if_version_and_publish(
3773                        &root.id, "1", &list, &updated, "2", check,
3774                    )
3775                    .await
3776            } else {
3777                fresh
3778                    .update_task_list_control_planes_if_version_and_publish(
3779                        child_id,
3780                        &root.id,
3781                        "1",
3782                        &list,
3783                        &updated,
3784                        "2",
3785                        |_, shared| check(shared),
3786                    )
3787                    .await
3788            };
3789            assert!(retried.unwrap());
3790            assert!(published.load(Ordering::SeqCst));
3791            assert_eq!(
3792                inner
3793                    .load_runtime_control_plane(&root.id)
3794                    .await
3795                    .unwrap()
3796                    .unwrap()
3797                    .task_list_version_meta()
3798                    .as_deref(),
3799                Some("2")
3800            );
3801        }
3802    }
3803
3804    #[async_trait::async_trait]
3805    impl Storage for SingleCommitBarrierStorage {
3806        async fn save_session(&self, session: &Session) -> std::io::Result<()> {
3807            self.inner.save_session(session).await
3808        }
3809
3810        async fn load_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
3811            self.inner.load_session(session_id).await
3812        }
3813
3814        async fn delete_session(&self, session_id: &str) -> std::io::Result<bool> {
3815            self.inner.delete_session(session_id).await
3816        }
3817
3818        async fn load_runtime_control_plane(
3819            &self,
3820            session_id: &str,
3821        ) -> std::io::Result<Option<Session>> {
3822            self.inner.load_runtime_control_plane(session_id).await
3823        }
3824
3825        async fn save_task_control_plane_if_matches(
3826            &self,
3827            original: &Session,
3828            updated: &Session,
3829        ) -> std::io::Result<bool> {
3830            self.before_commit.wait().await;
3831            self.inner
3832                .save_task_control_plane_if_matches(original, updated)
3833                .await
3834        }
3835    }
3836
3837    #[async_trait::async_trait]
3838    impl Storage for PairCommitBarrierStorage {
3839        async fn save_session(&self, session: &Session) -> std::io::Result<()> {
3840            self.inner.save_session(session).await
3841        }
3842
3843        async fn load_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
3844            self.inner.load_session(session_id).await
3845        }
3846
3847        async fn delete_session(&self, session_id: &str) -> std::io::Result<bool> {
3848            self.inner.delete_session(session_id).await
3849        }
3850
3851        async fn save_runtime_state(&self, session: &Session) -> std::io::Result<()> {
3852            self.inner.save_runtime_state(session).await
3853        }
3854
3855        async fn load_runtime_control_plane(
3856            &self,
3857            session_id: &str,
3858        ) -> std::io::Result<Option<Session>> {
3859            self.inner.load_runtime_control_plane(session_id).await
3860        }
3861
3862        async fn recover_task_control_plane_transaction(
3863            &self,
3864            first_session_id: &str,
3865            second_session_id: &str,
3866        ) -> std::io::Result<()> {
3867            self.inner
3868                .recover_task_control_plane_transaction(first_session_id, second_session_id)
3869                .await
3870        }
3871
3872        async fn save_task_control_planes_atomically(
3873            &self,
3874            first_original: &Session,
3875            first_updated: &Session,
3876            second_original: &Session,
3877            second_updated: &Session,
3878        ) -> std::io::Result<bool> {
3879            // Both independent LockedSessionStores have already recovered,
3880            // loaded, and validated v1 when they meet here. Their per-instance
3881            // lexical locks cannot serialize each other; the V2 commit-point
3882            // revalidation must select exactly one winner.
3883            self.before_commit.wait().await;
3884            self.inner
3885                .save_task_control_planes_atomically(
3886                    first_original,
3887                    first_updated,
3888                    second_original,
3889                    second_updated,
3890                )
3891                .await
3892        }
3893    }
3894
3895    #[async_trait::async_trait]
3896    impl Storage for CountingControlPlaneStorage {
3897        async fn save_session(&self, session: &Session) -> std::io::Result<()> {
3898            self.full_saves.fetch_add(1, Ordering::SeqCst);
3899            self.inner.save_session(session).await
3900        }
3901
3902        async fn load_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
3903            self.inner.load_session(session_id).await
3904        }
3905
3906        async fn delete_session(&self, session_id: &str) -> std::io::Result<bool> {
3907            self.inner.delete_session(session_id).await
3908        }
3909
3910        async fn save_runtime_state(&self, session: &Session) -> std::io::Result<()> {
3911            self.runtime_state_saves.fetch_add(1, Ordering::SeqCst);
3912            self.inner.save_runtime_state(session).await
3913        }
3914
3915        async fn load_runtime_control_plane(
3916            &self,
3917            session_id: &str,
3918        ) -> std::io::Result<Option<Session>> {
3919            self.control_plane_loads.fetch_add(1, Ordering::SeqCst);
3920            self.inner.load_runtime_control_plane(session_id).await
3921        }
3922
3923        async fn recover_task_control_plane_transaction(
3924            &self,
3925            first_session_id: &str,
3926            second_session_id: &str,
3927        ) -> std::io::Result<()> {
3928            self.inner
3929                .recover_task_control_plane_transaction(first_session_id, second_session_id)
3930                .await
3931        }
3932
3933        async fn save_task_control_plane_if_matches(
3934            &self,
3935            original: &Session,
3936            updated: &Session,
3937        ) -> std::io::Result<bool> {
3938            let committed = self
3939                .inner
3940                .save_task_control_plane_if_matches(original, updated)
3941                .await?;
3942            if committed {
3943                self.runtime_state_saves.fetch_add(1, Ordering::SeqCst);
3944            }
3945            Ok(committed)
3946        }
3947
3948        async fn save_task_control_planes_atomically(
3949            &self,
3950            first_original: &Session,
3951            first_updated: &Session,
3952            second_original: &Session,
3953            second_updated: &Session,
3954        ) -> std::io::Result<bool> {
3955            let committed = self
3956                .inner
3957                .save_task_control_planes_atomically(
3958                    first_original,
3959                    first_updated,
3960                    second_original,
3961                    second_updated,
3962                )
3963                .await?;
3964            if committed {
3965                self.runtime_state_saves.fetch_add(2, Ordering::SeqCst);
3966            }
3967            Ok(committed)
3968        }
3969    }
3970
3971    async fn make_storage() -> (tempfile::TempDir, Arc<dyn Storage>) {
3972        let temp = tempfile::tempdir().unwrap();
3973        let storage = SessionStoreV2::new(temp.path().to_path_buf())
3974            .await
3975            .expect("storage init");
3976        (temp, Arc::new(storage) as Arc<dyn Storage>)
3977    }
3978
3979    fn fresh(id: &str) -> Session {
3980        Session::new(id.to_string(), "test-model".to_string())
3981    }
3982
3983    fn stage_retrieval_window_archive(expected: &Session, message_index: usize) -> Session {
3984        let mut staged = expected.clone();
3985        let mut event = bamboo_domain::CompressionEvent::new(
3986            1,
3987            1,
3988            80.0,
3989            60.0,
3990            0,
3991            bamboo_domain::CompressionTriggerType::Auto,
3992            0.0,
3993            None,
3994            0,
3995        );
3996        event.kind = bamboo_domain::CompressionEventKind::RetrievalWindow;
3997        let event_id = event.id.clone();
3998        staged.messages[message_index].compressed = true;
3999        staged.messages[message_index].compressed_by_event_id = Some(event_id);
4000        staged.compression_events.push(event);
4001        staged.reset_model_context_epoch(bamboo_domain::ModelContextResetReason::Compression);
4002        staged
4003    }
4004
4005    fn typed_permission_result(
4006        tool_call_id: &str,
4007        message_id: &str,
4008        generation: &str,
4009        content: &str,
4010    ) -> bamboo_domain::session::types::Message {
4011        let mut message =
4012            bamboo_domain::session::types::Message::tool_result(tool_call_id, content);
4013        message.id = message_id.to_string();
4014        message.metadata = Some(serde_json::json!({
4015            "permission_request": {
4016                "request_generation": generation,
4017            }
4018        }));
4019        message
4020    }
4021
4022    fn ledger_state(state_revision: u64, marker: &str) -> bamboo_domain::ModelContextState {
4023        bamboo_domain::ModelContextState {
4024            state_revision,
4025            prefix_epoch: state_revision,
4026            cache_scope_sha256: Some("scope".to_string()),
4027            transcript_item_sha256: vec![marker.to_string()],
4028            ..bamboo_domain::ModelContextState::default()
4029        }
4030    }
4031
4032    fn set_permission_audit(
4033        session: &mut Session,
4034        requested: bamboo_domain::SessionPermissionMode,
4035        policy_revision: u64,
4036        mapping: &str,
4037        transitioned_at: &str,
4038    ) -> u64 {
4039        let resolution = bamboo_domain::resolve_permission_mode(requested, PermissionMode::Default);
4040        session
4041            .agent_runtime_state
4042            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
4043            .set_permission_mode(requested);
4044        bamboo_domain::record_permission_audit(
4045            &mut session.metadata,
4046            &PermissionAuditSeed::new(policy_revision, resolution, mapping),
4047            Some(transitioned_at),
4048        )
4049        .unwrap()
4050    }
4051
4052    #[tokio::test]
4053    async fn checked_runtime_mutation_persists_a_cache_only_pending_session() {
4054        let (_temp, storage) = make_storage().await;
4055        let store = LockedSessionStore::new(storage.clone());
4056        let mut cached = fresh("cache-only-response");
4057        cached.set_pending_question(
4058            "tool-1".to_string(),
4059            "ConclusionWithOptions".to_string(),
4060            "Choose".to_string(),
4061            vec!["A".to_string()],
4062            false,
4063        );
4064
4065        let saved = store
4066            .mutate_runtime_session_and_publish(
4067                &cached.id.clone(),
4068                move || Some(cached),
4069                |session| {
4070                    assert!(session.pending_question.is_some());
4071                    session.clear_pending_question();
4072                    Ok::<_, ()>(())
4073                },
4074                |_| {},
4075            )
4076            .await
4077            .unwrap()
4078            .unwrap()
4079            .expect("cache-only session should be created durably");
4080
4081        assert!(saved.pending_question.is_none());
4082        assert!(storage
4083            .load_session("cache-only-response")
4084            .await
4085            .unwrap()
4086            .unwrap()
4087            .pending_question
4088            .is_none());
4089    }
4090
4091    #[tokio::test]
4092    async fn checked_runtime_mutation_preserves_durable_authorities_for_newer_cache() {
4093        let (_temp, storage) = make_storage().await;
4094        let store = LockedSessionStore::new(storage.clone());
4095        let session_id = "cached-response-authorities";
4096        let mut durable = fresh(session_id);
4097        durable.title = "Durable title".to_string();
4098        durable.title_version = 4;
4099        durable.title_generated = true;
4100        durable.metadata_version = 9;
4101        set_permission_audit(
4102            &mut durable,
4103            bamboo_domain::SessionPermissionMode::Auto,
4104            7,
4105            "bamboo_runtime:durable-auto",
4106            "2026-08-10T09:00:00Z",
4107        );
4108        storage.save_session(&durable).await.unwrap();
4109
4110        let mut cached = fresh(session_id);
4111        cached.created_at = durable.created_at;
4112        cached.title = "Stale cached title".to_string();
4113        cached.updated_at = durable.updated_at + chrono::Duration::seconds(1);
4114        cached.set_pending_question(
4115            "tool-1".to_string(),
4116            "ConclusionWithOptions".to_string(),
4117            "Choose".to_string(),
4118            vec!["A".to_string()],
4119            false,
4120        );
4121
4122        store
4123            .mutate_runtime_session_and_publish(
4124                session_id,
4125                move || Some(cached),
4126                |session| {
4127                    session.clear_pending_question();
4128                    Ok::<_, ()>(())
4129                },
4130                |_| {},
4131            )
4132            .await
4133            .unwrap()
4134            .unwrap()
4135            .expect("session should exist");
4136
4137        let saved = storage.load_session(session_id).await.unwrap().unwrap();
4138        assert_eq!(saved.title, "Durable title");
4139        assert_eq!(saved.title_version, 4);
4140        assert_eq!(saved.metadata_version, 9);
4141        assert_eq!(
4142            saved
4143                .agent_runtime_state
4144                .as_ref()
4145                .unwrap()
4146                .effective_permission_mode(),
4147            bamboo_domain::SessionPermissionMode::Auto
4148        );
4149        let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&saved.metadata).unwrap();
4150        assert_eq!(audit.policy_revision, 7);
4151        assert_eq!(audit.executor_mapping, "bamboo_runtime:durable-auto");
4152    }
4153
4154    #[tokio::test]
4155    async fn checked_runtime_mutation_cannot_resurrect_consumed_ask_from_newer_cache() {
4156        use bamboo_domain::session::types::Message;
4157
4158        let (_temp, storage) = make_storage().await;
4159        let store = LockedSessionStore::new(storage.clone());
4160        let session_id = "cached-consumed-response";
4161        let mut stale_cached = fresh(session_id);
4162        stale_cached.add_message(Message::tool_result("call-1", "waiting"));
4163        stale_cached.set_pending_question(
4164            "call-1".to_string(),
4165            "ConclusionWithOptions".to_string(),
4166            "Choose".to_string(),
4167            vec!["A".to_string()],
4168            false,
4169        );
4170
4171        let mut durable = stale_cached.clone();
4172        durable.clear_pending_question();
4173        durable.metadata.insert(
4174            CONSUMED_CLARIFICATION_IDS_KEY.to_string(),
4175            r#"["call-1"]"#.to_string(),
4176        );
4177        durable.messages[0].content = "Selected response: A".to_string();
4178        durable.add_message(Message::user("durable concurrent message"));
4179        storage.save_session(&durable).await.unwrap();
4180
4181        // A cache write after the durable response can have a newer wall-clock
4182        // timestamp while still containing the old runner snapshot.
4183        stale_cached.updated_at = durable.updated_at + chrono::Duration::seconds(1);
4184        let saved = store
4185            .mutate_runtime_session_and_publish(
4186                session_id,
4187                move || Some(stale_cached),
4188                |session| {
4189                    assert!(session.pending_question.is_none());
4190                    Ok::<_, ()>(())
4191                },
4192                |_| {},
4193            )
4194            .await
4195            .unwrap()
4196            .unwrap()
4197            .unwrap();
4198
4199        assert!(saved.pending_question.is_none());
4200        assert_eq!(saved.messages.len(), 2);
4201        assert_eq!(saved.messages[0].content, "Selected response: A");
4202        assert_eq!(saved.messages[1].content, "durable concurrent message");
4203    }
4204
4205    #[tokio::test]
4206    async fn response_inspection_adopts_durable_consumption_without_writing() {
4207        let (_temp, storage) = make_storage().await;
4208        let store = LockedSessionStore::new(storage.clone());
4209        let session_id = "inspect-consumed-response";
4210        let mut stale_cached = fresh(session_id);
4211        // Legacy id-only adoption is still bound to the concrete response
4212        // occurrence. Real pending questions always have a paired tool-result;
4213        // keep that identity in this compatibility fixture so it cannot model
4214        // an unsafe id-only consume.
4215        stale_cached.add_message(bamboo_domain::session::types::Message::tool_result(
4216            "call-1", "waiting",
4217        ));
4218        stale_cached.set_pending_question(
4219            "call-1".to_string(),
4220            "ConclusionWithOptions".to_string(),
4221            "Choose".to_string(),
4222            vec!["A".to_string()],
4223            false,
4224        );
4225
4226        let mut durable = stale_cached.clone();
4227        durable.clear_pending_question();
4228        durable.metadata.insert(
4229            CONSUMED_CLARIFICATION_IDS_KEY.to_string(),
4230            r#"["call-1"]"#.to_string(),
4231        );
4232        storage.save_session(&durable).await.unwrap();
4233        stale_cached.updated_at = durable.updated_at + chrono::Duration::seconds(1);
4234
4235        let inspected = store
4236            .inspect_runtime_session_for_response(session_id, move || Some(stale_cached))
4237            .await
4238            .unwrap()
4239            .expect("session should be inspectable");
4240        assert!(inspected.pending_question.is_none());
4241
4242        let unchanged = storage.load_session(session_id).await.unwrap().unwrap();
4243        assert_eq!(unchanged.updated_at, durable.updated_at);
4244        assert!(unchanged.pending_question.is_none());
4245    }
4246
4247    #[tokio::test]
4248    async fn same_mode_newer_run_start_audit_survives_every_runtime_save_path() {
4249        for path in ["merge", "checkpoint", "control-plane"] {
4250            let (_temp, storage) = make_storage().await;
4251            let store = LockedSessionStore::new(storage.clone());
4252            let session_id = format!("same-mode-newer-{path}");
4253            let mut durable = fresh(&session_id);
4254            set_permission_audit(
4255                &mut durable,
4256                bamboo_domain::SessionPermissionMode::Default,
4257                1,
4258                "bamboo_runtime:old-policy",
4259                "2026-07-31T12:00:00Z",
4260            );
4261            storage.save_session(&durable).await.unwrap();
4262
4263            let mut run_start = durable.clone();
4264            let old_revision = PermissionAuditSnapshot::from_metadata(&durable.metadata)
4265                .unwrap()
4266                .audit_revision;
4267            let new_revision = set_permission_audit(
4268                &mut run_start,
4269                bamboo_domain::SessionPermissionMode::Default,
4270                2,
4271                "bamboo_runtime:new-policy",
4272                "2026-07-31T12:00:00Z",
4273            );
4274            assert!(new_revision > old_revision);
4275
4276            match path {
4277                "merge" => store.merge_save_runtime(&mut run_start).await.unwrap(),
4278                "checkpoint" => store
4279                    .checkpoint_runtime_session(&mut run_start)
4280                    .await
4281                    .unwrap(),
4282                "control-plane" => store.save_runtime_only(&mut run_start).await.unwrap(),
4283                _ => unreachable!(),
4284            }
4285
4286            let saved = storage.load_session(&session_id).await.unwrap().unwrap();
4287            let audit = PermissionAuditSnapshot::from_metadata(&saved.metadata).unwrap();
4288            assert_eq!(audit.audit_revision, new_revision, "path={path}");
4289            assert_eq!(audit.policy_revision, 2, "path={path}");
4290            assert_eq!(audit.executor_mapping, "bamboo_runtime:new-policy");
4291        }
4292    }
4293
4294    #[tokio::test]
4295    async fn newer_disk_transition_wins_after_mode_cycles_back() {
4296        let (_temp, storage) = make_storage().await;
4297        let store = LockedSessionStore::new(storage.clone());
4298        let session_id = "permission-cycle-back";
4299        let mut baseline = fresh(session_id);
4300        let stale_revision = set_permission_audit(
4301            &mut baseline,
4302            bamboo_domain::SessionPermissionMode::Default,
4303            1,
4304            "bamboo_runtime:initial",
4305            "2026-07-31T12:00:00Z",
4306        );
4307        storage.save_session(&baseline).await.unwrap();
4308        let mut stale_runtime = baseline.clone();
4309
4310        let mut durable = baseline;
4311        set_permission_audit(
4312            &mut durable,
4313            bamboo_domain::SessionPermissionMode::Auto,
4314            2,
4315            "bamboo_runtime:auto",
4316            "2026-07-31T12:01:00Z",
4317        );
4318        let durable_revision = set_permission_audit(
4319            &mut durable,
4320            bamboo_domain::SessionPermissionMode::Default,
4321            3,
4322            "bamboo_runtime:cycled-default",
4323            "2026-07-31T12:02:00Z",
4324        );
4325        assert!(durable_revision > stale_revision);
4326        storage.save_session(&durable).await.unwrap();
4327
4328        store.merge_save_runtime(&mut stale_runtime).await.unwrap();
4329        let saved = storage.load_session(session_id).await.unwrap().unwrap();
4330        let audit = PermissionAuditSnapshot::from_metadata(&saved.metadata).unwrap();
4331        assert_eq!(audit.audit_revision, durable_revision);
4332        assert_eq!(audit.policy_revision, 3);
4333        assert_eq!(audit.executor_mapping, "bamboo_runtime:cycled-default");
4334    }
4335
4336    #[tokio::test]
4337    async fn authoritative_activation_seed_replaces_every_warm_worker_posture() {
4338        let (_temp, storage) = make_storage().await;
4339        let store = LockedSessionStore::new(storage.clone());
4340        let session_id = "warm-permission-matrix";
4341        let cases = [
4342            (
4343                bamboo_domain::SessionPermissionMode::Auto,
4344                PermissionMode::Default,
4345                PermissionMode::Auto,
4346            ),
4347            (
4348                bamboo_domain::SessionPermissionMode::Default,
4349                PermissionMode::Default,
4350                PermissionMode::Default,
4351            ),
4352            (
4353                bamboo_domain::SessionPermissionMode::Auto,
4354                PermissionMode::Default,
4355                PermissionMode::Auto,
4356            ),
4357            (
4358                bamboo_domain::SessionPermissionMode::Bypass,
4359                PermissionMode::Auto,
4360                PermissionMode::BypassPermissions,
4361            ),
4362        ];
4363        let mut previous_revision = 0;
4364        let created_at = fresh(session_id).created_at;
4365
4366        for (index, (requested, configured, expected_effective)) in cases.into_iter().enumerate() {
4367            let mut activation = fresh(session_id);
4368            activation.created_at = created_at;
4369            activation
4370                .agent_runtime_state
4371                .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
4372                .set_permission_mode(requested);
4373            let resolution = bamboo_domain::resolve_permission_mode(requested, configured);
4374            bamboo_domain::record_permission_audit(
4375                &mut activation.metadata,
4376                &PermissionAuditSeed::new(
4377                    index as u64 + 1,
4378                    resolution,
4379                    format!("bamboo_worker:{}", resolution.effective.as_str()),
4380                ),
4381                Some("2026-07-31T12:00:00Z"),
4382            )
4383            .unwrap();
4384
4385            RuntimeSessionPersistence::seed_runtime_activation(&store, &mut activation)
4386                .await
4387                .unwrap();
4388            let durable = storage.load_session(session_id).await.unwrap().unwrap();
4389            assert_eq!(
4390                durable
4391                    .agent_runtime_state
4392                    .as_ref()
4393                    .unwrap()
4394                    .effective_permission_mode(),
4395                requested,
4396                "activation {index}"
4397            );
4398            let audit = PermissionAuditSnapshot::from_metadata(&durable.metadata).unwrap();
4399            assert_eq!(audit.resolution.requested, requested);
4400            assert_eq!(audit.resolution.effective, expected_effective);
4401            assert!(audit.audit_revision > previous_revision);
4402            previous_revision = audit.audit_revision;
4403        }
4404    }
4405
4406    #[tokio::test]
4407    async fn resident_reseed_bumps_etag_only_for_typed_transition() {
4408        let (_temp, storage) = make_storage().await;
4409        let store = LockedSessionStore::new(storage.clone());
4410        let session_id = "resident-atomic-permission";
4411        let mut baseline = fresh(session_id);
4412        baseline.metadata_version = 7;
4413        set_permission_audit(
4414            &mut baseline,
4415            bamboo_domain::SessionPermissionMode::Auto,
4416            1,
4417            "bamboo_runtime:auto",
4418            "2026-07-31T12:00:00Z",
4419        );
4420        storage.save_session(&baseline).await.unwrap();
4421        let initial_audit = PermissionAuditSnapshot::from_metadata(&baseline.metadata).unwrap();
4422
4423        let same_mode_seed = PermissionAuditSeed::bamboo_runtime(
4424            2,
4425            bamboo_domain::resolve_permission_mode(
4426                bamboo_domain::SessionPermissionMode::Auto,
4427                PermissionMode::Default,
4428            ),
4429        );
4430        let refreshed = store
4431            .update_authoritative_permission_posture_and_publish(
4432                session_id,
4433                &same_mode_seed,
4434                |session| {
4435                    session
4436                        .metadata
4437                        .insert("resident.marker".to_string(), "same-mode".to_string());
4438                },
4439                |_| {},
4440            )
4441            .await
4442            .unwrap()
4443            .unwrap();
4444        let refreshed_audit = PermissionAuditSnapshot::from_metadata(&refreshed.metadata).unwrap();
4445        assert_eq!(refreshed.metadata_version, 7);
4446        assert!(refreshed_audit.audit_revision > initial_audit.audit_revision);
4447        assert_eq!(refreshed_audit.policy_revision, 2);
4448
4449        let transition_seed = PermissionAuditSeed::bamboo_runtime(
4450            3,
4451            bamboo_domain::resolve_permission_mode(
4452                bamboo_domain::SessionPermissionMode::Default,
4453                PermissionMode::Default,
4454            ),
4455        );
4456        let transitioned = store
4457            .update_authoritative_permission_posture_and_publish(
4458                session_id,
4459                &transition_seed,
4460                |session| {
4461                    session
4462                        .metadata
4463                        .insert("resident.marker".to_string(), "transition".to_string());
4464                },
4465                |_| {},
4466            )
4467            .await
4468            .unwrap()
4469            .unwrap();
4470        let transitioned_audit =
4471            PermissionAuditSnapshot::from_metadata(&transitioned.metadata).unwrap();
4472        assert_eq!(transitioned.metadata_version, 8, "old ETag must be invalid");
4473        assert_eq!(
4474            transitioned
4475                .agent_runtime_state
4476                .as_ref()
4477                .unwrap()
4478                .effective_permission_mode(),
4479            bamboo_domain::SessionPermissionMode::Default
4480        );
4481        assert_eq!(
4482            transitioned_audit.resolution.requested,
4483            bamboo_domain::SessionPermissionMode::Default
4484        );
4485        assert!(transitioned_audit.audit_revision > refreshed_audit.audit_revision);
4486        assert_eq!(
4487            transitioned
4488                .metadata
4489                .get("resident.marker")
4490                .map(String::as_str),
4491            Some("transition")
4492        );
4493    }
4494
4495    #[tokio::test]
4496    async fn worker_activation_cas_cannot_overwrite_concurrent_permission_patch() {
4497        let (_temp, storage) = make_storage().await;
4498        let store = LockedSessionStore::new(storage.clone());
4499        let session_id = "permission-activation-cas";
4500        let mut baseline = fresh(session_id);
4501        set_permission_audit(
4502            &mut baseline,
4503            bamboo_domain::SessionPermissionMode::Default,
4504            1,
4505            "bamboo_runtime:default",
4506            "2026-07-31T12:00:00Z",
4507        );
4508        storage.save_session(&baseline).await.unwrap();
4509        let dispatched_revision = PermissionAuditSnapshot::from_metadata(&baseline.metadata)
4510            .unwrap()
4511            .audit_revision;
4512
4513        let patched_resolution = bamboo_domain::resolve_permission_mode(
4514            bamboo_domain::SessionPermissionMode::Auto,
4515            PermissionMode::Default,
4516        );
4517        let patched = store
4518            .update_authoritative_permission_posture_and_publish(
4519                session_id,
4520                &PermissionAuditSeed::new(2, patched_resolution, "patch:auto"),
4521                |_| {},
4522                |_| {},
4523            )
4524            .await
4525            .unwrap()
4526            .unwrap();
4527        let patched_audit = PermissionAuditSnapshot::from_metadata(&patched.metadata).unwrap();
4528        assert!(patched_audit.audit_revision > dispatched_revision);
4529
4530        let stale_worker_seed = PermissionAuditSeed::new(
4531            1,
4532            bamboo_domain::resolve_permission_mode(
4533                bamboo_domain::SessionPermissionMode::Default,
4534                PermissionMode::Default,
4535            ),
4536            "worker:stale-default",
4537        );
4538        let error = store
4539            .record_permission_posture_activation_and_publish(
4540                session_id,
4541                Some(dispatched_revision),
4542                &stale_worker_seed,
4543                |_| {},
4544            )
4545            .await
4546            .unwrap_err();
4547        assert!(error.to_string().contains("durable audit changed"));
4548
4549        let durable = storage.load_session(session_id).await.unwrap().unwrap();
4550        let durable_audit = PermissionAuditSnapshot::from_metadata(&durable.metadata).unwrap();
4551        assert_eq!(durable_audit, patched_audit);
4552        assert_eq!(durable_audit.executor_mapping, "patch:auto");
4553    }
4554
4555    // ── update_runtime_config: config patches must never clobber messages ──
4556
4557    #[tokio::test]
4558    async fn update_runtime_config_preserves_concurrently_appended_messages() {
4559        use bamboo_domain::session::types::Message;
4560        use bamboo_domain::ReasoningEffort;
4561
4562        let (_temp, storage) = make_storage().await;
4563        let store = LockedSessionStore::new(storage.clone());
4564        let session_id = "cfg-preserve";
4565
4566        // Persisted baseline: one user + one assistant turn.
4567        let mut initial = fresh(session_id);
4568        initial.add_message(Message::user("hello"));
4569        initial.add_message(Message::assistant("hi", None));
4570        storage.save_session(&initial).await.unwrap();
4571
4572        // Simulate `POST /chat` appending a new user message to disk.
4573        let mut after_chat = storage.load_session(session_id).await.unwrap().unwrap();
4574        after_chat.add_message(Message::user("second question"));
4575        storage.save_session(&after_chat).await.unwrap();
4576        assert_eq!(after_chat.messages.len(), 3);
4577
4578        // A config-only patch must load the freshest session and preserve the
4579        // appended message (this is the regression that broke message sending on
4580        // existing sessions).
4581        let updated = store
4582            .update_runtime_config(session_id, |s| {
4583                s.reasoning_effort = Some(ReasoningEffort::Max);
4584            })
4585            .await
4586            .unwrap()
4587            .expect("session exists");
4588
4589        assert_eq!(updated.reasoning_effort, Some(ReasoningEffort::Max));
4590        assert_eq!(
4591            updated.messages.len(),
4592            3,
4593            "config patch must not revert a concurrently-appended message"
4594        );
4595
4596        let on_disk = storage.load_session(session_id).await.unwrap().unwrap();
4597        assert_eq!(on_disk.messages.len(), 3);
4598        assert_eq!(on_disk.reasoning_effort, Some(ReasoningEffort::Max));
4599    }
4600
4601    #[tokio::test]
4602    async fn update_runtime_config_returns_none_for_missing_session() {
4603        use bamboo_domain::ReasoningEffort;
4604
4605        let (_temp, storage) = make_storage().await;
4606        let store = LockedSessionStore::new(storage);
4607        let result = store
4608            .update_runtime_config("does-not-exist", |s| {
4609                s.reasoning_effort = Some(ReasoningEffort::Low);
4610            })
4611            .await
4612            .unwrap();
4613        assert!(result.is_none());
4614    }
4615
4616    #[tokio::test]
4617    async fn merge_save_runtime_overwrites_messages_from_stale_snapshot() {
4618        // Characterization of the bug that motivated `update_runtime_config`:
4619        // `merge_save_runtime` writes the caller's `messages` verbatim, so a
4620        // stale snapshot reverts a concurrent append. Config-only writers must
4621        // therefore use `update_runtime_config`, never `merge_save_runtime`.
4622        use bamboo_domain::session::types::Message;
4623
4624        let (_temp, storage) = make_storage().await;
4625        let store = LockedSessionStore::new(storage.clone());
4626        let session_id = "stale-clobber";
4627
4628        // A handler loads the session (1 message) …
4629        let mut baseline = fresh(session_id);
4630        baseline.add_message(Message::user("hello"));
4631        storage.save_session(&baseline).await.unwrap();
4632        let mut stale_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
4633
4634        // … then `POST /chat` appends a second message to disk …
4635        let mut after_chat = storage.load_session(session_id).await.unwrap().unwrap();
4636        after_chat.add_message(Message::user("second"));
4637        storage.save_session(&after_chat).await.unwrap();
4638        assert_eq!(
4639            storage
4640                .load_session(session_id)
4641                .await
4642                .unwrap()
4643                .unwrap()
4644                .messages
4645                .len(),
4646            2
4647        );
4648
4649        // … and the stale handler saves via merge_save_runtime -> append reverted.
4650        store.merge_save_runtime(&mut stale_snapshot).await.unwrap();
4651        let after = storage.load_session(session_id).await.unwrap().unwrap();
4652        assert_eq!(
4653            after.messages.len(),
4654            1,
4655            "merge_save_runtime clobbers concurrent appends — this is why config patches must use update_runtime_config"
4656        );
4657    }
4658
4659    #[tokio::test]
4660    async fn stale_runtime_save_cannot_remove_admitted_inbox_transcript() {
4661        use bamboo_domain::session::types::Message;
4662        use bamboo_domain::SessionMessageId;
4663
4664        let (_temp, storage) = make_storage().await;
4665        let store = LockedSessionStore::new(storage.clone());
4666        let session_id = "stale-inbox-preserve";
4667
4668        let mut baseline = fresh(session_id);
4669        let mut base = Message::user("base");
4670        base.id = "base".to_string();
4671        baseline.add_message(base);
4672        storage.save_session(&baseline).await.unwrap();
4673        let mut stale = baseline.clone();
4674        let mut later_assistant = Message::assistant("runner output", None);
4675        later_assistant.id = "later-assistant".to_string();
4676        stale.add_message(later_assistant);
4677
4678        let mut durable = baseline;
4679        let inbox_id = SessionMessageId::parse("durable-inbox-id").unwrap();
4680        let mut admitted = Message::user("durable inbox message");
4681        admitted.id = inbox_id.as_str().to_string();
4682        durable.add_message(admitted);
4683        durable
4684            .session_inbox_admission_mut()
4685            .record(inbox_id.clone(), 7);
4686        storage.save_session(&durable).await.unwrap();
4687
4688        store.merge_save_runtime(&mut stale).await.unwrap();
4689        let saved = storage.load_session(session_id).await.unwrap().unwrap();
4690        let ids = saved
4691            .messages
4692            .iter()
4693            .map(|message| message.id.as_str())
4694            .collect::<Vec<_>>();
4695        assert_eq!(ids, vec!["base", "durable-inbox-id", "later-assistant"]);
4696        assert_eq!(ids.iter().filter(|id| **id == inbox_id.as_str()).count(), 1);
4697        assert!(saved
4698            .session_inbox_admission()
4699            .is_some_and(|state| state.contains(&inbox_id)));
4700    }
4701
4702    #[tokio::test]
4703    async fn stale_runtime_save_preserves_typed_inbox_message_after_cursor_eviction() {
4704        use bamboo_domain::{
4705            SessionMessageEnvelope, SessionMessageId, SESSION_INBOX_ADMITTED_CAPACITY,
4706        };
4707
4708        let (_temp, storage) = make_storage().await;
4709        let store = LockedSessionStore::new(storage.clone());
4710        let session_id = "evicted-inbox-preserve";
4711        let mut durable = fresh(session_id);
4712        let mut envelope = SessionMessageEnvelope::user_input(session_id, "old durable inbox");
4713        envelope.id = SessionMessageId::parse("old-inbox-id").unwrap();
4714        durable.add_message(envelope.to_provider_message().unwrap());
4715        durable
4716            .session_inbox_admission_mut()
4717            .record(envelope.id.clone(), 1);
4718        for sequence in 2..=(SESSION_INBOX_ADMITTED_CAPACITY as u64 + 1) {
4719            durable.session_inbox_admission_mut().record(
4720                SessionMessageId::parse(format!("newer-{sequence}")).unwrap(),
4721                sequence,
4722            );
4723        }
4724        assert!(!durable
4725            .session_inbox_admission()
4726            .unwrap()
4727            .contains(&envelope.id));
4728        storage.save_session(&durable).await.unwrap();
4729
4730        let mut stale = fresh(session_id);
4731        stale.created_at = durable.created_at;
4732        store.merge_save_runtime(&mut stale).await.unwrap();
4733        let saved = storage.load_session(session_id).await.unwrap().unwrap();
4734        assert_eq!(
4735            saved
4736                .messages
4737                .iter()
4738                .filter(|message| message.id == envelope.id.as_str())
4739                .count(),
4740            1
4741        );
4742    }
4743
4744    #[tokio::test]
4745    async fn runtime_final_save_cannot_resurrect_a_consumed_clarification() {
4746        use bamboo_domain::session::types::Message;
4747
4748        let (_temp, storage) = make_storage().await;
4749        let store = LockedSessionStore::new(storage.clone());
4750        let session_id = "consumed-clarification-final-save";
4751
4752        let mut suspended = fresh(session_id);
4753        suspended.add_message(Message::tool_result(
4754            "call-1",
4755            r#"{"status":"awaiting_clarification"}"#,
4756        ));
4757        suspended.set_pending_question(
4758            "call-1".to_string(),
4759            "ConclusionWithOptions".to_string(),
4760            "Choose".to_string(),
4761            vec!["A".to_string()],
4762            false,
4763        );
4764        suspended.metadata.insert(
4765            "runtime.suspend_reason".to_string(),
4766            "awaiting_clarification".to_string(),
4767        );
4768        storage.save_session(&suspended).await.unwrap();
4769        let mut stale_runner = suspended.clone();
4770
4771        let mut answered = suspended;
4772        answered.clear_pending_question();
4773        answered.metadata.remove("runtime.suspend_reason");
4774        answered.metadata.insert(
4775            CONSUMED_CLARIFICATION_IDS_KEY.to_string(),
4776            r#"["call-1"]"#.to_string(),
4777        );
4778        answered.metadata.insert(
4779            "clarification_resume_pending".to_string(),
4780            "true".to_string(),
4781        );
4782        answered.metadata.insert(
4783            "conclusion_with_options_resume_pending".to_string(),
4784            "true".to_string(),
4785        );
4786        answered.metadata.insert(
4787            "execute.startup_handoff_at".to_string(),
4788            "2026-08-10T09:00:00.000Z".to_string(),
4789        );
4790        let answer = answered
4791            .messages
4792            .iter_mut()
4793            .find(|message| message.tool_call_id.as_deref() == Some("call-1"))
4794            .unwrap();
4795        answer.content = "Selected response: A".to_string();
4796        storage.save_session(&answered).await.unwrap();
4797
4798        store.merge_save_runtime(&mut stale_runner).await.unwrap();
4799
4800        let saved = storage.load_session(session_id).await.unwrap().unwrap();
4801        assert!(saved.pending_question.is_none());
4802        assert!(!saved.metadata.contains_key("runtime.suspend_reason"));
4803        assert_eq!(
4804            saved
4805                .metadata
4806                .get("clarification_resume_pending")
4807                .map(String::as_str),
4808            Some("true")
4809        );
4810        assert_eq!(
4811            saved
4812                .metadata
4813                .get("execute.startup_handoff_at")
4814                .map(String::as_str),
4815            Some("2026-08-10T09:00:00.000Z")
4816        );
4817        let answers = saved
4818            .messages
4819            .iter()
4820            .filter(|message| message.tool_call_id.as_deref() == Some("call-1"))
4821            .collect::<Vec<_>>();
4822        assert_eq!(answers.len(), 1);
4823        assert_eq!(answers[0].content, "Selected response: A");
4824    }
4825
4826    #[tokio::test]
4827    async fn actor_parent_question_handoff_adopts_canonical_question_and_discards_terminal_text() {
4828        use bamboo_domain::{
4829            FunctionCall, Message, ParentQuestion, PendingQuestionSource, ToolCall,
4830            PARENT_QUESTION_REQUEST_KEY,
4831        };
4832
4833        let (_temp, storage) = make_storage().await;
4834        let store = LockedSessionStore::new(storage.clone());
4835        let parent = fresh("question-handoff-parent");
4836        storage.save_session(&parent).await.unwrap();
4837        let mut child = Session::new_child_of("question-handoff-child", &parent, "model", "child");
4838        child.add_message(Message::user("assignment"));
4839        storage.save_session(&child).await.unwrap();
4840        let mut old_run = child.clone();
4841        old_run
4842            .metadata
4843            .insert(ACTOR_PARENT_QUESTION_HANDOFF_KEY.into(), "true".into());
4844        old_run.set_last_run_status("suspended");
4845        old_run.add_message(Message::assistant("old terminal text", None));
4846
4847        child.add_message(Message::assistant(
4848            "",
4849            Some(vec![ToolCall {
4850                id: "ask-call".into(),
4851                tool_type: "function".into(),
4852                function: FunctionCall {
4853                    name: "AskUserQuestion".into(),
4854                    arguments: "{}".into(),
4855                },
4856            }]),
4857        ));
4858        child.add_message(Message::tool_result_with_status(
4859            "ask-call",
4860            "Clarification needed: Choose?",
4861            true,
4862        ));
4863        child.set_pending_question_with_source(
4864            "ask-call".into(),
4865            "AskUserQuestion".into(),
4866            "Choose?".into(),
4867            vec!["A".into(), "B".into()],
4868            true,
4869            PendingQuestionSource::DirectParent,
4870        );
4871        child.metadata.insert(
4872            "runtime.suspend_reason".into(),
4873            "awaiting_clarification".into(),
4874        );
4875        let question = ParentQuestion::issue_at(&parent, &child, chrono::Utc::now()).unwrap();
4876        child.metadata.insert(
4877            PARENT_QUESTION_REQUEST_KEY.into(),
4878            serde_json::to_string(&question).unwrap(),
4879        );
4880        storage.save_session(&child).await.unwrap();
4881
4882        store.merge_save_runtime(&mut old_run).await.unwrap();
4883        let saved = storage.load_session(&child.id).await.unwrap().unwrap();
4884        assert_eq!(
4885            serde_json::to_value(&saved.messages).unwrap(),
4886            serde_json::to_value(&child.messages).unwrap()
4887        );
4888        assert_eq!(
4889            serde_json::to_value(&saved.pending_question).unwrap(),
4890            serde_json::to_value(&child.pending_question).unwrap()
4891        );
4892        assert_eq!(
4893            saved.metadata.get(PARENT_QUESTION_REQUEST_KEY),
4894            child.metadata.get(PARENT_QUESTION_REQUEST_KEY)
4895        );
4896        assert_eq!(saved.last_run_status().as_deref(), Some("suspended"));
4897        assert!(!saved
4898            .metadata
4899            .contains_key(ACTOR_PARENT_QUESTION_HANDOFF_KEY));
4900    }
4901
4902    #[tokio::test]
4903    async fn actor_parent_question_handoff_cannot_undo_fast_parent_answer() {
4904        use bamboo_domain::{
4905            FunctionCall, Message, ParentQuestion, ParentQuestionResolution, PendingQuestionSource,
4906            ToolCall, PARENT_QUESTION_REQUEST_KEY, PARENT_QUESTION_RESOLUTION_KEY,
4907        };
4908
4909        let (_temp, storage) = make_storage().await;
4910        let store = LockedSessionStore::new(storage.clone());
4911        let parent = fresh("answered-handoff-parent");
4912        storage.save_session(&parent).await.unwrap();
4913        let mut child = Session::new_child_of("answered-handoff-child", &parent, "model", "child");
4914        child.add_message(Message::user("assignment"));
4915        storage.save_session(&child).await.unwrap();
4916        let mut old_run = child.clone();
4917        old_run
4918            .metadata
4919            .insert(ACTOR_PARENT_QUESTION_HANDOFF_KEY.into(), "true".into());
4920        old_run.set_last_run_status("suspended");
4921
4922        child.add_message(Message::assistant(
4923            "",
4924            Some(vec![ToolCall {
4925                id: "ask-call".into(),
4926                tool_type: "function".into(),
4927                function: FunctionCall {
4928                    name: "AskUserQuestion".into(),
4929                    arguments: "{}".into(),
4930                },
4931            }]),
4932        ));
4933        child.add_message(Message::tool_result_with_status(
4934            "ask-call",
4935            "Clarification needed: Choose?",
4936            true,
4937        ));
4938        child.set_pending_question_with_source(
4939            "ask-call".into(),
4940            "AskUserQuestion".into(),
4941            "Choose?".into(),
4942            vec!["A".into(), "B".into()],
4943            true,
4944            PendingQuestionSource::DirectParent,
4945        );
4946        child.metadata.insert(
4947            "runtime.suspend_reason".into(),
4948            "awaiting_clarification".into(),
4949        );
4950        let issued_at = chrono::Utc::now();
4951        let question = ParentQuestion::issue_at(&parent, &child, issued_at).unwrap();
4952        child.metadata.insert(
4953            PARENT_QUESTION_REQUEST_KEY.into(),
4954            serde_json::to_string(&question).unwrap(),
4955        );
4956        storage.save_session(&child).await.unwrap();
4957
4958        let mut answered = child;
4959        answered.clear_pending_question();
4960        answered.metadata.remove("runtime.suspend_reason");
4961        let paired = answered
4962            .messages
4963            .iter_mut()
4964            .find(|message| message.id == question.tool_result_message_id)
4965            .unwrap();
4966        paired.content = "A".into();
4967        paired.tool_success = Some(true);
4968        let resolution = ParentQuestionResolution::answered(
4969            &question,
4970            issued_at + chrono::Duration::milliseconds(1),
4971            "A",
4972        )
4973        .unwrap();
4974        answered.metadata.insert(
4975            PARENT_QUESTION_RESOLUTION_KEY.into(),
4976            serde_json::to_string(&resolution).unwrap(),
4977        );
4978        let source_key = "runtime.child_completion_source_v1";
4979        // Storage preserves opaque source bytes; only the engine validates them.
4980        answered
4981            .metadata
4982            .insert(source_key.into(), "new-run-terminal-source".into());
4983        storage.save_session(&answered).await.unwrap();
4984
4985        store.merge_save_runtime(&mut old_run).await.unwrap();
4986        let saved = storage.load_session(&answered.id).await.unwrap().unwrap();
4987        assert_eq!(
4988            saved.metadata.get(source_key),
4989            answered.metadata.get(source_key),
4990            "an old question handoff must preserve the source of the adopted canonical transcript"
4991        );
4992        assert!(saved.pending_question.is_none());
4993        assert!(!saved.metadata.contains_key("runtime.suspend_reason"));
4994        assert_eq!(
4995            saved.metadata.get(PARENT_QUESTION_RESOLUTION_KEY),
4996            answered.metadata.get(PARENT_QUESTION_RESOLUTION_KEY)
4997        );
4998        assert_eq!(
4999            serde_json::to_value(&saved.messages).unwrap(),
5000            serde_json::to_value(&answered.messages).unwrap()
5001        );
5002        assert_eq!(saved.last_run_status().as_deref(), Some("suspended"));
5003        assert!(!saved
5004            .metadata
5005            .contains_key(ACTOR_PARENT_QUESTION_HANDOFF_KEY));
5006    }
5007
5008    #[tokio::test]
5009    async fn runtime_checkpoint_cannot_resurrect_a_consumed_clarification() {
5010        use bamboo_domain::session::types::Message;
5011
5012        let (_temp, storage) = make_storage().await;
5013        let store = LockedSessionStore::new(storage.clone());
5014        let session_id = "consumed-clarification-checkpoint";
5015        let mut stale_runner = fresh(session_id);
5016        stale_runner.add_message(Message::tool_result("call-1", "waiting"));
5017        stale_runner.set_pending_question(
5018            "call-1".to_string(),
5019            "ConclusionWithOptions".to_string(),
5020            "Choose".to_string(),
5021            vec!["A".to_string()],
5022            false,
5023        );
5024        stale_runner.metadata.insert(
5025            "runtime.suspend_reason".to_string(),
5026            "awaiting_clarification".to_string(),
5027        );
5028
5029        let mut answered = stale_runner.clone();
5030        answered.clear_pending_question();
5031        answered.metadata.remove("runtime.suspend_reason");
5032        answered.metadata.insert(
5033            CONSUMED_CLARIFICATION_IDS_KEY.to_string(),
5034            r#"["call-1"]"#.to_string(),
5035        );
5036        answered.metadata.insert(
5037            "clarification_resume_pending".to_string(),
5038            "true".to_string(),
5039        );
5040        answered.messages[0].content = "Selected response: A".to_string();
5041        storage.save_session(&answered).await.unwrap();
5042
5043        store
5044            .checkpoint_runtime_session(&mut stale_runner)
5045            .await
5046            .unwrap();
5047
5048        let saved = storage.load_session(session_id).await.unwrap().unwrap();
5049        assert!(saved.pending_question.is_none());
5050        assert!(!saved.metadata.contains_key("runtime.suspend_reason"));
5051        assert_eq!(saved.messages.len(), 1);
5052        assert_eq!(saved.messages[0].content, "Selected response: A");
5053    }
5054
5055    #[tokio::test]
5056    async fn runtime_final_save_does_not_consume_a_new_reused_permission_occurrence() {
5057        let (_temp, storage) = make_storage().await;
5058        let store = LockedSessionStore::new(storage.clone());
5059        let session_id = "reused-permission-final-save";
5060
5061        let mut durable = fresh(session_id);
5062        durable.add_message(typed_permission_result(
5063            "reused-call",
5064            "old-result",
5065            "generation-old",
5066            "Selected response: Approve",
5067        ));
5068        durable.metadata.insert(
5069            CONSUMED_RESPONSE_OCCURRENCES_KEY.to_string(),
5070            serde_json::to_string(&vec![ResponseOccurrence {
5071                tool_call_id: "reused-call".to_string(),
5072                tool_result_message_id: "old-result".to_string(),
5073                permission_generation: Some("generation-old".to_string()),
5074            }])
5075            .unwrap(),
5076        );
5077        storage.save_session(&durable).await.unwrap();
5078
5079        let mut new_runner = durable;
5080        new_runner.add_message(typed_permission_result(
5081            "reused-call",
5082            "new-result",
5083            "generation-new",
5084            "waiting for the new decision",
5085        ));
5086        new_runner.set_pending_question(
5087            "reused-call".to_string(),
5088            "Permission".to_string(),
5089            "Approve new operation?".to_string(),
5090            vec!["Approve".to_string(), "Deny".to_string()],
5091            false,
5092        );
5093        new_runner.metadata.insert(
5094            "runtime.suspend_reason".to_string(),
5095            "awaiting_permission_approval".to_string(),
5096        );
5097
5098        store.merge_save_runtime(&mut new_runner).await.unwrap();
5099
5100        let saved = storage.load_session(session_id).await.unwrap().unwrap();
5101        assert_eq!(
5102            saved
5103                .pending_question
5104                .as_ref()
5105                .map(|pending| pending.tool_call_id.as_str()),
5106            Some("reused-call")
5107        );
5108        assert_eq!(
5109            saved.messages.last().map(|message| message.id.as_str()),
5110            Some("new-result")
5111        );
5112        assert_eq!(
5113            saved.messages.last().unwrap().content,
5114            "waiting for the new decision"
5115        );
5116        assert_eq!(
5117            latest_response_occurrence(&saved, "reused-call")
5118                .and_then(|occurrence| occurrence.permission_generation),
5119            Some("generation-new".to_string())
5120        );
5121    }
5122
5123    #[tokio::test]
5124    async fn legacy_consumed_id_does_not_consume_a_new_reused_occurrence_after_upgrade() {
5125        let (_temp, storage) = make_storage().await;
5126        let store = LockedSessionStore::new(storage.clone());
5127        let session_id = "legacy-reused-permission-final-save";
5128
5129        let mut durable = fresh(session_id);
5130        durable.add_message(typed_permission_result(
5131            "reused-call",
5132            "old-result",
5133            "generation-old",
5134            "Selected response: Approve",
5135        ));
5136        durable.metadata.insert(
5137            CONSUMED_CLARIFICATION_IDS_KEY.to_string(),
5138            r#"["reused-call"]"#.to_string(),
5139        );
5140        storage.save_session(&durable).await.unwrap();
5141
5142        let mut new_runner = durable;
5143        new_runner.add_message(typed_permission_result(
5144            "reused-call",
5145            "new-result",
5146            "generation-new",
5147            "waiting for the new decision",
5148        ));
5149        new_runner.set_pending_question(
5150            "reused-call".to_string(),
5151            "Permission".to_string(),
5152            "Approve new operation?".to_string(),
5153            vec!["Approve".to_string(), "Deny".to_string()],
5154            false,
5155        );
5156
5157        store.merge_save_runtime(&mut new_runner).await.unwrap();
5158
5159        let saved = storage.load_session(session_id).await.unwrap().unwrap();
5160        assert_eq!(
5161            saved
5162                .pending_question
5163                .as_ref()
5164                .map(|pending| pending.tool_call_id.as_str()),
5165            Some("reused-call")
5166        );
5167        assert_eq!(
5168            saved.messages.last().map(|message| message.id.as_str()),
5169            Some("new-result")
5170        );
5171        assert_eq!(
5172            latest_response_occurrence(&saved, "reused-call")
5173                .and_then(|occurrence| occurrence.permission_generation),
5174            Some("generation-new".to_string())
5175        );
5176    }
5177
5178    #[tokio::test]
5179    async fn runtime_checkpoint_does_not_consume_a_new_reused_permission_occurrence() {
5180        let (_temp, storage) = make_storage().await;
5181        let store = LockedSessionStore::new(storage.clone());
5182        let session_id = "reused-permission-checkpoint";
5183
5184        let mut durable = fresh(session_id);
5185        durable.add_message(typed_permission_result(
5186            "reused-call",
5187            "old-result",
5188            "generation-old",
5189            "Selected response: Deny",
5190        ));
5191        durable.metadata.insert(
5192            CONSUMED_RESPONSE_OCCURRENCES_KEY.to_string(),
5193            serde_json::to_string(&vec![ResponseOccurrence {
5194                tool_call_id: "reused-call".to_string(),
5195                tool_result_message_id: "old-result".to_string(),
5196                permission_generation: Some("generation-old".to_string()),
5197            }])
5198            .unwrap(),
5199        );
5200        storage.save_session(&durable).await.unwrap();
5201
5202        let mut new_runner = durable;
5203        new_runner.add_message(typed_permission_result(
5204            "reused-call",
5205            "new-result",
5206            "generation-new",
5207            "waiting for the new decision",
5208        ));
5209        new_runner.set_pending_question(
5210            "reused-call".to_string(),
5211            "Permission".to_string(),
5212            "Approve new operation?".to_string(),
5213            vec!["Approve".to_string(), "Deny".to_string()],
5214            false,
5215        );
5216
5217        store
5218            .checkpoint_runtime_session(&mut new_runner)
5219            .await
5220            .unwrap();
5221
5222        let saved = storage.load_session(session_id).await.unwrap().unwrap();
5223        assert_eq!(
5224            saved
5225                .pending_question
5226                .as_ref()
5227                .map(|pending| pending.tool_call_id.as_str()),
5228            Some("reused-call")
5229        );
5230        assert_eq!(
5231            saved.messages.last().map(|message| message.id.as_str()),
5232            Some("new-result")
5233        );
5234        assert_eq!(
5235            latest_response_occurrence(&saved, "reused-call")
5236                .and_then(|occurrence| occurrence.permission_generation),
5237            Some("generation-new".to_string())
5238        );
5239    }
5240
5241    #[tokio::test]
5242    async fn consumed_permission_adoption_keeps_reexecute_id_and_generation_paired() {
5243        let (_temp, storage) = make_storage().await;
5244        let store = LockedSessionStore::new(storage.clone());
5245        let session_id = "consumed-permission-control-pair";
5246
5247        let mut stale_runner = fresh(session_id);
5248        stale_runner.add_message(typed_permission_result(
5249            "call-1",
5250            "result-1",
5251            "generation-1",
5252            "waiting",
5253        ));
5254        stale_runner.set_pending_question(
5255            "call-1".to_string(),
5256            "Permission".to_string(),
5257            "Approve?".to_string(),
5258            vec!["Approve".to_string(), "Deny".to_string()],
5259            false,
5260        );
5261
5262        let mut answered = stale_runner.clone();
5263        answered.clear_pending_question();
5264        answered.messages[0].content = "Selected response: Approve".to_string();
5265        answered.metadata.insert(
5266            CONSUMED_RESPONSE_OCCURRENCES_KEY.to_string(),
5267            serde_json::to_string(&vec![ResponseOccurrence {
5268                tool_call_id: "call-1".to_string(),
5269                tool_result_message_id: "result-1".to_string(),
5270                permission_generation: Some("generation-1".to_string()),
5271            }])
5272            .unwrap(),
5273        );
5274        answered.metadata.insert(
5275            "permission.reexecute_tool_call_id".to_string(),
5276            "call-1".to_string(),
5277        );
5278        answered.metadata.insert(
5279            "permission.reexecute_request_generation".to_string(),
5280            "generation-1".to_string(),
5281        );
5282        storage.save_session(&answered).await.unwrap();
5283
5284        store.merge_save_runtime(&mut stale_runner).await.unwrap();
5285
5286        let saved = storage.load_session(session_id).await.unwrap().unwrap();
5287        assert!(saved.pending_question.is_none());
5288        assert_eq!(
5289            saved
5290                .metadata
5291                .get("permission.reexecute_tool_call_id")
5292                .map(String::as_str),
5293            Some("call-1")
5294        );
5295        assert_eq!(
5296            saved
5297                .metadata
5298                .get("permission.reexecute_request_generation")
5299                .map(String::as_str),
5300            Some("generation-1")
5301        );
5302    }
5303
5304    #[tokio::test]
5305    async fn checkpoint_runtime_session_preserves_disk_suffix_and_appends_live_messages() {
5306        use bamboo_domain::session::types::Message;
5307
5308        let (_temp, storage) = make_storage().await;
5309        let store = LockedSessionStore::new(storage.clone());
5310        let session_id = "checkpoint-no-shrink";
5311
5312        let mut baseline = fresh(session_id);
5313        baseline.add_message(Message::user("base"));
5314        storage.save_session(&baseline).await.unwrap();
5315        let mut runner_snapshot = baseline.clone();
5316
5317        let mut durable = baseline;
5318        let mut disk_only = Message::user("concurrent injected message");
5319        disk_only.id = "disk-only".to_string();
5320        durable.add_message(disk_only);
5321        storage.save_session(&durable).await.unwrap();
5322
5323        let mut live_only = Message::assistant("partial runner output", None);
5324        live_only.id = "live-only".to_string();
5325        runner_snapshot.add_message(live_only);
5326
5327        store
5328            .checkpoint_runtime_session(&mut runner_snapshot)
5329            .await
5330            .unwrap();
5331
5332        let saved = storage.load_session(session_id).await.unwrap().unwrap();
5333        let ids = saved
5334            .messages
5335            .iter()
5336            .map(|message| message.id.as_str())
5337            .collect::<Vec<_>>();
5338        assert_eq!(
5339            ids,
5340            vec![durable.messages[0].id.as_str(), "disk-only", "live-only"]
5341        );
5342        assert_eq!(runner_snapshot.messages.len(), saved.messages.len());
5343        assert_eq!(runner_snapshot.messages[1].id, saved.messages[1].id);
5344        assert_eq!(runner_snapshot.messages[2].id, saved.messages[2].id);
5345        assert_eq!(saved.messages[1].content, "concurrent injected message");
5346        assert_eq!(saved.messages[2].content, "partial runner output");
5347    }
5348
5349    fn direct_parent_proof_pair(
5350        parent: &Session,
5351    ) -> (
5352        bamboo_domain::SessionMessageEnvelope,
5353        bamboo_domain::SessionMessageEnvelope,
5354    ) {
5355        use bamboo_domain::{
5356            ParentRequest, ParentRequestActivation, ParentRequestActor, ParentRequestDelegation,
5357            ParentRequestKind, ParentRequestOption, ParentResolution, SessionMessageBody,
5358            SessionMessageContent, SessionMessageEnvelope, SessionMessageId, SessionMessageKind,
5359            SessionMessageSource, SessionProviderMessage, SessionRuntimeInstruction,
5360            PARENT_REQUEST_VERSION,
5361        };
5362        use serde_json::json;
5363
5364        let generation = uuid::Uuid::new_v4().to_string();
5365        let child_birth = parent.created_at + chrono::Duration::milliseconds(1);
5366        let created_at = child_birth;
5367        let deadline = created_at + chrono::Duration::seconds(120);
5368        let activation = ParentRequestActivation {
5369            host_scope: uuid::Uuid::new_v4().to_string(),
5370            attempt: 1,
5371            run: "run-1".into(),
5372            epoch: 1,
5373            reply: "reply-1".into(),
5374        };
5375        let request_id = SessionMessageId::stable(
5376            "direct-parent-forced-approval-v1",
5377            &json!({
5378                "child": "approval-child",
5379                "birth": child_birth,
5380                "parent": parent.id,
5381                "parent_birth": parent.created_at,
5382                "generation": generation,
5383            }),
5384        );
5385        let typed = ParentRequest {
5386            version: PARENT_REQUEST_VERSION,
5387            id: request_id.clone(),
5388            generation: generation.clone(),
5389            child: ParentRequestActor {
5390                session_id: "approval-child".into(),
5391                created_at: child_birth,
5392            },
5393            parent: ParentRequestActor {
5394                session_id: parent.id.clone(),
5395                created_at: parent.created_at,
5396            },
5397            root_session_id: parent.root_session_id.clone(),
5398            project_id: parent.project_id_meta(),
5399            activation: activation.clone(),
5400            deadline,
5401            kind: ParentRequestKind::ForcedPermission {
5402                operation_digest: format!("stable-{}", "a".repeat(64)),
5403                policy_revision: 1,
5404                maximum_delegation: ParentRequestDelegation::ExactOperationOnce,
5405                options: vec![ParentRequestOption::Deny, ParentRequestOption::ApproveOnce],
5406            },
5407        };
5408        let live = json!({
5409            "host_scope": activation.host_scope,
5410            "attempt": activation.attempt,
5411            "run": activation.run,
5412            "epoch": activation.epoch,
5413            "reply": activation.reply,
5414            "deadline": deadline,
5415        });
5416        let request_text = "Parent permission request";
5417        let request = SessionMessageEnvelope {
5418            id: request_id.clone(),
5419            source: SessionMessageSource::Runtime {
5420                subsystem: "direct_parent_permission_review".into(),
5421            },
5422            target_session_id: parent.id.clone(),
5423            kind: SessionMessageKind::RuntimeInstruction,
5424            body: SessionMessageBody::RuntimeInstruction(SessionRuntimeInstruction {
5425                instruction: "direct_parent_forced_permission_request_v1".into(),
5426                content: Some(SessionMessageContent::text(request_text)),
5427                data: Some(json!({
5428                    "parent_request": typed,
5429                    "parent_created_at": parent.created_at,
5430                    "child_session_id": "approval-child",
5431                    "child_created_at": child_birth,
5432                    "root_session_id": parent.root_session_id,
5433                    "project_id": parent.project_id_meta(),
5434                    "request_generation": generation,
5435                    "operation_digest": format!("stable-{}", "a".repeat(64)),
5436                    "policy_revision": 1,
5437                    "live": live,
5438                })),
5439                provider_message: Some(SessionProviderMessage {
5440                    content: SessionMessageContent::text(request_text),
5441                    metadata: Default::default(),
5442                    never_compress: true,
5443                }),
5444            }),
5445            created_at,
5446            thread_id: None,
5447            in_reply_to: None,
5448            attempt: None,
5449            correlation_id: None,
5450        };
5451        assert!(ParentRequest::from_forced_permission_envelope(&request).is_some());
5452        let resolved_at = created_at + chrono::Duration::seconds(1);
5453        let resolution = ParentResolution::for_forced_permission(&typed, resolved_at, false);
5454        let terminal_text =
5455            "Live parent permission review recorded Denied. This record is not a permission grant.";
5456        let mut terminal = request.clone();
5457        terminal.id = resolution.id.clone();
5458        terminal.created_at = resolved_at;
5459        terminal.in_reply_to = Some(request_id.clone());
5460        terminal.body = SessionMessageBody::RuntimeInstruction(SessionRuntimeInstruction {
5461            instruction: "direct_parent_forced_permission_terminal_v1".into(),
5462            content: Some(SessionMessageContent::text(terminal_text)),
5463            data: Some(json!({
5464                "request": request_id,
5465                "live": live,
5466                "approved": false,
5467                "parent_resolution": resolution,
5468            })),
5469            provider_message: Some(SessionProviderMessage {
5470                content: SessionMessageContent::text(terminal_text),
5471                metadata: Default::default(),
5472                never_compress: true,
5473            }),
5474        });
5475        assert!(ParentResolution::from_forced_permission_terminal(&request, &terminal).is_some());
5476        (request, terminal)
5477    }
5478
5479    #[tokio::test]
5480    async fn checkpoint_rebases_direct_parent_terminal_committed_by_another_store() {
5481        let home = tempfile::tempdir().unwrap();
5482        let first = Arc::new(SessionStoreV2::new(home.path().into()).await.unwrap());
5483        let mut parent = fresh("direct-parent-terminal-race");
5484        let (request, terminal) = direct_parent_proof_pair(&parent);
5485        parent.add_message(request.to_provider_message().unwrap());
5486        first.save_session(&parent).await.unwrap();
5487        let second = SessionStoreV2::new(home.path().into()).await.unwrap();
5488
5489        let paused = Arc::new(AuthoritySavePauseStorage {
5490            inner: first.clone(),
5491            calls: AtomicUsize::new(0),
5492            reached: tokio::sync::Barrier::new(2),
5493            release: tokio::sync::Barrier::new(2),
5494        });
5495        let checkpoint_store = LockedSessionStore::new(paused.clone());
5496        let mut stale = parent.clone();
5497        stale.add_message(bamboo_domain::Message::assistant("runner output", None));
5498        let checkpoint = tokio::spawn(async move {
5499            checkpoint_store
5500                .checkpoint_runtime_session(&mut stale)
5501                .await?;
5502            Ok::<_, std::io::Error>(stale)
5503        });
5504
5505        // The first Store has already loaded the pending request and is now
5506        // paused before its V2 file lock. Commit the decision through a second
5507        // Store, whose in-memory session lock is independent.
5508        paused.reached.wait().await;
5509        let mut decided = second.load_session(&parent.id).await.unwrap().unwrap();
5510        decided.add_message(terminal.to_provider_message().unwrap());
5511        second.save_session(&decided).await.unwrap();
5512
5513        let stale_write = first.save_session(&parent).await.unwrap_err();
5514        assert!(is_direct_parent_terminal_conflict(&stale_write));
5515        assert!(!may_publish_runtime_result(&Err(stale_write)));
5516        let mut forged = decided.clone();
5517        forged.messages.last_mut().unwrap().content = "forged decision".into();
5518        let forged_write = second.save_session(&forged).await.unwrap_err();
5519        assert_eq!(forged_write.kind(), std::io::ErrorKind::InvalidData);
5520        assert!(!may_publish_runtime_result(&Err(forged_write)));
5521
5522        paused.release.wait().await;
5523        paused.reached.wait().await;
5524        paused.release.wait().await;
5525        let reconciled = checkpoint.await.unwrap().unwrap();
5526        let saved = second.load_session(&parent.id).await.unwrap().unwrap();
5527        assert_eq!(paused.calls.load(Ordering::SeqCst), 2);
5528        assert_eq!(saved.messages.len(), 3);
5529        assert_eq!(saved.messages[0].id, request.id.as_str());
5530        assert_eq!(saved.messages[1].id, terminal.id.as_str());
5531        assert_eq!(saved.messages[2].content, "runner output");
5532        assert_eq!(reconciled.messages[1].id, terminal.id.as_str());
5533    }
5534
5535    #[tokio::test]
5536    async fn prompt_rewrite_checkpoint_commits_without_an_archive_event() {
5537        use bamboo_domain::session::types::Message;
5538
5539        let (_temp, storage) = make_storage().await;
5540        let store = LockedSessionStore::new(storage.clone());
5541        let session_id = "prompt-rewrite-checkpoint";
5542        let mut expected = fresh(session_id);
5543        expected.add_message(Message::system("Base\n\nDEGRADABLE TOOL GUIDE"));
5544        expected.add_message(Message::user("continue"));
5545        expected.metadata.insert(
5546            "responses.previous_response_id".to_string(),
5547            "response-before-rewrite".to_string(),
5548        );
5549        storage.save_session(&expected).await.unwrap();
5550
5551        let mut staged = expected.clone();
5552        staged.messages[0].content = "Base".to_string();
5553        staged.metadata.remove("responses.previous_response_id");
5554        staged.reset_model_context_epoch(
5555            bamboo_domain::ModelContextResetReason::ExplicitHistoryRewrite,
5556        );
5557
5558        let outcome = store
5559            .checkpoint_prompt_rewrite_and_publish(&expected, &mut staged, |_| {})
5560            .await
5561            .unwrap();
5562
5563        assert_eq!(outcome, RetrievalWindowCheckpointOutcome::Committed);
5564        let saved = storage.load_session(session_id).await.unwrap().unwrap();
5565        assert_eq!(saved.messages[0].content, "Base");
5566        assert!(saved.compression_events.is_empty());
5567        assert!(!saved
5568            .metadata
5569            .contains_key("responses.previous_response_id"));
5570        assert_eq!(
5571            saved
5572                .model_context_state
5573                .as_ref()
5574                .and_then(|state| state.last_reset_reason),
5575            Some(bamboo_domain::ModelContextResetReason::ExplicitHistoryRewrite)
5576        );
5577        assert_eq!(
5578            serde_json::to_value(&staged).unwrap(),
5579            serde_json::to_value(&saved).unwrap()
5580        );
5581    }
5582
5583    #[tokio::test]
5584    async fn prompt_rewrite_checkpoint_rebases_a_concurrent_suffix_without_writing() {
5585        use bamboo_domain::session::types::Message;
5586
5587        let (_temp, storage) = make_storage().await;
5588        let store = LockedSessionStore::new(storage.clone());
5589        let session_id = "prompt-rewrite-rebase";
5590        let mut expected = fresh(session_id);
5591        expected.add_message(Message::system("Base\n\nDEGRADABLE TOOL GUIDE"));
5592        storage.save_session(&expected).await.unwrap();
5593
5594        let mut staged = expected.clone();
5595        staged.messages[0].content = "Base".to_string();
5596        staged.reset_model_context_epoch(
5597            bamboo_domain::ModelContextResetReason::ExplicitHistoryRewrite,
5598        );
5599
5600        let mut durable = expected.clone();
5601        let mut concurrent = Message::user("concurrent durable suffix");
5602        concurrent.id = "prompt-rewrite-concurrent-suffix".to_string();
5603        durable.add_message(concurrent);
5604        storage.save_session(&durable).await.unwrap();
5605
5606        let outcome = store
5607            .checkpoint_prompt_rewrite_and_publish(&expected, &mut staged, |_| {})
5608            .await
5609            .unwrap();
5610
5611        assert_eq!(outcome, RetrievalWindowCheckpointOutcome::Rebased);
5612        let saved = storage.load_session(session_id).await.unwrap().unwrap();
5613        assert_eq!(
5614            serde_json::to_value(&saved).unwrap(),
5615            serde_json::to_value(&durable).unwrap()
5616        );
5617        assert_eq!(staged.messages[0].content, expected.messages[0].content);
5618        assert_eq!(staged.messages[1].id, "prompt-rewrite-concurrent-suffix");
5619        assert!(staged.model_context_state.is_none());
5620    }
5621
5622    #[tokio::test]
5623    async fn prompt_rewrite_checkpoint_rebases_a_concurrent_pending_injection() {
5624        use bamboo_domain::session::types::Message;
5625
5626        let (_temp, storage) = make_storage().await;
5627        let store = LockedSessionStore::new(storage.clone());
5628        let session_id = "prompt-rewrite-pending-injection";
5629        let mut expected = fresh(session_id);
5630        expected.add_message(Message::system("Base\n\nDEGRADABLE TOOL GUIDE"));
5631        storage.save_session(&expected).await.unwrap();
5632
5633        let mut staged = expected.clone();
5634        staged.messages[0].content = "Base".to_string();
5635        staged.reset_model_context_epoch(
5636            bamboo_domain::ModelContextResetReason::ExplicitHistoryRewrite,
5637        );
5638
5639        let mut durable = expected.clone();
5640        durable.set_pending_injected_messages(vec![serde_json::json!({
5641            "content": "background shell completed",
5642        })]);
5643        storage.save_session(&durable).await.unwrap();
5644
5645        let outcome = store
5646            .checkpoint_prompt_rewrite_and_publish(&expected, &mut staged, |_| {})
5647            .await
5648            .unwrap();
5649
5650        assert_eq!(outcome, RetrievalWindowCheckpointOutcome::Rebased);
5651        let saved = storage.load_session(session_id).await.unwrap().unwrap();
5652        assert_eq!(
5653            serde_json::to_value(&saved).unwrap(),
5654            serde_json::to_value(&durable).unwrap()
5655        );
5656        assert_eq!(staged.messages[0].content, expected.messages[0].content);
5657        assert_eq!(
5658            staged.pending_injected_messages(),
5659            durable.pending_injected_messages()
5660        );
5661        assert!(staged.model_context_state.is_none());
5662    }
5663
5664    #[tokio::test]
5665    async fn prompt_rewrite_checkpoint_rebases_concurrent_open_runtime_metadata() {
5666        use bamboo_domain::session::types::Message;
5667
5668        let (_temp, storage) = make_storage().await;
5669        let store = LockedSessionStore::new(storage.clone());
5670        let session_id = "prompt-rewrite-open-runtime-metadata";
5671        let mut expected = fresh(session_id);
5672        expected.add_message(Message::system("Base\n\nDEGRADABLE TOOL GUIDE"));
5673        storage.save_session(&expected).await.unwrap();
5674
5675        let mut staged = expected.clone();
5676        staged.messages[0].content = "Base".to_string();
5677        staged.reset_model_context_epoch(
5678            bamboo_domain::ModelContextResetReason::ExplicitHistoryRewrite,
5679        );
5680
5681        let mut durable = expected.clone();
5682        durable.metadata.insert(
5683            "concurrent.runtime.marker".to_string(),
5684            "latest".to_string(),
5685        );
5686        storage.save_session(&durable).await.unwrap();
5687
5688        let outcome = store
5689            .checkpoint_prompt_rewrite_and_publish(&expected, &mut staged, |_| {})
5690            .await
5691            .unwrap();
5692
5693        assert_eq!(outcome, RetrievalWindowCheckpointOutcome::Rebased);
5694        assert_eq!(
5695            staged
5696                .metadata
5697                .get("concurrent.runtime.marker")
5698                .map(String::as_str),
5699            Some("latest")
5700        );
5701        assert_eq!(staged.messages[0].content, expected.messages[0].content);
5702        assert!(staged.model_context_state.is_none());
5703        let saved = storage.load_session(session_id).await.unwrap().unwrap();
5704        assert_eq!(
5705            serde_json::to_value(&saved).unwrap(),
5706            serde_json::to_value(&durable).unwrap()
5707        );
5708    }
5709
5710    #[tokio::test]
5711    async fn prompt_rewrite_checkpoint_rebases_concurrent_reasoning_update() {
5712        use bamboo_domain::{reasoning::ReasoningEffort, session::types::Message};
5713
5714        let (_temp, storage) = make_storage().await;
5715        let store = LockedSessionStore::new(storage.clone());
5716        let session_id = "prompt-rewrite-reasoning-update";
5717        let mut expected = fresh(session_id);
5718        expected.add_message(Message::system("Base\n\nDEGRADABLE TOOL GUIDE"));
5719        storage.save_session(&expected).await.unwrap();
5720
5721        let mut staged = expected.clone();
5722        staged.messages[0].content = "Base".to_string();
5723        staged.reset_model_context_epoch(
5724            bamboo_domain::ModelContextResetReason::ExplicitHistoryRewrite,
5725        );
5726
5727        let mut durable = expected.clone();
5728        durable.reasoning_effort = Some(ReasoningEffort::High);
5729        durable.metadata_version = 1;
5730        storage.save_session(&durable).await.unwrap();
5731
5732        let outcome = store
5733            .checkpoint_prompt_rewrite_and_publish(&expected, &mut staged, |_| {})
5734            .await
5735            .unwrap();
5736
5737        assert_eq!(outcome, RetrievalWindowCheckpointOutcome::Rebased);
5738        assert_eq!(staged.reasoning_effort, Some(ReasoningEffort::High));
5739        assert_eq!(staged.metadata_version, 1);
5740        assert_eq!(staged.messages[0].content, expected.messages[0].content);
5741        assert!(staged.model_context_state.is_none());
5742        let saved = storage.load_session(session_id).await.unwrap().unwrap();
5743        assert_eq!(
5744            serde_json::to_value(&saved).unwrap(),
5745            serde_json::to_value(&durable).unwrap()
5746        );
5747    }
5748
5749    #[tokio::test]
5750    async fn manual_archive_rejection_checkpoint_persists_the_rewritten_tool_result() {
5751        use bamboo_domain::{FunctionCall, Message, ToolCall};
5752
5753        let (_temp, storage) = make_storage().await;
5754        let store = LockedSessionStore::new(storage.clone());
5755        let session_id = "manual-archive-rejection-checkpoint";
5756        let mut expected = fresh(session_id);
5757        expected.add_message(Message::user("archive older context"));
5758        let mut assistant = Message::assistant("", None);
5759        assistant.tool_calls = Some(vec![ToolCall {
5760            id: "archive-call".to_string(),
5761            tool_type: "function".to_string(),
5762            function: FunctionCall {
5763                name: "archive_context".to_string(),
5764                arguments: "{}".to_string(),
5765            },
5766        }]);
5767        expected.add_message(assistant);
5768        let mut result = Message::tool_result("archive-call", "Retrieval-window archive requested");
5769        result.id = "archive-result".to_string();
5770        result.tool_success = Some(true);
5771        expected.add_message(result);
5772        expected.metadata.insert(
5773            RESPONSES_PREVIOUS_RESPONSE_ID_KEY.to_string(),
5774            "response-before-rejection".to_string(),
5775        );
5776        storage.save_session(&expected).await.unwrap();
5777
5778        let occurrence = ResponseOccurrence {
5779            tool_call_id: "archive-call".to_string(),
5780            tool_result_message_id: "archive-result".to_string(),
5781            permission_generation: None,
5782        };
5783        let reason = "no eligible active logical group can be archived";
5784        let stage_rejection = |base: &Session| {
5785            let mut staged = base.clone();
5786            let staged_result = staged
5787                .messages
5788                .iter_mut()
5789                .find(|message| message.id == "archive-result")
5790                .unwrap();
5791            staged_result.tool_success = Some(false);
5792            staged_result.content = format!("archive_context rejected: {reason}");
5793            staged.metadata.insert(
5794                LAST_MANUAL_ARCHIVE_OCCURRENCE_KEY.to_string(),
5795                serde_json::to_string(&occurrence).unwrap(),
5796            );
5797            staged.metadata.insert(
5798                MANUAL_ARCHIVE_REJECTIONS_KEY.to_string(),
5799                serde_json::to_string(&vec![serde_json::json!({
5800                    "occurrence": occurrence.clone(),
5801                    "reason": reason,
5802                })])
5803                .unwrap(),
5804            );
5805            staged.metadata.remove(RESPONSES_PREVIOUS_RESPONSE_ID_KEY);
5806            staged.reset_model_context_epoch(
5807                bamboo_domain::ModelContextResetReason::ExplicitHistoryRewrite,
5808            );
5809            staged
5810        };
5811
5812        let mut staged = stage_rejection(&expected);
5813        let mut durable = expected.clone();
5814        durable.set_pending_injected_messages(vec![serde_json::json!({
5815            "content": "background shell completed",
5816        })]);
5817        storage.save_session(&durable).await.unwrap();
5818
5819        let outcome = store
5820            .checkpoint_manual_archive_rejection_and_publish(&expected, &mut staged, |_| {})
5821            .await
5822            .unwrap();
5823
5824        assert_eq!(outcome, RetrievalWindowCheckpointOutcome::Rebased);
5825        assert_eq!(
5826            staged.pending_injected_messages(),
5827            durable.pending_injected_messages()
5828        );
5829        let unchanged = storage.load_session(session_id).await.unwrap().unwrap();
5830        assert_eq!(
5831            serde_json::to_value(&unchanged).unwrap(),
5832            serde_json::to_value(&durable).unwrap()
5833        );
5834
5835        expected = staged;
5836        let mut staged = stage_rejection(&expected);
5837
5838        let outcome = store
5839            .checkpoint_manual_archive_rejection_and_publish(&expected, &mut staged, |_| {})
5840            .await
5841            .unwrap();
5842
5843        assert_eq!(outcome, RetrievalWindowCheckpointOutcome::Committed);
5844        let saved = storage.load_session(session_id).await.unwrap().unwrap();
5845        let saved_result = saved
5846            .messages
5847            .iter()
5848            .find(|message| message.id == "archive-result")
5849            .unwrap();
5850        assert_eq!(saved_result.tool_success, Some(false));
5851        assert_eq!(
5852            saved_result.content,
5853            "archive_context rejected: no eligible active logical group can be archived"
5854        );
5855        assert!(saved
5856            .metadata
5857            .contains_key(LAST_MANUAL_ARCHIVE_OCCURRENCE_KEY));
5858        assert!(saved.metadata.contains_key(MANUAL_ARCHIVE_REJECTIONS_KEY));
5859        assert!(!saved
5860            .metadata
5861            .contains_key(RESPONSES_PREVIOUS_RESPONSE_ID_KEY));
5862        assert_eq!(
5863            saved.pending_injected_messages(),
5864            durable.pending_injected_messages()
5865        );
5866        assert_eq!(
5867            saved
5868                .model_context_state
5869                .as_ref()
5870                .and_then(|state| state.last_reset_reason),
5871            Some(bamboo_domain::ModelContextResetReason::ExplicitHistoryRewrite)
5872        );
5873        assert_eq!(
5874            serde_json::to_value(&saved).unwrap(),
5875            serde_json::to_value(&staged).unwrap()
5876        );
5877    }
5878
5879    #[tokio::test]
5880    async fn manual_archive_consumption_checkpoint_rebases_concurrent_open_metadata() {
5881        use bamboo_domain::{FunctionCall, Message, ToolCall};
5882
5883        let (_temp, storage) = make_storage().await;
5884        let store = LockedSessionStore::new(storage.clone());
5885        let session_id = "manual-archive-consumption-checkpoint";
5886        let mut expected = fresh(session_id);
5887        expected.add_message(Message::user("archive older context"));
5888        let mut assistant = Message::assistant("", None);
5889        assistant.tool_calls = Some(vec![ToolCall {
5890            id: "archive-call".to_string(),
5891            tool_type: "function".to_string(),
5892            function: FunctionCall {
5893                name: "archive_context".to_string(),
5894                arguments: "{}".to_string(),
5895            },
5896        }]);
5897        expected.add_message(assistant);
5898        let mut result = Message::tool_result("archive-call", "Retrieval-window archive requested");
5899        result.id = "archive-result".to_string();
5900        result.tool_success = Some(true);
5901        expected.add_message(result);
5902        storage.save_session(&expected).await.unwrap();
5903
5904        let occurrence = ResponseOccurrence {
5905            tool_call_id: "archive-call".to_string(),
5906            tool_result_message_id: "archive-result".to_string(),
5907            permission_generation: None,
5908        };
5909        let stage_consumption = |base: &Session| {
5910            let mut staged = base.clone();
5911            staged.metadata.insert(
5912                LAST_MANUAL_ARCHIVE_OCCURRENCE_KEY.to_string(),
5913                serde_json::to_string(&occurrence).unwrap(),
5914            );
5915            staged
5916        };
5917
5918        let mut staged = stage_consumption(&expected);
5919        let mut durable = expected.clone();
5920        durable.metadata.insert(
5921            "concurrent.workflow_index".to_string(),
5922            "workflow-42".to_string(),
5923        );
5924        durable.set_pending_injected_messages(vec![serde_json::json!({
5925            "id": "bash-1",
5926            "status": "completed",
5927        })]);
5928        storage.save_session(&durable).await.unwrap();
5929
5930        let outcome = store
5931            .checkpoint_manual_archive_consumption_and_publish(&expected, &mut staged, |_| {})
5932            .await
5933            .unwrap();
5934
5935        assert_eq!(outcome, RetrievalWindowCheckpointOutcome::Rebased);
5936        assert_eq!(
5937            serde_json::to_value(&staged).unwrap(),
5938            serde_json::to_value(&durable).unwrap()
5939        );
5940        let unchanged = storage.load_session(session_id).await.unwrap().unwrap();
5941        assert_eq!(
5942            serde_json::to_value(&unchanged).unwrap(),
5943            serde_json::to_value(&durable).unwrap()
5944        );
5945
5946        expected = staged;
5947        let mut staged = stage_consumption(&expected);
5948        let outcome = store
5949            .checkpoint_manual_archive_consumption_and_publish(&expected, &mut staged, |_| {})
5950            .await
5951            .unwrap();
5952
5953        assert_eq!(outcome, RetrievalWindowCheckpointOutcome::Committed);
5954        let saved = storage.load_session(session_id).await.unwrap().unwrap();
5955        assert_eq!(
5956            saved.metadata.get("concurrent.workflow_index"),
5957            Some(&"workflow-42".to_string())
5958        );
5959        assert_eq!(
5960            saved.pending_injected_messages(),
5961            Some(vec![serde_json::json!({
5962                "id": "bash-1",
5963                "status": "completed",
5964            })])
5965        );
5966        assert_eq!(
5967            saved.metadata.get(LAST_MANUAL_ARCHIVE_OCCURRENCE_KEY),
5968            Some(&serde_json::to_string(&occurrence).unwrap())
5969        );
5970    }
5971
5972    #[tokio::test]
5973    async fn retrieval_window_checkpoint_preserves_staged_archive_flags() {
5974        use bamboo_domain::session::types::Message;
5975
5976        let (_temp, storage) = make_storage().await;
5977        let store = LockedSessionStore::new(storage.clone());
5978        let session_id = "retrieval-checkpoint-archive-flags";
5979        let mut expected = fresh(session_id);
5980        expected.add_message(Message::user("archive me"));
5981        expected.add_message(Message::assistant("retain me", None));
5982        storage.save_session(&expected).await.unwrap();
5983        let mut staged = stage_retrieval_window_archive(&expected, 0);
5984        let event_id = staged.compression_events[0].id.clone();
5985        let published = Arc::new(std::sync::Mutex::new(None));
5986        let published_clone = Arc::clone(&published);
5987
5988        let outcome = store
5989            .checkpoint_retrieval_window_and_publish(&expected, &mut staged, move |saved| {
5990                *published_clone.lock().unwrap() = Some(saved.clone());
5991            })
5992            .await
5993            .unwrap();
5994
5995        assert_eq!(outcome, RetrievalWindowCheckpointOutcome::Committed);
5996        let saved = storage.load_session(session_id).await.unwrap().unwrap();
5997        assert!(saved.messages[0].compressed);
5998        assert_eq!(
5999            saved.messages[0].compressed_by_event_id.as_deref(),
6000            Some(event_id.as_str())
6001        );
6002        assert!(!saved.messages[1].compressed);
6003        assert_eq!(saved.compression_events.len(), 1);
6004        assert_eq!(
6005            saved.compression_events[0].kind,
6006            bamboo_domain::CompressionEventKind::RetrievalWindow
6007        );
6008        assert_eq!(
6009            serde_json::to_value(published.lock().unwrap().as_ref().unwrap()).unwrap(),
6010            serde_json::to_value(&saved).unwrap()
6011        );
6012        assert_eq!(
6013            serde_json::to_value(&staged).unwrap(),
6014            serde_json::to_value(&saved).unwrap()
6015        );
6016    }
6017
6018    #[tokio::test]
6019    async fn retrieval_window_checkpoint_rebases_concurrent_suffix_without_writing() {
6020        use bamboo_domain::session::types::Message;
6021
6022        let (_temp, storage) = make_storage().await;
6023        let store = LockedSessionStore::new(storage.clone());
6024        let session_id = "retrieval-checkpoint-concurrent-suffix";
6025        let mut expected = fresh(session_id);
6026        expected.add_message(Message::user("archive candidate"));
6027        storage.save_session(&expected).await.unwrap();
6028        let mut staged = stage_retrieval_window_archive(&expected, 0);
6029
6030        let mut durable = expected.clone();
6031        let mut concurrent = Message::user("concurrent durable suffix");
6032        concurrent.id = "concurrent-durable-suffix".to_string();
6033        durable.add_message(concurrent);
6034        storage.save_session(&durable).await.unwrap();
6035        let published = Arc::new(AtomicBool::new(false));
6036        let published_clone = Arc::clone(&published);
6037
6038        let outcome = store
6039            .checkpoint_retrieval_window_and_publish(&expected, &mut staged, move |_| {
6040                published_clone.store(true, Ordering::SeqCst);
6041            })
6042            .await
6043            .unwrap();
6044
6045        assert_eq!(outcome, RetrievalWindowCheckpointOutcome::Rebased);
6046        assert!(!published.load(Ordering::SeqCst));
6047        let saved = storage.load_session(session_id).await.unwrap().unwrap();
6048        assert_eq!(
6049            serde_json::to_value(&saved).unwrap(),
6050            serde_json::to_value(&durable).unwrap(),
6051            "a conflict must not write the staged archive"
6052        );
6053        assert_eq!(staged.messages.len(), 2);
6054        assert_eq!(staged.messages[1].id, "concurrent-durable-suffix");
6055        assert!(staged.messages.iter().all(|message| !message.compressed));
6056        assert!(staged.compression_events.is_empty());
6057        assert!(staged.model_context_state.is_none());
6058    }
6059
6060    #[tokio::test]
6061    async fn runtime_only_save_preserves_checkpointed_ledger_and_publishes_merged_state() {
6062        let (_temp, storage) = make_storage().await;
6063        let store = LockedSessionStore::new(storage.clone());
6064        let session_id = "runtime-only-ledger-race";
6065        let baseline = fresh(session_id);
6066        storage.save_session(&baseline).await.unwrap();
6067        let mut stale_control = storage.load_session(session_id).await.unwrap().unwrap();
6068
6069        let mut runner = baseline;
6070        runner.model_context_state = Some(ledger_state(1, "runner-l1"));
6071        store.checkpoint_runtime_session(&mut runner).await.unwrap();
6072
6073        stale_control.metadata.insert(
6074            "runtime.suspend_reason".to_string(),
6075            "waiting_for_children".to_string(),
6076        );
6077        let published = std::sync::Arc::new(std::sync::Mutex::new(None));
6078        let published_clone = published.clone();
6079        store
6080            .save_runtime_only_and_publish(&mut stale_control, move |saved| {
6081                *published_clone.lock().unwrap() = Some(saved.clone());
6082            })
6083            .await
6084            .unwrap();
6085
6086        let expected = runner.model_context_state.clone();
6087        assert_eq!(stale_control.model_context_state, expected);
6088        assert_eq!(
6089            published
6090                .lock()
6091                .unwrap()
6092                .as_ref()
6093                .unwrap()
6094                .model_context_state,
6095            expected
6096        );
6097        let sidecar = storage
6098            .load_runtime_control_plane(session_id)
6099            .await
6100            .unwrap()
6101            .unwrap();
6102        assert_eq!(sidecar.model_context_state, expected);
6103        assert_eq!(
6104            sidecar
6105                .metadata
6106                .get("runtime.suspend_reason")
6107                .map(String::as_str),
6108            Some("waiting_for_children")
6109        );
6110        let reloaded = storage.load_session(session_id).await.unwrap().unwrap();
6111        assert_eq!(reloaded.model_context_state, expected);
6112    }
6113
6114    #[tokio::test]
6115    async fn full_runtime_save_preserves_newer_ledger_but_commits_control_mutation() {
6116        let (_temp, storage) = make_storage().await;
6117        let store = LockedSessionStore::new(storage.clone());
6118        let session_id = "full-save-ledger-race";
6119        let baseline = fresh(session_id);
6120        storage.save_session(&baseline).await.unwrap();
6121        let mut stale = storage.load_session(session_id).await.unwrap().unwrap();
6122
6123        let mut runner = baseline;
6124        runner.model_context_state = Some(ledger_state(1, "runner-l1"));
6125        store.checkpoint_runtime_session(&mut runner).await.unwrap();
6126
6127        stale
6128            .metadata
6129            .insert("activated_tools".to_string(), "[\"search\"]".to_string());
6130        let published = std::sync::Arc::new(std::sync::Mutex::new(None));
6131        let published_clone = published.clone();
6132        store
6133            .merge_save_runtime_and_publish(&mut stale, move |saved, committed| {
6134                assert!(committed);
6135                *published_clone.lock().unwrap() = Some(saved.clone());
6136            })
6137            .await
6138            .unwrap();
6139
6140        let expected = runner.model_context_state.clone();
6141        assert_eq!(stale.model_context_state, expected);
6142        assert_eq!(
6143            published
6144                .lock()
6145                .unwrap()
6146                .as_ref()
6147                .unwrap()
6148                .model_context_state,
6149            expected
6150        );
6151        let sidecar = storage
6152            .load_runtime_control_plane(session_id)
6153            .await
6154            .unwrap()
6155            .unwrap();
6156        assert_eq!(sidecar.model_context_state, expected);
6157        let reloaded = storage.load_session(session_id).await.unwrap().unwrap();
6158        assert_eq!(reloaded.model_context_state, expected);
6159        assert_eq!(
6160            reloaded.metadata.get("activated_tools").map(String::as_str),
6161            Some("[\"search\"]")
6162        );
6163    }
6164
6165    #[tokio::test]
6166    async fn newer_explicit_epoch_reset_wins_an_ordinary_full_runtime_save() {
6167        let (_temp, storage) = make_storage().await;
6168        let store = LockedSessionStore::new(storage.clone());
6169        let session_id = "full-save-ledger-reset";
6170        let mut durable = fresh(session_id);
6171        durable.model_context_state = Some(ledger_state(1, "runner-l1"));
6172        storage.save_session(&durable).await.unwrap();
6173
6174        let mut compression = storage.load_session(session_id).await.unwrap().unwrap();
6175        compression.reset_model_context_epoch(bamboo_domain::ModelContextResetReason::Compression);
6176        let reset = compression.model_context_state.clone();
6177        assert_eq!(reset.as_ref().unwrap().state_revision, 2);
6178        store.merge_save_runtime(&mut compression).await.unwrap();
6179
6180        let reloaded = storage.load_session(session_id).await.unwrap().unwrap();
6181        assert_eq!(reloaded.model_context_state, reset);
6182        assert_eq!(
6183            reloaded
6184                .model_context_state
6185                .as_ref()
6186                .and_then(|state| state.last_reset_reason),
6187            Some(bamboo_domain::ModelContextResetReason::Compression)
6188        );
6189    }
6190
6191    #[tokio::test]
6192    async fn checkpoint_rejects_equal_revision_divergence_without_overwriting_disk() {
6193        let (_temp, storage) = make_storage().await;
6194        let store = LockedSessionStore::new(storage.clone());
6195        let session_id = "ledger-checkpoint-cas";
6196        let mut baseline = fresh(session_id);
6197        baseline.model_context_state = Some(ledger_state(1, "runner-l1"));
6198        storage.save_session(&baseline).await.unwrap();
6199
6200        let mut first = baseline.clone();
6201        first.reset_model_context_epoch(bamboo_domain::ModelContextResetReason::Compression);
6202        let mut conflicting = baseline;
6203        conflicting.reset_model_context_epoch(bamboo_domain::ModelContextResetReason::Rollback);
6204        assert_eq!(
6205            first.model_context_state.as_ref().unwrap().state_revision,
6206            conflicting
6207                .model_context_state
6208                .as_ref()
6209                .unwrap()
6210                .state_revision
6211        );
6212
6213        store.checkpoint_runtime_session(&mut first).await.unwrap();
6214        let error = store
6215            .checkpoint_runtime_session(&mut conflicting)
6216            .await
6217            .unwrap_err();
6218        assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock);
6219        let reloaded = storage.load_session(session_id).await.unwrap().unwrap();
6220        assert_eq!(reloaded.model_context_state, first.model_context_state);
6221    }
6222
6223    #[tokio::test]
6224    async fn activation_checkpoint_clears_presentation_without_shrinking_concurrent_turn() {
6225        use bamboo_domain::session::runtime_state::{
6226            AgentRuntimeState, AgentStatusState, WaitingForChildrenState,
6227        };
6228        use bamboo_domain::session::types::Message;
6229
6230        let (_temp, storage) = make_storage().await;
6231        let store = LockedSessionStore::new(storage.clone());
6232        let session_id = "activation-no-shrink";
6233        let mut baseline = fresh(session_id);
6234        baseline.add_message(Message::user("base"));
6235        let mut state = AgentRuntimeState::new("activation-run");
6236        state.status = AgentStatusState::Suspended;
6237        state.waiting_for_children = Some(WaitingForChildrenState::for_children(
6238            vec!["child-1".to_string()],
6239            bamboo_domain::session::runtime_state::ChildWaitPolicy::All,
6240            chrono::Utc::now(),
6241        ));
6242        baseline.agent_runtime_state = Some(state);
6243        baseline.metadata.insert(
6244            "runtime.suspend_reason".to_string(),
6245            "waiting_for_children".to_string(),
6246        );
6247        storage.save_session(&baseline).await.unwrap();
6248        let mut activation_snapshot = baseline.clone();
6249
6250        let mut concurrent = baseline;
6251        let mut normal = Message::assistant("normal concurrent answer", None);
6252        normal.id = "normal-concurrent".to_string();
6253        concurrent.add_message(normal);
6254        storage.save_session(&concurrent).await.unwrap();
6255
6256        let state = activation_snapshot.agent_runtime_state.as_mut().unwrap();
6257        state.status = AgentStatusState::Idle;
6258        state.suspension = None;
6259        activation_snapshot
6260            .metadata
6261            .remove("runtime.suspend_reason");
6262        store
6263            .checkpoint_runtime_session(&mut activation_snapshot)
6264            .await
6265            .unwrap();
6266
6267        let saved = storage.load_session(session_id).await.unwrap().unwrap();
6268        assert!(saved
6269            .messages
6270            .iter()
6271            .any(|message| message.id == "normal-concurrent"));
6272        let state = saved.agent_runtime_state.unwrap();
6273        assert_eq!(state.status, AgentStatusState::Idle);
6274        assert!(state.waiting_for_children.is_some());
6275        assert!(!saved.metadata.contains_key("runtime.suspend_reason"));
6276    }
6277
6278    #[tokio::test]
6279    async fn ordinary_and_checkpoint_tagged_wait_adoption_preserves_current_run_and_clear_winner() {
6280        use bamboo_domain::session::runtime_state::SuspensionState;
6281        use bamboo_domain::session::types::Message;
6282        use bamboo_domain::{
6283            AgentRuntimeState, AgentStatusState, ChildWaitPolicy, WaitingForChildrenState,
6284        };
6285
6286        for checkpoint in [false, true] {
6287            for status in [AgentStatusState::Initializing, AgentStatusState::Running] {
6288                for current_wait in 0..3 {
6289                    let (_temp, storage) = make_storage().await;
6290                    let store = LockedSessionStore::new(storage.clone());
6291                    let mut parent = fresh("mid-run-tagged-wait");
6292                    let mut wait = WaitingForChildrenState::for_children(
6293                        vec!["child-b".into(), "child-a".into()],
6294                        ChildWaitPolicy::Any,
6295                        chrono::Utc::now() - chrono::Duration::minutes(5),
6296                    );
6297                    wait.registered_by_tool_call_id = Some("original-call".into());
6298                    let mut previous = AgentRuntimeState::new("old-run");
6299                    previous.status = AgentStatusState::Suspended;
6300                    previous.waiting_for_children = Some(wait.clone());
6301                    parent.agent_runtime_state = Some(previous);
6302                    parent.add_message(Message::user("original task"));
6303                    storage.save_session(&parent).await.unwrap();
6304                    let mut incoming = parent.clone();
6305                    let runtime = incoming.agent_runtime_state.as_mut().unwrap();
6306                    runtime.run_id = "reasoning-run".into();
6307                    runtime.status = status;
6308                    incoming.metadata.remove("runtime.suspend_reason");
6309                    incoming.add_message(Message::user("new admitted reasoning"));
6310                    let expected = match current_wait {
6311                        0 => Some(wait),
6312                        1 => None,
6313                        _ => {
6314                            let mut new_wait = wait;
6315                            new_wait.wait_for = ChildWaitPolicy::FirstError;
6316                            new_wait.registered_by_tool_call_id = Some("new-tool-call".into());
6317                            Some(new_wait)
6318                        }
6319                    };
6320                    let current = expected.clone();
6321                    store
6322                        .update_runtime_config(&parent.id, move |latest| {
6323                            let runtime = latest.agent_runtime_state.as_mut().unwrap();
6324                            runtime.waiting_for_children = current;
6325                            if runtime.waiting_for_children.is_none() {
6326                                runtime.status = AgentStatusState::Idle;
6327                                runtime.suspension = None;
6328                                latest.metadata.remove("runtime.suspend_reason");
6329                            }
6330                        })
6331                        .await
6332                        .unwrap()
6333                        .unwrap();
6334                    if expected.is_none() {
6335                        let mut completed =
6336                            storage.load_session(&parent.id).await.unwrap().unwrap();
6337                        for (content, created_at) in [
6338                            (
6339                                "old consumed outcome",
6340                                chrono::Utc::now() - chrono::Duration::hours(1),
6341                            ),
6342                            ("current completed outcome", chrono::Utc::now()),
6343                        ] {
6344                            let mut message = Message::user(content);
6345                            message.created_at = created_at;
6346                            message.metadata =
6347                                Some(serde_json::json!({"runtime_kind":"child_completion_resume"}));
6348                            completed.add_message(message);
6349                        }
6350                        store.merge_save_runtime(&mut completed).await.unwrap();
6351                    }
6352                    if checkpoint {
6353                        store
6354                            .checkpoint_runtime_session(&mut incoming)
6355                            .await
6356                            .unwrap();
6357                    } else {
6358                        store.merge_save_runtime(&mut incoming).await.unwrap();
6359                    }
6360                    for saved in [
6361                        incoming.clone(),
6362                        storage.load_session(&parent.id).await.unwrap().unwrap(),
6363                    ] {
6364                        let runtime = saved.agent_runtime_state.unwrap();
6365                        assert_eq!(runtime.waiting_for_children, expected);
6366                        assert_eq!(runtime.status, status);
6367                        assert_eq!(runtime.run_id, "reasoning-run");
6368                        assert!(runtime.suspension.is_none());
6369                        assert!(!saved.metadata.contains_key("runtime.suspend_reason"));
6370                        assert_eq!(
6371                            saved
6372                                .messages
6373                                .iter()
6374                                .filter(|m| m.content == "new admitted reasoning")
6375                                .count(),
6376                            1
6377                        );
6378                        assert!(saved.messages.iter().any(|m| m.content == "original task"));
6379                        if expected.is_none() {
6380                            assert_eq!(
6381                                saved
6382                                    .messages
6383                                    .iter()
6384                                    .filter(|m| m.content == "current completed outcome")
6385                                    .count(),
6386                                1
6387                            );
6388                            assert_eq!(
6389                                saved
6390                                    .messages
6391                                    .iter()
6392                                    .any(|m| m.content == "old consumed outcome"),
6393                                checkpoint
6394                            );
6395                        }
6396                    }
6397
6398                    if expected.is_none() {
6399                        incoming.agent_runtime_state.as_mut().unwrap().status =
6400                            AgentStatusState::Completed;
6401                        store
6402                            .merge_save_finalized_runtime(&mut incoming)
6403                            .await
6404                            .unwrap();
6405                        let finalized = storage.load_session(&parent.id).await.unwrap().unwrap();
6406                        assert!(finalized
6407                            .agent_runtime_state
6408                            .as_ref()
6409                            .unwrap()
6410                            .waiting_for_children
6411                            .is_none());
6412                        assert_eq!(
6413                            finalized
6414                                .messages
6415                                .iter()
6416                                .filter(|m| m.content == "current completed outcome")
6417                                .count(),
6418                            1
6419                        );
6420                    }
6421
6422                    // A separate current suspension is not owned by wait adoption.
6423                    if expected.is_none() {
6424                        let mut unrelated = parent;
6425                        let runtime = unrelated.agent_runtime_state.as_mut().unwrap();
6426                        runtime.status = AgentStatusState::Suspended;
6427                        runtime.suspension = Some(SuspensionState {
6428                            reason: "awaiting_clarification".into(),
6429                            suspended_at: chrono::Utc::now(),
6430                            resumable: true,
6431                            hook_point: Some("AfterToolExecution".into()),
6432                        });
6433                        let suspension = runtime.suspension.clone();
6434                        unrelated.metadata.insert(
6435                            "runtime.suspend_reason".into(),
6436                            "awaiting_clarification".into(),
6437                        );
6438                        store
6439                            .checkpoint_runtime_session(&mut unrelated)
6440                            .await
6441                            .unwrap();
6442                        let runtime = unrelated.agent_runtime_state.as_ref().unwrap();
6443                        assert!(runtime.waiting_for_children.is_none());
6444                        assert_eq!(runtime.status, AgentStatusState::Suspended);
6445                        assert_eq!(runtime.suspension, suspension);
6446                        assert_eq!(
6447                            unrelated
6448                                .metadata
6449                                .get("runtime.suspend_reason")
6450                                .map(String::as_str),
6451                            Some("awaiting_clarification")
6452                        );
6453                    }
6454                }
6455            }
6456        }
6457    }
6458
6459    #[tokio::test]
6460    async fn tagged_control_plane_first_arm_and_untagged_runner_first_save_remain_owned() {
6461        use bamboo_domain::{AgentRuntimeState, ChildWaitPolicy, WaitingForChildrenState};
6462        for tagged in [true, false] {
6463            let (_temp, storage) = make_storage().await;
6464            let store = LockedSessionStore::new(storage.clone());
6465            let mut parent = fresh("first-wait-publication");
6466            parent.agent_runtime_state = Some(AgentRuntimeState::new("parent-run"));
6467            storage.save_session(&parent).await.unwrap();
6468            let mut wait = WaitingForChildrenState::for_children(
6469                vec!["new-child".into()],
6470                ChildWaitPolicy::All,
6471                chrono::Utc::now(),
6472            );
6473            if tagged {
6474                wait.registered_by_tool_call_id = Some("first-arm-call".into());
6475            }
6476            parent
6477                .agent_runtime_state
6478                .as_mut()
6479                .unwrap()
6480                .waiting_for_children = Some(wait.clone());
6481            if tagged {
6482                // The real ChildSessionAdapter authors tools through this API.
6483                store.save_runtime_only(&mut parent).await.unwrap();
6484            } else {
6485                store.merge_save_runtime(&mut parent).await.unwrap();
6486            }
6487            let saved = storage.load_session(&parent.id).await.unwrap().unwrap();
6488            assert_eq!(
6489                saved
6490                    .agent_runtime_state
6491                    .as_ref()
6492                    .unwrap()
6493                    .waiting_for_children
6494                    .as_ref(),
6495                Some(&wait)
6496            );
6497        }
6498    }
6499
6500    #[tokio::test]
6501    async fn finalized_save_adopts_child_completion_after_pipeline_wait_read() {
6502        use bamboo_domain::session::runtime_state::{
6503            AgentRuntimeState, AgentStatusState, ChildWaitPolicy, SuspensionState,
6504            WaitingForChildrenState,
6505        };
6506        use bamboo_domain::session::types::Message;
6507
6508        let (_temp, storage) = make_storage().await;
6509        let store = LockedSessionStore::new(storage.clone());
6510        let mut parent = fresh("finalize-fast-child");
6511        let mut runtime = AgentRuntimeState::new("parent-run");
6512        runtime.status = AgentStatusState::Suspended;
6513        runtime.suspension = Some(SuspensionState {
6514            reason: "waiting_for_children".to_string(),
6515            suspended_at: chrono::Utc::now(),
6516            resumable: true,
6517            hook_point: Some("AfterToolExecution".to_string()),
6518        });
6519        let mut wait = WaitingForChildrenState::for_children(
6520            vec!["fast-child".to_string()],
6521            ChildWaitPolicy::All,
6522            chrono::Utc::now(),
6523        );
6524        wait.registered_by_tool_call_id = Some("spawn-call".to_string());
6525        let registered_at = wait.registered_at;
6526        runtime.waiting_for_children = Some(wait);
6527        parent.agent_runtime_state = Some(runtime);
6528        parent.metadata.insert(
6529            "agent.runtime.state".to_string(),
6530            serde_json::to_string(parent.agent_runtime_state.as_ref().unwrap()).unwrap(),
6531        );
6532        parent.metadata.insert(
6533            "runtime.suspend_reason".to_string(),
6534            "waiting_for_children".to_string(),
6535        );
6536        parent.set_last_run_status("suspended");
6537        storage.save_session(&parent).await.unwrap();
6538
6539        // The pipeline already read this snapshot. Completion then commits
6540        // before the runner's final merge-save reaches the per-session lock.
6541        let mut stale_runner = parent.clone();
6542        let mut completed = parent;
6543        let completed_runtime = completed.agent_runtime_state.as_mut().unwrap();
6544        completed_runtime.waiting_for_children = None;
6545        completed_runtime.status = AgentStatusState::Idle;
6546        completed_runtime.suspension = None;
6547        completed.metadata.remove("runtime.suspend_reason");
6548        let mut old_resume = Message::user("old consumed child outcome");
6549        old_resume.created_at = registered_at - chrono::Duration::seconds(1);
6550        old_resume.metadata = Some(serde_json::json!({
6551            "runtime_kind": "child_completion_resume"
6552        }));
6553        completed.add_message(old_resume);
6554        let mut outcome = Message::user("fast child outcome");
6555        outcome.metadata = Some(serde_json::json!({
6556            "runtime_kind": "child_completion_resume"
6557        }));
6558        completed.add_message(outcome);
6559        storage.save_session(&completed).await.unwrap();
6560
6561        RuntimeSessionPersistence::save_finalized_runtime_session(&store, &mut stale_runner)
6562            .await
6563            .unwrap();
6564        let saved = storage
6565            .load_session("finalize-fast-child")
6566            .await
6567            .unwrap()
6568            .unwrap();
6569        let state = saved.agent_runtime_state.as_ref().unwrap();
6570        assert!(state.waiting_for_children.is_none());
6571        assert!(state.suspension.is_none());
6572        assert_eq!(state.status, AgentStatusState::Idle);
6573        assert!(!saved.metadata.contains_key("runtime.suspend_reason"));
6574        assert_eq!(saved.last_run_status().as_deref(), Some("completed"));
6575        let mirrored: AgentRuntimeState =
6576            serde_json::from_str(saved.metadata.get("agent.runtime.state").unwrap()).unwrap();
6577        assert!(mirrored.waiting_for_children.is_none());
6578        assert_eq!(mirrored.status, AgentStatusState::Idle);
6579        assert!(saved
6580            .messages
6581            .iter()
6582            .any(|message| message.content == "fast child outcome"));
6583        assert!(!saved
6584            .messages
6585            .iter()
6586            .any(|message| message.content == "old consumed child outcome"));
6587    }
6588
6589    #[tokio::test]
6590    async fn finalized_save_retains_unpersisted_untagged_safety_net_wait() {
6591        use bamboo_domain::session::runtime_state::{
6592            AgentRuntimeState, AgentStatusState, ChildWaitPolicy, WaitingForChildrenState,
6593        };
6594
6595        let (_temp, storage) = make_storage().await;
6596        let store = LockedSessionStore::new(storage.clone());
6597        let mut parent = fresh("finalize-safety-net");
6598        parent.agent_runtime_state = Some(AgentRuntimeState::new("parent-run"));
6599        storage.save_session(&parent).await.unwrap();
6600
6601        // A runner-created safety-net wait may reach finalization after its
6602        // first persistence attempt failed. There is no durable clear to adopt.
6603        let mut runner = parent;
6604        let runtime = runner.agent_runtime_state.as_mut().unwrap();
6605        runtime.status = AgentStatusState::Suspended;
6606        runtime.waiting_for_children = Some(WaitingForChildrenState::for_children(
6607            vec!["active-child".to_string()],
6608            ChildWaitPolicy::All,
6609            chrono::Utc::now(),
6610        ));
6611        runner.metadata.insert(
6612            "runtime.suspend_reason".to_string(),
6613            "waiting_for_children".to_string(),
6614        );
6615        runner.set_last_run_status("suspended");
6616
6617        RuntimeSessionPersistence::save_finalized_runtime_session(&store, &mut runner)
6618            .await
6619            .unwrap();
6620        let saved = storage
6621            .load_session("finalize-safety-net")
6622            .await
6623            .unwrap()
6624            .unwrap();
6625        assert!(saved
6626            .agent_runtime_state
6627            .as_ref()
6628            .unwrap()
6629            .waiting_for_children
6630            .is_some());
6631        assert_eq!(
6632            saved
6633                .metadata
6634                .get("runtime.suspend_reason")
6635                .map(String::as_str),
6636            Some("waiting_for_children")
6637        );
6638        assert_eq!(saved.last_run_status().as_deref(), Some("suspended"));
6639    }
6640
6641    #[tokio::test]
6642    async fn merge_save_runtime_preserves_disk_authoritative_metadata_with_single_load() {
6643        // Regression guard for the single-load refactor of `merge_save_runtime`:
6644        // it must STILL pull the authoritative metadata group (title / pinned /
6645        // metadata_version) from the freshest on-disk copy when disk's
6646        // metadata_version >= the in-memory one, even though it now reads disk
6647        // only once.
6648        let (_temp, storage) = make_storage().await;
6649        let store = LockedSessionStore::new(storage.clone());
6650        let session_id = "runtime-merge-meta";
6651
6652        // Baseline persisted by a runtime writer (metadata_version 0).
6653        let mut baseline = fresh(session_id);
6654        baseline.title = "Auto Title".to_string();
6655        baseline.metadata_version = 0;
6656        storage.save_session(&baseline).await.unwrap();
6657
6658        // A stale runtime snapshot (still metadata_version 0, old title).
6659        let mut stale_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
6660
6661        // An authoritative UI rename bumps metadata_version on disk.
6662        let mut renamed = storage.load_session(session_id).await.unwrap().unwrap();
6663        renamed.title = "User Renamed".to_string();
6664        renamed.title_version = 1;
6665        renamed.pinned = true;
6666        renamed.metadata_version = 1;
6667        store.commit_metadata(&renamed).await.unwrap();
6668
6669        // The stale runtime writer saves: it must adopt the disk title/pinned.
6670        stale_snapshot.title = "Auto Title".to_string();
6671        store.merge_save_runtime(&mut stale_snapshot).await.unwrap();
6672
6673        let after = storage.load_session(session_id).await.unwrap().unwrap();
6674        assert_eq!(after.title, "User Renamed");
6675        assert!(after.pinned);
6676        assert_eq!(after.metadata_version, 1);
6677        // And the in-memory copy was corrected by the merge too.
6678        assert_eq!(stale_snapshot.title, "User Renamed");
6679        assert_eq!(stale_snapshot.metadata_version, 1);
6680    }
6681
6682    #[tokio::test]
6683    async fn merge_save_runtime_preserves_durable_workflow_run_index_from_stale_runner() {
6684        let (_temp, storage) = make_storage().await;
6685        let store = LockedSessionStore::new(storage.clone());
6686        let session_id = "runtime-workflow-run-index";
6687
6688        let baseline = fresh(session_id);
6689        storage.save_session(&baseline).await.unwrap();
6690        let mut stale_runner = storage.load_session(session_id).await.unwrap().unwrap();
6691
6692        store
6693            .update_runtime_config(session_id, |session| {
6694                session.metadata.insert(
6695                    "workflow.run_ids.v1".to_string(),
6696                    r#"["http-started-run"]"#.to_string(),
6697                );
6698            })
6699            .await
6700            .unwrap()
6701            .expect("session exists");
6702
6703        store.merge_save_runtime(&mut stale_runner).await.unwrap();
6704
6705        assert_eq!(
6706            stale_runner
6707                .metadata
6708                .get("workflow.run_ids.v1")
6709                .map(String::as_str),
6710            Some(r#"["http-started-run"]"#)
6711        );
6712        let durable = storage.load_session(session_id).await.unwrap().unwrap();
6713        assert_eq!(
6714            durable
6715                .metadata
6716                .get("workflow.run_ids.v1")
6717                .map(String::as_str),
6718            Some(r#"["http-started-run"]"#)
6719        );
6720    }
6721
6722    // #540: a running loop's `merge_save_runtime` (carrying the run-start bypass
6723    // value) must NOT revert a concurrent mid-run `PATCH /sessions
6724    // {bypass_permissions}` write on disk — disk is the authoritative writer.
6725    #[tokio::test]
6726    async fn merge_save_runtime_adopts_disk_bypass_permissions() {
6727        use bamboo_domain::AgentRuntimeState;
6728
6729        let (_temp, storage) = make_storage().await;
6730        let store = LockedSessionStore::new(storage.clone());
6731        let session_id = "runtime-bypass";
6732
6733        // Baseline persisted with bypass OFF.
6734        let baseline = fresh(session_id);
6735        storage.save_session(&baseline).await.unwrap();
6736
6737        // The running loop holds a snapshot with bypass OFF (run-start value).
6738        let mut loop_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
6739        loop_snapshot.agent_runtime_state = Some(AgentRuntimeState::default());
6740
6741        // A concurrent PATCH flips bypass ON on disk (via update_runtime_config).
6742        store
6743            .update_runtime_config(session_id, |s| {
6744                s.agent_runtime_state
6745                    .get_or_insert_with(AgentRuntimeState::default)
6746                    .bypass_permissions = true;
6747            })
6748            .await
6749            .unwrap()
6750            .expect("session exists");
6751
6752        // The loop saves its stale snapshot: it must adopt disk's ON value, not
6753        // revert to OFF.
6754        store.merge_save_runtime(&mut loop_snapshot).await.unwrap();
6755
6756        let after = storage.load_session(session_id).await.unwrap().unwrap();
6757        assert!(
6758            after
6759                .agent_runtime_state
6760                .as_ref()
6761                .is_some_and(|s| s.bypass_permissions),
6762            "disk bypass=ON must survive a stale runtime save (#540)"
6763        );
6764        // The in-memory copy is corrected too.
6765        assert!(loop_snapshot
6766            .agent_runtime_state
6767            .as_ref()
6768            .is_some_and(|s| s.bypass_permissions));
6769    }
6770
6771    // #770: the generalized disk-wins path must preserve Auto as a distinct
6772    // typed mode rather than collapsing it into the legacy bypass boolean.
6773    #[tokio::test]
6774    async fn merge_save_runtime_adopts_disk_auto_permission_mode() {
6775        use bamboo_domain::{AgentRuntimeState, SessionPermissionMode};
6776
6777        let (_temp, storage) = make_storage().await;
6778        let store = LockedSessionStore::new(storage.clone());
6779        let session_id = "runtime-auto";
6780
6781        storage.save_session(&fresh(session_id)).await.unwrap();
6782        let mut loop_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
6783        loop_snapshot.agent_runtime_state = Some(AgentRuntimeState::default());
6784        loop_snapshot.metadata.insert(
6785            "permission.requested_mode".to_string(),
6786            "default".to_string(),
6787        );
6788        loop_snapshot.metadata.insert(
6789            "permission.effective_mode".to_string(),
6790            "default".to_string(),
6791        );
6792        loop_snapshot.metadata.insert(
6793            "permission.executor_mapping".to_string(),
6794            "bamboo_runtime:default".to_string(),
6795        );
6796
6797        store
6798            .update_runtime_config(session_id, |session| {
6799                session
6800                    .agent_runtime_state
6801                    .get_or_insert_with(AgentRuntimeState::default)
6802                    .set_permission_mode(SessionPermissionMode::Auto);
6803                session
6804                    .metadata
6805                    .insert("permission.policy_revision".to_string(), "12".to_string());
6806                session
6807                    .metadata
6808                    .insert("permission.requested_mode".to_string(), "auto".to_string());
6809                session
6810                    .metadata
6811                    .insert("permission.effective_mode".to_string(), "auto".to_string());
6812                session.metadata.insert(
6813                    "permission.executor_mapping".to_string(),
6814                    "bamboo_runtime:auto".to_string(),
6815                );
6816                session.metadata.insert(
6817                    "permission.transitioned_at".to_string(),
6818                    "2026-07-31T12:00:00Z".to_string(),
6819                );
6820                session.metadata_version = session.metadata_version.saturating_add(1);
6821            })
6822            .await
6823            .unwrap()
6824            .expect("session exists");
6825
6826        store.merge_save_runtime(&mut loop_snapshot).await.unwrap();
6827
6828        let durable = storage.load_session(session_id).await.unwrap().unwrap();
6829        for state in [
6830            durable.agent_runtime_state.as_ref(),
6831            loop_snapshot.agent_runtime_state.as_ref(),
6832        ] {
6833            assert_eq!(
6834                state.map(AgentRuntimeState::effective_permission_mode),
6835                Some(SessionPermissionMode::Auto)
6836            );
6837        }
6838        for session in [&durable, &loop_snapshot] {
6839            assert_eq!(
6840                session.metadata.get("permission.policy_revision"),
6841                Some(&"12".to_string())
6842            );
6843            assert_eq!(
6844                session.metadata.get("permission.requested_mode"),
6845                Some(&"auto".to_string())
6846            );
6847            assert_eq!(
6848                session.metadata.get("permission.effective_mode"),
6849                Some(&"auto".to_string())
6850            );
6851            assert_eq!(
6852                session.metadata.get("permission.executor_mapping"),
6853                Some(&"bamboo_runtime:auto".to_string())
6854            );
6855            assert_eq!(
6856                session.metadata.get("permission.transitioned_at"),
6857                Some(&"2026-07-31T12:00:00Z".to_string())
6858            );
6859        }
6860    }
6861
6862    // The reverse direction: a PATCH turning bypass OFF must also stick against
6863    // a stale loop snapshot that still has it ON.
6864    #[tokio::test]
6865    async fn merge_save_runtime_adopts_disk_bypass_off() {
6866        use bamboo_domain::AgentRuntimeState;
6867
6868        let (_temp, storage) = make_storage().await;
6869        let store = LockedSessionStore::new(storage.clone());
6870        let session_id = "runtime-bypass-off";
6871
6872        // Baseline persisted with bypass ON.
6873        let mut baseline = fresh(session_id);
6874        let on_state = AgentRuntimeState {
6875            bypass_permissions: true,
6876            ..AgentRuntimeState::default()
6877        };
6878        baseline.agent_runtime_state = Some(on_state);
6879        storage.save_session(&baseline).await.unwrap();
6880
6881        // Loop snapshot still ON.
6882        let mut loop_snapshot = storage.load_session(session_id).await.unwrap().unwrap();
6883
6884        // PATCH flips OFF on disk.
6885        store
6886            .update_runtime_config(session_id, |s| {
6887                s.agent_runtime_state
6888                    .get_or_insert_with(AgentRuntimeState::default)
6889                    .bypass_permissions = false;
6890            })
6891            .await
6892            .unwrap()
6893            .expect("session exists");
6894
6895        store.merge_save_runtime(&mut loop_snapshot).await.unwrap();
6896
6897        let after = storage.load_session(session_id).await.unwrap().unwrap();
6898        assert!(
6899            !after
6900                .agent_runtime_state
6901                .as_ref()
6902                .is_some_and(|s| s.bypass_permissions),
6903            "disk bypass=OFF must survive a stale runtime save (#540)"
6904        );
6905    }
6906
6907    // #540 review: the authoritative flag writer (#74 child-reseed) must NOT be
6908    // reverted by the disk-wins protection — its in-memory value persists as-is.
6909    #[tokio::test]
6910    async fn save_runtime_authoritative_flags_persists_in_memory_posture_and_audit() {
6911        use bamboo_domain::AgentRuntimeState;
6912
6913        let (_temp, storage) = make_storage().await;
6914        let store = LockedSessionStore::new(storage.clone());
6915        let session_id = "child-reseed";
6916
6917        // Child on disk has bypass ON (created under a bypassed parent).
6918        let mut baseline = fresh(session_id);
6919        let on_state = AgentRuntimeState {
6920            bypass_permissions: true,
6921            ..AgentRuntimeState::default()
6922        };
6923        baseline.agent_runtime_state = Some(on_state);
6924        for (key, value) in [
6925            ("permission.policy_revision", "12"),
6926            ("permission.requested_mode", "bypass"),
6927            ("permission.effective_mode", "bypass"),
6928            ("permission.executor_mapping", "bamboo_runtime:bypass"),
6929            ("permission.transitioned_at", "2026-07-31T12:00:00Z"),
6930        ] {
6931            baseline.metadata.insert(key.to_string(), value.to_string());
6932        }
6933        storage.save_session(&baseline).await.unwrap();
6934
6935        // Parent re-seeds the reused child to OFF (parent flipped bypass off),
6936        // loading the child then setting the flag in memory.
6937        let mut child = storage.load_session(session_id).await.unwrap().unwrap();
6938        child
6939            .agent_runtime_state
6940            .get_or_insert_with(AgentRuntimeState::default)
6941            .bypass_permissions = false;
6942        for (key, value) in [
6943            ("permission.policy_revision", "13"),
6944            ("permission.requested_mode", "default"),
6945            ("permission.effective_mode", "default"),
6946            ("permission.executor_mapping", "bamboo_runtime:default"),
6947            ("permission.transitioned_at", "2026-07-31T12:01:00Z"),
6948        ] {
6949            child.metadata.insert(key.to_string(), value.to_string());
6950        }
6951
6952        // Authoritative write must persist OFF, not adopt the disk's stale ON.
6953        store
6954            .save_runtime_authoritative_flags(&mut child)
6955            .await
6956            .unwrap();
6957
6958        let after = storage.load_session(session_id).await.unwrap().unwrap();
6959        assert!(
6960            !after
6961                .agent_runtime_state
6962                .as_ref()
6963                .is_some_and(|s| s.bypass_permissions),
6964            "authoritative re-seed of bypass=OFF must persist, not be reverted (#540/#74)"
6965        );
6966        for (key, value) in [
6967            ("permission.policy_revision", "13"),
6968            ("permission.requested_mode", "default"),
6969            ("permission.effective_mode", "default"),
6970            ("permission.executor_mapping", "bamboo_runtime:default"),
6971            ("permission.transitioned_at", "2026-07-31T12:01:00Z"),
6972        ] {
6973            assert_eq!(after.metadata.get(key).map(String::as_str), Some(value));
6974        }
6975    }
6976
6977    // A disk copy lacking runtime state must not force the in-memory bypass OFF.
6978    #[tokio::test]
6979    async fn merge_save_runtime_leaves_bypass_when_disk_has_no_runtime_state() {
6980        use bamboo_domain::AgentRuntimeState;
6981
6982        let (_temp, storage) = make_storage().await;
6983        let store = LockedSessionStore::new(storage.clone());
6984        let session_id = "no-runtime-state";
6985
6986        // Disk copy with NO agent_runtime_state.
6987        let baseline = fresh(session_id);
6988        assert!(baseline.agent_runtime_state.is_none());
6989        storage.save_session(&baseline).await.unwrap();
6990
6991        // A running loop legitimately carries bypass ON in memory.
6992        let mut running = storage.load_session(session_id).await.unwrap().unwrap();
6993        let on_state = AgentRuntimeState {
6994            bypass_permissions: true,
6995            ..AgentRuntimeState::default()
6996        };
6997        running.agent_runtime_state = Some(on_state);
6998
6999        store.merge_save_runtime(&mut running).await.unwrap();
7000
7001        assert!(
7002            running
7003                .agent_runtime_state
7004                .as_ref()
7005                .is_some_and(|s| s.bypass_permissions),
7006            "a runtime-state-less disk copy must not force bypass OFF (#540)"
7007        );
7008    }
7009
7010    // ── Free-function merge tests (updated for metadata-group) ──────
7011
7012    #[tokio::test]
7013    async fn merge_preserves_disk_title_when_versions_equal() {
7014        let (_temp, storage) = make_storage().await;
7015        let session_id = "merge-equal";
7016
7017        let mut on_disk = fresh(session_id);
7018        on_disk.title = "User Set This".to_string();
7019        on_disk.title_version = 0;
7020        on_disk.title_generated = true;
7021        on_disk.metadata_version = 0;
7022        storage.save_session(&on_disk).await.unwrap();
7023
7024        let mut runtime_copy = fresh(session_id);
7025        runtime_copy.created_at = on_disk.created_at;
7026        runtime_copy.title = "Stale Default".to_string();
7027        runtime_copy.title_version = 0;
7028        runtime_copy.title_generated = false;
7029        runtime_copy.metadata_version = 0;
7030        runtime_copy.messages = vec![];
7031
7032        merge_save_session(&storage, &mut runtime_copy)
7033            .await
7034            .unwrap();
7035
7036        let after = storage.load_session(session_id).await.unwrap().unwrap();
7037        assert_eq!(after.title, "User Set This");
7038        assert_eq!(after.title_version, 0);
7039        assert!(after.title_generated);
7040        assert_eq!(runtime_copy.title, "User Set This");
7041        assert!(runtime_copy.title_generated);
7042    }
7043
7044    #[tokio::test]
7045    async fn merge_preserves_disk_when_disk_version_higher() {
7046        let (_temp, storage) = make_storage().await;
7047        let session_id = "merge-higher";
7048
7049        let mut on_disk = fresh(session_id);
7050        on_disk.title = "User Title v3".to_string();
7051        on_disk.title_version = 3;
7052        on_disk.metadata_version = 5;
7053        storage.save_session(&on_disk).await.unwrap();
7054
7055        let mut runtime_copy = fresh(session_id);
7056        runtime_copy.created_at = on_disk.created_at;
7057        runtime_copy.title = "Stale".to_string();
7058        runtime_copy.title_version = 1;
7059        runtime_copy.metadata_version = 0;
7060
7061        merge_save_session(&storage, &mut runtime_copy)
7062            .await
7063            .unwrap();
7064
7065        let after = storage.load_session(session_id).await.unwrap().unwrap();
7066        assert_eq!(after.title, "User Title v3");
7067        assert_eq!(after.title_version, 3);
7068        assert_eq!(after.metadata_version, 5);
7069    }
7070
7071    #[tokio::test]
7072    async fn merge_now_preserves_disk_pinned_in_metadata_group() {
7073        let (_temp, storage) = make_storage().await;
7074        let session_id = "pinned-merge";
7075
7076        let mut on_disk = fresh(session_id);
7077        on_disk.pinned = true;
7078        on_disk.metadata_version = 2;
7079        storage.save_session(&on_disk).await.unwrap();
7080
7081        let mut runtime_copy = fresh(session_id);
7082        runtime_copy.created_at = on_disk.created_at;
7083        runtime_copy.pinned = false;
7084        runtime_copy.metadata_version = 0;
7085
7086        merge_save_session(&storage, &mut runtime_copy)
7087            .await
7088            .unwrap();
7089
7090        let after = storage.load_session(session_id).await.unwrap().unwrap();
7091        assert!(
7092            after.pinned,
7093            "disk pinned=true should win over runtime false"
7094        );
7095        assert_eq!(after.metadata_version, 2);
7096    }
7097
7098    #[tokio::test]
7099    async fn merge_keeps_in_memory_when_session_version_higher() {
7100        let (_temp, storage) = make_storage().await;
7101        let session_id = "merge-bumped";
7102
7103        let mut on_disk = fresh(session_id);
7104        on_disk.title = "Old".to_string();
7105        on_disk.title_version = 1;
7106        on_disk.metadata_version = 3;
7107        storage.save_session(&on_disk).await.unwrap();
7108
7109        let mut authoritative_copy = fresh(session_id);
7110        authoritative_copy.created_at = on_disk.created_at;
7111        authoritative_copy.title = "New Authoritative".to_string();
7112        authoritative_copy.title_version = 2;
7113        authoritative_copy.metadata_version = 4;
7114        authoritative_copy.pinned = true;
7115
7116        merge_save_session(&storage, &mut authoritative_copy)
7117            .await
7118            .unwrap();
7119
7120        let after = storage.load_session(session_id).await.unwrap().unwrap();
7121        assert_eq!(after.title, "New Authoritative");
7122        assert_eq!(after.title_version, 2);
7123        assert_eq!(after.metadata_version, 4);
7124        assert!(after.pinned);
7125    }
7126
7127    #[tokio::test]
7128    async fn merge_keeps_runtime_messages_when_disk_only_changed_metadata() {
7129        let (_temp, storage) = make_storage().await;
7130        let session_id = "merge-messages";
7131
7132        let mut on_disk = fresh(session_id);
7133        on_disk.title = "Fresh Title".to_string();
7134        on_disk.title_version = 2;
7135        on_disk.metadata_version = 5;
7136        storage.save_session(&on_disk).await.unwrap();
7137
7138        let mut runtime_copy = fresh(session_id);
7139        runtime_copy.created_at = on_disk.created_at;
7140        runtime_copy.title = "Stale".to_string();
7141        runtime_copy.metadata_version = 0;
7142        runtime_copy.messages = vec![bamboo_domain::session::types::Message {
7143            role: bamboo_domain::session::types::Role::User,
7144            content: "keep me".to_string(),
7145            id: "msg-1".to_string(),
7146            created_at: chrono::Utc::now(),
7147            reasoning: None,
7148            reasoning_signature: None,
7149            content_parts: None,
7150            image_ocr: None,
7151            phase: None,
7152            tool_calls: None,
7153            tool_call_id: None,
7154            tool_success: None,
7155            compressed: false,
7156            compressed_by_event_id: None,
7157            never_compress: false,
7158            compression_level: 0,
7159            metadata: None,
7160        }];
7161
7162        merge_save_session(&storage, &mut runtime_copy)
7163            .await
7164            .unwrap();
7165
7166        let after = storage.load_session(session_id).await.unwrap().unwrap();
7167        assert_eq!(after.title, "Fresh Title");
7168        assert_eq!(after.metadata_version, 5);
7169        assert_eq!(after.messages.len(), 1);
7170        assert_eq!(after.messages[0].content, "keep me");
7171    }
7172
7173    #[tokio::test]
7174    async fn runtime_control_plane_port_uses_sidecar_without_rewriting_messages() {
7175        use bamboo_domain::session::types::Message;
7176
7177        let (_temp, storage) = make_storage().await;
7178        let store = LockedSessionStore::new(storage.clone());
7179        let session_id = "runtime-control-plane";
7180
7181        let mut durable = fresh(session_id);
7182        durable.add_message(Message::user("durable transcript"));
7183        storage.save_session(&durable).await.unwrap();
7184
7185        let mut runtime = durable.clone();
7186        runtime.model = "updated-control-plane-model".to_string();
7187        runtime.add_message(Message::assistant("uncheckpointed runtime message", None));
7188        RuntimeSessionPersistence::save_runtime_control_plane(&store, &mut runtime)
7189            .await
7190            .unwrap();
7191
7192        let control_plane =
7193            RuntimeSessionPersistence::load_runtime_control_plane(&store, session_id)
7194                .await
7195                .unwrap()
7196                .expect("control-plane exists");
7197        assert!(
7198            control_plane.messages.is_empty(),
7199            "LockedSessionStore must expose its message-free sidecar"
7200        );
7201        assert_eq!(control_plane.model, "updated-control-plane-model");
7202
7203        let reloaded = storage
7204            .load_session(session_id)
7205            .await
7206            .unwrap()
7207            .expect("session exists");
7208        assert_eq!(reloaded.model, "updated-control-plane-model");
7209        assert_eq!(
7210            reloaded.messages.len(),
7211            1,
7212            "control-plane save must not write the uncheckpointed message"
7213        );
7214        assert_eq!(reloaded.messages[0].content, "durable transcript");
7215    }
7216
7217    #[tokio::test]
7218    async fn atomic_task_patch_loads_inside_lock_and_preserves_interleaved_runtime_state() {
7219        let temp = tempfile::tempdir().unwrap();
7220        let inner = Arc::new(
7221            SessionStoreV2::new(temp.path().to_path_buf())
7222                .await
7223                .expect("storage init"),
7224        );
7225        let session_id = "atomic-task-patch";
7226        inner
7227            .save_session(&fresh(session_id))
7228            .await
7229            .expect("seed session");
7230
7231        let counted = Arc::new(CountingControlPlaneStorage {
7232            inner: inner.clone(),
7233            control_plane_loads: AtomicUsize::new(0),
7234            full_saves: AtomicUsize::new(0),
7235            runtime_state_saves: AtomicUsize::new(0),
7236        });
7237        let storage: Arc<dyn Storage> = counted.clone();
7238        let store = Arc::new(LockedSessionStore::new(storage));
7239        let guard = store.acquire_lock(session_id).await;
7240        let now = chrono::Utc::now();
7241        let task_list = bamboo_domain::TaskList {
7242            session_id: session_id.to_string(),
7243            title: "Atomic Task patch".to_string(),
7244            items: Vec::new(),
7245            created_at: now,
7246            updated_at: now,
7247        };
7248        let (started_tx, started_rx) = tokio::sync::oneshot::channel();
7249        let patch_store = store.clone();
7250        let patch = tokio::spawn(async move {
7251            let _ = started_tx.send(());
7252            RuntimeSessionPersistence::update_task_list_control_plane(
7253                patch_store.as_ref(),
7254                session_id,
7255                &task_list,
7256                "9",
7257            )
7258            .await
7259        });
7260        started_rx.await.expect("patch task started");
7261        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
7262        assert_eq!(
7263            counted.control_plane_loads.load(Ordering::SeqCst),
7264            0,
7265            "Task patch must acquire the session lock before loading its snapshot"
7266        );
7267
7268        // Publish a newer unrelated runtime transition while the Task patch is
7269        // queued on the same session lock. Once the guard releases, the patch
7270        // must load this latest snapshot and change only Task-owned fields.
7271        let mut latest = inner
7272            .load_runtime_control_plane(session_id)
7273            .await
7274            .expect("load latest control-plane")
7275            .expect("control-plane exists");
7276        latest.agent_runtime_state = Some(bamboo_domain::AgentRuntimeState::new("latest-run"));
7277        latest
7278            .metadata
7279            .insert("concurrent.runtime".to_string(), "preserve".to_string());
7280        inner
7281            .save_runtime_state(&latest)
7282            .await
7283            .expect("publish concurrent runtime transition");
7284        drop(guard);
7285
7286        assert!(
7287            patch.await.expect("patch join").expect("patch succeeds"),
7288            "existing root must be patched"
7289        );
7290        assert_eq!(counted.control_plane_loads.load(Ordering::SeqCst), 1);
7291        let reloaded = inner
7292            .load_session(session_id)
7293            .await
7294            .expect("reload")
7295            .expect("session exists");
7296        assert_eq!(
7297            reloaded
7298                .agent_runtime_state
7299                .as_ref()
7300                .map(|state| state.run_id.as_str()),
7301            Some("latest-run")
7302        );
7303        assert_eq!(
7304            reloaded
7305                .metadata
7306                .get("concurrent.runtime")
7307                .map(String::as_str),
7308            Some("preserve")
7309        );
7310        assert_eq!(reloaded.task_list_version_meta().as_deref(), Some("9"));
7311        assert_eq!(
7312            reloaded.task_list.as_ref().map(|list| list.title.as_str()),
7313            Some("Atomic Task patch")
7314        );
7315    }
7316
7317    #[tokio::test]
7318    async fn paired_task_cas_conflict_cannot_overwrite_newer_root_or_child_state() {
7319        let (_temp, storage) = make_storage().await;
7320        let store = LockedSessionStore::new(storage.clone());
7321        let root_id = "task-cas-root";
7322        let child_id = "task-cas-child";
7323        let now = chrono::Utc::now();
7324        let task_list = |title: &str| bamboo_domain::TaskList {
7325            session_id: root_id.to_string(),
7326            title: title.to_string(),
7327            items: Vec::new(),
7328            created_at: now,
7329            updated_at: now,
7330        };
7331
7332        let mut root = fresh(root_id);
7333        root.set_task_list(task_list("newer root"));
7334        root.set_task_list_version_meta("2");
7335        storage.save_session(&root).await.expect("seed root");
7336        let mut child = Session::new_child(child_id, root_id, "model", "child");
7337        child.set_task_list(task_list("current child"));
7338        child.set_task_list_version_meta("1");
7339        storage.save_session(&child).await.expect("seed child");
7340
7341        let updated = RuntimeSessionPersistence::update_task_list_control_planes_if_version(
7342            &store,
7343            child_id,
7344            root_id,
7345            "1",
7346            &task_list("current child"),
7347            &task_list("stale evaluator"),
7348            "3",
7349        )
7350        .await
7351        .expect("CAS returns clean conflict");
7352        assert!(
7353            !updated,
7354            "mismatched root generation must reject both writes"
7355        );
7356
7357        let durable_root = storage
7358            .load_session(root_id)
7359            .await
7360            .expect("load root")
7361            .expect("root exists");
7362        let durable_child = storage
7363            .load_session(child_id)
7364            .await
7365            .expect("load child")
7366            .expect("child exists");
7367        assert_eq!(durable_root.task_list_version_meta().as_deref(), Some("2"));
7368        assert_eq!(
7369            durable_root
7370                .task_list
7371                .as_ref()
7372                .map(|list| list.title.as_str()),
7373            Some("newer root")
7374        );
7375        assert_eq!(durable_child.task_list_version_meta().as_deref(), Some("1"));
7376        assert_eq!(
7377            durable_child
7378                .task_list
7379                .as_ref()
7380                .map(|list| list.title.as_str()),
7381            Some("current child")
7382        );
7383    }
7384
7385    #[tokio::test]
7386    async fn paired_task_cas_success_uses_only_targeted_saves_and_preserves_both_transcripts() {
7387        use bamboo_domain::session::types::Message;
7388
7389        let temp = tempfile::tempdir().unwrap();
7390        let inner = Arc::new(
7391            SessionStoreV2::new(temp.path().to_path_buf())
7392                .await
7393                .expect("storage init"),
7394        );
7395        let root_id = "task-cas-success-root";
7396        let child_id = "task-cas-success-child";
7397        let now = chrono::Utc::now();
7398        let task_list = |title: &str| bamboo_domain::TaskList {
7399            session_id: root_id.to_string(),
7400            title: title.to_string(),
7401            items: Vec::new(),
7402            created_at: now,
7403            updated_at: now,
7404        };
7405
7406        let mut root = fresh(root_id);
7407        root.add_message(Message::user("root transcript"));
7408        root.metadata
7409            .insert("unrelated.root".to_string(), "preserve".to_string());
7410        root.agent_runtime_state = Some(bamboo_domain::AgentRuntimeState::new("root-run"));
7411        root.set_task_list(task_list("old shared"));
7412        root.set_task_list_version_meta("1");
7413        inner.save_session(&root).await.expect("seed root");
7414
7415        let mut child = Session::new_child(child_id, root_id, "model", "child");
7416        child.add_message(Message::user("child transcript"));
7417        child
7418            .metadata
7419            .insert("unrelated.child".to_string(), "preserve".to_string());
7420        child.agent_runtime_state = Some(bamboo_domain::AgentRuntimeState::new("child-run"));
7421        child.set_task_list(task_list("old shared"));
7422        child.set_task_list_version_meta("1");
7423        inner.save_session(&child).await.expect("seed child");
7424
7425        let counted = Arc::new(CountingControlPlaneStorage {
7426            inner: inner.clone(),
7427            control_plane_loads: AtomicUsize::new(0),
7428            full_saves: AtomicUsize::new(0),
7429            runtime_state_saves: AtomicUsize::new(0),
7430        });
7431        let storage: Arc<dyn Storage> = counted.clone();
7432        let store = LockedSessionStore::new(storage);
7433        assert!(
7434            RuntimeSessionPersistence::update_task_list_control_planes_if_version(
7435                &store,
7436                child_id,
7437                root_id,
7438                "1",
7439                &task_list("old shared"),
7440                &task_list("evaluated"),
7441                "2",
7442            )
7443            .await
7444            .expect("paired CAS succeeds")
7445        );
7446        assert_eq!(counted.control_plane_loads.load(Ordering::SeqCst), 2);
7447        assert_eq!(counted.runtime_state_saves.load(Ordering::SeqCst), 2);
7448        assert_eq!(
7449            counted.full_saves.load(Ordering::SeqCst),
7450            0,
7451            "evaluation CAS must not call full save_session for child or root"
7452        );
7453
7454        let durable_root = inner.load_session(root_id).await.unwrap().unwrap();
7455        let durable_child = inner.load_session(child_id).await.unwrap().unwrap();
7456        for (session, transcript, metadata_key, run_id) in [
7457            (
7458                &durable_root,
7459                "root transcript",
7460                "unrelated.root",
7461                "root-run",
7462            ),
7463            (
7464                &durable_child,
7465                "child transcript",
7466                "unrelated.child",
7467                "child-run",
7468            ),
7469        ] {
7470            assert_eq!(session.task_list_version_meta().as_deref(), Some("2"));
7471            assert_eq!(
7472                session.task_list.as_ref().map(|list| list.title.as_str()),
7473                Some("evaluated")
7474            );
7475            assert_eq!(session.messages.len(), 1);
7476            assert_eq!(session.messages[0].content, transcript);
7477            assert_eq!(
7478                session.metadata.get(metadata_key).map(String::as_str),
7479                Some("preserve")
7480            );
7481            assert_eq!(
7482                session
7483                    .agent_runtime_state
7484                    .as_ref()
7485                    .map(|state| state.run_id.as_str()),
7486                Some(run_id)
7487            );
7488        }
7489    }
7490
7491    #[tokio::test]
7492    async fn locked_runtime_and_full_saves_adopt_task_conflicts_before_publish() {
7493        let temp = tempfile::tempdir().unwrap();
7494        let home = temp.path().to_path_buf();
7495        let first_storage = Arc::new(
7496            SessionStoreV2::new(home.clone())
7497                .await
7498                .expect("first storage init"),
7499        );
7500        let second_storage = Arc::new(
7501            SessionStoreV2::new(home)
7502                .await
7503                .expect("second storage init"),
7504        );
7505        let now = chrono::Utc::now();
7506        let task_list = |session_id: &str, title: &str| bamboo_domain::TaskList {
7507            session_id: session_id.to_string(),
7508            title: title.to_string(),
7509            items: Vec::new(),
7510            created_at: now,
7511            updated_at: now,
7512        };
7513
7514        let runtime_id = "ordinary-task-retry-runtime";
7515        let full_id = "ordinary-task-retry-full";
7516        let mut runtime_initial = fresh(runtime_id);
7517        runtime_initial.set_task_list(task_list(runtime_id, "runtime v1"));
7518        runtime_initial.set_task_list_version_meta("1");
7519        first_storage
7520            .save_session(&runtime_initial)
7521            .await
7522            .expect("seed runtime session");
7523        let mut full_initial = fresh(full_id);
7524        full_initial.set_task_list(task_list(full_id, "full v1"));
7525        full_initial.set_task_list_version_meta("1");
7526        first_storage
7527            .save_session(&full_initial)
7528            .await
7529            .expect("seed full session");
7530
7531        let mut runtime_advanced = runtime_initial.clone();
7532        runtime_advanced.set_task_list(task_list(runtime_id, "runtime v2"));
7533        runtime_advanced.set_task_list_version_meta("2");
7534        second_storage
7535            .save_runtime_state(&runtime_advanced)
7536            .await
7537            .expect("advance runtime Task generation");
7538        let mut full_advanced = full_initial.clone();
7539        full_advanced.set_task_list(task_list(full_id, "full v2"));
7540        full_advanced.set_task_list_version_meta("2");
7541        second_storage
7542            .save_runtime_state(&full_advanced)
7543            .await
7544            .expect("advance full Task generation");
7545
7546        let storage: Arc<dyn Storage> = first_storage.clone();
7547        let store = LockedSessionStore::new(storage);
7548        let runtime_published = Arc::new(std::sync::Mutex::new(None));
7549        let runtime_callback = runtime_published.clone();
7550        let mut runtime_stale = runtime_initial;
7551        runtime_stale
7552            .metadata
7553            .insert("runtime.non-task".to_string(), "preserved".to_string());
7554        store
7555            .save_runtime_only_and_publish(&mut runtime_stale, move |saved| {
7556                *runtime_callback.lock().expect("runtime publish lock") = Some(saved.clone());
7557            })
7558            .await
7559            .expect("locked runtime save rebases and retries");
7560
7561        let full_published = Arc::new(std::sync::Mutex::new(None));
7562        let full_callback = full_published.clone();
7563        let mut full_stale = full_initial;
7564        full_stale
7565            .metadata
7566            .insert("full.non-task".to_string(), "preserved".to_string());
7567        store
7568            .merge_save_runtime_and_publish(&mut full_stale, move |saved, committed| {
7569                assert!(committed);
7570                *full_callback.lock().expect("full publish lock") = Some(saved.clone());
7571            })
7572            .await
7573            .expect("locked full save rebases and retries");
7574
7575        let durable_runtime = first_storage
7576            .load_session(runtime_id)
7577            .await
7578            .unwrap()
7579            .expect("durable runtime session");
7580        let durable_full = first_storage
7581            .load_session(full_id)
7582            .await
7583            .unwrap()
7584            .expect("durable full session");
7585        let published_runtime = runtime_published
7586            .lock()
7587            .expect("runtime publish lock")
7588            .clone()
7589            .expect("runtime published snapshot");
7590        let published_full = full_published
7591            .lock()
7592            .expect("full publish lock")
7593            .clone()
7594            .expect("full published snapshot");
7595
7596        for (session, expected_title, metadata_key) in [
7597            (&runtime_stale, "runtime v2", "runtime.non-task"),
7598            (&durable_runtime, "runtime v2", "runtime.non-task"),
7599            (&published_runtime, "runtime v2", "runtime.non-task"),
7600            (&full_stale, "full v2", "full.non-task"),
7601            (&durable_full, "full v2", "full.non-task"),
7602            (&published_full, "full v2", "full.non-task"),
7603        ] {
7604            assert_eq!(session.task_list_version_meta().as_deref(), Some("2"));
7605            assert_eq!(
7606                session.task_list.as_ref().map(|list| list.title.as_str()),
7607                Some(expected_title)
7608            );
7609            assert_eq!(
7610                session.metadata.get(metadata_key).map(String::as_str),
7611                Some("preserved")
7612            );
7613        }
7614    }
7615
7616    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
7617    async fn single_task_cas_rejects_same_version_divergent_snapshot_without_callback() {
7618        let (_temp, storage) = make_storage().await;
7619        let store = LockedSessionStore::new(storage.clone());
7620        let session_id = "single-task-exact-snapshot";
7621        let now = chrono::Utc::now();
7622        let task_list = |title: &str| bamboo_domain::TaskList {
7623            session_id: session_id.to_string(),
7624            title: title.to_string(),
7625            items: Vec::new(),
7626            created_at: now,
7627            updated_at: now,
7628        };
7629        let durable_winner = task_list("durable winner");
7630        let stale_snapshot = task_list("stale same-version snapshot");
7631        let mut session = fresh(session_id);
7632        session.set_task_list(durable_winner.clone());
7633        session.set_task_list_version_meta("1");
7634        storage.save_session(&session).await.expect("seed session");
7635
7636        let published = Arc::new(AtomicBool::new(false));
7637        let callback = published.clone();
7638        assert!(!store
7639            .update_task_list_control_plane_if_version_and_publish(
7640                session_id,
7641                "1",
7642                &stale_snapshot,
7643                &task_list("stale evaluation"),
7644                "2",
7645                move |_| callback.store(true, Ordering::SeqCst),
7646            )
7647            .await
7648            .expect("same-version divergence is a clean stale result"));
7649        assert!(!published.load(Ordering::SeqCst));
7650        let durable = storage
7651            .load_session(session_id)
7652            .await
7653            .unwrap()
7654            .expect("session remains");
7655        assert_eq!(durable.task_list_version_meta().as_deref(), Some("1"));
7656        assert_eq!(
7657            serde_json::to_value(&durable.task_list).expect("serialize durable Task list"),
7658            serde_json::to_value(Some(&durable_winner)).expect("serialize expected Task list")
7659        );
7660    }
7661
7662    #[tokio::test]
7663    async fn paired_task_cas_rejects_same_version_divergent_snapshot_without_callback() {
7664        let (_temp, storage) = make_storage().await;
7665        let store = LockedSessionStore::new(storage.clone());
7666        let root_id = "paired-task-exact-snapshot-root";
7667        let child_id = "paired-task-exact-snapshot-child";
7668        let now = chrono::Utc::now();
7669        let task_list = |title: &str| bamboo_domain::TaskList {
7670            session_id: root_id.to_string(),
7671            title: title.to_string(),
7672            items: Vec::new(),
7673            created_at: now,
7674            updated_at: now,
7675        };
7676        let durable_winner = task_list("durable winner");
7677        let stale_snapshot = task_list("stale same-version snapshot");
7678        let mut root = fresh(root_id);
7679        root.set_task_list(durable_winner.clone());
7680        root.set_task_list_version_meta("1");
7681        storage.save_session(&root).await.expect("seed root");
7682        let mut child = Session::new_child(child_id, root_id, "model", "child");
7683        child.set_task_list(durable_winner.clone());
7684        child.set_task_list_version_meta("1");
7685        storage.save_session(&child).await.expect("seed child");
7686
7687        let published = Arc::new(AtomicBool::new(false));
7688        let callback = published.clone();
7689        assert!(!store
7690            .update_task_list_control_planes_if_version_and_publish(
7691                child_id,
7692                root_id,
7693                "1",
7694                &stale_snapshot,
7695                &task_list("stale evaluation"),
7696                "2",
7697                move |_, _| callback.store(true, Ordering::SeqCst),
7698            )
7699            .await
7700            .expect("same-version divergence is a clean stale result"));
7701        assert!(!published.load(Ordering::SeqCst));
7702        for id in [child_id, root_id] {
7703            let durable = storage
7704                .load_session(id)
7705                .await
7706                .unwrap()
7707                .expect("session remains");
7708            assert_eq!(durable.task_list_version_meta().as_deref(), Some("1"));
7709            assert_eq!(
7710                serde_json::to_value(&durable.task_list).expect("serialize durable Task list"),
7711                serde_json::to_value(Some(&durable_winner)).expect("serialize expected Task list")
7712            );
7713        }
7714    }
7715
7716    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
7717    async fn unconditional_root_task_patch_reports_final_cas_conflict_without_publishing() {
7718        let temp = tempfile::tempdir().unwrap();
7719        let home = temp.path().to_path_buf();
7720        let first_inner = Arc::new(
7721            SessionStoreV2::new(home.clone())
7722                .await
7723                .expect("first storage init"),
7724        );
7725        let root_id = "single-task-unconditional-loser";
7726        let now = chrono::Utc::now();
7727        let task_list = |title: &str| bamboo_domain::TaskList {
7728            session_id: root_id.to_string(),
7729            title: title.to_string(),
7730            items: Vec::new(),
7731            created_at: now,
7732            updated_at: now,
7733        };
7734        let mut root = fresh(root_id);
7735        root.task_list = Some(task_list("original"));
7736        root.set_task_list_version_meta("1");
7737        first_inner.save_session(&root).await.expect("seed root");
7738        let second_inner = Arc::new(
7739            SessionStoreV2::new(home)
7740                .await
7741                .expect("second storage init"),
7742        );
7743        let commit_reached = Arc::new(tokio::sync::Barrier::new(2));
7744        let release_commit = Arc::new(tokio::sync::Barrier::new(2));
7745        let storage: Arc<dyn Storage> = Arc::new(SingleCommitPauseStorage {
7746            inner: first_inner.clone(),
7747            commit_reached: commit_reached.clone(),
7748            release_commit: release_commit.clone(),
7749        });
7750        let store = LockedSessionStore::new(storage);
7751        let published = Arc::new(AtomicBool::new(false));
7752        let callback = published.clone();
7753        let loser_candidate = task_list("loser");
7754
7755        let loser = store.update_task_list_control_plane_and_publish(
7756            root_id,
7757            &loser_candidate,
7758            "2",
7759            move |_| callback.store(true, Ordering::SeqCst),
7760        );
7761        let winner = async {
7762            commit_reached.wait().await;
7763            let original = second_inner
7764                .load_runtime_control_plane(root_id)
7765                .await
7766                .expect("load winner original")
7767                .expect("winner original exists");
7768            let mut updated = original.clone();
7769            updated.task_list = Some(task_list("winner"));
7770            updated.set_task_list_version_meta("2");
7771            assert!(second_inner
7772                .save_task_control_plane_if_matches(&original, &updated)
7773                .await
7774                .expect("commit winner"));
7775            release_commit.wait().await;
7776        };
7777        let (loser_result, ()) = tokio::join!(loser, winner);
7778        let error = loser_result.expect_err("unconditional loser must be an explicit conflict");
7779        assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock);
7780        assert!(!published.load(Ordering::SeqCst));
7781        let durable = first_inner
7782            .load_session(root_id)
7783            .await
7784            .unwrap()
7785            .expect("durable root");
7786        assert_eq!(durable.task_list_version_meta().as_deref(), Some("2"));
7787        assert_eq!(
7788            durable.task_list.as_ref().map(|list| list.title.as_str()),
7789            Some("winner")
7790        );
7791    }
7792
7793    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
7794    async fn independent_root_task_patches_have_one_final_cas_winner() {
7795        let temp = tempfile::tempdir().unwrap();
7796        let home = temp.path().to_path_buf();
7797        let first_inner = Arc::new(
7798            SessionStoreV2::new(home.clone())
7799                .await
7800                .expect("first storage init"),
7801        );
7802        let root_id = "single-task-cas-race-root";
7803        let now = chrono::Utc::now();
7804        let task_list = |title: &str| bamboo_domain::TaskList {
7805            session_id: root_id.to_string(),
7806            title: title.to_string(),
7807            items: Vec::new(),
7808            created_at: now,
7809            updated_at: now,
7810        };
7811        let mut root = fresh(root_id);
7812        root.set_task_list(task_list("original"));
7813        root.set_task_list_version_meta("1");
7814        first_inner.save_session(&root).await.expect("seed root");
7815        let second_inner = Arc::new(
7816            SessionStoreV2::new(home)
7817                .await
7818                .expect("second storage init"),
7819        );
7820        let before_commit = Arc::new(tokio::sync::Barrier::new(2));
7821        let first_storage: Arc<dyn Storage> = Arc::new(SingleCommitBarrierStorage {
7822            inner: first_inner.clone(),
7823            before_commit: before_commit.clone(),
7824        });
7825        let second_storage: Arc<dyn Storage> = Arc::new(SingleCommitBarrierStorage {
7826            inner: second_inner.clone(),
7827            before_commit,
7828        });
7829        let first_store = LockedSessionStore::new(first_storage);
7830        let second_store = LockedSessionStore::new(second_storage);
7831        let first_published = Arc::new(AtomicBool::new(false));
7832        let second_published = Arc::new(AtomicBool::new(false));
7833        let first_callback = first_published.clone();
7834        let second_callback = second_published.clone();
7835        let expected = task_list("original");
7836        let first_candidate = task_list("candidate one");
7837        let second_candidate = task_list("candidate two");
7838
7839        // Two expected-version patches stage from v1, but the storage final-CAS
7840        // admits only one regardless of process-local lock ownership.
7841        let first = first_store.update_task_list_control_plane_if_version_and_publish(
7842            root_id,
7843            "1",
7844            &expected,
7845            &first_candidate,
7846            "2",
7847            move |_| first_callback.store(true, Ordering::SeqCst),
7848        );
7849        let second = second_store.update_task_list_control_plane_if_version_and_publish(
7850            root_id,
7851            "1",
7852            &expected,
7853            &second_candidate,
7854            "2",
7855            move |_| second_callback.store(true, Ordering::SeqCst),
7856        );
7857        let (first_result, second_result) = tokio::join!(first, second);
7858        let first_won = first_result.expect("first root result");
7859        let second_won = second_result.expect("second root result");
7860        assert_ne!(first_won, second_won, "exactly one root candidate wins");
7861        assert_eq!(first_published.load(Ordering::SeqCst), first_won);
7862        assert_eq!(second_published.load(Ordering::SeqCst), second_won);
7863
7864        let durable = first_inner
7865            .load_session(root_id)
7866            .await
7867            .unwrap()
7868            .expect("root");
7869        assert_eq!(durable.task_list_version_meta().as_deref(), Some("2"));
7870        assert_eq!(
7871            durable.task_list.as_ref().map(|list| list.title.as_str()),
7872            Some(if first_won {
7873                "candidate one"
7874            } else {
7875                "candidate two"
7876            })
7877        );
7878    }
7879
7880    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
7881    async fn independent_locked_stores_revalidate_pair_cas_at_storage_commit_point() {
7882        let temp = tempfile::tempdir().unwrap();
7883        let home = temp.path().to_path_buf();
7884        let first_inner = Arc::new(
7885            SessionStoreV2::new(home.clone())
7886                .await
7887                .expect("first storage init"),
7888        );
7889        let root_id = "task-cas-race-root";
7890        let child_id = "task-cas-race-child";
7891        let now = chrono::Utc::now();
7892        let task_list = |title: &str| bamboo_domain::TaskList {
7893            session_id: root_id.to_string(),
7894            title: title.to_string(),
7895            items: Vec::new(),
7896            created_at: now,
7897            updated_at: now,
7898        };
7899
7900        let mut root = fresh(root_id);
7901        root.set_task_list(task_list("original shared"));
7902        root.set_task_list_version_meta("1");
7903        first_inner.save_session(&root).await.expect("seed root");
7904        let mut child = Session::new_child(child_id, root_id, "model", "child");
7905        child.set_task_list(task_list("original shared"));
7906        child.set_task_list_version_meta("1");
7907        first_inner.save_session(&child).await.expect("seed child");
7908
7909        let second_inner = Arc::new(
7910            SessionStoreV2::new(home)
7911                .await
7912                .expect("second storage init"),
7913        );
7914        let before_commit = Arc::new(tokio::sync::Barrier::new(2));
7915        let first_storage: Arc<dyn Storage> = Arc::new(PairCommitBarrierStorage {
7916            inner: first_inner.clone(),
7917            before_commit: before_commit.clone(),
7918        });
7919        let second_storage: Arc<dyn Storage> = Arc::new(PairCommitBarrierStorage {
7920            inner: second_inner.clone(),
7921            before_commit,
7922        });
7923        let first_store = LockedSessionStore::new(first_storage);
7924        let second_store = LockedSessionStore::new(second_storage);
7925        let first_published = Arc::new(AtomicBool::new(false));
7926        let second_published = Arc::new(AtomicBool::new(false));
7927        let first_published_callback = first_published.clone();
7928        let second_published_callback = second_published.clone();
7929        let expected = task_list("original shared");
7930        let first_candidate = task_list("candidate one");
7931        let second_candidate = task_list("candidate two");
7932
7933        let first = first_store.update_task_list_control_planes_if_version_and_publish(
7934            child_id,
7935            root_id,
7936            "1",
7937            &expected,
7938            &first_candidate,
7939            "2",
7940            move |_, _| first_published_callback.store(true, Ordering::SeqCst),
7941        );
7942        let second = second_store.update_task_list_control_planes_if_version_and_publish(
7943            child_id,
7944            root_id,
7945            "1",
7946            &expected,
7947            &second_candidate,
7948            "2",
7949            move |_, _| second_published_callback.store(true, Ordering::SeqCst),
7950        );
7951        let (first_result, second_result) = tokio::join!(first, second);
7952        let first_won = first_result.expect("first CAS result");
7953        let second_won = second_result.expect("second CAS result");
7954        assert_ne!(first_won, second_won, "exactly one staged v1 CAS may win");
7955        assert_eq!(first_published.load(Ordering::SeqCst), first_won);
7956        assert_eq!(second_published.load(Ordering::SeqCst), second_won);
7957
7958        let expected_title = if first_won {
7959            "candidate one"
7960        } else {
7961            "candidate two"
7962        };
7963        let durable_child = first_inner
7964            .load_session(child_id)
7965            .await
7966            .unwrap()
7967            .expect("child");
7968        let durable_root = second_inner
7969            .load_session(root_id)
7970            .await
7971            .unwrap()
7972            .expect("root");
7973        for session in [&durable_child, &durable_root] {
7974            assert_eq!(session.task_list_version_meta().as_deref(), Some("2"));
7975            assert_eq!(
7976                session.task_list.as_ref().map(|list| list.title.as_str()),
7977                Some(expected_title)
7978            );
7979        }
7980    }
7981
7982    #[tokio::test]
7983    async fn paired_task_second_write_failure_rolls_back_and_skips_publish_callback() {
7984        use bamboo_domain::session::types::Message;
7985
7986        let temp = tempfile::tempdir().unwrap();
7987        let inner = Arc::new(
7988            SessionStoreV2::new(temp.path().to_path_buf())
7989                .await
7990                .expect("storage init"),
7991        );
7992        let root_id = "task-cas-failure-root";
7993        let child_id = "task-cas-failure-child";
7994        let now = chrono::Utc::now();
7995        let task_list = |title: &str| bamboo_domain::TaskList {
7996            session_id: root_id.to_string(),
7997            title: title.to_string(),
7998            items: Vec::new(),
7999            created_at: now,
8000            updated_at: now,
8001        };
8002
8003        let mut root = fresh(root_id);
8004        root.add_message(Message::user("root transcript"));
8005        root.metadata
8006            .insert("unrelated.root".to_string(), "preserve".to_string());
8007        root.set_task_list(task_list("old shared"));
8008        root.set_task_list_version_meta("1");
8009        inner.save_session(&root).await.expect("seed root");
8010
8011        let mut child = Session::new_child(child_id, root_id, "model", "child");
8012        child.add_message(Message::user("child transcript"));
8013        child
8014            .metadata
8015            .insert("unrelated.child".to_string(), "preserve".to_string());
8016        child.set_task_list(task_list("old shared"));
8017        child.set_task_list_version_meta("1");
8018        inner.save_session(&child).await.expect("seed child");
8019
8020        inner
8021            .inject_runtime_task_transaction_fault(RuntimeTaskTransactionFault::SecondUpdatedWrite);
8022        let storage: Arc<dyn Storage> = inner.clone();
8023        let store = LockedSessionStore::new(storage);
8024        let published = Arc::new(AtomicBool::new(false));
8025        let published_for_callback = published.clone();
8026        let error = store
8027            .update_task_list_control_planes_if_version_and_publish(
8028                child_id,
8029                root_id,
8030                "1",
8031                &task_list("old shared"),
8032                &task_list("must roll back"),
8033                "2",
8034                move |_, _| published_for_callback.store(true, Ordering::SeqCst),
8035            )
8036            .await
8037            .expect_err("injected second write fails");
8038        assert!(error.to_string().contains("rolled back"), "{error}");
8039        assert!(
8040            !published.load(Ordering::SeqCst),
8041            "durable failure must not publish either cache snapshot"
8042        );
8043
8044        let durable_root = inner.load_session(root_id).await.unwrap().unwrap();
8045        let durable_child = inner.load_session(child_id).await.unwrap().unwrap();
8046        for (session, title, transcript, metadata_key) in [
8047            (
8048                &durable_root,
8049                "old shared",
8050                "root transcript",
8051                "unrelated.root",
8052            ),
8053            (
8054                &durable_child,
8055                "old shared",
8056                "child transcript",
8057                "unrelated.child",
8058            ),
8059        ] {
8060            assert_eq!(session.task_list_version_meta().as_deref(), Some("1"));
8061            assert_eq!(
8062                session.task_list.as_ref().map(|list| list.title.as_str()),
8063                Some(title)
8064            );
8065            assert_eq!(session.messages[0].content, transcript);
8066            assert_eq!(
8067                session.metadata.get(metadata_key).map(String::as_str),
8068                Some("preserve")
8069            );
8070        }
8071    }
8072
8073    // ── LockedSessionStore tests ────────────────────────────────────
8074
8075    #[tokio::test]
8076    async fn locked_merge_save_runtime_serialises_concurrent_writes() {
8077        let (_temp, storage) = make_storage().await;
8078        let store = Arc::new(LockedSessionStore::new(storage));
8079        let session_id = "lock-serial".to_string();
8080
8081        // Seed with base version.
8082        let base = fresh(&session_id);
8083        store.storage().save_session(&base).await.unwrap();
8084
8085        // Two concurrent authorised writers each bump and commit.
8086        // We'll simulate via clone-and-bump-then-commit.
8087        let store_a = store.clone();
8088        let store_b = store.clone();
8089        let sid_a = session_id.clone();
8090        let sid_b = session_id.clone();
8091
8092        let a = tokio::spawn(async move {
8093            let _guard = store_a.acquire_lock(&sid_a).await;
8094            let mut s = store_a
8095                .storage()
8096                .load_session(&sid_a)
8097                .await
8098                .unwrap()
8099                .unwrap();
8100            s.title = "Writer A".to_string();
8101            s.title_version = s.title_version.saturating_add(1);
8102            s.metadata_version = s.metadata_version.saturating_add(1);
8103            s.updated_at = chrono::Utc::now();
8104            store_a.storage().save_session(&s).await.unwrap();
8105            s.title_version
8106        });
8107
8108        // Tiny yield so A goes first.
8109        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
8110
8111        let b = tokio::spawn(async move {
8112            let _guard = store_b.acquire_lock(&sid_b).await;
8113            let mut s = store_b
8114                .storage()
8115                .load_session(&sid_b)
8116                .await
8117                .unwrap()
8118                .unwrap();
8119            s.title = "Writer B".to_string();
8120            s.title_version = s.title_version.saturating_add(1);
8121            s.metadata_version = s.metadata_version.saturating_add(1);
8122            s.updated_at = chrono::Utc::now();
8123            store_b.storage().save_session(&s).await.unwrap();
8124            s.title_version
8125        });
8126
8127        let (ver_a, ver_b) = tokio::join!(a, b);
8128        let final_s = store
8129            .storage()
8130            .load_session(&session_id)
8131            .await
8132            .unwrap()
8133            .unwrap();
8134        assert!(
8135            ver_a.unwrap() != ver_b.unwrap(),
8136            "concurrent writers must produce distinct versions"
8137        );
8138        assert_eq!(final_s.metadata_version, 2);
8139    }
8140
8141    #[tokio::test]
8142    async fn commit_metadata_is_plain_save_inside_lock() {
8143        let (_temp, storage) = make_storage().await;
8144        let store = LockedSessionStore::new(storage);
8145        let session_id = "commit-plain";
8146
8147        let mut s = fresh(session_id);
8148        s.title = "Committed".to_string();
8149        s.metadata_version = 1;
8150        s.title_version = 2;
8151
8152        store.commit_metadata(&s).await.unwrap();
8153
8154        let after = store
8155            .storage()
8156            .load_session(session_id)
8157            .await
8158            .unwrap()
8159            .unwrap();
8160        assert_eq!(after.title, "Committed");
8161        assert_eq!(after.metadata_version, 1);
8162        assert_eq!(after.title_version, 2);
8163    }
8164
8165    // ── Self-cleaning per-session lock (issue #346) ─────────────────
8166
8167    #[tokio::test]
8168    async fn acquire_lock_self_evicts_when_no_other_holder() {
8169        let (_temp, storage) = make_storage().await;
8170        let store = LockedSessionStore::new(storage);
8171
8172        {
8173            let _guard = store.acquire_lock("solo").await;
8174            assert_eq!(store.locks.len(), 1, "entry present while the lock is held");
8175        }
8176        // Dropping the guard runs the self-cleaning `remove_if`. Without the
8177        // eviction logic this stays at 1 forever (the pre-#346 leak).
8178        assert_eq!(
8179            store.locks.len(),
8180            0,
8181            "lock entry must be evicted once released with no other holder"
8182        );
8183    }
8184
8185    #[tokio::test]
8186    async fn acquire_lock_many_distinct_ids_do_not_accumulate() {
8187        let (_temp, storage) = make_storage().await;
8188        let store = LockedSessionStore::new(storage);
8189
8190        // Serially acquire+release for 100 distinct session ids.
8191        for i in 0..100 {
8192            let _guard = store.acquire_lock(&format!("sess-{i}")).await;
8193        }
8194        assert_eq!(
8195            store.locks.len(),
8196            0,
8197            "acquiring locks for many distinct ids must not grow the map"
8198        );
8199    }
8200
8201    #[tokio::test]
8202    async fn cancelled_last_waiter_reclaims_hundreds_of_session_locks() {
8203        let (_temp, storage) = make_storage().await;
8204        let store = LockedSessionStore::new(storage);
8205        for index in 0..512 {
8206            let id = format!("cancelled-child-{index}");
8207            let held = store.acquire_lock(&id).await;
8208            let mut waiter = Box::pin(store.acquire_lock(&id));
8209            assert!(
8210                std::future::poll_fn(|cx| std::task::Poll::Ready(
8211                    std::future::Future::poll(waiter.as_mut(), cx).is_pending()
8212                ))
8213                .await
8214            );
8215            drop(held);
8216            // Cancel after the previous holder handed ownership to the waiter,
8217            // but before the waiter is polled again to construct its guard.
8218            drop(waiter);
8219        }
8220        assert!(store.locks.is_empty());
8221    }
8222
8223    #[tokio::test]
8224    async fn acquire_lock_concurrent_waiter_keeps_valid_lock_and_map_drains() {
8225        use std::sync::atomic::{AtomicUsize, Ordering};
8226
8227        let (_temp, storage) = make_storage().await;
8228        let store = Arc::new(LockedSessionStore::new(storage));
8229
8230        // Tracks concurrent holders of the SAME session lock; must never exceed 1.
8231        let active = Arc::new(AtomicUsize::new(0));
8232        let max_seen = Arc::new(AtomicUsize::new(0));
8233
8234        let mut handles = Vec::new();
8235        for _ in 0..8 {
8236            let store = store.clone();
8237            let active = active.clone();
8238            let max_seen = max_seen.clone();
8239            handles.push(tokio::spawn(async move {
8240                let _guard = store.acquire_lock("contended").await;
8241                let now = active.fetch_add(1, Ordering::SeqCst) + 1;
8242                max_seen.fetch_max(now, Ordering::SeqCst);
8243                // Hold briefly so the other tasks actually queue on the mutex.
8244                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
8245                active.fetch_sub(1, Ordering::SeqCst);
8246            }));
8247        }
8248        for h in handles {
8249            h.await.unwrap();
8250        }
8251
8252        // Mutual exclusion must hold: a self-cleaning removal that raced (removed
8253        // the entry a waiter had already cloned, letting a later task create and
8254        // lock a *second* mutex for the same id) would show 2 concurrent holders.
8255        // `remove_if`'s atomic strong-count check under the shard lock prevents it.
8256        assert_eq!(
8257            max_seen.load(Ordering::SeqCst),
8258            1,
8259            "at most one holder of a given session lock at a time"
8260        );
8261        assert_eq!(
8262            store.locks.len(),
8263            0,
8264            "after all holders release, the contended entry must be fully evicted"
8265        );
8266    }
8267}