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