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