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