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