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