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