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, GoldConfig, GuardianConfig,
22    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:
534        Option<Arc<dyn Fn() -> (BTreeSet<String>, BTreeSet<String>) + Send + Sync>>,
535    pub disabled_tools: Option<BTreeSet<String>>,
536    pub disabled_skill_ids: Option<BTreeSet<String>>,
537    pub selected_skill_ids: Option<Vec<String>>,
538    pub selected_skill_mode: Option<String>,
539    pub mpsc_tx: mpsc::Sender<AgentEvent>,
540    pub image_fallback: Option<ImageFallbackConfig>,
541    pub gold_config: Option<GoldConfig>,
542    /// Optional guardian adversarial-review gate configuration.
543    pub guardian_config: Option<GuardianConfig>,
544    /// Late-bound guardian reviewer spawner (server-provided; the runner cannot
545    /// construct a child directly).
546    pub guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
547    /// Late-bound bash self-resume hook (issue #84 Phase 2b).
548    pub bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
549    /// Late-bound bash completion sink (issue #84 Phase 2b follow-up).
550    pub bash_completion_sink: Option<Arc<dyn BashCompletionSink>>,
551    pub app_data_dir: Option<std::path::PathBuf>,
552    /// Per-run resource guardrail override (issue #221). `None` uses the
553    /// config-level `Config::run_budget` default unmodified.
554    pub run_budget: Option<bamboo_config::RunBudgetConfig>,
555
556    // Post-execution resources.
557    pub runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
558    pub sessions_cache: SessionCache,
559
560    /// Optional bespoke finalization, run after the runner is finalized and
561    /// before the session is persisted. See [`SessionCompletionHook`].
562    pub on_complete: Option<SessionCompletionHook>,
563
564    /// Child-completion publisher for CHILD sessions driven through this path
565    /// (issue #546). The canonical first run of a child goes through
566    /// `run_child_spawn`, which publishes its own terminal completion — but a
567    /// child RESUMED later (after an approval, a clarification answer, or a
568    /// nested child-parent woken by its own children) runs through
569    /// `spawn_session_execution` and previously published nothing, so the
570    /// waiting parent was never woken. When set and `session.kind == Child`
571    /// with a parent id, the terminal block invokes this handler after the
572    /// final persist + runner finalization. Non-terminal suspends publish the
573    /// non-terminal "suspended" status, which the coordinator's terminality
574    /// guard ignores.
575    pub child_completion_handler: Option<Arc<dyn super::ChildCompletionHandler>>,
576}
577
578/// The per-request parameter subset of [`SessionExecutionArgs`] — everything
579/// that maps onto an [`ExecuteRequest`], minus the three required positional
580/// fields (`initial_message`, `event_tx`, `cancel_token`) and the post-execution
581/// resources (runners, sessions cache, completion hook).
582///
583/// Grouping these here lets [`build_execute_request`] perform the
584/// `SessionExecutionArgs` → [`ExecuteRequest`] mapping through the canonical
585/// [`ExecuteRequestBuilder`] in one place, instead of a hand-written struct
586/// literal that must be kept field-aligned with [`ExecuteRequest`] by hand.
587struct ExecuteRequestParams {
588    tools: Option<Arc<dyn ToolExecutor>>,
589    provider_override: Option<Arc<dyn LLMProvider>>,
590    model_roster: ModelRoster,
591    reasoning_effort: Option<ReasoningEffort>,
592    auxiliary_model_resolver: Option<Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync>>,
593    disabled_filter_resolver:
594        Option<Arc<dyn Fn() -> (BTreeSet<String>, BTreeSet<String>) + Send + Sync>>,
595    disabled_tools: Option<BTreeSet<String>>,
596    disabled_skill_ids: Option<BTreeSet<String>>,
597    selected_skill_ids: Option<Vec<String>>,
598    selected_skill_mode: Option<String>,
599    image_fallback: Option<ImageFallbackConfig>,
600    gold_config: Option<GoldConfig>,
601    guardian_config: Option<GuardianConfig>,
602    guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
603    bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
604    bash_completion_sink: Option<Arc<dyn BashCompletionSink>>,
605    app_data_dir: Option<std::path::PathBuf>,
606    run_budget: Option<bamboo_config::RunBudgetConfig>,
607}
608
609/// Assemble an [`ExecuteRequest`] from the resolved spawn parameters via the
610/// canonical [`ExecuteRequestBuilder`].
611///
612/// Centralizing this mapping keeps every optional field threaded with exactly
613/// the same value the old struct literal carried (the builder defaults each
614/// unset field to `None`), while removing the field-by-field duplication.
615fn build_execute_request(
616    initial_message: String,
617    event_tx: mpsc::Sender<AgentEvent>,
618    cancel_token: CancellationToken,
619    params: ExecuteRequestParams,
620) -> ExecuteRequest {
621    let ExecuteRequestParams {
622        tools,
623        provider_override,
624        model_roster,
625        reasoning_effort,
626        auxiliary_model_resolver,
627        disabled_filter_resolver,
628        disabled_tools,
629        disabled_skill_ids,
630        selected_skill_ids,
631        selected_skill_mode,
632        image_fallback,
633        gold_config,
634        guardian_config,
635        guardian_spawner,
636        bash_resume_hook,
637        bash_completion_sink,
638        app_data_dir,
639        run_budget,
640    } = params;
641
642    let mut builder = ExecuteRequestBuilder::new(initial_message, event_tx, cancel_token)
643        .model_roster(model_roster)
644        .gold_config(gold_config)
645        .guardian_config(guardian_config)
646        .guardian_spawner(guardian_spawner)
647        .bash_resume_hook(bash_resume_hook)
648        .bash_completion_sink(bash_completion_sink);
649
650    if let Some(run_budget) = run_budget {
651        builder = builder.run_budget(run_budget);
652    }
653
654    if let Some(tools) = tools {
655        builder = builder.tools(tools);
656    }
657    if let Some(provider_override) = provider_override {
658        builder = builder.provider_override(provider_override);
659    }
660    if let Some(reasoning_effort) = reasoning_effort {
661        builder = builder.reasoning_effort(reasoning_effort);
662    }
663    if let Some(disabled_filter_resolver) = disabled_filter_resolver {
664        builder = builder.disabled_filter_resolver(disabled_filter_resolver);
665    }
666    if let Some(auxiliary_model_resolver) = auxiliary_model_resolver {
667        builder = builder.auxiliary_model_resolver(auxiliary_model_resolver);
668    }
669    if let Some(disabled_tools) = disabled_tools {
670        builder = builder.disabled_tools(disabled_tools);
671    }
672    if let Some(disabled_skill_ids) = disabled_skill_ids {
673        builder = builder.disabled_skill_ids(disabled_skill_ids);
674    }
675    if let Some(selected_skill_ids) = selected_skill_ids {
676        builder = builder.selected_skill_ids(selected_skill_ids);
677    }
678    if let Some(selected_skill_mode) = selected_skill_mode {
679        builder = builder.selected_skill_mode(selected_skill_mode);
680    }
681    if let Some(image_fallback) = image_fallback {
682        builder = builder.image_fallback(image_fallback);
683    }
684    if let Some(app_data_dir) = app_data_dir {
685        builder = builder.app_data_dir(app_data_dir);
686    }
687
688    builder.build()
689}
690
691/// Spawn a background agent execution task.
692///
693/// This function spawns a tokio task that:
694/// 1. Executes the agent loop via `agent.execute()`
695/// 2. Sends a terminal error event if the execution fails
696/// 3. Finalizes the runner status
697/// 4. Persists the session via merge-save (preserves concurrent UI title/pin edits)
698/// 5. Updates the in-memory session cache
699pub fn spawn_session_execution(args: SessionExecutionArgs) {
700    let span_session_id = args.session_id.clone();
701    let session_span = tracing::info_span!("agent_execution", session_id = %span_session_id);
702
703    tokio::spawn(
704        async move {
705            let SessionExecutionArgs {
706                agent,
707                session_id,
708                mut session,
709                mut execution_reservation,
710                tools_override,
711                provider_override,
712                model_roster,
713                reasoning_effort,
714                reasoning_effort_source,
715                auxiliary_model_resolver,
716                disabled_filter_resolver,
717                disabled_tools,
718                disabled_skill_ids,
719                selected_skill_ids,
720                selected_skill_mode,
721                mpsc_tx,
722                image_fallback,
723                gold_config,
724                guardian_config,
725                guardian_spawner,
726                bash_resume_hook,
727                bash_completion_sink,
728                app_data_dir,
729                run_budget,
730                runners,
731                sessions_cache,
732                on_complete,
733                child_completion_handler,
734            } = args;
735
736            if !execution_reservation.matches_execution_target(&session_id, &session.id, &runners) {
737                tracing::error!(
738                    %session_id,
739                    domain_session_id = %session.id,
740                    reservation_session_id = %execution_reservation.session_id(),
741                    run_id = %execution_reservation.run_id(),
742                    same_runner_registry = Arc::ptr_eq(&execution_reservation.runners, &runners),
743                    "refusing mismatched session execution reservation"
744                );
745                execution_reservation.abandon().await;
746                return;
747            }
748            if let Err(error) = execution_reservation.ensure_registered().await {
749                tracing::warn!(
750                    %session_id,
751                    run_id = %execution_reservation.run_id(),
752                    %error,
753                    "session execution could not adopt its router activation owner"
754                );
755                return;
756            }
757            let (cancel_token, mut activation_registration) =
758                execution_reservation.disarm_for_execution();
759
760            // The primary model is required for a spawn; the roster stores it as
761            // `Option<String>` for uniformity, so recover the owned String here
762            // for session attribution / logging (same value the caller set).
763            let model = model_roster.model.clone().unwrap_or_default();
764
765            let initial_message = initial_user_message_for_session(&session);
766            let selected_skill_ids =
767                selected_skill_ids.or_else(|| selected_skill_ids_for_session(&session));
768            let selected_skill_mode =
769                selected_skill_mode.or_else(|| selected_skill_mode_for_session(&session));
770
771            tracing::info!(
772                "[{}] Using resolved session model: {}, reasoning_effort={}, reasoning_source={}",
773                session_id,
774                model,
775                reasoning_effort
776                    .map(ReasoningEffort::as_str)
777                    .unwrap_or("none"),
778                reasoning_effort_source
779            );
780
781            // Set the resolved model via the single authoritative pre-execution
782            // mutation point. The caller already placed the system prompt on the
783            // session, so pass `None` for `system_prompt` (the subsequent
784            // `system_prompt_for_session` read below sees the caller's message).
785            // This must run before that read / logging so the observable
786            // sequence (model set, then prompt snapshot) is identical.
787            crate::session_app::execution_prep::prepare_session_for_execution(
788                &mut session,
789                None,
790                Some(&model),
791            );
792
793            let system_prompt = system_prompt_for_session(&session);
794            if let Some(prompt) = system_prompt.as_ref() {
795                log_base_system_prompt_snapshot(&session_id, prompt);
796            }
797
798            let execute_request = build_execute_request(
799                initial_message,
800                mpsc_tx.clone(),
801                cancel_token,
802                ExecuteRequestParams {
803                    tools: tools_override,
804                    provider_override,
805                    model_roster,
806                    reasoning_effort,
807                    auxiliary_model_resolver,
808                    disabled_filter_resolver,
809                    disabled_tools,
810                    disabled_skill_ids,
811                    selected_skill_ids,
812                    selected_skill_mode,
813                    image_fallback,
814                    gold_config,
815                    guardian_config,
816                    guardian_spawner,
817                    bash_resume_hook,
818                    bash_completion_sink,
819                    app_data_dir,
820                    run_budget,
821                },
822            );
823
824            // Panic containment (issue #546): everything below — the terminal
825            // status persist, finalize_runner, and the child-completion
826            // publish — only runs if this task survives execution. An
827            // unwinding panic would leave a zombie Running runner entry (which
828            // blinds liveness checks) and, for a child session, strand its
829            // waiting parent. Map a panic to a terminal error instead.
830            let result = {
831                use futures::FutureExt;
832                match std::panic::AssertUnwindSafe(agent.execute(&mut session, execute_request))
833                    .catch_unwind()
834                    .await
835                {
836                    Ok(result) => result,
837                    Err(panic) => {
838                        let message = panic
839                            .downcast_ref::<&str>()
840                            .map(|s| (*s).to_string())
841                            .or_else(|| panic.downcast_ref::<String>().cloned())
842                            .unwrap_or_else(|| "non-string panic payload".to_string());
843                        tracing::error!(
844                            "[{}] agent execution panicked; finalizing as terminal error: {}",
845                            session_id,
846                            message
847                        );
848                        Err(AgentError::LLM(format!(
849                            "agent execution panicked: {message}"
850                        )))
851                    }
852                }
853            };
854
855            // Send terminal event for all error cases (including cancellation).
856            if let Some(error_event) = terminal_error_event_for_result(&result) {
857                let _ = mpsc_tx.send(error_event).await;
858            }
859
860            // Record the terminal run status on the session BEFORE persisting so
861            // the session summary reports a real `last_run_status`. Top-level
862            // sessions otherwise never set it, so summaries show
863            // `last_run_status: null`; the frontend then cannot confirm the run
864            // finished and falls back on a ~5s optimistic-settle window, leaving
865            // a phantom "thinking" indicator after the reply is already done
866            // (notably on a session's first turn). Same Ok/cancelled/error
867            // mapping `status_from_execution_result` applies to the runner.
868            //
869            // A suspended run also returns `Ok(())` but is NOT terminal: it
870            // stamped `runtime.suspend_reason` (awaiting_clarification /
871            // waiting_for_children / waiting_for_bash / awaiting_parent_approval)
872            // and will resume later (which removes the reason — see respond.rs /
873            // child_completion_coordinator). Mark it "suspended" rather than
874            // "completed" so a session waiting on the user or on children isn't
875            // reported as finished (mirrors the child path in `sdk::spawn`).
876            let suspended_non_terminal = result.is_ok()
877                && session
878                    .metadata
879                    .get("runtime.suspend_reason")
880                    .is_some_and(|reason| !reason.trim().is_empty());
881            match &result {
882                Ok(()) if suspended_non_terminal => {
883                    session.set_last_run_status("suspended");
884                    session.clear_last_run_error();
885                }
886                Ok(()) => {
887                    session.set_last_run_status("completed");
888                    session.clear_last_run_error();
889                }
890                Err(error) if error.is_cancelled() => {
891                    session.set_last_run_status("cancelled");
892                    session.set_last_run_error(error.to_string());
893                }
894                Err(error) => {
895                    session.set_last_run_status("error");
896                    session.set_last_run_error(error.to_string());
897                }
898            }
899
900            // Bespoke terminal bookkeeping (e.g. a scheduled-run status) runs
901            // here — before persistence — so any closing message the hook
902            // appends is saved with the session below.
903            if let Some(on_complete) = on_complete {
904                on_complete(SessionExecutionOutcome::from_result(&result), &mut session).await;
905            }
906
907            // Freeze the generation actually consumed by provider reasoning.
908            // Terminal compatibility migration below may enqueue newer work,
909            // but must never make that work look executed.
910            let executed_admitted_generation = session
911                .session_inbox_admission()
912                .map_or(0, |state| state.last_admitted_sequence);
913            let legacy_migration =
914                crate::runtime::runner::state_bridge::migrate_legacy_pending_only(
915                    &mut session,
916                    Some(agent.storage()),
917                    Some(agent.persistence()),
918                    agent.session_inbox(),
919                )
920                .await;
921            let pending_boundary_generation = session
922                .session_inbox_admission()
923                .and_then(|state| state.pending_activation_generation());
924            let pending_generation = match (
925                pending_boundary_generation,
926                legacy_migration.highest_generation,
927            ) {
928                (Some(left), Some(right)) => Some(left.max(right)),
929                (left, right) => left.or(right),
930            };
931            if let (Some(generation), Some(router)) =
932                (pending_generation, agent.activation_router())
933            {
934                let activation_ready = if let Some(inbox) = agent.session_inbox() {
935                    match inbox
936                        .mark_activation_eligible(
937                            &session_id,
938                            generation,
939                            bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
940                        )
941                        .await
942                    {
943                        Ok(()) => true,
944                        Err(error) => {
945                            tracing::error!(
946                                session_id = %session_id,
947                                %error,
948                                "failed to persist unadmitted SessionInbox activation watermark"
949                            );
950                            false
951                        }
952                    }
953                } else {
954                    false
955                };
956                if activation_ready {
957                    if let Err(error) = bamboo_domain::SessionActivationPort::request_activation(
958                        router.as_ref(),
959                        &session_id,
960                        generation,
961                    )
962                    .await
963                    {
964                        tracing::error!(
965                            session_id = %session_id,
966                            %error,
967                            "failed to hand unadmitted SessionInbox generation to activation router"
968                        );
969                    }
970                }
971            }
972
973            // Close the save→terminal race before the final persistence write:
974            // a delivery from this point onward is coalesced for a successor
975            // rather than being "notified" to a loop that has already exited.
976            if let Some(registration) = activation_registration.as_mut() {
977                registration.begin_finalization().await;
978            }
979
980            // Save session via merge-save so any concurrent UI edits to
981            // title / pinned / title_version are preserved (the runtime is not
982            // an authoritative title writer).
983            if let Err(error) = agent.persistence().save_runtime_session(&mut session).await {
984                tracing::warn!("[{}] Failed to save session: {}", session_id, error);
985            }
986
987            // Flip the runner registry to a terminal status (which makes session
988            // summaries report `is_running: false`) ONLY AFTER the run status is
989            // persisted above, so `is_running` and `last_run_status` become
990            // visible together and the frontend settles immediately instead of
991            // lingering in its optimistic-settle window.
992            finalize_runner(&runners, &session_id, &result).await;
993
994            let finalization = if let Some(registration) = activation_registration.take() {
995                registration.finish(executed_admitted_generation).await
996            } else {
997                Ok(None)
998            };
999            if let Err(error) = finalization {
1000                // Delivery is durable even if activation infrastructure is
1001                // temporarily unavailable; startup/backlog reconciliation
1002                // can retry without loss.
1003                tracing::error!(
1004                    session_id = %session_id,
1005                    %error,
1006                    "failed to activate successor for finalization-racing SessionInbox delivery"
1007                );
1008            }
1009
1010            // A CHILD session finishing through this path (a resumed child, or
1011            // a nested child-parent woken by its own children) must wake its
1012            // waiting parent (issue #546). Publish AFTER the final persist and
1013            // runner finalization so the coordinator reads the child's settled
1014            // terminal state — the resume message folds in the child's final
1015            // assistant content from storage. The status mirrors the
1016            // `last_run_status` mapping above; a non-terminal "suspended" is
1017            // published too and ignored by the coordinator's terminality guard.
1018            let child_completion = child_completion_handler.filter(|_| {
1019                session.kind == bamboo_agent_core::SessionKind::Child
1020                    && session.parent_session_id.is_some()
1021            });
1022            let parent_session_id = session.parent_session_id.clone();
1023            let child_status = session.last_run_status();
1024            let child_error = session.last_run_error();
1025
1026            // Update memory cache.
1027            sessions_cache.insert(
1028                session_id.clone(),
1029                Arc::new(parking_lot::RwLock::new(session)),
1030            );
1031
1032            if let (Some(handler), Some(parent_session_id), Some(status)) =
1033                (child_completion, parent_session_id, child_status)
1034            {
1035                use futures::FutureExt;
1036                let completion = ChildCompletion {
1037                    parent_session_id: parent_session_id.clone(),
1038                    child_session_id: session_id.clone(),
1039                    status,
1040                    error: child_error,
1041                    completed_at: chrono::Utc::now(),
1042                };
1043                if std::panic::AssertUnwindSafe(handler.on_child_completed(completion))
1044                    .catch_unwind()
1045                    .await
1046                    .is_err()
1047                {
1048                    tracing::error!(
1049                        %parent_session_id,
1050                        child_session_id = %session_id,
1051                        "child completion handler panicked on resumed-child terminal"
1052                    );
1053                }
1054            }
1055
1056            tracing::info!("[{}] Agent execution completed", session_id);
1057        }
1058        .instrument(session_span),
1059    );
1060}
1061
1062/// Log a snapshot of the base system prompt for debugging.
1063pub fn log_base_system_prompt_snapshot(session_id: &str, prompt: &str) {
1064    tracing::info!(
1065        "[{}] Base system prompt snapshot: len={} chars, has_skill={}, has_tool_guide={}, has_external_memory={}, has_task_list={}",
1066        session_id,
1067        prompt.len(),
1068        prompt.contains(SKILL_CONTEXT_START_MARKER),
1069        prompt.contains(TOOL_GUIDE_START_MARKER),
1070        prompt.contains(EXTERNAL_MEMORY_START_MARKER),
1071        prompt.contains(TASK_LIST_START_MARKER),
1072    );
1073
1074    tracing::debug!(
1075        "[{}] ========== BASE SYSTEM PROMPT SNAPSHOT ==========",
1076        session_id
1077    );
1078    tracing::debug!("[{}] Snapshot length: {} chars", session_id, prompt.len());
1079    tracing::debug!("[{}] -----------------------------------", session_id);
1080    tracing::debug!("[{}] {}", session_id, prompt);
1081    tracing::debug!(
1082        "[{}] ========== END BASE SYSTEM PROMPT SNAPSHOT ==========",
1083        session_id
1084    );
1085}
1086
1087/// Map an execution result to a terminal error event.
1088pub fn terminal_error_event_for_result(result: &Result<(), AgentError>) -> Option<AgentEvent> {
1089    match result {
1090        Ok(_) => None,
1091        Err(error) if error.is_cancelled() => Some(AgentEvent::Error {
1092            message: "Agent execution cancelled by user".to_string(),
1093        }),
1094        Err(error) => Some(AgentEvent::Error {
1095            message: error.to_string(),
1096        }),
1097    }
1098}
1099
1100// Session metadata helpers (pure functions, no server dependency).
1101
1102fn system_prompt_for_session(session: &Session) -> Option<String> {
1103    session
1104        .messages
1105        .iter()
1106        .find(|message| matches!(message.role, bamboo_agent_core::Role::System))
1107        .map(|message| message.content.clone())
1108}
1109
1110fn initial_user_message_for_session(session: &Session) -> String {
1111    session
1112        .messages
1113        .last()
1114        .filter(|message| matches!(message.role, bamboo_agent_core::Role::User))
1115        .map(|message| message.content.clone())
1116        .unwrap_or_default()
1117}
1118
1119fn selected_skill_ids_for_session(session: &Session) -> Option<Vec<String>> {
1120    session
1121        .metadata
1122        .get("selected_skill_ids")
1123        .and_then(|raw| bamboo_skills::selection::parse_selected_skill_ids_metadata(raw))
1124}
1125
1126fn selected_skill_mode_for_session(session: &Session) -> Option<String> {
1127    let value = session
1128        .metadata
1129        .get("skill_mode")
1130        .or_else(|| session.metadata.get("mode"))?;
1131    let trimmed = value.trim();
1132    if trimmed.is_empty() {
1133        None
1134    } else {
1135        Some(trimmed.to_string())
1136    }
1137}
1138
1139#[cfg(test)]
1140mod reservation_tests {
1141    use super::*;
1142    use crate::runtime::execution::runner_state::AgentStatus;
1143
1144    #[test]
1145    fn reservation_target_requires_domain_id_and_exact_runner_registry() {
1146        let runners = Arc::new(RwLock::new(HashMap::new()));
1147        let other_runners = Arc::new(RwLock::new(HashMap::new()));
1148        let mut reservation = SessionExecutionReservation {
1149            session_id: "session-a".to_string(),
1150            run_id: "run-a".to_string(),
1151            cancel_token: CancellationToken::new(),
1152            runners: runners.clone(),
1153            activation: SessionExecutionActivationOwnership::Unrouted,
1154            armed: true,
1155        };
1156
1157        assert!(reservation.matches_execution_target("session-a", "session-a", &runners));
1158        assert!(!reservation.matches_execution_target("session-b", "session-a", &runners));
1159        assert!(!reservation.matches_execution_target("session-a", "session-b", &runners));
1160        assert!(!reservation.matches_execution_target("session-a", "session-a", &other_runners));
1161        reservation.armed = false;
1162    }
1163
1164    #[tokio::test]
1165    async fn forced_same_run_registration_rejection_never_cancels_live_owner() {
1166        let runners = Arc::new(RwLock::new(HashMap::new()));
1167        let mut runner = AgentRunner::new();
1168        runner.status = AgentStatus::Running;
1169        let run_id = runner.run_id.clone();
1170        let live_cancel_token = runner.cancel_token.clone();
1171        runners.write().await.insert("same-run".to_string(), runner);
1172
1173        let router = SessionActivationRouter::new();
1174        let mut live_registration = router
1175            .register_run("same-run", &run_id)
1176            .await
1177            .expect("first registration owns the run");
1178        let mut rejected = SessionExecutionReservation {
1179            session_id: "same-run".to_string(),
1180            run_id: run_id.clone(),
1181            cancel_token: live_cancel_token.clone(),
1182            runners: runners.clone(),
1183            activation: SessionExecutionActivationOwnership::RegistrationPending(router.clone()),
1184            armed: true,
1185        };
1186
1187        let error = match rejected.ensure_registered().await {
1188            Ok(()) => panic!("a duplicate registration for the same run must be rejected"),
1189            Err(error) => error,
1190        };
1191        assert_eq!(error.existing_run_id(), run_id);
1192        drop(rejected);
1193
1194        assert!(!live_cancel_token.is_cancelled());
1195        assert!(matches!(
1196            runners
1197                .read()
1198                .await
1199                .get("same-run")
1200                .map(|runner| &runner.status),
1201            Some(AgentStatus::Running)
1202        ));
1203        assert!(router.owns_run("same-run", &run_id).await);
1204        live_registration.begin_finalization().await;
1205        assert_eq!(live_registration.finish(0).await.unwrap(), None);
1206    }
1207}