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