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
37/// Shared, per-session-locked session cache.
38///
39/// A `DashMap` gives each session id its own shard-level lock (so unrelated
40/// sessions never contend), and the inner `parking_lot::RwLock` is a *sync*
41/// lock held only to briefly clone-out or mutate-and-write-back a single
42/// `Session` — never across an `.await`. Using a sync lock makes "no guard
43/// across await" a compile-time guarantee in Send futures.
44pub type SessionCache = std::sync::Arc<
45    dashmap::DashMap<String, std::sync::Arc<parking_lot::RwLock<bamboo_agent_core::Session>>>,
46>;
47
48enum SessionExecutionActivationOwnership {
49    /// This runtime was built without a SessionInbox activation router.
50    Unrouted,
51    /// The runtime spawner reserved an external runner, but the router has not
52    /// yet committed the matching owner or invoked the launch closure.
53    UnpublishedActivation(Arc<SessionActivationRouter>),
54    /// This exact raw runner is waiting to acquire or adopt its router
55    /// registration. Manual/server callers enter this state before awaiting an
56    /// in-flight activation token; router-dispatched launches enter it after
57    /// publishing their zero-registration placeholder.
58    RegistrationPending(Arc<SessionActivationRouter>),
59    /// Manual/server reservation acquired router ownership before any
60    /// execution-specific mutation or external side effect.
61    Registered(SessionRunRegistration),
62}
63
64/// One exact runner reservation plus its logical-session activation ownership.
65///
66/// Callers must obtain this through [`reserve_session_execution`]. The only
67/// exception is the router's own activation launcher, which uses the
68/// crate-private placeholder constructor while assembling its two-phase launch.
69pub struct SessionExecutionReservation {
70    session_id: String,
71    run_id: String,
72    cancel_token: CancellationToken,
73    runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
74    activation: SessionExecutionActivationOwnership,
75    armed: bool,
76}
77
78impl SessionExecutionReservation {
79    pub fn session_id(&self) -> &str {
80        &self.session_id
81    }
82
83    pub fn run_id(&self) -> &str {
84        &self.run_id
85    }
86
87    pub fn cancel_token(&self) -> &CancellationToken {
88        &self.cancel_token
89    }
90
91    /// Build the handoff owned by a router activation launch.
92    ///
93    /// The value starts unpublished. Its launch closure must call
94    /// [`mark_activation_published`](Self::mark_activation_published) after the
95    /// router commits the matching zero-registration owner.
96    pub(crate) fn from_activation_placeholder(
97        session_id: impl Into<String>,
98        reservation: RunnerReservation,
99        router: Arc<SessionActivationRouter>,
100        runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
101    ) -> Self {
102        Self {
103            session_id: session_id.into(),
104            run_id: reservation.run_id,
105            cancel_token: reservation.cancel_token,
106            runners,
107            activation: SessionExecutionActivationOwnership::UnpublishedActivation(router),
108            armed: true,
109        }
110    }
111
112    /// Build a manual/queued execution handoff immediately after reserving its
113    /// raw runner. If a router exists, Drop can safely wait for and adopt an
114    /// activation placeholder that observed this exact runner.
115    pub(crate) fn from_pending_registration(
116        session_id: impl Into<String>,
117        reservation: RunnerReservation,
118        router: Option<Arc<SessionActivationRouter>>,
119        runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
120    ) -> Self {
121        let activation = match router {
122            Some(router) => SessionExecutionActivationOwnership::RegistrationPending(router),
123            None => SessionExecutionActivationOwnership::Unrouted,
124        };
125        Self {
126            session_id: session_id.into(),
127            run_id: reservation.run_id,
128            cancel_token: reservation.cancel_token,
129            runners,
130            activation,
131            armed: true,
132        }
133    }
134
135    /// Mark that the router has published this exact owner and invoked its
136    /// launch closure.
137    pub(crate) fn mark_activation_published(&mut self) {
138        let activation = std::mem::replace(
139            &mut self.activation,
140            SessionExecutionActivationOwnership::Unrouted,
141        );
142        self.activation = match activation {
143            SessionExecutionActivationOwnership::UnpublishedActivation(router) => {
144                SessionExecutionActivationOwnership::RegistrationPending(router)
145            }
146            other => other,
147        };
148    }
149
150    /// Roll back an external runner whose router launch was never published.
151    ///
152    /// The router reservation lease exclusively owns token release and retry
153    /// ordering here, so this path removes only the exact raw runner slot.
154    pub(crate) async fn rollback_unpublished_activation(mut self) {
155        self.armed = false;
156        self.cancel_token.cancel();
157        let activation = std::mem::replace(
158            &mut self.activation,
159            SessionExecutionActivationOwnership::Unrouted,
160        );
161        debug_assert!(matches!(
162            activation,
163            SessionExecutionActivationOwnership::UnpublishedActivation(_)
164        ));
165        remove_runner_exact(&self.runners, &self.session_id, &self.run_id).await;
166    }
167
168    /// Adopt a router-published activation placeholder before any adapter-side
169    /// tool replay, workspace mutation, persistence, relay, or spawned task.
170    ///
171    /// Reservations returned by [`reserve_session_execution`] are already
172    /// registered, so calling this on the normal manual/server path is a no-op.
173    pub async fn ensure_registered(&mut self) -> Result<(), SessionRunRegistrationError> {
174        let router = match &self.activation {
175            SessionExecutionActivationOwnership::RegistrationPending(router) => router.clone(),
176            SessionExecutionActivationOwnership::UnpublishedActivation(_) => {
177                unreachable!("unpublished activation entered an execution adapter")
178            }
179            SessionExecutionActivationOwnership::Unrouted
180            | SessionExecutionActivationOwnership::Registered(_) => return Ok(()),
181        };
182        match register_reserved_activation(router, &self.runners, &self.session_id, &self.run_id)
183            .await
184        {
185            Ok(registration) => {
186                self.activation = SessionExecutionActivationOwnership::Registered(registration);
187                Ok(())
188            }
189            Err(error) => {
190                // The helper already terminalized only a distinct attempted
191                // runner. Disarm Drop here as part of the shared API so every
192                // adapter preserves a same-run live owner's cancellation
193                // token, even when it returns immediately on the collision.
194                self.disarm_after_registration_rejection(error.existing_run_id());
195                Err(error)
196            }
197        }
198    }
199
200    /// Release this exact runner/router reservation before a failed startup is
201    /// reported as retryable. The cleanup owns its state in a detached task so
202    /// cancellation of this explicit wait cannot strand either ownership plane.
203    pub async fn abandon(mut self) {
204        self.armed = false;
205        self.cancel_token.cancel();
206        let activation = std::mem::replace(
207            &mut self.activation,
208            SessionExecutionActivationOwnership::Unrouted,
209        );
210        let runners = self.runners.clone();
211        let session_id = self.session_id.clone();
212        let run_id = self.run_id.clone();
213        let cleanup_session_id = session_id.clone();
214        let cleanup_run_id = run_id.clone();
215        let cleanup = tokio::spawn(async move {
216            cleanup_execution_reservation(activation, runners, cleanup_session_id, cleanup_run_id)
217                .await;
218        });
219        if let Err(error) = cleanup.await {
220            tracing::error!(
221                %session_id,
222                %run_id,
223                %error,
224                "detached execution-reservation cleanup failed"
225            );
226        }
227    }
228
229    fn disarm_after_registration_rejection(&mut self, existing_run_id: &str) {
230        if existing_run_id != self.run_id {
231            self.cancel_token.cancel();
232        }
233        self.armed = false;
234    }
235
236    pub(crate) fn matches_execution_target(
237        &self,
238        session_id: &str,
239        domain_session_id: &str,
240        runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
241    ) -> bool {
242        self.session_id == session_id
243            && domain_session_id == session_id
244            && Arc::ptr_eq(&self.runners, runners)
245    }
246
247    pub(crate) fn disarm_for_execution(
248        &mut self,
249    ) -> (CancellationToken, Option<SessionRunRegistration>) {
250        self.armed = false;
251        let registration = match std::mem::replace(
252            &mut self.activation,
253            SessionExecutionActivationOwnership::Unrouted,
254        ) {
255            SessionExecutionActivationOwnership::Registered(registration) => Some(registration),
256            SessionExecutionActivationOwnership::Unrouted => None,
257            SessionExecutionActivationOwnership::UnpublishedActivation(_) => {
258                unreachable!("unpublished activation cannot transfer to execution")
259            }
260            SessionExecutionActivationOwnership::RegistrationPending(_) => {
261                unreachable!("ensure_registered adopts every pending router registration")
262            }
263        };
264        (self.cancel_token.clone(), registration)
265    }
266}
267
268impl Drop for SessionExecutionReservation {
269    fn drop(&mut self) {
270        if !self.armed {
271            return;
272        }
273        self.armed = false;
274        self.cancel_token.cancel();
275        let activation = std::mem::replace(
276            &mut self.activation,
277            SessionExecutionActivationOwnership::Unrouted,
278        );
279        let runners = self.runners.clone();
280        let session_id = self.session_id.clone();
281        let run_id = self.run_id.clone();
282        if let Ok(runtime) = tokio::runtime::Handle::try_current() {
283            runtime.spawn(async move {
284                cleanup_execution_reservation(activation, runners, session_id, run_id).await;
285            });
286        }
287    }
288}
289
290/// Combined runner/router reservation result.
291pub enum SessionExecutionReserveOutcome {
292    Reserved(SessionExecutionReservation),
293    AlreadyRunning { run_id: String },
294}
295
296async fn register_reserved_activation(
297    router: Arc<SessionActivationRouter>,
298    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
299    session_id: &str,
300    run_id: &str,
301) -> Result<SessionRunRegistration, SessionRunRegistrationError> {
302    let mut registration = match router.register_run(session_id, run_id).await {
303        Ok(registration) => registration,
304        Err(error) => {
305            let collision = Err(AgentError::LLM(error.to_string()));
306            finalize_rejected_runner_if_distinct(
307                runners,
308                session_id,
309                error.existing_run_id(),
310                run_id,
311                &collision,
312            )
313            .await;
314            return Err(error);
315        }
316    };
317    let cleanup_runners = runners.clone();
318    let cleanup_session_id = session_id.to_string();
319    let cleanup_run_id = run_id.to_string();
320    registration.set_abort_cleanup(move || async move {
321        let abandoned = Err(AgentError::Cancelled);
322        finalize_runner_exact(
323            &cleanup_runners,
324            &cleanup_session_id,
325            &cleanup_run_id,
326            &abandoned,
327        )
328        .await;
329    });
330    Ok(registration)
331}
332
333async fn cleanup_execution_reservation(
334    activation: SessionExecutionActivationOwnership,
335    runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
336    session_id: String,
337    run_id: String,
338) {
339    match activation {
340        SessionExecutionActivationOwnership::Registered(registration) => {
341            registration.abandon().await;
342        }
343        SessionExecutionActivationOwnership::RegistrationPending(router) => {
344            match register_reserved_activation(router, &runners, &session_id, &run_id).await {
345                Ok(registration) => registration.abandon().await,
346                Err(error) => {
347                    tracing::debug!(
348                        %session_id,
349                        %run_id,
350                        existing_run_id = %error.existing_run_id(),
351                        "abandoned activation placeholder was already adopted or superseded"
352                    );
353                }
354            }
355        }
356        SessionExecutionActivationOwnership::UnpublishedActivation(_) => {
357            remove_runner_exact(&runners, &session_id, &run_id).await;
358        }
359        SessionExecutionActivationOwnership::Unrouted => {
360            let abandoned = Err(AgentError::Cancelled);
361            finalize_runner_exact(&runners, &session_id, &run_id, &abandoned).await;
362        }
363    }
364}
365
366async fn remove_runner_exact(
367    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
368    session_id: &str,
369    run_id: &str,
370) -> bool {
371    let mut runners = runners.write().await;
372    if runners
373        .get(session_id)
374        .is_some_and(|runner| runner.run_id == run_id)
375    {
376        runners.remove(session_id);
377        true
378    } else {
379        false
380    }
381}
382
383/// Reserve the shared runner slot and logical-session router as one handoff.
384///
385/// The raw runner reservation remains the common low-level primitive used by
386/// the router's own two-phase activation protocol. Every manual/server entry
387/// point uses this wrapper so an SDK owner or another entry surface collides
388/// before approved tools, workspace state, persistence, relays, or a
389/// background execution task can run.
390pub async fn reserve_session_execution(
391    agent: &Arc<Agent>,
392    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
393    senders: &Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
394    session_id: &str,
395    event_sender: &broadcast::Sender<AgentEvent>,
396) -> SessionExecutionReserveOutcome {
397    let reservation = match reserve_runner_core(runners, senders, session_id, event_sender).await {
398        ReserveOutcome::AlreadyRunning(run_id) => {
399            return SessionExecutionReserveOutcome::AlreadyRunning { run_id };
400        }
401        ReserveOutcome::Reserved(reservation) => reservation,
402    };
403
404    // Construct the RAII handoff immediately after the raw runner mutation.
405    // If an activation router exists, publish `RegistrationPending` before the
406    // registration await. Cancellation cleanup then waits through any
407    // in-flight router token, adopts an exact placeholder if the activation
408    // observed this raw runner, and abandons both ownership planes together.
409    let mut execution_reservation = SessionExecutionReservation {
410        session_id: session_id.to_string(),
411        run_id: reservation.run_id,
412        cancel_token: reservation.cancel_token,
413        runners: runners.clone(),
414        activation: SessionExecutionActivationOwnership::Unrouted,
415        armed: true,
416    };
417
418    if let Some(router) = agent.activation_router().cloned() {
419        execution_reservation.activation =
420            SessionExecutionActivationOwnership::RegistrationPending(router);
421        if let Err(error) = execution_reservation.ensure_registered().await {
422            tracing::warn!(
423                %session_id,
424                attempted_run_id = %execution_reservation.run_id(),
425                existing_run_id = %error.existing_run_id(),
426                %error,
427                "runner reservation collided with an existing logical-session owner"
428            );
429            return SessionExecutionReserveOutcome::AlreadyRunning {
430                run_id: error.existing_run_id().to_string(),
431            };
432        }
433    }
434    SessionExecutionReserveOutcome::Reserved(execution_reservation)
435}
436
437/// Read a session out of the in-memory cache, cloning it out from under the
438/// brief sync read-lock. Returns `None` on a cache miss.
439///
440/// This is the single canonical cache-read used everywhere a caller holds a
441/// `SessionCache` (HTTP handlers, server tools, the app-state loader). It
442/// replaced ~13 verbatim copies of the
443/// `cache.get(id).map(|e| e.value().clone()).map(|a| a.read().clone())` idiom.
444pub fn read_cached_session(cache: &SessionCache, id: &str) -> Option<bamboo_agent_core::Session> {
445    cache
446        .get(id)
447        .map(|e| e.value().clone())
448        .map(|a| a.read().clone())
449}
450
451const SKILL_CONTEXT_START_MARKER: &str = "<!-- BAMBOO_SKILL_CONTEXT_START -->";
452const TOOL_GUIDE_START_MARKER: &str = "<!-- BAMBOO_TOOL_GUIDE_START -->";
453const EXTERNAL_MEMORY_START_MARKER: &str = "<!-- BAMBOO_EXTERNAL_MEMORY_START -->";
454const TASK_LIST_START_MARKER: &str = "<!-- BAMBOO_TASK_LIST_START -->";
455
456/// Outcome of an agent execution, handed to an optional
457/// [`SessionCompletionHook`].
458///
459/// Deliberately decoupled from the runtime's internal error type so the hook
460/// API stays stable across crates and callers don't need to match on engine
461/// error variants.
462pub struct SessionExecutionOutcome {
463    /// The run finished without error.
464    pub success: bool,
465    /// The run ended because it was cancelled (a non-success subset).
466    pub cancelled: bool,
467    /// Stringified error, present when `!success`.
468    pub error: Option<String>,
469}
470
471impl SessionExecutionOutcome {
472    fn from_result(result: &Result<(), AgentError>) -> Self {
473        match result {
474            Ok(()) => Self {
475                success: true,
476                cancelled: false,
477                error: None,
478            },
479            Err(error) => Self {
480                success: false,
481                cancelled: error.is_cancelled(),
482                error: Some(error.to_string()),
483            },
484        }
485    }
486}
487
488/// Optional post-execution hook for [`spawn_session_execution`].
489///
490/// Invoked after the runner is finalized but **before** the session is
491/// persisted, so a caller can record bespoke terminal bookkeeping (e.g. a
492/// scheduled-run status) and/or append a closing message that is then saved
493/// with the session. Receives the execution outcome plus a mutable handle to
494/// the session. This is how callers with extra finalization (the schedule
495/// manager) route through the single canonical execution path instead of
496/// forking their own spawn + `execute` + persist sequence.
497pub type SessionCompletionHook = Box<
498    dyn for<'a> FnOnce(
499            SessionExecutionOutcome,
500            &'a mut Session,
501        ) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>>
502        + Send,
503>;
504
505/// Arguments for spawning a background agent execution.
506///
507/// This is the crate-agnostic equivalent of the server's `SpawnAgentExecution`.
508/// It holds everything needed to run the agent loop and persist the result,
509/// without depending on HTTP types or `AppState`.
510pub struct SessionExecutionArgs {
511    // Core execution.
512    pub agent: Arc<Agent>,
513    pub session_id: String,
514    pub session: Session,
515    /// Exact shared runner/router ownership acquired before caller-side
516    /// execution mutations.
517    pub execution_reservation: SessionExecutionReservation,
518
519    // Execution parameters.
520    pub tools_override: Option<Arc<dyn ToolExecutor>>,
521    pub provider_override: Option<Arc<dyn LLMProvider>>,
522    /// Cohesive primary + auxiliary model/provider selection. The primary
523    /// `model` is required for a spawn (see [`ModelRoster::model`]); the three
524    /// auxiliary roles default to their `Config::get_*` fallbacks when `None`.
525    pub model_roster: ModelRoster,
526    pub reasoning_effort: Option<ReasoningEffort>,
527    pub reasoning_effort_source: String,
528    pub auxiliary_model_resolver:
529        Option<Arc<dyn Fn() -> crate::runtime::config::AuxiliaryModelConfig + Send + Sync>>,
530    /// Optional per-round live resolver for the disabled tool/skill sets (#136).
531    /// When `None` the per-run snapshot is used (sub-agent spawns pass `None`, so
532    /// short-lived children keep the spawn-time snapshot — by design).
533    pub disabled_filter_resolver: Option<DisabledFilterResolver>,
534    pub disabled_tools: Option<BTreeSet<String>>,
535    pub disabled_skill_ids: Option<BTreeSet<String>>,
536    pub selected_skill_ids: Option<Vec<String>>,
537    pub selected_skill_mode: Option<String>,
538    pub mpsc_tx: mpsc::Sender<AgentEvent>,
539    pub image_fallback: Option<ImageFallbackConfig>,
540    pub gold_config: Option<GoldConfig>,
541    /// Optional guardian adversarial-review gate configuration.
542    pub guardian_config: Option<GuardianConfig>,
543    /// Late-bound guardian reviewer spawner (server-provided; the runner cannot
544    /// construct a child directly).
545    pub guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
546    /// Late-bound bash self-resume hook (issue #84 Phase 2b).
547    pub bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
548    /// Late-bound bash completion sink (issue #84 Phase 2b follow-up).
549    pub bash_completion_sink: Option<Arc<dyn BashCompletionSink>>,
550    pub app_data_dir: Option<std::path::PathBuf>,
551    /// Per-run resource guardrail override (issue #221). `None` uses the
552    /// config-level `Config::run_budget` default unmodified.
553    pub run_budget: Option<bamboo_config::RunBudgetConfig>,
554
555    // Post-execution resources.
556    pub runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
557    pub sessions_cache: SessionCache,
558
559    /// Optional bespoke finalization, run after the runner is finalized and
560    /// before the session is persisted. See [`SessionCompletionHook`].
561    pub on_complete: Option<SessionCompletionHook>,
562
563    /// Child-completion publisher for CHILD sessions driven through this path
564    /// (issue #546). The canonical first run of a child goes through
565    /// `run_child_spawn`, which publishes its own terminal completion — but a
566    /// child RESUMED later (after an approval, a clarification answer, or a
567    /// nested child-parent woken by its own children) runs through
568    /// `spawn_session_execution` and previously published nothing, so the
569    /// waiting parent was never woken. When set and `session.kind == Child`
570    /// with a parent id, the terminal block invokes this handler after the
571    /// final persist + runner finalization. Non-terminal suspends publish the
572    /// non-terminal "suspended" status, which the coordinator's terminality
573    /// guard ignores.
574    pub child_completion_handler: Option<Arc<dyn super::ChildCompletionHandler>>,
575}
576
577/// The per-request parameter subset of [`SessionExecutionArgs`] — everything
578/// that maps onto an [`ExecuteRequest`], minus the three required positional
579/// fields (`initial_message`, `event_tx`, `cancel_token`) and the post-execution
580/// resources (runners, sessions cache, completion hook).
581///
582/// Grouping these here lets [`build_execute_request`] perform the
583/// `SessionExecutionArgs` → [`ExecuteRequest`] mapping through the canonical
584/// [`ExecuteRequestBuilder`] in one place, instead of a hand-written struct
585/// literal that must be kept field-aligned with [`ExecuteRequest`] by hand.
586struct ExecuteRequestParams {
587    tools: Option<Arc<dyn ToolExecutor>>,
588    provider_override: Option<Arc<dyn LLMProvider>>,
589    model_roster: ModelRoster,
590    reasoning_effort: Option<ReasoningEffort>,
591    auxiliary_model_resolver: Option<Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync>>,
592    disabled_filter_resolver: Option<DisabledFilterResolver>,
593    disabled_tools: Option<BTreeSet<String>>,
594    disabled_skill_ids: Option<BTreeSet<String>>,
595    selected_skill_ids: Option<Vec<String>>,
596    selected_skill_mode: Option<String>,
597    image_fallback: Option<ImageFallbackConfig>,
598    gold_config: Option<GoldConfig>,
599    guardian_config: Option<GuardianConfig>,
600    guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
601    bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
602    bash_completion_sink: Option<Arc<dyn BashCompletionSink>>,
603    app_data_dir: Option<std::path::PathBuf>,
604    run_budget: Option<bamboo_config::RunBudgetConfig>,
605}
606
607/// Assemble an [`ExecuteRequest`] from the resolved spawn parameters via the
608/// canonical [`ExecuteRequestBuilder`].
609///
610/// Centralizing this mapping keeps every optional field threaded with exactly
611/// the same value the old struct literal carried (the builder defaults each
612/// unset field to `None`), while removing the field-by-field duplication.
613fn build_execute_request(
614    initial_message: String,
615    event_tx: mpsc::Sender<AgentEvent>,
616    cancel_token: CancellationToken,
617    params: ExecuteRequestParams,
618) -> ExecuteRequest {
619    let ExecuteRequestParams {
620        tools,
621        provider_override,
622        model_roster,
623        reasoning_effort,
624        auxiliary_model_resolver,
625        disabled_filter_resolver,
626        disabled_tools,
627        disabled_skill_ids,
628        selected_skill_ids,
629        selected_skill_mode,
630        image_fallback,
631        gold_config,
632        guardian_config,
633        guardian_spawner,
634        bash_resume_hook,
635        bash_completion_sink,
636        app_data_dir,
637        run_budget,
638    } = params;
639
640    let mut builder = ExecuteRequestBuilder::new(initial_message, event_tx, cancel_token)
641        .model_roster(model_roster)
642        .gold_config(gold_config)
643        .guardian_config(guardian_config)
644        .guardian_spawner(guardian_spawner)
645        .bash_resume_hook(bash_resume_hook)
646        .bash_completion_sink(bash_completion_sink);
647
648    if let Some(run_budget) = run_budget {
649        builder = builder.run_budget(run_budget);
650    }
651
652    if let Some(tools) = tools {
653        builder = builder.tools(tools);
654    }
655    if let Some(provider_override) = provider_override {
656        builder = builder.provider_override(provider_override);
657    }
658    if let Some(reasoning_effort) = reasoning_effort {
659        builder = builder.reasoning_effort(reasoning_effort);
660    }
661    if let Some(disabled_filter_resolver) = disabled_filter_resolver {
662        builder = builder.disabled_filter_resolver(disabled_filter_resolver);
663    }
664    if let Some(auxiliary_model_resolver) = auxiliary_model_resolver {
665        builder = builder.auxiliary_model_resolver(auxiliary_model_resolver);
666    }
667    if let Some(disabled_tools) = disabled_tools {
668        builder = builder.disabled_tools(disabled_tools);
669    }
670    if let Some(disabled_skill_ids) = disabled_skill_ids {
671        builder = builder.disabled_skill_ids(disabled_skill_ids);
672    }
673    if let Some(selected_skill_ids) = selected_skill_ids {
674        builder = builder.selected_skill_ids(selected_skill_ids);
675    }
676    if let Some(selected_skill_mode) = selected_skill_mode {
677        builder = builder.selected_skill_mode(selected_skill_mode);
678    }
679    if let Some(image_fallback) = image_fallback {
680        builder = builder.image_fallback(image_fallback);
681    }
682    if let Some(app_data_dir) = app_data_dir {
683        builder = builder.app_data_dir(app_data_dir);
684    }
685
686    builder.build()
687}
688
689/// Spawn a background agent execution task.
690///
691/// This function spawns a tokio task that:
692/// 1. Executes the agent loop via `agent.execute()`
693/// 2. Sends a terminal error event if the execution fails
694/// 3. Finalizes the runner status
695/// 4. Persists the session via merge-save (preserves concurrent UI title/pin edits)
696/// 5. Updates the in-memory session cache
697pub fn spawn_session_execution(args: SessionExecutionArgs) {
698    let span_session_id = args.session_id.clone();
699    let session_span = tracing::info_span!("agent_execution", session_id = %span_session_id);
700
701    tokio::spawn(
702        async move {
703            let SessionExecutionArgs {
704                agent,
705                session_id,
706                mut session,
707                mut execution_reservation,
708                tools_override,
709                provider_override,
710                model_roster,
711                reasoning_effort,
712                reasoning_effort_source,
713                auxiliary_model_resolver,
714                disabled_filter_resolver,
715                disabled_tools,
716                disabled_skill_ids,
717                selected_skill_ids,
718                selected_skill_mode,
719                mpsc_tx,
720                image_fallback,
721                gold_config,
722                guardian_config,
723                guardian_spawner,
724                bash_resume_hook,
725                bash_completion_sink,
726                app_data_dir,
727                run_budget,
728                runners,
729                sessions_cache,
730                on_complete,
731                child_completion_handler,
732            } = args;
733
734            if !execution_reservation.matches_execution_target(&session_id, &session.id, &runners) {
735                tracing::error!(
736                    %session_id,
737                    domain_session_id = %session.id,
738                    reservation_session_id = %execution_reservation.session_id(),
739                    run_id = %execution_reservation.run_id(),
740                    same_runner_registry = Arc::ptr_eq(&execution_reservation.runners, &runners),
741                    "refusing mismatched session execution reservation"
742                );
743                execution_reservation.abandon().await;
744                return;
745            }
746            if let Err(error) = execution_reservation.ensure_registered().await {
747                tracing::warn!(
748                    %session_id,
749                    run_id = %execution_reservation.run_id(),
750                    %error,
751                    "session execution could not adopt its router activation owner"
752                );
753                return;
754            }
755            let (cancel_token, mut activation_registration) =
756                execution_reservation.disarm_for_execution();
757
758            // The primary model is required for a spawn; the roster stores it as
759            // `Option<String>` for uniformity, so recover the owned String here
760            // for session attribution / logging (same value the caller set).
761            let model = model_roster.model.clone().unwrap_or_default();
762
763            let initial_message = initial_user_message_for_session(&session);
764            let selected_skill_ids =
765                selected_skill_ids.or_else(|| selected_skill_ids_for_session(&session));
766            let selected_skill_mode =
767                selected_skill_mode.or_else(|| selected_skill_mode_for_session(&session));
768
769            tracing::info!(
770                "[{}] Using resolved session model: {}, reasoning_effort={}, reasoning_source={}",
771                session_id,
772                model,
773                reasoning_effort
774                    .map(ReasoningEffort::as_str)
775                    .unwrap_or("none"),
776                reasoning_effort_source
777            );
778
779            // Set the resolved model via the single authoritative pre-execution
780            // mutation point. The caller already placed the system prompt on the
781            // session, so pass `None` for `system_prompt` (the subsequent
782            // `system_prompt_for_session` read below sees the caller's message).
783            // This must run before that read / logging so the observable
784            // sequence (model set, then prompt snapshot) is identical.
785            crate::session_app::execution_prep::prepare_session_for_execution(
786                &mut session,
787                None,
788                Some(&model),
789            );
790
791            let system_prompt = system_prompt_for_session(&session);
792            if let Some(prompt) = system_prompt.as_ref() {
793                log_base_system_prompt_snapshot(&session_id, prompt);
794            }
795
796            let execute_request = build_execute_request(
797                initial_message,
798                mpsc_tx.clone(),
799                cancel_token,
800                ExecuteRequestParams {
801                    tools: tools_override,
802                    provider_override,
803                    model_roster,
804                    reasoning_effort,
805                    auxiliary_model_resolver,
806                    disabled_filter_resolver,
807                    disabled_tools,
808                    disabled_skill_ids,
809                    selected_skill_ids,
810                    selected_skill_mode,
811                    image_fallback,
812                    gold_config,
813                    guardian_config,
814                    guardian_spawner,
815                    bash_resume_hook,
816                    bash_completion_sink,
817                    app_data_dir,
818                    run_budget,
819                },
820            );
821
822            // Panic containment (issue #546): everything below — the terminal
823            // status persist, finalize_runner, and the child-completion
824            // publish — only runs if this task survives execution. An
825            // unwinding panic would leave a zombie Running runner entry (which
826            // blinds liveness checks) and, for a child session, strand its
827            // waiting parent. Map a panic to a terminal error instead.
828            let result = {
829                use futures::FutureExt;
830                match std::panic::AssertUnwindSafe(agent.execute(&mut session, execute_request))
831                    .catch_unwind()
832                    .await
833                {
834                    Ok(result) => result,
835                    Err(panic) => {
836                        let message = panic
837                            .downcast_ref::<&str>()
838                            .map(|s| (*s).to_string())
839                            .or_else(|| panic.downcast_ref::<String>().cloned())
840                            .unwrap_or_else(|| "non-string panic payload".to_string());
841                        tracing::error!(
842                            "[{}] agent execution panicked; finalizing as terminal error: {}",
843                            session_id,
844                            message
845                        );
846                        Err(AgentError::LLM(format!(
847                            "agent execution panicked: {message}"
848                        )))
849                    }
850                }
851            };
852
853            // Send terminal event for all error cases (including cancellation).
854            if let Some(error_event) = terminal_error_event_for_result(&result) {
855                let _ = mpsc_tx.send(error_event).await;
856            }
857
858            // Record the terminal run status on the session BEFORE persisting so
859            // the session summary reports a real `last_run_status`. Top-level
860            // sessions otherwise never set it, so summaries show
861            // `last_run_status: null`; the frontend then cannot confirm the run
862            // finished and falls back on a ~5s optimistic-settle window, leaving
863            // a phantom "thinking" indicator after the reply is already done
864            // (notably on a session's first turn). Same Ok/cancelled/error
865            // mapping `status_from_execution_result` applies to the runner.
866            //
867            // A suspended run also returns `Ok(())` but is NOT terminal: it
868            // stamped `runtime.suspend_reason` (awaiting_clarification /
869            // waiting_for_children / waiting_for_bash / awaiting_parent_approval)
870            // and will resume later (which removes the reason — see respond.rs /
871            // child_completion_coordinator). Mark it "suspended" rather than
872            // "completed" so a session waiting on the user or on children isn't
873            // reported as finished (mirrors the child path in `sdk::spawn`).
874            let suspended_non_terminal = result.is_ok()
875                && session
876                    .metadata
877                    .get("runtime.suspend_reason")
878                    .is_some_and(|reason| !reason.trim().is_empty());
879            match &result {
880                Ok(()) if suspended_non_terminal => {
881                    session.set_last_run_status("suspended");
882                    session.clear_last_run_error();
883                }
884                Ok(()) => {
885                    session.set_last_run_status("completed");
886                    session.clear_last_run_error();
887                }
888                Err(error) if error.is_cancelled() => {
889                    session.set_last_run_status("cancelled");
890                    session.set_last_run_error(error.to_string());
891                }
892                Err(error) => {
893                    session.set_last_run_status("error");
894                    session.set_last_run_error(error.to_string());
895                }
896            }
897
898            // Bespoke terminal bookkeeping (e.g. a scheduled-run status) runs
899            // here — before persistence — so any closing message the hook
900            // appends is saved with the session below.
901            if let Some(on_complete) = on_complete {
902                on_complete(SessionExecutionOutcome::from_result(&result), &mut session).await;
903            }
904
905            // Freeze the generation actually consumed by provider reasoning.
906            // Terminal compatibility migration below may enqueue newer work,
907            // but must never make that work look executed.
908            let executed_admitted_generation = session
909                .session_inbox_admission()
910                .map_or(0, |state| state.last_admitted_sequence);
911            let legacy_migration =
912                crate::runtime::runner::state_bridge::migrate_legacy_pending_only(
913                    &mut session,
914                    Some(agent.storage()),
915                    Some(agent.persistence()),
916                    agent.session_inbox(),
917                )
918                .await;
919            let pending_boundary_generation = session
920                .session_inbox_admission()
921                .and_then(|state| state.pending_activation_generation());
922            let pending_generation = match (
923                pending_boundary_generation,
924                legacy_migration.highest_generation,
925            ) {
926                (Some(left), Some(right)) => Some(left.max(right)),
927                (left, right) => left.or(right),
928            };
929            if let (Some(generation), Some(router)) =
930                (pending_generation, agent.activation_router())
931            {
932                let activation_ready = if let Some(inbox) = agent.session_inbox() {
933                    match inbox
934                        .mark_activation_eligible(
935                            &session_id,
936                            generation,
937                            bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
938                        )
939                        .await
940                    {
941                        Ok(()) => true,
942                        Err(error) => {
943                            tracing::error!(
944                                session_id = %session_id,
945                                %error,
946                                "failed to persist unadmitted SessionInbox activation watermark"
947                            );
948                            false
949                        }
950                    }
951                } else {
952                    false
953                };
954                if activation_ready {
955                    if let Err(error) = bamboo_domain::SessionActivationPort::request_activation(
956                        router.as_ref(),
957                        &session_id,
958                        generation,
959                    )
960                    .await
961                    {
962                        tracing::error!(
963                            session_id = %session_id,
964                            %error,
965                            "failed to hand unadmitted SessionInbox generation to activation router"
966                        );
967                    }
968                }
969            }
970
971            // Close the save→terminal race before the final persistence write:
972            // a delivery from this point onward is coalesced for a successor
973            // rather than being "notified" to a loop that has already exited.
974            if let Some(registration) = activation_registration.as_mut() {
975                registration.begin_finalization().await;
976            }
977
978            // Save session via merge-save so any concurrent UI edits to
979            // title / pinned / title_version are preserved (the runtime is not
980            // an authoritative title writer).
981            if let Err(error) = agent.persistence().save_runtime_session(&mut session).await {
982                tracing::warn!("[{}] Failed to save session: {}", session_id, error);
983            }
984
985            // Flip the runner registry to a terminal status (which makes session
986            // summaries report `is_running: false`) ONLY AFTER the run status is
987            // persisted above, so `is_running` and `last_run_status` become
988            // visible together and the frontend settles immediately instead of
989            // lingering in its optimistic-settle window.
990            finalize_runner(&runners, &session_id, &result).await;
991
992            let finalization = if let Some(registration) = activation_registration.take() {
993                registration.finish(executed_admitted_generation).await
994            } else {
995                Ok(None)
996            };
997            if let Err(error) = finalization {
998                // Delivery is durable even if activation infrastructure is
999                // temporarily unavailable; startup/backlog reconciliation
1000                // can retry without loss.
1001                tracing::error!(
1002                    session_id = %session_id,
1003                    %error,
1004                    "failed to activate successor for finalization-racing SessionInbox delivery"
1005                );
1006            }
1007
1008            // A CHILD session finishing through this path (a resumed child, or
1009            // a nested child-parent woken by its own children) must wake its
1010            // waiting parent (issue #546). Publish AFTER the final persist and
1011            // runner finalization so the coordinator reads the child's settled
1012            // terminal state — the resume message folds in the child's final
1013            // assistant content from storage. The status mirrors the
1014            // `last_run_status` mapping above; a non-terminal "suspended" is
1015            // published too and ignored by the coordinator's terminality guard.
1016            let child_completion = child_completion_handler.filter(|_| {
1017                session.kind == bamboo_agent_core::SessionKind::Child
1018                    && session.parent_session_id.is_some()
1019            });
1020            let parent_session_id = session.parent_session_id.clone();
1021            let child_status = session.last_run_status();
1022            let child_error = session.last_run_error();
1023
1024            // Update memory cache.
1025            sessions_cache.insert(
1026                session_id.clone(),
1027                Arc::new(parking_lot::RwLock::new(session)),
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
1108fn initial_user_message_for_session(session: &Session) -> String {
1109    session
1110        .messages
1111        .last()
1112        .filter(|message| matches!(message.role, bamboo_agent_core::Role::User))
1113        .map(|message| message.content.clone())
1114        .unwrap_or_default()
1115}
1116
1117fn selected_skill_ids_for_session(session: &Session) -> Option<Vec<String>> {
1118    session
1119        .metadata
1120        .get("selected_skill_ids")
1121        .and_then(|raw| bamboo_skills::selection::parse_selected_skill_ids_metadata(raw))
1122}
1123
1124fn selected_skill_mode_for_session(session: &Session) -> Option<String> {
1125    let value = session
1126        .metadata
1127        .get("skill_mode")
1128        .or_else(|| session.metadata.get("mode"))?;
1129    let trimmed = value.trim();
1130    if trimmed.is_empty() {
1131        None
1132    } else {
1133        Some(trimmed.to_string())
1134    }
1135}
1136
1137#[cfg(test)]
1138mod reservation_tests {
1139    use super::*;
1140    use crate::runtime::execution::runner_state::AgentStatus;
1141
1142    #[test]
1143    fn reservation_target_requires_domain_id_and_exact_runner_registry() {
1144        let runners = Arc::new(RwLock::new(HashMap::new()));
1145        let other_runners = Arc::new(RwLock::new(HashMap::new()));
1146        let mut reservation = SessionExecutionReservation {
1147            session_id: "session-a".to_string(),
1148            run_id: "run-a".to_string(),
1149            cancel_token: CancellationToken::new(),
1150            runners: runners.clone(),
1151            activation: SessionExecutionActivationOwnership::Unrouted,
1152            armed: true,
1153        };
1154
1155        assert!(reservation.matches_execution_target("session-a", "session-a", &runners));
1156        assert!(!reservation.matches_execution_target("session-b", "session-a", &runners));
1157        assert!(!reservation.matches_execution_target("session-a", "session-b", &runners));
1158        assert!(!reservation.matches_execution_target("session-a", "session-a", &other_runners));
1159        reservation.armed = false;
1160    }
1161
1162    #[tokio::test]
1163    async fn forced_same_run_registration_rejection_never_cancels_live_owner() {
1164        let runners = Arc::new(RwLock::new(HashMap::new()));
1165        let mut runner = AgentRunner::new();
1166        runner.status = AgentStatus::Running;
1167        let run_id = runner.run_id.clone();
1168        let live_cancel_token = runner.cancel_token.clone();
1169        runners.write().await.insert("same-run".to_string(), runner);
1170
1171        let router = SessionActivationRouter::new();
1172        let mut live_registration = router
1173            .register_run("same-run", &run_id)
1174            .await
1175            .expect("first registration owns the run");
1176        let mut rejected = SessionExecutionReservation {
1177            session_id: "same-run".to_string(),
1178            run_id: run_id.clone(),
1179            cancel_token: live_cancel_token.clone(),
1180            runners: runners.clone(),
1181            activation: SessionExecutionActivationOwnership::RegistrationPending(router.clone()),
1182            armed: true,
1183        };
1184
1185        let error = match rejected.ensure_registered().await {
1186            Ok(()) => panic!("a duplicate registration for the same run must be rejected"),
1187            Err(error) => error,
1188        };
1189        assert_eq!(error.existing_run_id(), run_id);
1190        drop(rejected);
1191
1192        assert!(!live_cancel_token.is_cancelled());
1193        assert!(matches!(
1194            runners
1195                .read()
1196                .await
1197                .get("same-run")
1198                .map(|runner| &runner.status),
1199            Some(AgentStatus::Running)
1200        ));
1201        assert!(router.owns_run("same-run", &run_id).await);
1202        live_registration.begin_finalization().await;
1203        assert_eq!(live_registration.finish(0).await.unwrap(), None);
1204    }
1205}