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