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