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