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