1use std::collections::{BTreeSet, HashMap};
7use std::future::Future;
8use std::pin::Pin;
9use std::sync::Arc;
10
11use tokio::sync::{broadcast, mpsc, RwLock};
12use tokio_util::sync::CancellationToken;
13use tracing::Instrument;
14
15use bamboo_agent_core::tools::ToolExecutor;
16use bamboo_agent_core::{AgentError, AgentEvent, Session};
17use bamboo_domain::ReasoningEffort;
18use bamboo_llm::LLMProvider;
19
20use crate::runtime::config::{
21 AuxiliaryModelConfig, BashCompletionSink, BashResumeHook, DisabledFilterResolver, GoldConfig,
22 GuardianConfig, GuardianSpawner, ImageFallbackConfig,
23};
24use crate::runtime::execution::child_completion::ChildCompletion;
25use crate::runtime::execution::event_forwarder::HistoryCommitBarrier;
26use crate::runtime::execution::runner_lifecycle::{
27 finalize_rejected_runner_if_distinct, finalize_runner, finalize_runner_exact,
28 reserve_runner_core, ReserveOutcome, RunnerReservation,
29};
30use crate::runtime::execution::runner_state::AgentRunner;
31use crate::runtime::model_roster::ModelRoster;
32use crate::runtime::Agent;
33use crate::runtime::{ExecuteRequest, ExecuteRequestBuilder};
34use crate::session_activation::{
35 SessionActivationRouter, SessionRunRegistration, SessionRunRegistrationError,
36};
37
38pub use crate::session_cache::SessionCache;
39
40enum SessionExecutionActivationOwnership {
41 Unrouted,
43 UnpublishedActivation(Arc<SessionActivationRouter>),
46 RegistrationPending(Arc<SessionActivationRouter>),
51 Registered(SessionRunRegistration),
54}
55
56pub struct SessionExecutionReservation {
62 session_id: String,
63 run_id: String,
64 cancel_token: CancellationToken,
65 runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
66 activation: SessionExecutionActivationOwnership,
67 root_actor: Option<Box<RootActorExecution>>,
68 execution_persistence: Option<Arc<dyn bamboo_domain::RuntimeSessionPersistence>>,
69 persistence_bound: bool,
70 armed: bool,
71}
72
73impl SessionExecutionReservation {
74 pub fn session_id(&self) -> &str {
75 &self.session_id
76 }
77
78 pub fn run_id(&self) -> &str {
79 &self.run_id
80 }
81
82 pub fn cancel_token(&self) -> &CancellationToken {
83 &self.cancel_token
84 }
85
86 pub async fn bind_root_actor(
89 &mut self,
90 agent: &Agent,
91 session: &Session,
92 ) -> std::io::Result<()> {
93 if self.root_actor.is_none() {
94 if self.session_id != session.id {
95 return Err(std::io::Error::new(
96 std::io::ErrorKind::InvalidInput,
97 "Root binding target mismatch",
98 ));
99 }
100 if let Some(binding) = agent
101 .persistence()
102 .bind_root_actor_execution(session, &self.run_id)
103 .await?
104 {
105 self.root_actor = Some(Box::new(RootActorExecution::start(binding)));
106 }
107 }
108 self.bind_execution_persistence(agent, session)
109 }
110
111 fn bind_execution_persistence(
112 &mut self,
113 agent: &Agent,
114 session: &Session,
115 ) -> std::io::Result<()> {
116 if self.session_id != session.id {
117 return Err(std::io::Error::other(
118 "execution persistence target mismatch",
119 ));
120 }
121 if self.persistence_bound {
122 return Ok(());
123 }
124 let persistence = self
125 .root_actor
126 .as_ref()
127 .map_or_else(|| agent.persistence().clone(), |owner| owner.persistence());
128 if let Some(inherited) = persistence
129 .inherited_child_wait()
130 .or_else(|| bamboo_domain::InheritedChildWait::capture(session))
131 {
132 inherited.validate_session(session)?;
133 self.execution_persistence = Some(persistence.bind_inherited_child_wait(inherited)?);
134 } else if self.root_actor.is_some() {
135 self.execution_persistence = Some(persistence);
136 }
137 self.persistence_bound = true;
138 Ok(())
139 }
140
141 pub fn execution_persistence(
142 &self,
143 ) -> Option<Arc<dyn bamboo_domain::RuntimeSessionPersistence>> {
144 self.execution_persistence.clone()
145 }
146
147 pub fn root_actor_writer(&self) -> Option<bamboo_domain::RootActorRuntimeWrite> {
148 self.root_actor
149 .as_ref()
150 .map(|owner| owner.binding.owner.clone())
151 }
152
153 pub fn queue_root_response_event(&mut self, event: AgentEvent) -> Option<AgentEvent> {
156 let Some(owner) = self.root_actor.as_mut() else {
157 return Some(event);
158 };
159 owner.response_events.push(event);
160 None
161 }
162
163 pub fn take_root_response_events(&mut self) -> Vec<AgentEvent> {
164 self.root_actor
165 .as_mut()
166 .map(|owner| std::mem::take(&mut owner.response_events))
167 .unwrap_or_default()
168 }
169
170 pub async fn finish_root_actor(&mut self, outcome: bamboo_domain::ActorActivationFinish) {
173 if let Some(owner) = self.root_actor.take() {
174 (*owner).finish(outcome).await;
175 }
176 }
177
178 pub(crate) fn from_activation_placeholder(
184 session_id: impl Into<String>,
185 reservation: RunnerReservation,
186 router: Arc<SessionActivationRouter>,
187 runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
188 ) -> Self {
189 Self {
190 session_id: session_id.into(),
191 run_id: reservation.run_id,
192 cancel_token: reservation.cancel_token,
193 runners,
194 activation: SessionExecutionActivationOwnership::UnpublishedActivation(router),
195 root_actor: None,
196 execution_persistence: None,
197 persistence_bound: false,
198 armed: true,
199 }
200 }
201
202 pub(crate) fn from_pending_registration(
206 session_id: impl Into<String>,
207 reservation: RunnerReservation,
208 router: Option<Arc<SessionActivationRouter>>,
209 runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
210 ) -> Self {
211 let activation = match router {
212 Some(router) => SessionExecutionActivationOwnership::RegistrationPending(router),
213 None => SessionExecutionActivationOwnership::Unrouted,
214 };
215 Self {
216 session_id: session_id.into(),
217 run_id: reservation.run_id,
218 cancel_token: reservation.cancel_token,
219 runners,
220 activation,
221 root_actor: None,
222 execution_persistence: None,
223 persistence_bound: false,
224 armed: true,
225 }
226 }
227
228 pub(crate) fn mark_activation_published(&mut self) {
231 let activation = std::mem::replace(
232 &mut self.activation,
233 SessionExecutionActivationOwnership::Unrouted,
234 );
235 self.activation = match activation {
236 SessionExecutionActivationOwnership::UnpublishedActivation(router) => {
237 SessionExecutionActivationOwnership::RegistrationPending(router)
238 }
239 other => other,
240 };
241 }
242
243 pub(crate) async fn rollback_unpublished_activation(mut self) {
248 self.armed = false;
249 self.cancel_token.cancel();
250 self.finish_root_actor(bamboo_domain::ActorActivationFinish::Cancelled)
251 .await;
252 let activation = std::mem::replace(
253 &mut self.activation,
254 SessionExecutionActivationOwnership::Unrouted,
255 );
256 debug_assert!(matches!(
257 activation,
258 SessionExecutionActivationOwnership::UnpublishedActivation(_)
259 ));
260 remove_runner_exact(&self.runners, &self.session_id, &self.run_id).await;
261 }
262
263 pub async fn ensure_registered(&mut self) -> Result<(), SessionRunRegistrationError> {
269 let router = match &self.activation {
270 SessionExecutionActivationOwnership::RegistrationPending(router) => router.clone(),
271 SessionExecutionActivationOwnership::UnpublishedActivation(_) => {
272 unreachable!("unpublished activation entered an execution adapter")
273 }
274 SessionExecutionActivationOwnership::Unrouted
275 | SessionExecutionActivationOwnership::Registered(_) => return Ok(()),
276 };
277 match register_reserved_activation(router, &self.runners, &self.session_id, &self.run_id)
278 .await
279 {
280 Ok(registration) => {
281 self.activation = SessionExecutionActivationOwnership::Registered(registration);
282 Ok(())
283 }
284 Err(error) => {
285 self.disarm_after_registration_rejection(error.existing_run_id());
290 Err(error)
291 }
292 }
293 }
294
295 pub async fn abandon(mut self) {
299 self.armed = false;
300 self.cancel_token.cancel();
301 if let Some(owner) = self.root_actor.take() {
302 (*owner)
303 .finish(bamboo_domain::ActorActivationFinish::Cancelled)
304 .await;
305 }
306 let activation = std::mem::replace(
307 &mut self.activation,
308 SessionExecutionActivationOwnership::Unrouted,
309 );
310 let runners = self.runners.clone();
311 let session_id = self.session_id.clone();
312 let run_id = self.run_id.clone();
313 let cleanup_session_id = session_id.clone();
314 let cleanup_run_id = run_id.clone();
315 let cleanup = tokio::spawn(async move {
316 cleanup_execution_reservation(activation, runners, cleanup_session_id, cleanup_run_id)
317 .await;
318 });
319 if let Err(error) = cleanup.await {
320 tracing::error!(
321 %session_id,
322 %run_id,
323 %error,
324 "detached execution-reservation cleanup failed"
325 );
326 }
327 }
328
329 fn disarm_after_registration_rejection(&mut self, existing_run_id: &str) {
330 if existing_run_id != self.run_id {
331 self.cancel_token.cancel();
332 }
333 self.armed = false;
334 }
335
336 pub(crate) fn matches_execution_target(
337 &self,
338 session_id: &str,
339 domain_session_id: &str,
340 runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
341 ) -> bool {
342 self.session_id == session_id
343 && domain_session_id == session_id
344 && Arc::ptr_eq(&self.runners, runners)
345 }
346
347 pub(crate) fn disarm_for_execution(
348 &mut self,
349 ) -> (CancellationToken, Option<SessionRunRegistration>) {
350 self.armed = false;
351 let registration = match std::mem::replace(
352 &mut self.activation,
353 SessionExecutionActivationOwnership::Unrouted,
354 ) {
355 SessionExecutionActivationOwnership::Registered(registration) => Some(registration),
356 SessionExecutionActivationOwnership::Unrouted => None,
357 SessionExecutionActivationOwnership::UnpublishedActivation(_) => {
358 unreachable!("unpublished activation cannot transfer to execution")
359 }
360 SessionExecutionActivationOwnership::RegistrationPending(_) => {
361 unreachable!("ensure_registered adopts every pending router registration")
362 }
363 };
364 (self.cancel_token.clone(), registration)
365 }
366}
367
368impl Drop for SessionExecutionReservation {
369 fn drop(&mut self) {
370 if !self.armed {
371 return;
372 }
373 self.armed = false;
374 self.cancel_token.cancel();
375 let activation = std::mem::replace(
376 &mut self.activation,
377 SessionExecutionActivationOwnership::Unrouted,
378 );
379 let runners = self.runners.clone();
380 let session_id = self.session_id.clone();
381 let run_id = self.run_id.clone();
382 if let Ok(runtime) = tokio::runtime::Handle::try_current() {
383 runtime.spawn(async move {
384 cleanup_execution_reservation(activation, runners, session_id, run_id).await;
385 });
386 }
387 }
388}
389
390pub enum SessionExecutionReserveOutcome {
392 Reserved(SessionExecutionReservation),
393 AlreadyRunning { run_id: String },
394}
395
396async fn register_reserved_activation(
397 router: Arc<SessionActivationRouter>,
398 runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
399 session_id: &str,
400 run_id: &str,
401) -> Result<SessionRunRegistration, SessionRunRegistrationError> {
402 let mut registration = match router.register_run(session_id, run_id).await {
403 Ok(registration) => registration,
404 Err(error) => {
405 let collision = Err(AgentError::LLM(error.to_string()));
406 finalize_rejected_runner_if_distinct(
407 runners,
408 session_id,
409 error.existing_run_id(),
410 run_id,
411 &collision,
412 )
413 .await;
414 return Err(error);
415 }
416 };
417 let cleanup_runners = runners.clone();
418 let cleanup_session_id = session_id.to_string();
419 let cleanup_run_id = run_id.to_string();
420 registration.set_abort_cleanup(move || async move {
421 let abandoned = Err(AgentError::Cancelled);
422 finalize_runner_exact(
423 &cleanup_runners,
424 &cleanup_session_id,
425 &cleanup_run_id,
426 &abandoned,
427 )
428 .await;
429 });
430 Ok(registration)
431}
432
433async fn cleanup_execution_reservation(
434 activation: SessionExecutionActivationOwnership,
435 runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
436 session_id: String,
437 run_id: String,
438) {
439 match activation {
440 SessionExecutionActivationOwnership::Registered(registration) => {
441 registration.abandon().await;
442 }
443 SessionExecutionActivationOwnership::RegistrationPending(router) => {
444 match register_reserved_activation(router, &runners, &session_id, &run_id).await {
445 Ok(registration) => registration.abandon().await,
446 Err(error) => {
447 tracing::debug!(
448 %session_id,
449 %run_id,
450 existing_run_id = %error.existing_run_id(),
451 "abandoned activation placeholder was already adopted or superseded"
452 );
453 }
454 }
455 }
456 SessionExecutionActivationOwnership::UnpublishedActivation(_) => {
457 remove_runner_exact(&runners, &session_id, &run_id).await;
458 }
459 SessionExecutionActivationOwnership::Unrouted => {
460 let abandoned = Err(AgentError::Cancelled);
461 finalize_runner_exact(&runners, &session_id, &run_id, &abandoned).await;
462 }
463 }
464}
465
466async fn remove_runner_exact(
467 runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
468 session_id: &str,
469 run_id: &str,
470) -> bool {
471 let mut runners = runners.write().await;
472 if runners
473 .get(session_id)
474 .is_some_and(|runner| runner.run_id == run_id)
475 {
476 super::runner_lifecycle::remove_runner_entry(&mut runners, session_id).await;
477 true
478 } else {
479 false
480 }
481}
482
483pub async fn reserve_session_execution(
491 agent: &Arc<Agent>,
492 runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
493 senders: &Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
494 session_id: &str,
495 event_sender: &broadcast::Sender<AgentEvent>,
496) -> SessionExecutionReserveOutcome {
497 let reservation = match reserve_runner_core(runners, senders, session_id, event_sender).await {
498 ReserveOutcome::AlreadyRunning(run_id) => {
499 return SessionExecutionReserveOutcome::AlreadyRunning { run_id };
500 }
501 ReserveOutcome::Reserved(reservation) => reservation,
502 };
503
504 let mut execution_reservation = SessionExecutionReservation {
510 session_id: session_id.to_string(),
511 run_id: reservation.run_id,
512 cancel_token: reservation.cancel_token,
513 runners: runners.clone(),
514 activation: SessionExecutionActivationOwnership::Unrouted,
515 root_actor: None,
516 execution_persistence: None,
517 persistence_bound: false,
518 armed: true,
519 };
520
521 if let Some(router) = agent.activation_router().cloned() {
522 execution_reservation.activation =
523 SessionExecutionActivationOwnership::RegistrationPending(router);
524 if let Err(error) = execution_reservation.ensure_registered().await {
525 tracing::warn!(
526 %session_id,
527 attempted_run_id = %execution_reservation.run_id(),
528 existing_run_id = %error.existing_run_id(),
529 %error,
530 "runner reservation collided with an existing logical-session owner"
531 );
532 return SessionExecutionReserveOutcome::AlreadyRunning {
533 run_id: error.existing_run_id().to_string(),
534 };
535 }
536 }
537 SessionExecutionReserveOutcome::Reserved(execution_reservation)
538}
539
540pub fn read_cached_session(cache: &SessionCache, id: &str) -> Option<bamboo_agent_core::Session> {
548 cache
549 .get(id)
550 .map(|e| e.value().clone())
551 .map(|a| a.read().clone())
552}
553
554const SKILL_CONTEXT_START_MARKER: &str = "<!-- BAMBOO_SKILL_CONTEXT_START -->";
555const TOOL_GUIDE_START_MARKER: &str = "<!-- BAMBOO_TOOL_GUIDE_START -->";
556const EXTERNAL_MEMORY_START_MARKER: &str = "<!-- BAMBOO_EXTERNAL_MEMORY_START -->";
557const TASK_LIST_START_MARKER: &str = "<!-- BAMBOO_TASK_LIST_START -->";
558
559pub struct SessionExecutionOutcome {
566 pub success: bool,
568 pub cancelled: bool,
570 pub error: Option<String>,
572}
573
574impl SessionExecutionOutcome {
575 fn from_result(result: &Result<(), AgentError>) -> Self {
576 match result {
577 Ok(()) => Self {
578 success: true,
579 cancelled: false,
580 error: None,
581 },
582 Err(error) => Self {
583 success: false,
584 cancelled: error.is_cancelled(),
585 error: Some(error.to_string()),
586 },
587 }
588 }
589}
590
591pub type SessionCompletionHook = Box<
601 dyn for<'a> FnOnce(
602 SessionExecutionOutcome,
603 &'a mut Session,
604 ) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>>
605 + Send,
606>;
607
608pub struct SessionExecutionArgs {
614 pub agent: Arc<Agent>,
616 pub session_id: String,
617 pub session: Session,
618 pub execution_reservation: SessionExecutionReservation,
621
622 pub tools_override: Option<Arc<dyn ToolExecutor>>,
624 pub provider_override: Option<Arc<dyn LLMProvider>>,
625 pub model_roster: ModelRoster,
629 pub reasoning_effort: Option<ReasoningEffort>,
630 pub reasoning_effort_source: String,
631 pub auxiliary_model_resolver:
632 Option<Arc<dyn Fn() -> crate::runtime::config::AuxiliaryModelConfig + Send + Sync>>,
633 pub disabled_filter_resolver: Option<DisabledFilterResolver>,
637 pub disabled_tools: Option<BTreeSet<String>>,
638 pub disabled_skill_ids: Option<BTreeSet<String>>,
639 pub selected_skill_ids: Option<Vec<String>>,
640 pub selected_skill_mode: Option<String>,
641 pub mpsc_tx: mpsc::Sender<AgentEvent>,
642 pub history_commit_barrier: HistoryCommitBarrier,
645 pub image_fallback: Option<ImageFallbackConfig>,
646 pub gold_config: Option<GoldConfig>,
647 pub guardian_config: Option<GuardianConfig>,
649 pub guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
652 pub bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
654 pub bash_completion_sink: Option<Arc<dyn BashCompletionSink>>,
656 pub app_data_dir: Option<std::path::PathBuf>,
657 pub run_budget: Option<bamboo_config::RunBudgetConfig>,
660
661 pub runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
663 pub sessions_cache: SessionCache,
664
665 pub on_complete: Option<SessionCompletionHook>,
668
669 pub child_completion_handler: Option<Arc<dyn super::ChildCompletionHandler>>,
681}
682
683struct ExecuteRequestParams {
693 tools: Option<Arc<dyn ToolExecutor>>,
694 provider_override: Option<Arc<dyn LLMProvider>>,
695 model_roster: ModelRoster,
696 reasoning_effort: Option<ReasoningEffort>,
697 auxiliary_model_resolver: Option<Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync>>,
698 disabled_filter_resolver: Option<DisabledFilterResolver>,
699 disabled_tools: Option<BTreeSet<String>>,
700 disabled_skill_ids: Option<BTreeSet<String>>,
701 selected_skill_ids: Option<Vec<String>>,
702 selected_skill_mode: Option<String>,
703 image_fallback: Option<ImageFallbackConfig>,
704 gold_config: Option<GoldConfig>,
705 guardian_config: Option<GuardianConfig>,
706 guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
707 bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
708 bash_completion_sink: Option<Arc<dyn BashCompletionSink>>,
709 app_data_dir: Option<std::path::PathBuf>,
710 run_budget: Option<bamboo_config::RunBudgetConfig>,
711}
712
713fn build_execute_request(
720 initial_message: String,
721 event_tx: mpsc::Sender<AgentEvent>,
722 cancel_token: CancellationToken,
723 params: ExecuteRequestParams,
724) -> ExecuteRequest {
725 let ExecuteRequestParams {
726 tools,
727 provider_override,
728 model_roster,
729 reasoning_effort,
730 auxiliary_model_resolver,
731 disabled_filter_resolver,
732 disabled_tools,
733 disabled_skill_ids,
734 selected_skill_ids,
735 selected_skill_mode,
736 image_fallback,
737 gold_config,
738 guardian_config,
739 guardian_spawner,
740 bash_resume_hook,
741 bash_completion_sink,
742 app_data_dir,
743 run_budget,
744 } = params;
745
746 let mut builder = ExecuteRequestBuilder::new(initial_message, event_tx, cancel_token)
747 .model_roster(model_roster)
748 .gold_config(gold_config)
749 .guardian_config(guardian_config)
750 .guardian_spawner(guardian_spawner)
751 .bash_resume_hook(bash_resume_hook)
752 .bash_completion_sink(bash_completion_sink);
753
754 if let Some(run_budget) = run_budget {
755 builder = builder.run_budget(run_budget);
756 }
757
758 if let Some(tools) = tools {
759 builder = builder.tools(tools);
760 }
761 if let Some(provider_override) = provider_override {
762 builder = builder.provider_override(provider_override);
763 }
764 if let Some(reasoning_effort) = reasoning_effort {
765 builder = builder.reasoning_effort(reasoning_effort);
766 }
767 if let Some(disabled_filter_resolver) = disabled_filter_resolver {
768 builder = builder.disabled_filter_resolver(disabled_filter_resolver);
769 }
770 if let Some(auxiliary_model_resolver) = auxiliary_model_resolver {
771 builder = builder.auxiliary_model_resolver(auxiliary_model_resolver);
772 }
773 if let Some(disabled_tools) = disabled_tools {
774 builder = builder.disabled_tools(disabled_tools);
775 }
776 if let Some(disabled_skill_ids) = disabled_skill_ids {
777 builder = builder.disabled_skill_ids(disabled_skill_ids);
778 }
779 if let Some(selected_skill_ids) = selected_skill_ids {
780 builder = builder.selected_skill_ids(selected_skill_ids);
781 }
782 if let Some(selected_skill_mode) = selected_skill_mode {
783 builder = builder.selected_skill_mode(selected_skill_mode);
784 }
785 if let Some(image_fallback) = image_fallback {
786 builder = builder.image_fallback(image_fallback);
787 }
788 if let Some(app_data_dir) = app_data_dir {
789 builder = builder.app_data_dir(app_data_dir);
790 }
791
792 builder.build()
793}
794
795pub fn spawn_session_execution(args: SessionExecutionArgs) {
804 let span_session_id = args.session_id.clone();
805 let session_span = tracing::info_span!("agent_execution", session_id = %span_session_id);
806
807 tokio::spawn(
808 async move {
809 let SessionExecutionArgs {
810 agent,
811 session_id,
812 mut session,
813 mut execution_reservation,
814 tools_override,
815 provider_override,
816 model_roster,
817 reasoning_effort,
818 reasoning_effort_source,
819 auxiliary_model_resolver,
820 disabled_filter_resolver,
821 disabled_tools,
822 disabled_skill_ids,
823 selected_skill_ids,
824 selected_skill_mode,
825 mpsc_tx,
826 mut history_commit_barrier,
827 image_fallback,
828 gold_config,
829 guardian_config,
830 guardian_spawner,
831 bash_resume_hook,
832 bash_completion_sink,
833 app_data_dir,
834 run_budget,
835 runners,
836 sessions_cache,
837 on_complete,
838 child_completion_handler,
839 } = args;
840
841 if !execution_reservation.matches_execution_target(&session_id, &session.id, &runners) {
842 tracing::error!(
843 %session_id,
844 domain_session_id = %session.id,
845 reservation_session_id = %execution_reservation.session_id(),
846 run_id = %execution_reservation.run_id(),
847 same_runner_registry = Arc::ptr_eq(&execution_reservation.runners, &runners),
848 "refusing mismatched session execution reservation"
849 );
850 execution_reservation.abandon().await;
851 return;
852 }
853 if let Err(error) = execution_reservation.ensure_registered().await {
854 tracing::warn!(
855 %session_id,
856 run_id = %execution_reservation.run_id(),
857 %error,
858 "session execution could not adopt its router activation owner"
859 );
860 return;
861 }
862 if execution_reservation.root_actor.is_none()
863 && agent.persistence().root_actor_execution_required(&session)
864 {
865 let message =
866 "Root execution route requires a bound Actor writer and event handoff";
867 tracing::warn!(%session_id, "{message}");
868 let _ = mpsc_tx
869 .send(AgentEvent::Error {
870 message: message.into(),
871 })
872 .await;
873 execution_reservation.abandon().await;
874 return;
875 }
876 if let Err(error) = execution_reservation.bind_execution_persistence(&agent, &session) {
879 let _ = mpsc_tx
880 .send(AgentEvent::Error {
881 message: error.to_string(),
882 })
883 .await;
884 execution_reservation.abandon().await;
885 return;
886 }
887 let mut root_actor = execution_reservation.root_actor.take().map(|owner| *owner);
888 let root_actor_bound = root_actor.is_some();
889 let persistence = execution_reservation
890 .execution_persistence()
891 .unwrap_or_else(|| agent.persistence().clone());
892 let agent = Arc::new(agent.with_execution_persistence(persistence));
893 let activation_run_id = execution_reservation.run_id().to_string();
894 let prior_message_ids = session.messages.iter().map(|m| m.id.clone()).collect();
895 let (cancel_token, mut activation_registration) =
896 execution_reservation.disarm_for_execution();
897
898 let model = model_roster.model.clone().unwrap_or_default();
902
903 let initial_message = initial_user_message_for_session(&session);
904 let selected_skill_ids =
905 selected_skill_ids.or_else(|| selected_skill_ids_for_session(&session));
906 let selected_skill_mode =
907 selected_skill_mode.or_else(|| selected_skill_mode_for_session(&session));
908
909 tracing::info!(
910 "[{}] Using resolved session model: {}, reasoning_effort={}, reasoning_source={}",
911 session_id,
912 model,
913 reasoning_effort
914 .map(ReasoningEffort::as_str)
915 .unwrap_or("none"),
916 reasoning_effort_source
917 );
918
919 crate::session_app::execution_prep::prepare_session_for_execution(
926 &mut session,
927 None,
928 Some(&model),
929 );
930
931 let system_prompt = system_prompt_for_debug_log(&mut session);
932 if let Some(prompt) = system_prompt.as_ref() {
933 log_base_system_prompt_snapshot(&session_id, prompt);
934 }
935
936 let inherited_child_wait = agent.persistence().inherited_child_wait();
940 let execute_request = build_execute_request(
941 initial_message,
942 mpsc_tx.clone(),
943 cancel_token,
944 ExecuteRequestParams {
945 tools: tools_override,
946 provider_override,
947 model_roster,
948 reasoning_effort,
949 auxiliary_model_resolver,
950 disabled_filter_resolver,
951 disabled_tools,
952 disabled_skill_ids,
953 selected_skill_ids,
954 selected_skill_mode,
955 image_fallback,
956 gold_config,
957 guardian_config,
958 guardian_spawner,
959 bash_resume_hook,
960 bash_completion_sink,
961 app_data_dir,
962 run_budget,
963 },
964 );
965
966 let result = {
973 use futures::FutureExt;
974 match std::panic::AssertUnwindSafe(agent.execute(&mut session, execute_request))
975 .catch_unwind()
976 .await
977 {
978 Ok(result) => result,
979 Err(panic) => {
980 let message = panic
981 .downcast_ref::<&str>()
982 .map(|s| (*s).to_string())
983 .or_else(|| panic.downcast_ref::<String>().cloned())
984 .unwrap_or_else(|| "non-string panic payload".to_string());
985 tracing::error!(
986 "[{}] agent execution panicked; finalizing as terminal error: {}",
987 session_id,
988 message
989 );
990 Err(AgentError::LLM(format!(
991 "agent execution panicked: {message}"
992 )))
993 }
994 }
995 };
996
997 if let Some(error_event) = terminal_error_event_for_result(&result) {
999 let _ = mpsc_tx.send(error_event).await;
1000 }
1001
1002 let suspended_non_terminal = result.is_ok()
1019 && session
1020 .metadata
1021 .get("runtime.suspend_reason")
1022 .is_some_and(|reason| !reason.trim().is_empty());
1023 match &result {
1024 Ok(()) if suspended_non_terminal => {
1025 session.set_last_run_status("suspended");
1026 session.clear_last_run_error();
1027 }
1028 Ok(()) => {
1029 session.set_last_run_status("completed");
1030 session.clear_last_run_error();
1031 }
1032 Err(error) if error.is_cancelled() => {
1033 session.set_last_run_status("cancelled");
1034 session.set_last_run_error(error.to_string());
1035 }
1036 Err(error) => {
1037 session.set_last_run_status("error");
1038 session.set_last_run_error(error.to_string());
1039 }
1040 }
1041
1042 if let Some(on_complete) = on_complete {
1046 on_complete(SessionExecutionOutcome::from_result(&result), &mut session).await;
1047 }
1048
1049 let executed_admitted_generation = session
1053 .session_inbox_admission()
1054 .map_or(0, |state| state.last_admitted_sequence);
1055 let legacy_migration =
1056 crate::runtime::runner::state_bridge::migrate_legacy_pending_only(
1057 &mut session,
1058 Some(agent.storage()),
1059 Some(agent.persistence()),
1060 agent.session_inbox(),
1061 )
1062 .await;
1063 let pending_boundary_generation = session
1064 .session_inbox_admission()
1065 .and_then(|state| state.pending_activation_generation());
1066 let pending_generation = match (
1067 pending_boundary_generation,
1068 legacy_migration.highest_generation,
1069 ) {
1070 (Some(left), Some(right)) => Some(left.max(right)),
1071 (left, right) => left.or(right),
1072 };
1073 if let (Some(generation), Some(router)) =
1074 (pending_generation, agent.activation_router())
1075 {
1076 let activation_ready = if let Some(inbox) = agent.session_inbox() {
1077 match inbox
1078 .mark_activation_eligible(
1079 &session_id,
1080 generation,
1081 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
1082 )
1083 .await
1084 {
1085 Ok(()) => true,
1086 Err(error) => {
1087 tracing::error!(
1088 session_id = %session_id,
1089 %error,
1090 "failed to persist unadmitted SessionInbox activation watermark"
1091 );
1092 false
1093 }
1094 }
1095 } else {
1096 false
1097 };
1098 if activation_ready {
1099 if let Err(error) = bamboo_domain::SessionActivationPort::request_activation(
1100 router.as_ref(),
1101 &session_id,
1102 generation,
1103 )
1104 .await
1105 {
1106 tracing::error!(
1107 session_id = %session_id,
1108 %error,
1109 "failed to hand unadmitted SessionInbox generation to activation router"
1110 );
1111 }
1112 }
1113 }
1114
1115 if let Some(registration) = activation_registration.as_mut() {
1119 registration.begin_finalization().await;
1120 }
1121
1122 super::child_completion::ChildCompletionSource::prepare(
1126 &mut session,
1127 &activation_run_id,
1128 &prior_message_ids,
1129 );
1130 let saved = save_finalized_runtime_with_inherited_child_wait(
1131 agent.persistence().as_ref(),
1132 &mut session,
1133 inherited_child_wait
1134 .as_ref()
1135 .map(|inherited| inherited.wait()),
1136 )
1137 .await;
1138 let history_committed = saved.is_ok();
1139 let completion_source =
1140 super::child_completion::ChildCompletionSource::after_final_save(
1141 &session,
1142 history_committed,
1143 );
1144 let completed_at = chrono::Utc::now();
1145 if let Err(error) = &saved {
1146 tracing::warn!("[{}] Failed to save session: {}", session_id, error);
1147 }
1148
1149 if history_committed
1155 && !history_commit_barrier
1156 .send_and_wait(&mpsc_tx, session_id.clone())
1157 .await
1158 {
1159 tracing::warn!(
1160 session_id = %session_id,
1161 "history commit barrier could not be published before runner finalization"
1162 );
1163 }
1164
1165 finalize_runner(&runners, &session_id, &result).await;
1171
1172 if let Some(owner) = root_actor.take() {
1173 let outcome = if result.as_ref().is_err_and(|error| error.is_cancelled()) {
1174 bamboo_domain::ActorActivationFinish::Cancelled
1175 } else if result.is_err() || !history_committed {
1176 bamboo_domain::ActorActivationFinish::Failed
1177 } else {
1178 bamboo_domain::ActorActivationFinish::Succeeded
1179 };
1180 owner.finish(outcome).await;
1181 }
1182
1183 let finalization = if let Some(registration) = activation_registration.take() {
1184 registration.finish(executed_admitted_generation).await
1185 } else {
1186 Ok(None)
1187 };
1188 if let Err(error) = finalization {
1189 tracing::error!(
1193 session_id = %session_id,
1194 %error,
1195 "failed to activate successor for finalization-racing SessionInbox delivery"
1196 );
1197 }
1198
1199 let child_completion = child_completion_handler.filter(|_| {
1208 session.kind == bamboo_agent_core::SessionKind::Child
1209 && session.parent_session_id.is_some()
1210 });
1211 let parent_session_id = session.parent_session_id.clone();
1212 let child_status = session.last_run_status();
1213 let child_error = session.last_run_error();
1214
1215 publish_final_session_cache(&sessions_cache, session, root_actor_bound, &saved);
1216
1217 if let (Some(handler), Some(parent_session_id), Some(status)) =
1218 (child_completion, parent_session_id, child_status)
1219 {
1220 use futures::FutureExt;
1221 let completion = ChildCompletion {
1222 parent_session_id: parent_session_id.clone(),
1223 child_session_id: session_id.clone(),
1224 status,
1225 error: child_error,
1226 completed_at,
1227 source: completion_source,
1228 };
1229 if std::panic::AssertUnwindSafe(handler.on_child_completed(completion))
1230 .catch_unwind()
1231 .await
1232 .is_err()
1233 {
1234 tracing::error!(
1235 %parent_session_id,
1236 child_session_id = %session_id,
1237 "child completion handler panicked on resumed-child terminal"
1238 );
1239 }
1240 }
1241
1242 tracing::info!("[{}] Agent execution completed", session_id);
1243 }
1244 .instrument(session_span),
1245 );
1246}
1247
1248async fn save_finalized_runtime_with_inherited_child_wait(
1250 persistence: &dyn bamboo_domain::RuntimeSessionPersistence,
1251 session: &mut Session,
1252 inherited_child_wait: Option<&bamboo_domain::session::runtime_state::WaitingForChildrenState>,
1253) -> std::io::Result<()> {
1254 if let Some(inherited) = inherited_child_wait.filter(|wait| {
1255 session
1256 .agent_runtime_state
1257 .as_ref()
1258 .and_then(|runtime| runtime.waiting_for_children.as_ref())
1259 == Some(*wait)
1260 }) {
1261 persistence
1262 .save_finalized_runtime_with_inherited_child_wait(session, inherited)
1263 .await
1264 } else {
1265 persistence.save_finalized_runtime_session(session).await
1266 }
1267}
1268
1269fn publish_final_session_cache(
1270 cache: &SessionCache,
1271 session: Session,
1272 root_actor_bound: bool,
1273 saved: &std::io::Result<()>,
1274) {
1275 let authority_conflict = saved.as_ref().err().is_some_and(|error| {
1276 error
1277 .get_ref()
1278 .is_some_and(|cause| cause.is::<bamboo_domain::SessionAuthorityConflict>())
1279 });
1280 if !authority_conflict && !root_actor_bound {
1283 cache.insert(
1284 session.id.clone(),
1285 Arc::new(crate::SessionSnapshot::new(session)),
1286 );
1287 }
1288}
1289
1290pub fn log_base_system_prompt_snapshot(session_id: &str, prompt: &str) {
1292 tracing::info!(
1293 "[{}] Base system prompt snapshot: len={} chars, has_skill={}, has_tool_guide={}, has_external_memory={}, has_task_list={}",
1294 session_id,
1295 prompt.len(),
1296 prompt.contains(SKILL_CONTEXT_START_MARKER),
1297 prompt.contains(TOOL_GUIDE_START_MARKER),
1298 prompt.contains(EXTERNAL_MEMORY_START_MARKER),
1299 prompt.contains(TASK_LIST_START_MARKER),
1300 );
1301
1302 tracing::debug!(
1303 "[{}] ========== BASE SYSTEM PROMPT SNAPSHOT ==========",
1304 session_id
1305 );
1306 tracing::debug!("[{}] Snapshot length: {} chars", session_id, prompt.len());
1307 tracing::debug!("[{}] -----------------------------------", session_id);
1308 tracing::debug!("[{}] {}", session_id, prompt);
1309 tracing::debug!(
1310 "[{}] ========== END BASE SYSTEM PROMPT SNAPSHOT ==========",
1311 session_id
1312 );
1313}
1314
1315pub fn terminal_error_event_for_result(result: &Result<(), AgentError>) -> Option<AgentEvent> {
1317 match result {
1318 Ok(_) => None,
1319 Err(error) if error.is_cancelled() => Some(AgentEvent::Error {
1320 message: "Agent execution cancelled by user".to_string(),
1321 }),
1322 Err(error) => Some(AgentEvent::Error {
1323 message: error.to_string(),
1324 }),
1325 }
1326}
1327
1328fn system_prompt_for_session(session: &Session) -> Option<String> {
1331 session
1332 .messages
1333 .iter()
1334 .find(|message| matches!(message.role, bamboo_agent_core::Role::System))
1335 .map(|message| message.content.clone())
1336}
1337
1338fn system_prompt_for_debug_log(session: &mut Session) -> Option<String> {
1342 crate::runtime::runner::session_setup::migrate_legacy_workspace_prompt(session);
1343 system_prompt_for_session(session)
1344}
1345
1346fn initial_user_message_for_session(session: &Session) -> String {
1347 session
1348 .messages
1349 .last()
1350 .filter(|message| matches!(message.role, bamboo_agent_core::Role::User))
1351 .map(|message| message.content.clone())
1352 .unwrap_or_default()
1353}
1354
1355fn selected_skill_ids_for_session(session: &Session) -> Option<Vec<String>> {
1356 session
1357 .metadata
1358 .get("selected_skill_ids")
1359 .and_then(|raw| bamboo_skills::selection::parse_selected_skill_ids_metadata(raw))
1360}
1361
1362fn selected_skill_mode_for_session(session: &Session) -> Option<String> {
1363 let value = session
1364 .metadata
1365 .get("skill_mode")
1366 .or_else(|| session.metadata.get("mode"))?;
1367 let trimmed = value.trim();
1368 if trimmed.is_empty() {
1369 None
1370 } else {
1371 Some(trimmed.to_string())
1372 }
1373}
1374
1375pub(super) struct RootActorExecution {
1376 binding: bamboo_domain::RootActorExecutionBinding,
1377 stop_renewal: CancellationToken,
1378 renewal: Option<tokio::task::JoinHandle<()>>,
1379 response_events: Vec<AgentEvent>,
1380}
1381
1382impl RootActorExecution {
1383 pub(super) fn start(binding: bamboo_domain::RootActorExecutionBinding) -> Self {
1384 let stop_renewal = CancellationToken::new();
1385 let stop = stop_renewal.clone();
1386 let directory = binding.directory.clone();
1387 let fence = binding.owner.fence.clone();
1388 let duration = binding.lease_duration;
1389 let renewal = tokio::spawn(async move {
1390 let mut interval = tokio::time::interval(duration / 3);
1391 interval.tick().await;
1392 loop {
1393 tokio::select! {
1394 _ = stop.cancelled() => break,
1395 _ = interval.tick() => {
1396 let now = chrono::Utc::now();
1397 let expires = now + chrono::Duration::from_std(duration).expect("bounded Root lease");
1398 if let Err(error) = directory.renew_activation(&fence, now, expires).await {
1399 tracing::warn!(actor_id = %fence.actor_id, run_id = %fence.run_id,
1403 %error, "Root Actor renewal lost its execution authority");
1404 break;
1405 }
1406 }
1407 }
1408 }
1409 });
1410 Self {
1411 binding,
1412 stop_renewal,
1413 renewal: Some(renewal),
1414 response_events: Vec::new(),
1415 }
1416 }
1417
1418 pub(super) fn persistence(
1419 &self,
1420 ) -> std::sync::Arc<dyn bamboo_domain::RuntimeSessionPersistence> {
1421 self.binding.persistence.clone()
1422 }
1423
1424 pub(super) async fn finish(mut self, outcome: bamboo_domain::ActorActivationFinish) {
1425 self.stop_renewal.cancel();
1426 if let Some(renewal) = self.renewal.take() {
1427 let _ = renewal.await;
1428 }
1429 let directory = self.binding.directory.clone();
1430 let fence = self.binding.owner.fence.clone();
1431 let finish = tokio::spawn(async move {
1432 if let Err(error) = directory
1433 .finish_activation(&fence, chrono::Utc::now(), outcome)
1434 .await
1435 {
1436 tracing::warn!(actor_id = %fence.actor_id, run_id = %fence.run_id,
1437 %error, "Root Actor finish rejected its obsolete execution fence");
1438 }
1439 });
1440 self.binding.disarm_abandonment();
1441 let _ = finish.await;
1442 }
1443}
1444
1445impl Drop for RootActorExecution {
1446 fn drop(&mut self) {
1447 self.stop_renewal.cancel();
1448 }
1451}
1452
1453#[cfg(test)]
1454mod reservation_tests {
1455 use super::*;
1456 use crate::runtime::execution::runner_state::AgentStatus;
1457
1458 struct CompletionAtFinalSave {
1459 directory: std::path::PathBuf,
1460 persistence: bamboo_storage::LockedSessionStore,
1461 cleared: std::sync::atomic::AtomicBool,
1462 }
1463
1464 impl CompletionAtFinalSave {
1465 async fn clear_wait(&self, id: &str) {
1466 let mut process = std::process::Command::new(std::env::current_exe().unwrap())
1467 .args(["--exact", "runtime::execution::agent_spawn::reservation_tests::inherited_child_wait_adopts_independent_completion_at_final_save", "--nocapture"])
1468 .env("BAMBOO_FINAL_WAIT_COMPLETION_DIR", &self.directory)
1469 .env("BAMBOO_FINAL_WAIT_COMPLETION_ID", id)
1470 .spawn().unwrap();
1471 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
1472 loop {
1473 if let Some(status) = process.try_wait().unwrap() {
1474 assert!(status.success(), "completion process failed");
1475 break;
1476 }
1477 if std::time::Instant::now() >= deadline {
1478 let _ = process.kill();
1479 let _ = process.wait();
1480 panic!("bounded completion process deadline");
1481 }
1482 std::thread::sleep(std::time::Duration::from_millis(5));
1483 }
1484 self.cleared
1485 .store(true, std::sync::atomic::Ordering::SeqCst);
1486 }
1487 }
1488
1489 #[async_trait::async_trait]
1490 impl bamboo_domain::RuntimeSessionPersistence for CompletionAtFinalSave {
1491 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1492 self.clear_wait(&session.id).await;
1493 self.persistence.merge_save_finalized_runtime(session).await
1494 }
1495 async fn save_finalized_runtime_with_inherited_child_wait(
1496 &self,
1497 session: &mut Session,
1498 inherited: &bamboo_domain::session::runtime_state::WaitingForChildrenState,
1499 ) -> std::io::Result<()> {
1500 self.clear_wait(&session.id).await;
1501 self.persistence
1502 .merge_save_inherited_child_wait_and_publish(session, inherited, |_, _| {})
1503 .await
1504 }
1505 }
1506
1507 #[tokio::test]
1508 async fn inherited_child_wait_adopts_independent_completion_at_final_save() {
1509 use bamboo_agent_core::storage::Storage;
1510 use bamboo_domain::session::runtime_state::{
1511 AgentRuntimeState, AgentStatusState, ChildWaitPolicy, WaitingForChildrenState,
1512 };
1513 if let Some(directory) = std::env::var_os("BAMBOO_FINAL_WAIT_COMPLETION_DIR") {
1514 let remote = bamboo_storage::SessionStoreV2::new(directory.into())
1515 .await
1516 .unwrap();
1517 let id = std::env::var("BAMBOO_FINAL_WAIT_COMPLETION_ID").unwrap();
1518 let mut latest = remote.load_session(&id).await.unwrap().unwrap();
1519 let runtime = latest.agent_runtime_state.as_mut().unwrap();
1520 runtime.waiting_for_children = None;
1521 runtime.status = AgentStatusState::Idle;
1522 runtime.suspension = None;
1523 latest.metadata.remove("runtime.suspend_reason");
1524 remote.save_runtime_state(&latest).await.unwrap();
1525 return;
1526 }
1527 let temp = tempfile::tempdir().unwrap();
1528 let local = Arc::new(
1529 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
1530 .await
1531 .unwrap(),
1532 );
1533 let mut runner = Session::new("cross-host-final-wait", "model");
1537 let inherited = WaitingForChildrenState::for_children(
1538 vec!["child".into()],
1539 ChildWaitPolicy::All,
1540 chrono::Utc::now(),
1541 );
1542 let mut runtime = AgentRuntimeState::new("run");
1543 runtime.waiting_for_children = Some(inherited.clone());
1544 runtime.status = AgentStatusState::Suspended;
1545 runner.agent_runtime_state = Some(runtime);
1546 runner.metadata.insert(
1547 "runtime.suspend_reason".into(),
1548 "waiting_for_children".into(),
1549 );
1550 runner.set_last_run_status("suspended");
1551 local.save_session(&runner).await.unwrap();
1552 let persistence = CompletionAtFinalSave {
1553 directory: temp.path().to_path_buf(),
1554 persistence: bamboo_storage::LockedSessionStore::new(local.clone()),
1555 cleared: std::sync::atomic::AtomicBool::new(false),
1556 };
1557 save_finalized_runtime_with_inherited_child_wait(
1558 &persistence,
1559 &mut runner,
1560 Some(&inherited),
1561 )
1562 .await
1563 .unwrap();
1564 assert!(persistence
1565 .cleared
1566 .load(std::sync::atomic::Ordering::SeqCst));
1567 let durable = local.load_session(&runner.id).await.unwrap().unwrap();
1568 for session in [&runner, &durable] {
1569 let runtime = session.agent_runtime_state.as_ref().unwrap();
1570 assert!(
1571 runtime.waiting_for_children.is_none(),
1572 "independent completion must not be resurrected"
1573 );
1574 assert_eq!(runtime.status, AgentStatusState::Idle);
1575 assert!(runtime.suspension.is_none());
1576 assert!(!session.metadata.contains_key("runtime.suspend_reason"));
1577 assert_eq!(session.last_run_status().as_deref(), Some("completed"));
1578 }
1579 }
1580
1581 #[tokio::test]
1582 async fn inherited_child_wait_rejects_aba_and_missing_birth_without_cache_publication() {
1583 use bamboo_agent_core::storage::Storage;
1584 use bamboo_domain::session::runtime_state::{
1585 AgentRuntimeState, ChildWaitPolicy, WaitingForChildrenState,
1586 };
1587 use bamboo_storage::{LockedSessionStore, SessionStoreV2};
1588
1589 for missing in [false, true] {
1590 let temp = tempfile::tempdir().unwrap();
1591 let storage = Arc::new(
1592 SessionStoreV2::new(temp.path().to_path_buf())
1593 .await
1594 .unwrap(),
1595 );
1596 let persistence = LockedSessionStore::new(storage.clone());
1597 let cache: SessionCache = Arc::default();
1598 storage
1599 .save_session(&Session::new("root", "model"))
1600 .await
1601 .unwrap();
1602 let mut stale = Session::new("nested-child-parent", "model");
1603 stale.kind = bamboo_domain::SessionKind::Child;
1604 stale.parent_session_id = Some("root".into());
1605 stale.root_session_id = "root".into();
1606 stale.spawn_depth = 1;
1607 let wait = WaitingForChildrenState::for_children(
1608 vec!["grandchild".into()],
1609 ChildWaitPolicy::All,
1610 chrono::Utc::now(),
1611 );
1612 let mut runtime = AgentRuntimeState::new("old-run");
1613 runtime.waiting_for_children = Some(wait.clone());
1614 stale.agent_runtime_state = Some(runtime);
1615 stale.add_message(bamboo_agent_core::Message::assistant("STALE", None));
1616 storage.save_session(&stale).await.unwrap();
1617
1618 let mut replacement = stale.clone();
1619 replacement.created_at += chrono::Duration::seconds(1);
1620 replacement.messages.clear();
1621 replacement.add_message(bamboo_agent_core::Message::assistant("REPLACEMENT", None));
1622 replacement
1623 .agent_runtime_state
1624 .as_mut()
1625 .unwrap()
1626 .waiting_for_children = None;
1627 storage.delete_session(&stale.id).await.unwrap();
1628 if !missing {
1629 storage.save_session(&replacement).await.unwrap();
1630 }
1631 if !missing {
1632 cache.insert(
1633 replacement.id.clone(),
1634 Arc::new(crate::SessionSnapshot::new(replacement.clone())),
1635 );
1636 }
1637
1638 let saved = save_finalized_runtime_with_inherited_child_wait(
1639 &persistence,
1640 &mut stale,
1641 Some(&wait),
1642 )
1643 .await;
1644 assert!(saved.is_err(), "stale incarnation must not be saved");
1645 publish_final_session_cache(&cache, stale, false, &saved);
1648 if missing {
1649 assert!(
1650 read_cached_session(&cache, &replacement.id).is_none(),
1651 "deleted incarnation must not be resurrected in cache"
1652 );
1653 } else {
1654 let cached = read_cached_session(&cache, &replacement.id).unwrap();
1655 assert_eq!(
1656 cached.created_at, replacement.created_at,
1657 "ABA rejection must retain replacement cache birth"
1658 );
1659 assert_eq!(
1660 serde_json::to_value(&cached.messages).unwrap(),
1661 serde_json::to_value(&replacement.messages).unwrap(),
1662 "ABA rejection must retain replacement cache transcript"
1663 );
1664 }
1665 assert!(saved
1666 .as_ref()
1667 .unwrap_err()
1668 .get_ref()
1669 .is_some_and(|cause| cause.is::<bamboo_domain::SessionAuthorityConflict>()));
1670 let durable = storage.load_session(&replacement.id).await.unwrap();
1671 if missing {
1672 assert!(durable.is_none());
1673 } else {
1674 let durable = durable.unwrap();
1675 assert_eq!(durable.created_at, replacement.created_at);
1676 assert_eq!(
1677 serde_json::to_value(&durable.messages).unwrap(),
1678 serde_json::to_value(&replacement.messages).unwrap()
1679 );
1680 }
1681 }
1682 }
1683
1684 #[test]
1685 fn debug_prompt_snapshot_migrates_legacy_host_paths_before_logging() {
1686 let legacy_project = format!(
1687 "{}\nProject ID: legacy-project\nProject path: /private/legacy-project\nProject home: /private/legacy-home\n{}",
1688 crate::runtime::context::PROJECT_CONTEXT_START_MARKER,
1689 crate::runtime::context::PROJECT_CONTEXT_END_MARKER,
1690 );
1691 let legacy_workspace =
1692 crate::runtime::context::build_workspace_prompt_context("/private/legacy-workspace")
1693 .expect("legacy workspace context");
1694 let legacy_instruction = format!(
1695 "{}\nSource: /private/legacy-workspace/AGENTS.md\nlegacy policy\n{}",
1696 crate::runtime::context::instruction::INSTRUCTION_CONTEXT_START_MARKER,
1697 crate::runtime::context::instruction::INSTRUCTION_CONTEXT_END_MARKER,
1698 );
1699 let mut session = Session::new("legacy-debug-log", "model");
1700 session.add_message(bamboo_agent_core::Message::system(format!(
1701 "Base prompt\n\n{legacy_project}\n\n{legacy_workspace}\n\n{legacy_instruction}"
1702 )));
1703
1704 let prompt = system_prompt_for_debug_log(&mut session).expect("normalized System");
1705
1706 assert_eq!(prompt, "Base prompt");
1707 assert_eq!(
1708 session.workspace_path_meta().as_deref(),
1709 Some("/private/legacy-workspace")
1710 );
1711 for private_path in [
1712 "/private/legacy-project",
1713 "/private/legacy-home",
1714 "/private/legacy-workspace",
1715 ] {
1716 assert!(!prompt.contains(private_path));
1717 }
1718 assert!(!prompt.contains(crate::runtime::context::PROJECT_CONTEXT_START_MARKER));
1719 assert!(!prompt.contains(crate::runtime::context::WORKSPACE_CONTEXT_START_MARKER));
1720 assert!(!prompt
1721 .contains(crate::runtime::context::instruction::INSTRUCTION_CONTEXT_START_MARKER));
1722 }
1723
1724 #[test]
1725 fn reservation_target_requires_domain_id_and_exact_runner_registry() {
1726 let runners = Arc::new(RwLock::new(HashMap::new()));
1727 let other_runners = Arc::new(RwLock::new(HashMap::new()));
1728 let mut reservation = SessionExecutionReservation {
1729 session_id: "session-a".to_string(),
1730 run_id: "run-a".to_string(),
1731 cancel_token: CancellationToken::new(),
1732 runners: runners.clone(),
1733 activation: SessionExecutionActivationOwnership::Unrouted,
1734 root_actor: None,
1735 execution_persistence: None,
1736 persistence_bound: false,
1737 armed: true,
1738 };
1739
1740 assert!(reservation.matches_execution_target("session-a", "session-a", &runners));
1741 assert!(!reservation.matches_execution_target("session-b", "session-a", &runners));
1742 assert!(!reservation.matches_execution_target("session-a", "session-b", &runners));
1743 assert!(!reservation.matches_execution_target("session-a", "session-a", &other_runners));
1744 reservation.armed = false;
1745 }
1746
1747 #[tokio::test]
1748 async fn forced_same_run_registration_rejection_never_cancels_live_owner() {
1749 let runners = Arc::new(RwLock::new(HashMap::new()));
1750 let mut runner = AgentRunner::new();
1751 runner.status = AgentStatus::Running;
1752 let run_id = runner.run_id.clone();
1753 let live_cancel_token = runner.cancel_token.clone();
1754 runners.write().await.insert("same-run".to_string(), runner);
1755
1756 let router = SessionActivationRouter::new();
1757 let mut live_registration = router
1758 .register_run("same-run", &run_id)
1759 .await
1760 .expect("first registration owns the run");
1761 let mut rejected = SessionExecutionReservation {
1762 session_id: "same-run".to_string(),
1763 run_id: run_id.clone(),
1764 cancel_token: live_cancel_token.clone(),
1765 runners: runners.clone(),
1766 activation: SessionExecutionActivationOwnership::RegistrationPending(router.clone()),
1767 root_actor: None,
1768 execution_persistence: None,
1769 persistence_bound: false,
1770 armed: true,
1771 };
1772
1773 let error = match rejected.ensure_registered().await {
1774 Ok(()) => panic!("a duplicate registration for the same run must be rejected"),
1775 Err(error) => error,
1776 };
1777 assert_eq!(error.existing_run_id(), run_id);
1778 drop(rejected);
1779
1780 assert!(!live_cancel_token.is_cancelled());
1781 assert!(matches!(
1782 runners
1783 .read()
1784 .await
1785 .get("same-run")
1786 .map(|runner| &runner.status),
1787 Some(AgentStatus::Running)
1788 ));
1789 assert!(router.owns_run("same-run", &run_id).await);
1790 live_registration.begin_finalization().await;
1791 assert_eq!(live_registration.finish(0).await.unwrap(), None);
1792 }
1793}