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