Skip to main content

bamboo_engine/runtime/execution/
agent_spawn.rs

1//! Core agent execution spawning logic.
2//!
3//! Provides [`spawn_session_execution`] which handles the full lifecycle of a
4//! background agent run: spawn task → execute → finalize runner → persist session.
5
6use 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    /// This runtime was built without a SessionInbox activation router.
41    Unrouted,
42    /// The runtime spawner reserved an external runner, but the router has not
43    /// yet committed the matching owner or invoked the launch closure.
44    UnpublishedActivation(Arc<SessionActivationRouter>),
45    /// This exact raw runner is waiting to acquire or adopt its router
46    /// registration. Manual/server callers enter this state before awaiting an
47    /// in-flight activation token; router-dispatched launches enter it after
48    /// publishing their zero-registration placeholder.
49    RegistrationPending(Arc<SessionActivationRouter>),
50    /// Manual/server reservation acquired router ownership before any
51    /// execution-specific mutation or external side effect.
52    Registered(SessionRunRegistration),
53}
54
55/// One exact runner reservation plus its logical-session activation ownership.
56///
57/// Callers must obtain this through [`reserve_session_execution`]. The only
58/// exception is the router's own activation launcher, which uses the
59/// crate-private placeholder constructor while assembling its two-phase launch.
60pub 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    /// Build the handoff owned by a router activation launch.
83    ///
84    /// The value starts unpublished. Its launch closure must call
85    /// [`mark_activation_published`](Self::mark_activation_published) after the
86    /// router commits the matching zero-registration owner.
87    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    /// Build a manual/queued execution handoff immediately after reserving its
104    /// raw runner. If a router exists, Drop can safely wait for and adopt an
105    /// activation placeholder that observed this exact runner.
106    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    /// Mark that the router has published this exact owner and invoked its
127    /// launch closure.
128    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    /// Roll back an external runner whose router launch was never published.
142    ///
143    /// The router reservation lease exclusively owns token release and retry
144    /// ordering here, so this path removes only the exact raw runner slot.
145    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    /// Adopt a router-published activation placeholder before any adapter-side
160    /// tool replay, workspace mutation, persistence, relay, or spawned task.
161    ///
162    /// Reservations returned by [`reserve_session_execution`] are already
163    /// registered, so calling this on the normal manual/server path is a no-op.
164    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                // The helper already terminalized only a distinct attempted
182                // runner. Disarm Drop here as part of the shared API so every
183                // adapter preserves a same-run live owner's cancellation
184                // token, even when it returns immediately on the collision.
185                self.disarm_after_registration_rejection(error.existing_run_id());
186                Err(error)
187            }
188        }
189    }
190
191    /// Release this exact runner/router reservation before a failed startup is
192    /// reported as retryable. The cleanup owns its state in a detached task so
193    /// cancellation of this explicit wait cannot strand either ownership plane.
194    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
281/// Combined runner/router reservation result.
282pub 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
374/// Reserve the shared runner slot and logical-session router as one handoff.
375///
376/// The raw runner reservation remains the common low-level primitive used by
377/// the router's own two-phase activation protocol. Every manual/server entry
378/// point uses this wrapper so an SDK owner or another entry surface collides
379/// before approved tools, workspace state, persistence, relays, or a
380/// background execution task can run.
381pub 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    // Construct the RAII handoff immediately after the raw runner mutation.
396    // If an activation router exists, publish `RegistrationPending` before the
397    // registration await. Cancellation cleanup then waits through any
398    // in-flight router token, adopts an exact placeholder if the activation
399    // observed this raw runner, and abandons both ownership planes together.
400    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
428/// Read a consistent session snapshot without acquiring a session or index
429/// lock. Returns `None` on a cache miss.
430///
431/// This is the single canonical cache-read used everywhere a caller holds a
432/// `SessionCache` (HTTP handlers, server tools, the app-state loader). It
433/// replaced ~13 verbatim copies of the
434/// `cache.get(id).map(|e| e.value().clone()).map(|a| a.read().clone())` idiom.
435pub 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
447/// Outcome of an agent execution, handed to an optional
448/// [`SessionCompletionHook`].
449///
450/// Deliberately decoupled from the runtime's internal error type so the hook
451/// API stays stable across crates and callers don't need to match on engine
452/// error variants.
453pub struct SessionExecutionOutcome {
454    /// The run finished without error.
455    pub success: bool,
456    /// The run ended because it was cancelled (a non-success subset).
457    pub cancelled: bool,
458    /// Stringified error, present when `!success`.
459    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
479/// Optional post-execution hook for [`spawn_session_execution`].
480///
481/// Invoked after the runner is finalized but **before** the session is
482/// persisted, so a caller can record bespoke terminal bookkeeping (e.g. a
483/// scheduled-run status) and/or append a closing message that is then saved
484/// with the session. Receives the execution outcome plus a mutable handle to
485/// the session. This is how callers with extra finalization (the schedule
486/// manager) route through the single canonical execution path instead of
487/// forking their own spawn + `execute` + persist sequence.
488pub 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
496/// Arguments for spawning a background agent execution.
497///
498/// This is the crate-agnostic equivalent of the server's `SpawnAgentExecution`.
499/// It holds everything needed to run the agent loop and persist the result,
500/// without depending on HTTP types or `AppState`.
501pub struct SessionExecutionArgs {
502    // Core execution.
503    pub agent: Arc<Agent>,
504    pub session_id: String,
505    pub session: Session,
506    /// Exact shared runner/router ownership acquired before caller-side
507    /// execution mutations.
508    pub execution_reservation: SessionExecutionReservation,
509
510    // Execution parameters.
511    pub tools_override: Option<Arc<dyn ToolExecutor>>,
512    pub provider_override: Option<Arc<dyn LLMProvider>>,
513    /// Cohesive primary + auxiliary model/provider selection. The primary
514    /// `model` is required for a spawn (see [`ModelRoster::model`]); the three
515    /// auxiliary roles default to their `Config::get_*` fallbacks when `None`.
516    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    /// Optional per-round live resolver for the disabled tool/skill sets (#136).
522    /// When `None` the per-run snapshot is used (sub-agent spawns pass `None`, so
523    /// short-lived children keep the spawn-time snapshot — by design).
524    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    /// Optional guardian adversarial-review gate configuration.
533    pub guardian_config: Option<GuardianConfig>,
534    /// Late-bound guardian reviewer spawner (server-provided; the runner cannot
535    /// construct a child directly).
536    pub guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
537    /// Late-bound bash self-resume hook (issue #84 Phase 2b).
538    pub bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
539    /// Late-bound bash completion sink (issue #84 Phase 2b follow-up).
540    pub bash_completion_sink: Option<Arc<dyn BashCompletionSink>>,
541    pub app_data_dir: Option<std::path::PathBuf>,
542    /// Per-run resource guardrail override (issue #221). `None` uses the
543    /// config-level `Config::run_budget` default unmodified.
544    pub run_budget: Option<bamboo_config::RunBudgetConfig>,
545
546    // Post-execution resources.
547    pub runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
548    pub sessions_cache: SessionCache,
549
550    /// Optional bespoke finalization, run after the runner is finalized and
551    /// before the session is persisted. See [`SessionCompletionHook`].
552    pub on_complete: Option<SessionCompletionHook>,
553
554    /// Child-completion publisher for CHILD sessions driven through this path
555    /// (issue #546). The canonical first run of a child goes through
556    /// `run_child_spawn`, which publishes its own terminal completion — but a
557    /// child RESUMED later (after an approval, a clarification answer, or a
558    /// nested child-parent woken by its own children) runs through
559    /// `spawn_session_execution` and previously published nothing, so the
560    /// waiting parent was never woken. When set and `session.kind == Child`
561    /// with a parent id, the terminal block invokes this handler after the
562    /// final persist + runner finalization. Non-terminal suspends publish the
563    /// non-terminal "suspended" status, which the coordinator's terminality
564    /// guard ignores.
565    pub child_completion_handler: Option<Arc<dyn super::ChildCompletionHandler>>,
566}
567
568/// The per-request parameter subset of [`SessionExecutionArgs`] — everything
569/// that maps onto an [`ExecuteRequest`], minus the three required positional
570/// fields (`initial_message`, `event_tx`, `cancel_token`) and the post-execution
571/// resources (runners, sessions cache, completion hook).
572///
573/// Grouping these here lets [`build_execute_request`] perform the
574/// `SessionExecutionArgs` → [`ExecuteRequest`] mapping through the canonical
575/// [`ExecuteRequestBuilder`] in one place, instead of a hand-written struct
576/// literal that must be kept field-aligned with [`ExecuteRequest`] by hand.
577struct 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
598/// Assemble an [`ExecuteRequest`] from the resolved spawn parameters via the
599/// canonical [`ExecuteRequestBuilder`].
600///
601/// Centralizing this mapping keeps every optional field threaded with exactly
602/// the same value the old struct literal carried (the builder defaults each
603/// unset field to `None`), while removing the field-by-field duplication.
604fn 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
680/// Spawn a background agent execution task.
681///
682/// This function spawns a tokio task that:
683/// 1. Executes the agent loop via `agent.execute()`
684/// 2. Sends a terminal error event if the execution fails
685/// 3. Finalizes the runner status
686/// 4. Persists the session via merge-save (preserves concurrent UI title/pin edits)
687/// 5. Updates the in-memory session cache
688pub 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            // The primary model is required for a spawn; the roster stores it as
750            // `Option<String>` for uniformity, so recover the owned String here
751            // for session attribution / logging (same value the caller set).
752            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            // Set the resolved model via the single authoritative pre-execution
771            // mutation point. The caller already placed the system prompt on the
772            // session, so pass `None` for `system_prompt` (the subsequent
773            // `system_prompt_for_session` read below sees the caller's message).
774            // This must run before that read / logging so the observable
775            // sequence (model set, then prompt snapshot) is identical.
776            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            // Panic containment (issue #546): everything below — the terminal
814            // status persist, finalize_runner, and the child-completion
815            // publish — only runs if this task survives execution. An
816            // unwinding panic would leave a zombie Running runner entry (which
817            // blinds liveness checks) and, for a child session, strand its
818            // waiting parent. Map a panic to a terminal error instead.
819            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            // Send terminal event for all error cases (including cancellation).
845            if let Some(error_event) = terminal_error_event_for_result(&result) {
846                let _ = mpsc_tx.send(error_event).await;
847            }
848
849            // Record the terminal run status on the session BEFORE persisting so
850            // the session summary reports a real `last_run_status`. Top-level
851            // sessions otherwise never set it, so summaries show
852            // `last_run_status: null`; the frontend then cannot confirm the run
853            // finished and falls back on a ~5s optimistic-settle window, leaving
854            // a phantom "thinking" indicator after the reply is already done
855            // (notably on a session's first turn). Same Ok/cancelled/error
856            // mapping `status_from_execution_result` applies to the runner.
857            //
858            // A suspended run also returns `Ok(())` but is NOT terminal: it
859            // stamped `runtime.suspend_reason` (awaiting_clarification /
860            // waiting_for_children / waiting_for_bash / awaiting_parent_approval)
861            // and will resume later (which removes the reason — see respond.rs /
862            // child_completion_coordinator). Mark it "suspended" rather than
863            // "completed" so a session waiting on the user or on children isn't
864            // reported as finished (mirrors the child path in `sdk::spawn`).
865            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            // Bespoke terminal bookkeeping (e.g. a scheduled-run status) runs
890            // here — before persistence — so any closing message the hook
891            // appends is saved with the session below.
892            if let Some(on_complete) = on_complete {
893                on_complete(SessionExecutionOutcome::from_result(&result), &mut session).await;
894            }
895
896            // Freeze the generation actually consumed by provider reasoning.
897            // Terminal compatibility migration below may enqueue newer work,
898            // but must never make that work look executed.
899            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            // Close the save→terminal race before the final persistence write:
963            // a delivery from this point onward is coalesced for a successor
964            // rather than being "notified" to a loop that has already exited.
965            if let Some(registration) = activation_registration.as_mut() {
966                registration.begin_finalization().await;
967            }
968
969            // Save session via merge-save so any concurrent UI edits to
970            // title / title_generated / pinned / title_version are preserved (the runtime is not
971            // an authoritative title writer).
972            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            // Flip the runner registry to a terminal status (which makes session
983            // summaries report `is_running: false`) ONLY AFTER the run status is
984            // persisted above, so `is_running` and `last_run_status` become
985            // visible together and the frontend settles immediately instead of
986            // lingering in its optimistic-settle window.
987            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                // Delivery is durable even if activation infrastructure is
996                // temporarily unavailable; startup/backlog reconciliation
997                // can retry without loss.
998                tracing::error!(
999                    session_id = %session_id,
1000                    %error,
1001                    "failed to activate successor for finalization-racing SessionInbox delivery"
1002                );
1003            }
1004
1005            // A CHILD session finishing through this path (a resumed child, or
1006            // a nested child-parent woken by its own children) must wake its
1007            // waiting parent (issue #546). Publish AFTER the final persist and
1008            // runner finalization so the coordinator reads the child's settled
1009            // terminal state — the resume message folds in the child's final
1010            // assistant content from storage. The status mirrors the
1011            // `last_run_status` mapping above; a non-terminal "suspended" is
1012            // published too and ignored by the coordinator's terminality guard.
1013            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            // Preserve normal I/O failure behavior, but never overwrite a
1022            // current Root's cache with a rejected authority/incarnation.
1023            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
1060/// Log a snapshot of the base system prompt for debugging.
1061pub 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
1085/// Map an execution result to a terminal error event.
1086pub 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
1098// Session metadata helpers (pure functions, no server dependency).
1099
1100fn 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
1108/// Normalize legacy host-owned prompt sections before the debug logger can
1109/// observe persisted System text. The runner repeats this migration
1110/// idempotently before workspace-scoped setup.
1111fn 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}