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::runner_lifecycle::{
26 finalize_rejected_runner_if_distinct, finalize_runner, finalize_runner_exact,
27 reserve_runner_core, ReserveOutcome, RunnerReservation,
28};
29use crate::runtime::execution::runner_state::AgentRunner;
30use crate::runtime::model_roster::ModelRoster;
31use crate::runtime::Agent;
32use crate::runtime::{ExecuteRequest, ExecuteRequestBuilder};
33use crate::session_activation::{
34 SessionActivationRouter, SessionRunRegistration, SessionRunRegistrationError,
35};
36
37pub use crate::session_cache::SessionCache;
38
39enum SessionExecutionActivationOwnership {
40 Unrouted,
42 UnpublishedActivation(Arc<SessionActivationRouter>),
45 RegistrationPending(Arc<SessionActivationRouter>),
50 Registered(SessionRunRegistration),
53}
54
55pub struct SessionExecutionReservation {
61 session_id: String,
62 run_id: String,
63 cancel_token: CancellationToken,
64 runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
65 activation: SessionExecutionActivationOwnership,
66 armed: bool,
67}
68
69impl SessionExecutionReservation {
70 pub fn session_id(&self) -> &str {
71 &self.session_id
72 }
73
74 pub fn run_id(&self) -> &str {
75 &self.run_id
76 }
77
78 pub fn cancel_token(&self) -> &CancellationToken {
79 &self.cancel_token
80 }
81
82 pub(crate) fn from_activation_placeholder(
88 session_id: impl Into<String>,
89 reservation: RunnerReservation,
90 router: Arc<SessionActivationRouter>,
91 runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
92 ) -> Self {
93 Self {
94 session_id: session_id.into(),
95 run_id: reservation.run_id,
96 cancel_token: reservation.cancel_token,
97 runners,
98 activation: SessionExecutionActivationOwnership::UnpublishedActivation(router),
99 armed: true,
100 }
101 }
102
103 pub(crate) fn from_pending_registration(
107 session_id: impl Into<String>,
108 reservation: RunnerReservation,
109 router: Option<Arc<SessionActivationRouter>>,
110 runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
111 ) -> Self {
112 let activation = match router {
113 Some(router) => SessionExecutionActivationOwnership::RegistrationPending(router),
114 None => SessionExecutionActivationOwnership::Unrouted,
115 };
116 Self {
117 session_id: session_id.into(),
118 run_id: reservation.run_id,
119 cancel_token: reservation.cancel_token,
120 runners,
121 activation,
122 armed: true,
123 }
124 }
125
126 pub(crate) fn mark_activation_published(&mut self) {
129 let activation = std::mem::replace(
130 &mut self.activation,
131 SessionExecutionActivationOwnership::Unrouted,
132 );
133 self.activation = match activation {
134 SessionExecutionActivationOwnership::UnpublishedActivation(router) => {
135 SessionExecutionActivationOwnership::RegistrationPending(router)
136 }
137 other => other,
138 };
139 }
140
141 pub(crate) async fn rollback_unpublished_activation(mut self) {
146 self.armed = false;
147 self.cancel_token.cancel();
148 let activation = std::mem::replace(
149 &mut self.activation,
150 SessionExecutionActivationOwnership::Unrouted,
151 );
152 debug_assert!(matches!(
153 activation,
154 SessionExecutionActivationOwnership::UnpublishedActivation(_)
155 ));
156 remove_runner_exact(&self.runners, &self.session_id, &self.run_id).await;
157 }
158
159 pub async fn ensure_registered(&mut self) -> Result<(), SessionRunRegistrationError> {
165 let router = match &self.activation {
166 SessionExecutionActivationOwnership::RegistrationPending(router) => router.clone(),
167 SessionExecutionActivationOwnership::UnpublishedActivation(_) => {
168 unreachable!("unpublished activation entered an execution adapter")
169 }
170 SessionExecutionActivationOwnership::Unrouted
171 | SessionExecutionActivationOwnership::Registered(_) => return Ok(()),
172 };
173 match register_reserved_activation(router, &self.runners, &self.session_id, &self.run_id)
174 .await
175 {
176 Ok(registration) => {
177 self.activation = SessionExecutionActivationOwnership::Registered(registration);
178 Ok(())
179 }
180 Err(error) => {
181 self.disarm_after_registration_rejection(error.existing_run_id());
186 Err(error)
187 }
188 }
189 }
190
191 pub async fn abandon(mut self) {
195 self.armed = false;
196 self.cancel_token.cancel();
197 let activation = std::mem::replace(
198 &mut self.activation,
199 SessionExecutionActivationOwnership::Unrouted,
200 );
201 let runners = self.runners.clone();
202 let session_id = self.session_id.clone();
203 let run_id = self.run_id.clone();
204 let cleanup_session_id = session_id.clone();
205 let cleanup_run_id = run_id.clone();
206 let cleanup = tokio::spawn(async move {
207 cleanup_execution_reservation(activation, runners, cleanup_session_id, cleanup_run_id)
208 .await;
209 });
210 if let Err(error) = cleanup.await {
211 tracing::error!(
212 %session_id,
213 %run_id,
214 %error,
215 "detached execution-reservation cleanup failed"
216 );
217 }
218 }
219
220 fn disarm_after_registration_rejection(&mut self, existing_run_id: &str) {
221 if existing_run_id != self.run_id {
222 self.cancel_token.cancel();
223 }
224 self.armed = false;
225 }
226
227 pub(crate) fn matches_execution_target(
228 &self,
229 session_id: &str,
230 domain_session_id: &str,
231 runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
232 ) -> bool {
233 self.session_id == session_id
234 && domain_session_id == session_id
235 && Arc::ptr_eq(&self.runners, runners)
236 }
237
238 pub(crate) fn disarm_for_execution(
239 &mut self,
240 ) -> (CancellationToken, Option<SessionRunRegistration>) {
241 self.armed = false;
242 let registration = match std::mem::replace(
243 &mut self.activation,
244 SessionExecutionActivationOwnership::Unrouted,
245 ) {
246 SessionExecutionActivationOwnership::Registered(registration) => Some(registration),
247 SessionExecutionActivationOwnership::Unrouted => None,
248 SessionExecutionActivationOwnership::UnpublishedActivation(_) => {
249 unreachable!("unpublished activation cannot transfer to execution")
250 }
251 SessionExecutionActivationOwnership::RegistrationPending(_) => {
252 unreachable!("ensure_registered adopts every pending router registration")
253 }
254 };
255 (self.cancel_token.clone(), registration)
256 }
257}
258
259impl Drop for SessionExecutionReservation {
260 fn drop(&mut self) {
261 if !self.armed {
262 return;
263 }
264 self.armed = false;
265 self.cancel_token.cancel();
266 let activation = std::mem::replace(
267 &mut self.activation,
268 SessionExecutionActivationOwnership::Unrouted,
269 );
270 let runners = self.runners.clone();
271 let session_id = self.session_id.clone();
272 let run_id = self.run_id.clone();
273 if let Ok(runtime) = tokio::runtime::Handle::try_current() {
274 runtime.spawn(async move {
275 cleanup_execution_reservation(activation, runners, session_id, run_id).await;
276 });
277 }
278 }
279}
280
281pub enum SessionExecutionReserveOutcome {
283 Reserved(SessionExecutionReservation),
284 AlreadyRunning { run_id: String },
285}
286
287async fn register_reserved_activation(
288 router: Arc<SessionActivationRouter>,
289 runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
290 session_id: &str,
291 run_id: &str,
292) -> Result<SessionRunRegistration, SessionRunRegistrationError> {
293 let mut registration = match router.register_run(session_id, run_id).await {
294 Ok(registration) => registration,
295 Err(error) => {
296 let collision = Err(AgentError::LLM(error.to_string()));
297 finalize_rejected_runner_if_distinct(
298 runners,
299 session_id,
300 error.existing_run_id(),
301 run_id,
302 &collision,
303 )
304 .await;
305 return Err(error);
306 }
307 };
308 let cleanup_runners = runners.clone();
309 let cleanup_session_id = session_id.to_string();
310 let cleanup_run_id = run_id.to_string();
311 registration.set_abort_cleanup(move || async move {
312 let abandoned = Err(AgentError::Cancelled);
313 finalize_runner_exact(
314 &cleanup_runners,
315 &cleanup_session_id,
316 &cleanup_run_id,
317 &abandoned,
318 )
319 .await;
320 });
321 Ok(registration)
322}
323
324async fn cleanup_execution_reservation(
325 activation: SessionExecutionActivationOwnership,
326 runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
327 session_id: String,
328 run_id: String,
329) {
330 match activation {
331 SessionExecutionActivationOwnership::Registered(registration) => {
332 registration.abandon().await;
333 }
334 SessionExecutionActivationOwnership::RegistrationPending(router) => {
335 match register_reserved_activation(router, &runners, &session_id, &run_id).await {
336 Ok(registration) => registration.abandon().await,
337 Err(error) => {
338 tracing::debug!(
339 %session_id,
340 %run_id,
341 existing_run_id = %error.existing_run_id(),
342 "abandoned activation placeholder was already adopted or superseded"
343 );
344 }
345 }
346 }
347 SessionExecutionActivationOwnership::UnpublishedActivation(_) => {
348 remove_runner_exact(&runners, &session_id, &run_id).await;
349 }
350 SessionExecutionActivationOwnership::Unrouted => {
351 let abandoned = Err(AgentError::Cancelled);
352 finalize_runner_exact(&runners, &session_id, &run_id, &abandoned).await;
353 }
354 }
355}
356
357async fn remove_runner_exact(
358 runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
359 session_id: &str,
360 run_id: &str,
361) -> bool {
362 let mut runners = runners.write().await;
363 if runners
364 .get(session_id)
365 .is_some_and(|runner| runner.run_id == run_id)
366 {
367 super::runner_lifecycle::remove_runner_entry(&mut runners, session_id).await;
368 true
369 } else {
370 false
371 }
372}
373
374pub async fn reserve_session_execution(
382 agent: &Arc<Agent>,
383 runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
384 senders: &Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
385 session_id: &str,
386 event_sender: &broadcast::Sender<AgentEvent>,
387) -> SessionExecutionReserveOutcome {
388 let reservation = match reserve_runner_core(runners, senders, session_id, event_sender).await {
389 ReserveOutcome::AlreadyRunning(run_id) => {
390 return SessionExecutionReserveOutcome::AlreadyRunning { run_id };
391 }
392 ReserveOutcome::Reserved(reservation) => reservation,
393 };
394
395 let mut execution_reservation = SessionExecutionReservation {
401 session_id: session_id.to_string(),
402 run_id: reservation.run_id,
403 cancel_token: reservation.cancel_token,
404 runners: runners.clone(),
405 activation: SessionExecutionActivationOwnership::Unrouted,
406 armed: true,
407 };
408
409 if let Some(router) = agent.activation_router().cloned() {
410 execution_reservation.activation =
411 SessionExecutionActivationOwnership::RegistrationPending(router);
412 if let Err(error) = execution_reservation.ensure_registered().await {
413 tracing::warn!(
414 %session_id,
415 attempted_run_id = %execution_reservation.run_id(),
416 existing_run_id = %error.existing_run_id(),
417 %error,
418 "runner reservation collided with an existing logical-session owner"
419 );
420 return SessionExecutionReserveOutcome::AlreadyRunning {
421 run_id: error.existing_run_id().to_string(),
422 };
423 }
424 }
425 SessionExecutionReserveOutcome::Reserved(execution_reservation)
426}
427
428pub fn read_cached_session(cache: &SessionCache, id: &str) -> Option<bamboo_agent_core::Session> {
436 cache
437 .get(id)
438 .map(|e| e.value().clone())
439 .map(|a| a.read().clone())
440}
441
442const SKILL_CONTEXT_START_MARKER: &str = "<!-- BAMBOO_SKILL_CONTEXT_START -->";
443const TOOL_GUIDE_START_MARKER: &str = "<!-- BAMBOO_TOOL_GUIDE_START -->";
444const EXTERNAL_MEMORY_START_MARKER: &str = "<!-- BAMBOO_EXTERNAL_MEMORY_START -->";
445const TASK_LIST_START_MARKER: &str = "<!-- BAMBOO_TASK_LIST_START -->";
446
447pub struct SessionExecutionOutcome {
454 pub success: bool,
456 pub cancelled: bool,
458 pub error: Option<String>,
460}
461
462impl SessionExecutionOutcome {
463 fn from_result(result: &Result<(), AgentError>) -> Self {
464 match result {
465 Ok(()) => Self {
466 success: true,
467 cancelled: false,
468 error: None,
469 },
470 Err(error) => Self {
471 success: false,
472 cancelled: error.is_cancelled(),
473 error: Some(error.to_string()),
474 },
475 }
476 }
477}
478
479pub type SessionCompletionHook = Box<
489 dyn for<'a> FnOnce(
490 SessionExecutionOutcome,
491 &'a mut Session,
492 ) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>>
493 + Send,
494>;
495
496pub struct SessionExecutionArgs {
502 pub agent: Arc<Agent>,
504 pub session_id: String,
505 pub session: Session,
506 pub execution_reservation: SessionExecutionReservation,
509
510 pub tools_override: Option<Arc<dyn ToolExecutor>>,
512 pub provider_override: Option<Arc<dyn LLMProvider>>,
513 pub model_roster: ModelRoster,
517 pub reasoning_effort: Option<ReasoningEffort>,
518 pub reasoning_effort_source: String,
519 pub auxiliary_model_resolver:
520 Option<Arc<dyn Fn() -> crate::runtime::config::AuxiliaryModelConfig + Send + Sync>>,
521 pub disabled_filter_resolver: Option<DisabledFilterResolver>,
525 pub disabled_tools: Option<BTreeSet<String>>,
526 pub disabled_skill_ids: Option<BTreeSet<String>>,
527 pub selected_skill_ids: Option<Vec<String>>,
528 pub selected_skill_mode: Option<String>,
529 pub mpsc_tx: mpsc::Sender<AgentEvent>,
530 pub image_fallback: Option<ImageFallbackConfig>,
531 pub gold_config: Option<GoldConfig>,
532 pub guardian_config: Option<GuardianConfig>,
534 pub guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
537 pub bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
539 pub bash_completion_sink: Option<Arc<dyn BashCompletionSink>>,
541 pub app_data_dir: Option<std::path::PathBuf>,
542 pub run_budget: Option<bamboo_config::RunBudgetConfig>,
545
546 pub runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
548 pub sessions_cache: SessionCache,
549
550 pub on_complete: Option<SessionCompletionHook>,
553
554 pub child_completion_handler: Option<Arc<dyn super::ChildCompletionHandler>>,
566}
567
568struct ExecuteRequestParams {
578 tools: Option<Arc<dyn ToolExecutor>>,
579 provider_override: Option<Arc<dyn LLMProvider>>,
580 model_roster: ModelRoster,
581 reasoning_effort: Option<ReasoningEffort>,
582 auxiliary_model_resolver: Option<Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync>>,
583 disabled_filter_resolver: Option<DisabledFilterResolver>,
584 disabled_tools: Option<BTreeSet<String>>,
585 disabled_skill_ids: Option<BTreeSet<String>>,
586 selected_skill_ids: Option<Vec<String>>,
587 selected_skill_mode: Option<String>,
588 image_fallback: Option<ImageFallbackConfig>,
589 gold_config: Option<GoldConfig>,
590 guardian_config: Option<GuardianConfig>,
591 guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
592 bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
593 bash_completion_sink: Option<Arc<dyn BashCompletionSink>>,
594 app_data_dir: Option<std::path::PathBuf>,
595 run_budget: Option<bamboo_config::RunBudgetConfig>,
596}
597
598fn build_execute_request(
605 initial_message: String,
606 event_tx: mpsc::Sender<AgentEvent>,
607 cancel_token: CancellationToken,
608 params: ExecuteRequestParams,
609) -> ExecuteRequest {
610 let ExecuteRequestParams {
611 tools,
612 provider_override,
613 model_roster,
614 reasoning_effort,
615 auxiliary_model_resolver,
616 disabled_filter_resolver,
617 disabled_tools,
618 disabled_skill_ids,
619 selected_skill_ids,
620 selected_skill_mode,
621 image_fallback,
622 gold_config,
623 guardian_config,
624 guardian_spawner,
625 bash_resume_hook,
626 bash_completion_sink,
627 app_data_dir,
628 run_budget,
629 } = params;
630
631 let mut builder = ExecuteRequestBuilder::new(initial_message, event_tx, cancel_token)
632 .model_roster(model_roster)
633 .gold_config(gold_config)
634 .guardian_config(guardian_config)
635 .guardian_spawner(guardian_spawner)
636 .bash_resume_hook(bash_resume_hook)
637 .bash_completion_sink(bash_completion_sink);
638
639 if let Some(run_budget) = run_budget {
640 builder = builder.run_budget(run_budget);
641 }
642
643 if let Some(tools) = tools {
644 builder = builder.tools(tools);
645 }
646 if let Some(provider_override) = provider_override {
647 builder = builder.provider_override(provider_override);
648 }
649 if let Some(reasoning_effort) = reasoning_effort {
650 builder = builder.reasoning_effort(reasoning_effort);
651 }
652 if let Some(disabled_filter_resolver) = disabled_filter_resolver {
653 builder = builder.disabled_filter_resolver(disabled_filter_resolver);
654 }
655 if let Some(auxiliary_model_resolver) = auxiliary_model_resolver {
656 builder = builder.auxiliary_model_resolver(auxiliary_model_resolver);
657 }
658 if let Some(disabled_tools) = disabled_tools {
659 builder = builder.disabled_tools(disabled_tools);
660 }
661 if let Some(disabled_skill_ids) = disabled_skill_ids {
662 builder = builder.disabled_skill_ids(disabled_skill_ids);
663 }
664 if let Some(selected_skill_ids) = selected_skill_ids {
665 builder = builder.selected_skill_ids(selected_skill_ids);
666 }
667 if let Some(selected_skill_mode) = selected_skill_mode {
668 builder = builder.selected_skill_mode(selected_skill_mode);
669 }
670 if let Some(image_fallback) = image_fallback {
671 builder = builder.image_fallback(image_fallback);
672 }
673 if let Some(app_data_dir) = app_data_dir {
674 builder = builder.app_data_dir(app_data_dir);
675 }
676
677 builder.build()
678}
679
680pub fn spawn_session_execution(args: SessionExecutionArgs) {
689 let span_session_id = args.session_id.clone();
690 let session_span = tracing::info_span!("agent_execution", session_id = %span_session_id);
691
692 tokio::spawn(
693 async move {
694 let SessionExecutionArgs {
695 agent,
696 session_id,
697 mut session,
698 mut execution_reservation,
699 tools_override,
700 provider_override,
701 model_roster,
702 reasoning_effort,
703 reasoning_effort_source,
704 auxiliary_model_resolver,
705 disabled_filter_resolver,
706 disabled_tools,
707 disabled_skill_ids,
708 selected_skill_ids,
709 selected_skill_mode,
710 mpsc_tx,
711 image_fallback,
712 gold_config,
713 guardian_config,
714 guardian_spawner,
715 bash_resume_hook,
716 bash_completion_sink,
717 app_data_dir,
718 run_budget,
719 runners,
720 sessions_cache,
721 on_complete,
722 child_completion_handler,
723 } = args;
724
725 if !execution_reservation.matches_execution_target(&session_id, &session.id, &runners) {
726 tracing::error!(
727 %session_id,
728 domain_session_id = %session.id,
729 reservation_session_id = %execution_reservation.session_id(),
730 run_id = %execution_reservation.run_id(),
731 same_runner_registry = Arc::ptr_eq(&execution_reservation.runners, &runners),
732 "refusing mismatched session execution reservation"
733 );
734 execution_reservation.abandon().await;
735 return;
736 }
737 if let Err(error) = execution_reservation.ensure_registered().await {
738 tracing::warn!(
739 %session_id,
740 run_id = %execution_reservation.run_id(),
741 %error,
742 "session execution could not adopt its router activation owner"
743 );
744 return;
745 }
746 let (cancel_token, mut activation_registration) =
747 execution_reservation.disarm_for_execution();
748
749 let model = model_roster.model.clone().unwrap_or_default();
753
754 let initial_message = initial_user_message_for_session(&session);
755 let selected_skill_ids =
756 selected_skill_ids.or_else(|| selected_skill_ids_for_session(&session));
757 let selected_skill_mode =
758 selected_skill_mode.or_else(|| selected_skill_mode_for_session(&session));
759
760 tracing::info!(
761 "[{}] Using resolved session model: {}, reasoning_effort={}, reasoning_source={}",
762 session_id,
763 model,
764 reasoning_effort
765 .map(ReasoningEffort::as_str)
766 .unwrap_or("none"),
767 reasoning_effort_source
768 );
769
770 crate::session_app::execution_prep::prepare_session_for_execution(
777 &mut session,
778 None,
779 Some(&model),
780 );
781
782 let system_prompt = system_prompt_for_debug_log(&mut session);
783 if let Some(prompt) = system_prompt.as_ref() {
784 log_base_system_prompt_snapshot(&session_id, prompt);
785 }
786
787 let execute_request = build_execute_request(
788 initial_message,
789 mpsc_tx.clone(),
790 cancel_token,
791 ExecuteRequestParams {
792 tools: tools_override,
793 provider_override,
794 model_roster,
795 reasoning_effort,
796 auxiliary_model_resolver,
797 disabled_filter_resolver,
798 disabled_tools,
799 disabled_skill_ids,
800 selected_skill_ids,
801 selected_skill_mode,
802 image_fallback,
803 gold_config,
804 guardian_config,
805 guardian_spawner,
806 bash_resume_hook,
807 bash_completion_sink,
808 app_data_dir,
809 run_budget,
810 },
811 );
812
813 let result = {
820 use futures::FutureExt;
821 match std::panic::AssertUnwindSafe(agent.execute(&mut session, execute_request))
822 .catch_unwind()
823 .await
824 {
825 Ok(result) => result,
826 Err(panic) => {
827 let message = panic
828 .downcast_ref::<&str>()
829 .map(|s| (*s).to_string())
830 .or_else(|| panic.downcast_ref::<String>().cloned())
831 .unwrap_or_else(|| "non-string panic payload".to_string());
832 tracing::error!(
833 "[{}] agent execution panicked; finalizing as terminal error: {}",
834 session_id,
835 message
836 );
837 Err(AgentError::LLM(format!(
838 "agent execution panicked: {message}"
839 )))
840 }
841 }
842 };
843
844 if let Some(error_event) = terminal_error_event_for_result(&result) {
846 let _ = mpsc_tx.send(error_event).await;
847 }
848
849 let suspended_non_terminal = result.is_ok()
866 && session
867 .metadata
868 .get("runtime.suspend_reason")
869 .is_some_and(|reason| !reason.trim().is_empty());
870 match &result {
871 Ok(()) if suspended_non_terminal => {
872 session.set_last_run_status("suspended");
873 session.clear_last_run_error();
874 }
875 Ok(()) => {
876 session.set_last_run_status("completed");
877 session.clear_last_run_error();
878 }
879 Err(error) if error.is_cancelled() => {
880 session.set_last_run_status("cancelled");
881 session.set_last_run_error(error.to_string());
882 }
883 Err(error) => {
884 session.set_last_run_status("error");
885 session.set_last_run_error(error.to_string());
886 }
887 }
888
889 if let Some(on_complete) = on_complete {
893 on_complete(SessionExecutionOutcome::from_result(&result), &mut session).await;
894 }
895
896 let executed_admitted_generation = session
900 .session_inbox_admission()
901 .map_or(0, |state| state.last_admitted_sequence);
902 let legacy_migration =
903 crate::runtime::runner::state_bridge::migrate_legacy_pending_only(
904 &mut session,
905 Some(agent.storage()),
906 Some(agent.persistence()),
907 agent.session_inbox(),
908 )
909 .await;
910 let pending_boundary_generation = session
911 .session_inbox_admission()
912 .and_then(|state| state.pending_activation_generation());
913 let pending_generation = match (
914 pending_boundary_generation,
915 legacy_migration.highest_generation,
916 ) {
917 (Some(left), Some(right)) => Some(left.max(right)),
918 (left, right) => left.or(right),
919 };
920 if let (Some(generation), Some(router)) =
921 (pending_generation, agent.activation_router())
922 {
923 let activation_ready = if let Some(inbox) = agent.session_inbox() {
924 match inbox
925 .mark_activation_eligible(
926 &session_id,
927 generation,
928 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
929 )
930 .await
931 {
932 Ok(()) => true,
933 Err(error) => {
934 tracing::error!(
935 session_id = %session_id,
936 %error,
937 "failed to persist unadmitted SessionInbox activation watermark"
938 );
939 false
940 }
941 }
942 } else {
943 false
944 };
945 if activation_ready {
946 if let Err(error) = bamboo_domain::SessionActivationPort::request_activation(
947 router.as_ref(),
948 &session_id,
949 generation,
950 )
951 .await
952 {
953 tracing::error!(
954 session_id = %session_id,
955 %error,
956 "failed to hand unadmitted SessionInbox generation to activation router"
957 );
958 }
959 }
960 }
961
962 if let Some(registration) = activation_registration.as_mut() {
966 registration.begin_finalization().await;
967 }
968
969 let saved = agent.persistence().save_runtime_session(&mut session).await;
973 let authority_conflict = saved.as_ref().err().is_some_and(|error| {
974 error
975 .get_ref()
976 .is_some_and(|cause| cause.is::<bamboo_domain::SessionAuthorityConflict>())
977 });
978 if let Err(error) = saved {
979 tracing::warn!("[{}] Failed to save session: {}", session_id, error);
980 }
981
982 finalize_runner(&runners, &session_id, &result).await;
988
989 let finalization = if let Some(registration) = activation_registration.take() {
990 registration.finish(executed_admitted_generation).await
991 } else {
992 Ok(None)
993 };
994 if let Err(error) = finalization {
995 tracing::error!(
999 session_id = %session_id,
1000 %error,
1001 "failed to activate successor for finalization-racing SessionInbox delivery"
1002 );
1003 }
1004
1005 let child_completion = child_completion_handler.filter(|_| {
1014 session.kind == bamboo_agent_core::SessionKind::Child
1015 && session.parent_session_id.is_some()
1016 });
1017 let parent_session_id = session.parent_session_id.clone();
1018 let child_status = session.last_run_status();
1019 let child_error = session.last_run_error();
1020
1021 if !authority_conflict {
1024 sessions_cache.insert(
1025 session_id.clone(),
1026 Arc::new(crate::SessionSnapshot::new(session)),
1027 );
1028 }
1029
1030 if let (Some(handler), Some(parent_session_id), Some(status)) =
1031 (child_completion, parent_session_id, child_status)
1032 {
1033 use futures::FutureExt;
1034 let completion = ChildCompletion {
1035 parent_session_id: parent_session_id.clone(),
1036 child_session_id: session_id.clone(),
1037 status,
1038 error: child_error,
1039 completed_at: chrono::Utc::now(),
1040 };
1041 if std::panic::AssertUnwindSafe(handler.on_child_completed(completion))
1042 .catch_unwind()
1043 .await
1044 .is_err()
1045 {
1046 tracing::error!(
1047 %parent_session_id,
1048 child_session_id = %session_id,
1049 "child completion handler panicked on resumed-child terminal"
1050 );
1051 }
1052 }
1053
1054 tracing::info!("[{}] Agent execution completed", session_id);
1055 }
1056 .instrument(session_span),
1057 );
1058}
1059
1060pub fn log_base_system_prompt_snapshot(session_id: &str, prompt: &str) {
1062 tracing::info!(
1063 "[{}] Base system prompt snapshot: len={} chars, has_skill={}, has_tool_guide={}, has_external_memory={}, has_task_list={}",
1064 session_id,
1065 prompt.len(),
1066 prompt.contains(SKILL_CONTEXT_START_MARKER),
1067 prompt.contains(TOOL_GUIDE_START_MARKER),
1068 prompt.contains(EXTERNAL_MEMORY_START_MARKER),
1069 prompt.contains(TASK_LIST_START_MARKER),
1070 );
1071
1072 tracing::debug!(
1073 "[{}] ========== BASE SYSTEM PROMPT SNAPSHOT ==========",
1074 session_id
1075 );
1076 tracing::debug!("[{}] Snapshot length: {} chars", session_id, prompt.len());
1077 tracing::debug!("[{}] -----------------------------------", session_id);
1078 tracing::debug!("[{}] {}", session_id, prompt);
1079 tracing::debug!(
1080 "[{}] ========== END BASE SYSTEM PROMPT SNAPSHOT ==========",
1081 session_id
1082 );
1083}
1084
1085pub fn terminal_error_event_for_result(result: &Result<(), AgentError>) -> Option<AgentEvent> {
1087 match result {
1088 Ok(_) => None,
1089 Err(error) if error.is_cancelled() => Some(AgentEvent::Error {
1090 message: "Agent execution cancelled by user".to_string(),
1091 }),
1092 Err(error) => Some(AgentEvent::Error {
1093 message: error.to_string(),
1094 }),
1095 }
1096}
1097
1098fn system_prompt_for_session(session: &Session) -> Option<String> {
1101 session
1102 .messages
1103 .iter()
1104 .find(|message| matches!(message.role, bamboo_agent_core::Role::System))
1105 .map(|message| message.content.clone())
1106}
1107
1108fn system_prompt_for_debug_log(session: &mut Session) -> Option<String> {
1112 crate::runtime::runner::session_setup::migrate_legacy_workspace_prompt(session);
1113 system_prompt_for_session(session)
1114}
1115
1116fn initial_user_message_for_session(session: &Session) -> String {
1117 session
1118 .messages
1119 .last()
1120 .filter(|message| matches!(message.role, bamboo_agent_core::Role::User))
1121 .map(|message| message.content.clone())
1122 .unwrap_or_default()
1123}
1124
1125fn selected_skill_ids_for_session(session: &Session) -> Option<Vec<String>> {
1126 session
1127 .metadata
1128 .get("selected_skill_ids")
1129 .and_then(|raw| bamboo_skills::selection::parse_selected_skill_ids_metadata(raw))
1130}
1131
1132fn selected_skill_mode_for_session(session: &Session) -> Option<String> {
1133 let value = session
1134 .metadata
1135 .get("skill_mode")
1136 .or_else(|| session.metadata.get("mode"))?;
1137 let trimmed = value.trim();
1138 if trimmed.is_empty() {
1139 None
1140 } else {
1141 Some(trimmed.to_string())
1142 }
1143}
1144
1145#[cfg(test)]
1146mod reservation_tests {
1147 use super::*;
1148 use crate::runtime::execution::runner_state::AgentStatus;
1149
1150 #[test]
1151 fn debug_prompt_snapshot_migrates_legacy_host_paths_before_logging() {
1152 let legacy_project = format!(
1153 "{}\nProject ID: legacy-project\nProject path: /private/legacy-project\nProject home: /private/legacy-home\n{}",
1154 crate::runtime::context::PROJECT_CONTEXT_START_MARKER,
1155 crate::runtime::context::PROJECT_CONTEXT_END_MARKER,
1156 );
1157 let legacy_workspace =
1158 crate::runtime::context::build_workspace_prompt_context("/private/legacy-workspace")
1159 .expect("legacy workspace context");
1160 let legacy_instruction = format!(
1161 "{}\nSource: /private/legacy-workspace/AGENTS.md\nlegacy policy\n{}",
1162 crate::runtime::context::instruction::INSTRUCTION_CONTEXT_START_MARKER,
1163 crate::runtime::context::instruction::INSTRUCTION_CONTEXT_END_MARKER,
1164 );
1165 let mut session = Session::new("legacy-debug-log", "model");
1166 session.add_message(bamboo_agent_core::Message::system(format!(
1167 "Base prompt\n\n{legacy_project}\n\n{legacy_workspace}\n\n{legacy_instruction}"
1168 )));
1169
1170 let prompt = system_prompt_for_debug_log(&mut session).expect("normalized System");
1171
1172 assert_eq!(prompt, "Base prompt");
1173 assert_eq!(
1174 session.workspace_path_meta().as_deref(),
1175 Some("/private/legacy-workspace")
1176 );
1177 for private_path in [
1178 "/private/legacy-project",
1179 "/private/legacy-home",
1180 "/private/legacy-workspace",
1181 ] {
1182 assert!(!prompt.contains(private_path));
1183 }
1184 assert!(!prompt.contains(crate::runtime::context::PROJECT_CONTEXT_START_MARKER));
1185 assert!(!prompt.contains(crate::runtime::context::WORKSPACE_CONTEXT_START_MARKER));
1186 assert!(!prompt
1187 .contains(crate::runtime::context::instruction::INSTRUCTION_CONTEXT_START_MARKER));
1188 }
1189
1190 #[test]
1191 fn reservation_target_requires_domain_id_and_exact_runner_registry() {
1192 let runners = Arc::new(RwLock::new(HashMap::new()));
1193 let other_runners = Arc::new(RwLock::new(HashMap::new()));
1194 let mut reservation = SessionExecutionReservation {
1195 session_id: "session-a".to_string(),
1196 run_id: "run-a".to_string(),
1197 cancel_token: CancellationToken::new(),
1198 runners: runners.clone(),
1199 activation: SessionExecutionActivationOwnership::Unrouted,
1200 armed: true,
1201 };
1202
1203 assert!(reservation.matches_execution_target("session-a", "session-a", &runners));
1204 assert!(!reservation.matches_execution_target("session-b", "session-a", &runners));
1205 assert!(!reservation.matches_execution_target("session-a", "session-b", &runners));
1206 assert!(!reservation.matches_execution_target("session-a", "session-a", &other_runners));
1207 reservation.armed = false;
1208 }
1209
1210 #[tokio::test]
1211 async fn forced_same_run_registration_rejection_never_cancels_live_owner() {
1212 let runners = Arc::new(RwLock::new(HashMap::new()));
1213 let mut runner = AgentRunner::new();
1214 runner.status = AgentStatus::Running;
1215 let run_id = runner.run_id.clone();
1216 let live_cancel_token = runner.cancel_token.clone();
1217 runners.write().await.insert("same-run".to_string(), runner);
1218
1219 let router = SessionActivationRouter::new();
1220 let mut live_registration = router
1221 .register_run("same-run", &run_id)
1222 .await
1223 .expect("first registration owns the run");
1224 let mut rejected = SessionExecutionReservation {
1225 session_id: "same-run".to_string(),
1226 run_id: run_id.clone(),
1227 cancel_token: live_cancel_token.clone(),
1228 runners: runners.clone(),
1229 activation: SessionExecutionActivationOwnership::RegistrationPending(router.clone()),
1230 armed: true,
1231 };
1232
1233 let error = match rejected.ensure_registered().await {
1234 Ok(()) => panic!("a duplicate registration for the same run must be rejected"),
1235 Err(error) => error,
1236 };
1237 assert_eq!(error.existing_run_id(), run_id);
1238 drop(rejected);
1239
1240 assert!(!live_cancel_token.is_cancelled());
1241 assert!(matches!(
1242 runners
1243 .read()
1244 .await
1245 .get("same-run")
1246 .map(|runner| &runner.status),
1247 Some(AgentStatus::Running)
1248 ));
1249 assert!(router.owns_run("same-run", &run_id).await);
1250 live_registration.begin_finalization().await;
1251 assert_eq!(live_registration.finish(0).await.unwrap(), None);
1252 }
1253}