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