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::event_forwarder::HistoryCommitBarrier;
26use crate::runtime::execution::runner_lifecycle::{
27    finalize_rejected_runner_if_distinct, finalize_runner, finalize_runner_exact,
28    reserve_runner_core, ReserveOutcome, RunnerReservation,
29};
30use crate::runtime::execution::runner_state::AgentRunner;
31use crate::runtime::model_roster::ModelRoster;
32use crate::runtime::Agent;
33use crate::runtime::{ExecuteRequest, ExecuteRequestBuilder};
34use crate::session_activation::{
35    SessionActivationRouter, SessionRunRegistration, SessionRunRegistrationError,
36};
37
38pub use crate::session_cache::SessionCache;
39
40enum SessionExecutionActivationOwnership {
41    /// This runtime was built without a SessionInbox activation router.
42    Unrouted,
43    /// The runtime spawner reserved an external runner, but the router has not
44    /// yet committed the matching owner or invoked the launch closure.
45    UnpublishedActivation(Arc<SessionActivationRouter>),
46    /// This exact raw runner is waiting to acquire or adopt its router
47    /// registration. Manual/server callers enter this state before awaiting an
48    /// in-flight activation token; router-dispatched launches enter it after
49    /// publishing their zero-registration placeholder.
50    RegistrationPending(Arc<SessionActivationRouter>),
51    /// Manual/server reservation acquired router ownership before any
52    /// execution-specific mutation or external side effect.
53    Registered(SessionRunRegistration),
54}
55
56/// One exact runner reservation plus its logical-session activation ownership.
57///
58/// Callers must obtain this through [`reserve_session_execution`]. The only
59/// exception is the router's own activation launcher, which uses the
60/// crate-private placeholder constructor while assembling its two-phase launch.
61pub struct SessionExecutionReservation {
62    session_id: String,
63    run_id: String,
64    cancel_token: CancellationToken,
65    runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
66    activation: SessionExecutionActivationOwnership,
67    root_actor: Option<Box<RootActorExecution>>,
68    execution_persistence: Option<Arc<dyn bamboo_domain::RuntimeSessionPersistence>>,
69    persistence_bound: bool,
70    armed: bool,
71}
72
73impl SessionExecutionReservation {
74    pub fn session_id(&self) -> &str {
75        &self.session_id
76    }
77
78    pub fn run_id(&self) -> &str {
79        &self.run_id
80    }
81
82    pub fn cancel_token(&self) -> &CancellationToken {
83        &self.cancel_token
84    }
85
86    /// Bind before an adapter persists execution state or admits a provider.
87    /// A duplicate call on this exact reservation retains the same capability.
88    pub async fn bind_root_actor(
89        &mut self,
90        agent: &Agent,
91        session: &Session,
92    ) -> std::io::Result<()> {
93        if self.root_actor.is_none() {
94            if self.session_id != session.id {
95                return Err(std::io::Error::new(
96                    std::io::ErrorKind::InvalidInput,
97                    "Root binding target mismatch",
98                ));
99            }
100            if let Some(binding) = agent
101                .persistence()
102                .bind_root_actor_execution(session, &self.run_id)
103                .await?
104            {
105                self.root_actor = Some(Box::new(RootActorExecution::start(binding)));
106            }
107        }
108        self.bind_execution_persistence(agent, session)
109    }
110
111    fn bind_execution_persistence(
112        &mut self,
113        agent: &Agent,
114        session: &Session,
115    ) -> std::io::Result<()> {
116        if self.session_id != session.id {
117            return Err(std::io::Error::other(
118                "execution persistence target mismatch",
119            ));
120        }
121        if self.persistence_bound {
122            return Ok(());
123        }
124        let persistence = self
125            .root_actor
126            .as_ref()
127            .map_or_else(|| agent.persistence().clone(), |owner| owner.persistence());
128        if let Some(inherited) = persistence
129            .inherited_child_wait()
130            .or_else(|| bamboo_domain::InheritedChildWait::capture(session))
131        {
132            inherited.validate_session(session)?;
133            self.execution_persistence = Some(persistence.bind_inherited_child_wait(inherited)?);
134        } else if self.root_actor.is_some() {
135            self.execution_persistence = Some(persistence);
136        }
137        self.persistence_bound = true;
138        Ok(())
139    }
140
141    pub fn execution_persistence(
142        &self,
143    ) -> Option<Arc<dyn bamboo_domain::RuntimeSessionPersistence>> {
144        self.execution_persistence.clone()
145    }
146
147    pub fn root_actor_writer(&self) -> Option<bamboo_domain::RootActorRuntimeWrite> {
148        self.root_actor
149            .as_ref()
150            .map(|owner| owner.binding.owner.clone())
151    }
152
153    /// Response frames travel with the exact reserved Root until its detached
154    /// adapter can publish them through the existing fenced event forwarder.
155    pub fn queue_root_response_event(&mut self, event: AgentEvent) -> Option<AgentEvent> {
156        let Some(owner) = self.root_actor.as_mut() else {
157            return Some(event);
158        };
159        owner.response_events.push(event);
160        None
161    }
162
163    pub fn take_root_response_events(&mut self) -> Vec<AgentEvent> {
164        self.root_actor
165            .as_mut()
166            .map(|owner| std::mem::take(&mut owner.response_events))
167            .unwrap_or_default()
168    }
169
170    /// Finish a valid adapter-owned pause only after its actual history
171    /// publication barrier. Abandonment remains the rejected-startup fallback.
172    pub async fn finish_root_actor(&mut self, outcome: bamboo_domain::ActorActivationFinish) {
173        if let Some(owner) = self.root_actor.take() {
174            (*owner).finish(outcome).await;
175        }
176    }
177
178    /// Build the handoff owned by a router activation launch.
179    ///
180    /// The value starts unpublished. Its launch closure must call
181    /// [`mark_activation_published`](Self::mark_activation_published) after the
182    /// router commits the matching zero-registration owner.
183    pub(crate) fn from_activation_placeholder(
184        session_id: impl Into<String>,
185        reservation: RunnerReservation,
186        router: Arc<SessionActivationRouter>,
187        runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
188    ) -> Self {
189        Self {
190            session_id: session_id.into(),
191            run_id: reservation.run_id,
192            cancel_token: reservation.cancel_token,
193            runners,
194            activation: SessionExecutionActivationOwnership::UnpublishedActivation(router),
195            root_actor: None,
196            execution_persistence: None,
197            persistence_bound: false,
198            armed: true,
199        }
200    }
201
202    /// Build a manual/queued execution handoff immediately after reserving its
203    /// raw runner. If a router exists, Drop can safely wait for and adopt an
204    /// activation placeholder that observed this exact runner.
205    pub(crate) fn from_pending_registration(
206        session_id: impl Into<String>,
207        reservation: RunnerReservation,
208        router: Option<Arc<SessionActivationRouter>>,
209        runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
210    ) -> Self {
211        let activation = match router {
212            Some(router) => SessionExecutionActivationOwnership::RegistrationPending(router),
213            None => SessionExecutionActivationOwnership::Unrouted,
214        };
215        Self {
216            session_id: session_id.into(),
217            run_id: reservation.run_id,
218            cancel_token: reservation.cancel_token,
219            runners,
220            activation,
221            root_actor: None,
222            execution_persistence: None,
223            persistence_bound: false,
224            armed: true,
225        }
226    }
227
228    /// Mark that the router has published this exact owner and invoked its
229    /// launch closure.
230    pub(crate) fn mark_activation_published(&mut self) {
231        let activation = std::mem::replace(
232            &mut self.activation,
233            SessionExecutionActivationOwnership::Unrouted,
234        );
235        self.activation = match activation {
236            SessionExecutionActivationOwnership::UnpublishedActivation(router) => {
237                SessionExecutionActivationOwnership::RegistrationPending(router)
238            }
239            other => other,
240        };
241    }
242
243    /// Roll back an external runner whose router launch was never published.
244    ///
245    /// The router reservation lease exclusively owns token release and retry
246    /// ordering here, so this path removes only the exact raw runner slot.
247    pub(crate) async fn rollback_unpublished_activation(mut self) {
248        self.armed = false;
249        self.cancel_token.cancel();
250        self.finish_root_actor(bamboo_domain::ActorActivationFinish::Cancelled)
251            .await;
252        let activation = std::mem::replace(
253            &mut self.activation,
254            SessionExecutionActivationOwnership::Unrouted,
255        );
256        debug_assert!(matches!(
257            activation,
258            SessionExecutionActivationOwnership::UnpublishedActivation(_)
259        ));
260        remove_runner_exact(&self.runners, &self.session_id, &self.run_id).await;
261    }
262
263    /// Adopt a router-published activation placeholder before any adapter-side
264    /// tool replay, workspace mutation, persistence, relay, or spawned task.
265    ///
266    /// Reservations returned by [`reserve_session_execution`] are already
267    /// registered, so calling this on the normal manual/server path is a no-op.
268    pub async fn ensure_registered(&mut self) -> Result<(), SessionRunRegistrationError> {
269        let router = match &self.activation {
270            SessionExecutionActivationOwnership::RegistrationPending(router) => router.clone(),
271            SessionExecutionActivationOwnership::UnpublishedActivation(_) => {
272                unreachable!("unpublished activation entered an execution adapter")
273            }
274            SessionExecutionActivationOwnership::Unrouted
275            | SessionExecutionActivationOwnership::Registered(_) => return Ok(()),
276        };
277        match register_reserved_activation(router, &self.runners, &self.session_id, &self.run_id)
278            .await
279        {
280            Ok(registration) => {
281                self.activation = SessionExecutionActivationOwnership::Registered(registration);
282                Ok(())
283            }
284            Err(error) => {
285                // The helper already terminalized only a distinct attempted
286                // runner. Disarm Drop here as part of the shared API so every
287                // adapter preserves a same-run live owner's cancellation
288                // token, even when it returns immediately on the collision.
289                self.disarm_after_registration_rejection(error.existing_run_id());
290                Err(error)
291            }
292        }
293    }
294
295    /// Release this exact runner/router reservation before a failed startup is
296    /// reported as retryable. The cleanup owns its state in a detached task so
297    /// cancellation of this explicit wait cannot strand either ownership plane.
298    pub async fn abandon(mut self) {
299        self.armed = false;
300        self.cancel_token.cancel();
301        if let Some(owner) = self.root_actor.take() {
302            (*owner)
303                .finish(bamboo_domain::ActorActivationFinish::Cancelled)
304                .await;
305        }
306        let activation = std::mem::replace(
307            &mut self.activation,
308            SessionExecutionActivationOwnership::Unrouted,
309        );
310        let runners = self.runners.clone();
311        let session_id = self.session_id.clone();
312        let run_id = self.run_id.clone();
313        let cleanup_session_id = session_id.clone();
314        let cleanup_run_id = run_id.clone();
315        let cleanup = tokio::spawn(async move {
316            cleanup_execution_reservation(activation, runners, cleanup_session_id, cleanup_run_id)
317                .await;
318        });
319        if let Err(error) = cleanup.await {
320            tracing::error!(
321                %session_id,
322                %run_id,
323                %error,
324                "detached execution-reservation cleanup failed"
325            );
326        }
327    }
328
329    fn disarm_after_registration_rejection(&mut self, existing_run_id: &str) {
330        if existing_run_id != self.run_id {
331            self.cancel_token.cancel();
332        }
333        self.armed = false;
334    }
335
336    pub(crate) fn matches_execution_target(
337        &self,
338        session_id: &str,
339        domain_session_id: &str,
340        runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
341    ) -> bool {
342        self.session_id == session_id
343            && domain_session_id == session_id
344            && Arc::ptr_eq(&self.runners, runners)
345    }
346
347    pub(crate) fn disarm_for_execution(
348        &mut self,
349    ) -> (CancellationToken, Option<SessionRunRegistration>) {
350        self.armed = false;
351        let registration = match std::mem::replace(
352            &mut self.activation,
353            SessionExecutionActivationOwnership::Unrouted,
354        ) {
355            SessionExecutionActivationOwnership::Registered(registration) => Some(registration),
356            SessionExecutionActivationOwnership::Unrouted => None,
357            SessionExecutionActivationOwnership::UnpublishedActivation(_) => {
358                unreachable!("unpublished activation cannot transfer to execution")
359            }
360            SessionExecutionActivationOwnership::RegistrationPending(_) => {
361                unreachable!("ensure_registered adopts every pending router registration")
362            }
363        };
364        (self.cancel_token.clone(), registration)
365    }
366}
367
368impl Drop for SessionExecutionReservation {
369    fn drop(&mut self) {
370        if !self.armed {
371            return;
372        }
373        self.armed = false;
374        self.cancel_token.cancel();
375        let activation = std::mem::replace(
376            &mut self.activation,
377            SessionExecutionActivationOwnership::Unrouted,
378        );
379        let runners = self.runners.clone();
380        let session_id = self.session_id.clone();
381        let run_id = self.run_id.clone();
382        if let Ok(runtime) = tokio::runtime::Handle::try_current() {
383            runtime.spawn(async move {
384                cleanup_execution_reservation(activation, runners, session_id, run_id).await;
385            });
386        }
387    }
388}
389
390/// Combined runner/router reservation result.
391pub enum SessionExecutionReserveOutcome {
392    Reserved(SessionExecutionReservation),
393    AlreadyRunning { run_id: String },
394}
395
396async fn register_reserved_activation(
397    router: Arc<SessionActivationRouter>,
398    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
399    session_id: &str,
400    run_id: &str,
401) -> Result<SessionRunRegistration, SessionRunRegistrationError> {
402    let mut registration = match router.register_run(session_id, run_id).await {
403        Ok(registration) => registration,
404        Err(error) => {
405            let collision = Err(AgentError::LLM(error.to_string()));
406            finalize_rejected_runner_if_distinct(
407                runners,
408                session_id,
409                error.existing_run_id(),
410                run_id,
411                &collision,
412            )
413            .await;
414            return Err(error);
415        }
416    };
417    let cleanup_runners = runners.clone();
418    let cleanup_session_id = session_id.to_string();
419    let cleanup_run_id = run_id.to_string();
420    registration.set_abort_cleanup(move || async move {
421        let abandoned = Err(AgentError::Cancelled);
422        finalize_runner_exact(
423            &cleanup_runners,
424            &cleanup_session_id,
425            &cleanup_run_id,
426            &abandoned,
427        )
428        .await;
429    });
430    Ok(registration)
431}
432
433async fn cleanup_execution_reservation(
434    activation: SessionExecutionActivationOwnership,
435    runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
436    session_id: String,
437    run_id: String,
438) {
439    match activation {
440        SessionExecutionActivationOwnership::Registered(registration) => {
441            registration.abandon().await;
442        }
443        SessionExecutionActivationOwnership::RegistrationPending(router) => {
444            match register_reserved_activation(router, &runners, &session_id, &run_id).await {
445                Ok(registration) => registration.abandon().await,
446                Err(error) => {
447                    tracing::debug!(
448                        %session_id,
449                        %run_id,
450                        existing_run_id = %error.existing_run_id(),
451                        "abandoned activation placeholder was already adopted or superseded"
452                    );
453                }
454            }
455        }
456        SessionExecutionActivationOwnership::UnpublishedActivation(_) => {
457            remove_runner_exact(&runners, &session_id, &run_id).await;
458        }
459        SessionExecutionActivationOwnership::Unrouted => {
460            let abandoned = Err(AgentError::Cancelled);
461            finalize_runner_exact(&runners, &session_id, &run_id, &abandoned).await;
462        }
463    }
464}
465
466async fn remove_runner_exact(
467    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
468    session_id: &str,
469    run_id: &str,
470) -> bool {
471    let mut runners = runners.write().await;
472    if runners
473        .get(session_id)
474        .is_some_and(|runner| runner.run_id == run_id)
475    {
476        super::runner_lifecycle::remove_runner_entry(&mut runners, session_id).await;
477        true
478    } else {
479        false
480    }
481}
482
483/// Reserve the shared runner slot and logical-session router as one handoff.
484///
485/// The raw runner reservation remains the common low-level primitive used by
486/// the router's own two-phase activation protocol. Every manual/server entry
487/// point uses this wrapper so an SDK owner or another entry surface collides
488/// before approved tools, workspace state, persistence, relays, or a
489/// background execution task can run.
490pub async fn reserve_session_execution(
491    agent: &Arc<Agent>,
492    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
493    senders: &Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
494    session_id: &str,
495    event_sender: &broadcast::Sender<AgentEvent>,
496) -> SessionExecutionReserveOutcome {
497    let reservation = match reserve_runner_core(runners, senders, session_id, event_sender).await {
498        ReserveOutcome::AlreadyRunning(run_id) => {
499            return SessionExecutionReserveOutcome::AlreadyRunning { run_id };
500        }
501        ReserveOutcome::Reserved(reservation) => reservation,
502    };
503
504    // Construct the RAII handoff immediately after the raw runner mutation.
505    // If an activation router exists, publish `RegistrationPending` before the
506    // registration await. Cancellation cleanup then waits through any
507    // in-flight router token, adopts an exact placeholder if the activation
508    // observed this raw runner, and abandons both ownership planes together.
509    let mut execution_reservation = SessionExecutionReservation {
510        session_id: session_id.to_string(),
511        run_id: reservation.run_id,
512        cancel_token: reservation.cancel_token,
513        runners: runners.clone(),
514        activation: SessionExecutionActivationOwnership::Unrouted,
515        root_actor: None,
516        execution_persistence: None,
517        persistence_bound: false,
518        armed: true,
519    };
520
521    if let Some(router) = agent.activation_router().cloned() {
522        execution_reservation.activation =
523            SessionExecutionActivationOwnership::RegistrationPending(router);
524        if let Err(error) = execution_reservation.ensure_registered().await {
525            tracing::warn!(
526                %session_id,
527                attempted_run_id = %execution_reservation.run_id(),
528                existing_run_id = %error.existing_run_id(),
529                %error,
530                "runner reservation collided with an existing logical-session owner"
531            );
532            return SessionExecutionReserveOutcome::AlreadyRunning {
533                run_id: error.existing_run_id().to_string(),
534            };
535        }
536    }
537    SessionExecutionReserveOutcome::Reserved(execution_reservation)
538}
539
540/// Read a consistent session snapshot without acquiring a session or index
541/// lock. Returns `None` on a cache miss.
542///
543/// This is the single canonical cache-read used everywhere a caller holds a
544/// `SessionCache` (HTTP handlers, server tools, the app-state loader). It
545/// replaced ~13 verbatim copies of the
546/// `cache.get(id).map(|e| e.value().clone()).map(|a| a.read().clone())` idiom.
547pub fn read_cached_session(cache: &SessionCache, id: &str) -> Option<bamboo_agent_core::Session> {
548    cache
549        .get(id)
550        .map(|e| e.value().clone())
551        .map(|a| a.read().clone())
552}
553
554const SKILL_CONTEXT_START_MARKER: &str = "<!-- BAMBOO_SKILL_CONTEXT_START -->";
555const TOOL_GUIDE_START_MARKER: &str = "<!-- BAMBOO_TOOL_GUIDE_START -->";
556const EXTERNAL_MEMORY_START_MARKER: &str = "<!-- BAMBOO_EXTERNAL_MEMORY_START -->";
557const TASK_LIST_START_MARKER: &str = "<!-- BAMBOO_TASK_LIST_START -->";
558
559/// Outcome of an agent execution, handed to an optional
560/// [`SessionCompletionHook`].
561///
562/// Deliberately decoupled from the runtime's internal error type so the hook
563/// API stays stable across crates and callers don't need to match on engine
564/// error variants.
565pub struct SessionExecutionOutcome {
566    /// The run finished without error.
567    pub success: bool,
568    /// The run ended because it was cancelled (a non-success subset).
569    pub cancelled: bool,
570    /// Stringified error, present when `!success`.
571    pub error: Option<String>,
572}
573
574impl SessionExecutionOutcome {
575    fn from_result(result: &Result<(), AgentError>) -> Self {
576        match result {
577            Ok(()) => Self {
578                success: true,
579                cancelled: false,
580                error: None,
581            },
582            Err(error) => Self {
583                success: false,
584                cancelled: error.is_cancelled(),
585                error: Some(error.to_string()),
586            },
587        }
588    }
589}
590
591/// Optional post-execution hook for [`spawn_session_execution`].
592///
593/// Invoked after the runner is finalized but **before** the session is
594/// persisted, so a caller can record bespoke terminal bookkeeping (e.g. a
595/// scheduled-run status) and/or append a closing message that is then saved
596/// with the session. Receives the execution outcome plus a mutable handle to
597/// the session. This is how callers with extra finalization (the schedule
598/// manager) route through the single canonical execution path instead of
599/// forking their own spawn + `execute` + persist sequence.
600pub type SessionCompletionHook = Box<
601    dyn for<'a> FnOnce(
602            SessionExecutionOutcome,
603            &'a mut Session,
604        ) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>>
605        + Send,
606>;
607
608/// Arguments for spawning a background agent execution.
609///
610/// This is the crate-agnostic equivalent of the server's `SpawnAgentExecution`.
611/// It holds everything needed to run the agent loop and persist the result,
612/// without depending on HTTP types or `AppState`.
613pub struct SessionExecutionArgs {
614    // Core execution.
615    pub agent: Arc<Agent>,
616    pub session_id: String,
617    pub session: Session,
618    /// Exact shared runner/router ownership acquired before caller-side
619    /// execution mutations.
620    pub execution_reservation: SessionExecutionReservation,
621
622    // Execution parameters.
623    pub tools_override: Option<Arc<dyn ToolExecutor>>,
624    pub provider_override: Option<Arc<dyn LLMProvider>>,
625    /// Cohesive primary + auxiliary model/provider selection. The primary
626    /// `model` is required for a spawn (see [`ModelRoster::model`]); the three
627    /// auxiliary roles default to their `Config::get_*` fallbacks when `None`.
628    pub model_roster: ModelRoster,
629    pub reasoning_effort: Option<ReasoningEffort>,
630    pub reasoning_effort_source: String,
631    pub auxiliary_model_resolver:
632        Option<Arc<dyn Fn() -> crate::runtime::config::AuxiliaryModelConfig + Send + Sync>>,
633    /// Optional per-round live resolver for the disabled tool/skill sets (#136).
634    /// When `None` the per-run snapshot is used (sub-agent spawns pass `None`, so
635    /// short-lived children keep the spawn-time snapshot — by design).
636    pub disabled_filter_resolver: Option<DisabledFilterResolver>,
637    pub disabled_tools: Option<BTreeSet<String>>,
638    pub disabled_skill_ids: Option<BTreeSet<String>>,
639    pub selected_skill_ids: Option<Vec<String>>,
640    pub selected_skill_mode: Option<String>,
641    pub mpsc_tx: mpsc::Sender<AgentEvent>,
642    /// Acknowledges that the forwarder actually published the terminal durable
643    /// history barrier before this runner becomes replaceable.
644    pub history_commit_barrier: HistoryCommitBarrier,
645    pub image_fallback: Option<ImageFallbackConfig>,
646    pub gold_config: Option<GoldConfig>,
647    /// Optional guardian adversarial-review gate configuration.
648    pub guardian_config: Option<GuardianConfig>,
649    /// Late-bound guardian reviewer spawner (server-provided; the runner cannot
650    /// construct a child directly).
651    pub guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
652    /// Late-bound bash self-resume hook (issue #84 Phase 2b).
653    pub bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
654    /// Late-bound bash completion sink (issue #84 Phase 2b follow-up).
655    pub bash_completion_sink: Option<Arc<dyn BashCompletionSink>>,
656    pub app_data_dir: Option<std::path::PathBuf>,
657    /// Per-run resource guardrail override (issue #221). `None` uses the
658    /// config-level `Config::run_budget` default unmodified.
659    pub run_budget: Option<bamboo_config::RunBudgetConfig>,
660
661    // Post-execution resources.
662    pub runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
663    pub sessions_cache: SessionCache,
664
665    /// Optional bespoke finalization, run after the runner is finalized and
666    /// before the session is persisted. See [`SessionCompletionHook`].
667    pub on_complete: Option<SessionCompletionHook>,
668
669    /// Child-completion publisher for CHILD sessions driven through this path
670    /// (issue #546). The canonical first run of a child goes through
671    /// `run_child_spawn`, which publishes its own terminal completion — but a
672    /// child RESUMED later (after an approval, a clarification answer, or a
673    /// nested child-parent woken by its own children) runs through
674    /// `spawn_session_execution` and previously published nothing, so the
675    /// waiting parent was never woken. When set and `session.kind == Child`
676    /// with a parent id, the terminal block invokes this handler after the
677    /// final persist + runner finalization. Non-terminal suspends publish the
678    /// non-terminal "suspended" status, which the coordinator's terminality
679    /// guard ignores.
680    pub child_completion_handler: Option<Arc<dyn super::ChildCompletionHandler>>,
681}
682
683/// The per-request parameter subset of [`SessionExecutionArgs`] — everything
684/// that maps onto an [`ExecuteRequest`], minus the three required positional
685/// fields (`initial_message`, `event_tx`, `cancel_token`) and the post-execution
686/// resources (runners, sessions cache, completion hook).
687///
688/// Grouping these here lets [`build_execute_request`] perform the
689/// `SessionExecutionArgs` → [`ExecuteRequest`] mapping through the canonical
690/// [`ExecuteRequestBuilder`] in one place, instead of a hand-written struct
691/// literal that must be kept field-aligned with [`ExecuteRequest`] by hand.
692struct ExecuteRequestParams {
693    tools: Option<Arc<dyn ToolExecutor>>,
694    provider_override: Option<Arc<dyn LLMProvider>>,
695    model_roster: ModelRoster,
696    reasoning_effort: Option<ReasoningEffort>,
697    auxiliary_model_resolver: Option<Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync>>,
698    disabled_filter_resolver: Option<DisabledFilterResolver>,
699    disabled_tools: Option<BTreeSet<String>>,
700    disabled_skill_ids: Option<BTreeSet<String>>,
701    selected_skill_ids: Option<Vec<String>>,
702    selected_skill_mode: Option<String>,
703    image_fallback: Option<ImageFallbackConfig>,
704    gold_config: Option<GoldConfig>,
705    guardian_config: Option<GuardianConfig>,
706    guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
707    bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
708    bash_completion_sink: Option<Arc<dyn BashCompletionSink>>,
709    app_data_dir: Option<std::path::PathBuf>,
710    run_budget: Option<bamboo_config::RunBudgetConfig>,
711}
712
713/// Assemble an [`ExecuteRequest`] from the resolved spawn parameters via the
714/// canonical [`ExecuteRequestBuilder`].
715///
716/// Centralizing this mapping keeps every optional field threaded with exactly
717/// the same value the old struct literal carried (the builder defaults each
718/// unset field to `None`), while removing the field-by-field duplication.
719fn build_execute_request(
720    initial_message: String,
721    event_tx: mpsc::Sender<AgentEvent>,
722    cancel_token: CancellationToken,
723    params: ExecuteRequestParams,
724) -> ExecuteRequest {
725    let ExecuteRequestParams {
726        tools,
727        provider_override,
728        model_roster,
729        reasoning_effort,
730        auxiliary_model_resolver,
731        disabled_filter_resolver,
732        disabled_tools,
733        disabled_skill_ids,
734        selected_skill_ids,
735        selected_skill_mode,
736        image_fallback,
737        gold_config,
738        guardian_config,
739        guardian_spawner,
740        bash_resume_hook,
741        bash_completion_sink,
742        app_data_dir,
743        run_budget,
744    } = params;
745
746    let mut builder = ExecuteRequestBuilder::new(initial_message, event_tx, cancel_token)
747        .model_roster(model_roster)
748        .gold_config(gold_config)
749        .guardian_config(guardian_config)
750        .guardian_spawner(guardian_spawner)
751        .bash_resume_hook(bash_resume_hook)
752        .bash_completion_sink(bash_completion_sink);
753
754    if let Some(run_budget) = run_budget {
755        builder = builder.run_budget(run_budget);
756    }
757
758    if let Some(tools) = tools {
759        builder = builder.tools(tools);
760    }
761    if let Some(provider_override) = provider_override {
762        builder = builder.provider_override(provider_override);
763    }
764    if let Some(reasoning_effort) = reasoning_effort {
765        builder = builder.reasoning_effort(reasoning_effort);
766    }
767    if let Some(disabled_filter_resolver) = disabled_filter_resolver {
768        builder = builder.disabled_filter_resolver(disabled_filter_resolver);
769    }
770    if let Some(auxiliary_model_resolver) = auxiliary_model_resolver {
771        builder = builder.auxiliary_model_resolver(auxiliary_model_resolver);
772    }
773    if let Some(disabled_tools) = disabled_tools {
774        builder = builder.disabled_tools(disabled_tools);
775    }
776    if let Some(disabled_skill_ids) = disabled_skill_ids {
777        builder = builder.disabled_skill_ids(disabled_skill_ids);
778    }
779    if let Some(selected_skill_ids) = selected_skill_ids {
780        builder = builder.selected_skill_ids(selected_skill_ids);
781    }
782    if let Some(selected_skill_mode) = selected_skill_mode {
783        builder = builder.selected_skill_mode(selected_skill_mode);
784    }
785    if let Some(image_fallback) = image_fallback {
786        builder = builder.image_fallback(image_fallback);
787    }
788    if let Some(app_data_dir) = app_data_dir {
789        builder = builder.app_data_dir(app_data_dir);
790    }
791
792    builder.build()
793}
794
795/// Spawn a background agent execution task.
796///
797/// This function spawns a tokio task that:
798/// 1. Executes the agent loop via `agent.execute()`
799/// 2. Sends a terminal error event if the execution fails
800/// 3. Finalizes the runner status
801/// 4. Persists the session via merge-save (preserves concurrent UI title/pin edits)
802/// 5. Updates the in-memory session cache
803pub fn spawn_session_execution(args: SessionExecutionArgs) {
804    let span_session_id = args.session_id.clone();
805    let session_span = tracing::info_span!("agent_execution", session_id = %span_session_id);
806
807    tokio::spawn(
808        async move {
809            let SessionExecutionArgs {
810                agent,
811                session_id,
812                mut session,
813                mut execution_reservation,
814                tools_override,
815                provider_override,
816                model_roster,
817                reasoning_effort,
818                reasoning_effort_source,
819                auxiliary_model_resolver,
820                disabled_filter_resolver,
821                disabled_tools,
822                disabled_skill_ids,
823                selected_skill_ids,
824                selected_skill_mode,
825                mpsc_tx,
826                mut history_commit_barrier,
827                image_fallback,
828                gold_config,
829                guardian_config,
830                guardian_spawner,
831                bash_resume_hook,
832                bash_completion_sink,
833                app_data_dir,
834                run_budget,
835                runners,
836                sessions_cache,
837                on_complete,
838                child_completion_handler,
839            } = args;
840
841            if !execution_reservation.matches_execution_target(&session_id, &session.id, &runners) {
842                tracing::error!(
843                    %session_id,
844                    domain_session_id = %session.id,
845                    reservation_session_id = %execution_reservation.session_id(),
846                    run_id = %execution_reservation.run_id(),
847                    same_runner_registry = Arc::ptr_eq(&execution_reservation.runners, &runners),
848                    "refusing mismatched session execution reservation"
849                );
850                execution_reservation.abandon().await;
851                return;
852            }
853            if let Err(error) = execution_reservation.ensure_registered().await {
854                tracing::warn!(
855                    %session_id,
856                    run_id = %execution_reservation.run_id(),
857                    %error,
858                    "session execution could not adopt its router activation owner"
859                );
860                return;
861            }
862            if execution_reservation.root_actor.is_none()
863                && agent.persistence().root_actor_execution_required(&session)
864            {
865                let message =
866                    "Root execution route requires a bound Actor writer and event handoff";
867                tracing::warn!(%session_id, "{message}");
868                let _ = mpsc_tx
869                    .send(AgentEvent::Error {
870                        message: message.into(),
871                    })
872                    .await;
873                execution_reservation.abandon().await;
874                return;
875            }
876            // Bind before disarming: every pre-spawn and runtime checkpoint
877            // uses one private view, and binding failure abandons this runner.
878            if let Err(error) = execution_reservation.bind_execution_persistence(&agent, &session) {
879                let _ = mpsc_tx
880                    .send(AgentEvent::Error {
881                        message: error.to_string(),
882                    })
883                    .await;
884                execution_reservation.abandon().await;
885                return;
886            }
887            let mut root_actor = execution_reservation.root_actor.take().map(|owner| *owner);
888            let root_actor_bound = root_actor.is_some();
889            let persistence = execution_reservation
890                .execution_persistence()
891                .unwrap_or_else(|| agent.persistence().clone());
892            let agent = Arc::new(agent.with_execution_persistence(persistence));
893            let activation_run_id = execution_reservation.run_id().to_string();
894            let prior_message_ids = session.messages.iter().map(|m| m.id.clone()).collect();
895            let (cancel_token, mut activation_registration) =
896                execution_reservation.disarm_for_execution();
897
898            // The primary model is required for a spawn; the roster stores it as
899            // `Option<String>` for uniformity, so recover the owned String here
900            // for session attribution / logging (same value the caller set).
901            let model = model_roster.model.clone().unwrap_or_default();
902
903            let initial_message = initial_user_message_for_session(&session);
904            let selected_skill_ids =
905                selected_skill_ids.or_else(|| selected_skill_ids_for_session(&session));
906            let selected_skill_mode =
907                selected_skill_mode.or_else(|| selected_skill_mode_for_session(&session));
908
909            tracing::info!(
910                "[{}] Using resolved session model: {}, reasoning_effort={}, reasoning_source={}",
911                session_id,
912                model,
913                reasoning_effort
914                    .map(ReasoningEffort::as_str)
915                    .unwrap_or("none"),
916                reasoning_effort_source
917            );
918
919            // Set the resolved model via the single authoritative pre-execution
920            // mutation point. The caller already placed the system prompt on the
921            // session, so pass `None` for `system_prompt` (the subsequent
922            // `system_prompt_for_session` read below sees the caller's message).
923            // This must run before that read / logging so the observable
924            // sequence (model set, then prompt snapshot) is identical.
925            crate::session_app::execution_prep::prepare_session_for_execution(
926                &mut session,
927                None,
928                Some(&model),
929            );
930
931            let system_prompt = system_prompt_for_debug_log(&mut session);
932            if let Some(prompt) = system_prompt.as_ref() {
933                log_base_system_prompt_snapshot(&session_id, prompt);
934            }
935
936            // A carried untagged wait has already been persisted. Unlike a
937            // newly armed safety-net wait, it must not resurrect a completed
938            // child wait. Tagged tool waits are reconciled by the final writer.
939            let inherited_child_wait = agent.persistence().inherited_child_wait();
940            let execute_request = build_execute_request(
941                initial_message,
942                mpsc_tx.clone(),
943                cancel_token,
944                ExecuteRequestParams {
945                    tools: tools_override,
946                    provider_override,
947                    model_roster,
948                    reasoning_effort,
949                    auxiliary_model_resolver,
950                    disabled_filter_resolver,
951                    disabled_tools,
952                    disabled_skill_ids,
953                    selected_skill_ids,
954                    selected_skill_mode,
955                    image_fallback,
956                    gold_config,
957                    guardian_config,
958                    guardian_spawner,
959                    bash_resume_hook,
960                    bash_completion_sink,
961                    app_data_dir,
962                    run_budget,
963                },
964            );
965
966            // Panic containment (issue #546): everything below — the terminal
967            // status persist, finalize_runner, and the child-completion
968            // publish — only runs if this task survives execution. An
969            // unwinding panic would leave a zombie Running runner entry (which
970            // blinds liveness checks) and, for a child session, strand its
971            // waiting parent. Map a panic to a terminal error instead.
972            let result = {
973                use futures::FutureExt;
974                match std::panic::AssertUnwindSafe(agent.execute(&mut session, execute_request))
975                    .catch_unwind()
976                    .await
977                {
978                    Ok(result) => result,
979                    Err(panic) => {
980                        let message = panic
981                            .downcast_ref::<&str>()
982                            .map(|s| (*s).to_string())
983                            .or_else(|| panic.downcast_ref::<String>().cloned())
984                            .unwrap_or_else(|| "non-string panic payload".to_string());
985                        tracing::error!(
986                            "[{}] agent execution panicked; finalizing as terminal error: {}",
987                            session_id,
988                            message
989                        );
990                        Err(AgentError::LLM(format!(
991                            "agent execution panicked: {message}"
992                        )))
993                    }
994                }
995            };
996
997            // Send terminal event for all error cases (including cancellation).
998            if let Some(error_event) = terminal_error_event_for_result(&result) {
999                let _ = mpsc_tx.send(error_event).await;
1000            }
1001
1002            // Record the terminal run status on the session BEFORE persisting so
1003            // the session summary reports a real `last_run_status`. Top-level
1004            // sessions otherwise never set it, so summaries show
1005            // `last_run_status: null`; the frontend then cannot confirm the run
1006            // finished and falls back on a ~5s optimistic-settle window, leaving
1007            // a phantom "thinking" indicator after the reply is already done
1008            // (notably on a session's first turn). Same Ok/cancelled/error
1009            // mapping `status_from_execution_result` applies to the runner.
1010            //
1011            // A suspended run also returns `Ok(())` but is NOT terminal: it
1012            // stamped `runtime.suspend_reason` (awaiting_clarification /
1013            // waiting_for_children / waiting_for_bash / awaiting_parent_approval)
1014            // and will resume later (which removes the reason — see respond.rs /
1015            // child_completion_coordinator). Mark it "suspended" rather than
1016            // "completed" so a session waiting on the user or on children isn't
1017            // reported as finished (mirrors the child path in `sdk::spawn`).
1018            let suspended_non_terminal = result.is_ok()
1019                && session
1020                    .metadata
1021                    .get("runtime.suspend_reason")
1022                    .is_some_and(|reason| !reason.trim().is_empty());
1023            match &result {
1024                Ok(()) if suspended_non_terminal => {
1025                    session.set_last_run_status("suspended");
1026                    session.clear_last_run_error();
1027                }
1028                Ok(()) => {
1029                    session.set_last_run_status("completed");
1030                    session.clear_last_run_error();
1031                }
1032                Err(error) if error.is_cancelled() => {
1033                    session.set_last_run_status("cancelled");
1034                    session.set_last_run_error(error.to_string());
1035                }
1036                Err(error) => {
1037                    session.set_last_run_status("error");
1038                    session.set_last_run_error(error.to_string());
1039                }
1040            }
1041
1042            // Bespoke terminal bookkeeping (e.g. a scheduled-run status) runs
1043            // here — before persistence — so any closing message the hook
1044            // appends is saved with the session below.
1045            if let Some(on_complete) = on_complete {
1046                on_complete(SessionExecutionOutcome::from_result(&result), &mut session).await;
1047            }
1048
1049            // Freeze the generation actually consumed by provider reasoning.
1050            // Terminal compatibility migration below may enqueue newer work,
1051            // but must never make that work look executed.
1052            let executed_admitted_generation = session
1053                .session_inbox_admission()
1054                .map_or(0, |state| state.last_admitted_sequence);
1055            let legacy_migration =
1056                crate::runtime::runner::state_bridge::migrate_legacy_pending_only(
1057                    &mut session,
1058                    Some(agent.storage()),
1059                    Some(agent.persistence()),
1060                    agent.session_inbox(),
1061                )
1062                .await;
1063            let pending_boundary_generation = session
1064                .session_inbox_admission()
1065                .and_then(|state| state.pending_activation_generation());
1066            let pending_generation = match (
1067                pending_boundary_generation,
1068                legacy_migration.highest_generation,
1069            ) {
1070                (Some(left), Some(right)) => Some(left.max(right)),
1071                (left, right) => left.or(right),
1072            };
1073            if let (Some(generation), Some(router)) =
1074                (pending_generation, agent.activation_router())
1075            {
1076                let activation_ready = if let Some(inbox) = agent.session_inbox() {
1077                    match inbox
1078                        .mark_activation_eligible(
1079                            &session_id,
1080                            generation,
1081                            bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
1082                        )
1083                        .await
1084                    {
1085                        Ok(()) => true,
1086                        Err(error) => {
1087                            tracing::error!(
1088                                session_id = %session_id,
1089                                %error,
1090                                "failed to persist unadmitted SessionInbox activation watermark"
1091                            );
1092                            false
1093                        }
1094                    }
1095                } else {
1096                    false
1097                };
1098                if activation_ready {
1099                    if let Err(error) = bamboo_domain::SessionActivationPort::request_activation(
1100                        router.as_ref(),
1101                        &session_id,
1102                        generation,
1103                    )
1104                    .await
1105                    {
1106                        tracing::error!(
1107                            session_id = %session_id,
1108                            %error,
1109                            "failed to hand unadmitted SessionInbox generation to activation router"
1110                        );
1111                    }
1112                }
1113            }
1114
1115            // Close the save→terminal race before the final persistence write:
1116            // a delivery from this point onward is coalesced for a successor
1117            // rather than being "notified" to a loop that has already exited.
1118            if let Some(registration) = activation_registration.as_mut() {
1119                registration.begin_finalization().await;
1120            }
1121
1122            // Save session via merge-save so any concurrent UI edits to
1123            // title / title_generated / pinned / title_version are preserved (the runtime is not
1124            // an authoritative title writer).
1125            super::child_completion::ChildCompletionSource::prepare(
1126                &mut session,
1127                &activation_run_id,
1128                &prior_message_ids,
1129            );
1130            let saved = save_finalized_runtime_with_inherited_child_wait(
1131                agent.persistence().as_ref(),
1132                &mut session,
1133                inherited_child_wait
1134                    .as_ref()
1135                    .map(|inherited| inherited.wait()),
1136            )
1137            .await;
1138            let history_committed = saved.is_ok();
1139            let completion_source =
1140                super::child_completion::ChildCompletionSource::after_final_save(
1141                    &session,
1142                    history_committed,
1143                );
1144            let completed_at = chrono::Utc::now();
1145            if let Err(error) = &saved {
1146                tracing::warn!("[{}] Failed to save session: {}", session_id, error);
1147            }
1148
1149            // `Complete` intentionally closes the low-latency token stream as
1150            // soon as generation ends. Publish a separate durable barrier only
1151            // after the final runtime snapshot has been saved, while this run's
1152            // publication fence still owns event ordering. Account-feed clients
1153            // can now reconcile `/history` without racing the checkpoint.
1154            if history_committed
1155                && !history_commit_barrier
1156                    .send_and_wait(&mpsc_tx, session_id.clone())
1157                    .await
1158            {
1159                tracing::warn!(
1160                    session_id = %session_id,
1161                    "history commit barrier could not be published before runner finalization"
1162                );
1163            }
1164
1165            // Flip the runner registry to a terminal status (which makes session
1166            // summaries report `is_running: false`) ONLY AFTER the run status is
1167            // persisted above, so `is_running` and `last_run_status` become
1168            // visible together and the frontend settles immediately instead of
1169            // lingering in its optimistic-settle window.
1170            finalize_runner(&runners, &session_id, &result).await;
1171
1172            if let Some(owner) = root_actor.take() {
1173                let outcome = if result.as_ref().is_err_and(|error| error.is_cancelled()) {
1174                    bamboo_domain::ActorActivationFinish::Cancelled
1175                } else if result.is_err() || !history_committed {
1176                    bamboo_domain::ActorActivationFinish::Failed
1177                } else {
1178                    bamboo_domain::ActorActivationFinish::Succeeded
1179                };
1180                owner.finish(outcome).await;
1181            }
1182
1183            let finalization = if let Some(registration) = activation_registration.take() {
1184                registration.finish(executed_admitted_generation).await
1185            } else {
1186                Ok(None)
1187            };
1188            if let Err(error) = finalization {
1189                // Delivery is durable even if activation infrastructure is
1190                // temporarily unavailable; startup/backlog reconciliation
1191                // can retry without loss.
1192                tracing::error!(
1193                    session_id = %session_id,
1194                    %error,
1195                    "failed to activate successor for finalization-racing SessionInbox delivery"
1196                );
1197            }
1198
1199            // A CHILD session finishing through this path (a resumed child, or
1200            // a nested child-parent woken by its own children) must wake its
1201            // waiting parent (issue #546). Publish AFTER the final persist and
1202            // runner finalization so the coordinator reads the child's settled
1203            // terminal state — the resume message folds in the child's final
1204            // assistant content from storage. The status mirrors the
1205            // `last_run_status` mapping above; a non-terminal "suspended" is
1206            // published too and ignored by the coordinator's terminality guard.
1207            let child_completion = child_completion_handler.filter(|_| {
1208                session.kind == bamboo_agent_core::SessionKind::Child
1209                    && session.parent_session_id.is_some()
1210            });
1211            let parent_session_id = session.parent_session_id.clone();
1212            let child_status = session.last_run_status();
1213            let child_error = session.last_run_error();
1214
1215            publish_final_session_cache(&sessions_cache, session, root_actor_bound, &saved);
1216
1217            if let (Some(handler), Some(parent_session_id), Some(status)) =
1218                (child_completion, parent_session_id, child_status)
1219            {
1220                use futures::FutureExt;
1221                let completion = ChildCompletion {
1222                    parent_session_id: parent_session_id.clone(),
1223                    child_session_id: session_id.clone(),
1224                    status,
1225                    error: child_error,
1226                    completed_at,
1227                    source: completion_source,
1228                };
1229                if std::panic::AssertUnwindSafe(handler.on_child_completed(completion))
1230                    .catch_unwind()
1231                    .await
1232                    .is_err()
1233                {
1234                    tracing::error!(
1235                        %parent_session_id,
1236                        child_session_id = %session_id,
1237                        "child completion handler panicked on resumed-child terminal"
1238                    );
1239                }
1240            }
1241
1242            tracing::info!("[{}] Agent execution completed", session_id);
1243        }
1244        .instrument(session_span),
1245    );
1246}
1247
1248// Keep the final writer and its rejection path directly regression-testable.
1249async fn save_finalized_runtime_with_inherited_child_wait(
1250    persistence: &dyn bamboo_domain::RuntimeSessionPersistence,
1251    session: &mut Session,
1252    inherited_child_wait: Option<&bamboo_domain::session::runtime_state::WaitingForChildrenState>,
1253) -> std::io::Result<()> {
1254    if let Some(inherited) = inherited_child_wait.filter(|wait| {
1255        session
1256            .agent_runtime_state
1257            .as_ref()
1258            .and_then(|runtime| runtime.waiting_for_children.as_ref())
1259            == Some(*wait)
1260    }) {
1261        persistence
1262            .save_finalized_runtime_with_inherited_child_wait(session, inherited)
1263            .await
1264    } else {
1265        persistence.save_finalized_runtime_session(session).await
1266    }
1267}
1268
1269fn publish_final_session_cache(
1270    cache: &SessionCache,
1271    session: Session,
1272    root_actor_bound: bool,
1273    saved: &std::io::Result<()>,
1274) {
1275    let authority_conflict = saved.as_ref().err().is_some_and(|error| {
1276        error
1277            .get_ref()
1278            .is_some_and(|cause| cause.is::<bamboo_domain::SessionAuthorityConflict>())
1279    });
1280    // Preserve unrelated I/O failure behavior, but never publish a rejected
1281    // incarnation. Bound Root executions publish through their owned writer.
1282    if !authority_conflict && !root_actor_bound {
1283        cache.insert(
1284            session.id.clone(),
1285            Arc::new(crate::SessionSnapshot::new(session)),
1286        );
1287    }
1288}
1289
1290/// Log a snapshot of the base system prompt for debugging.
1291pub fn log_base_system_prompt_snapshot(session_id: &str, prompt: &str) {
1292    tracing::info!(
1293        "[{}] Base system prompt snapshot: len={} chars, has_skill={}, has_tool_guide={}, has_external_memory={}, has_task_list={}",
1294        session_id,
1295        prompt.len(),
1296        prompt.contains(SKILL_CONTEXT_START_MARKER),
1297        prompt.contains(TOOL_GUIDE_START_MARKER),
1298        prompt.contains(EXTERNAL_MEMORY_START_MARKER),
1299        prompt.contains(TASK_LIST_START_MARKER),
1300    );
1301
1302    tracing::debug!(
1303        "[{}] ========== BASE SYSTEM PROMPT SNAPSHOT ==========",
1304        session_id
1305    );
1306    tracing::debug!("[{}] Snapshot length: {} chars", session_id, prompt.len());
1307    tracing::debug!("[{}] -----------------------------------", session_id);
1308    tracing::debug!("[{}] {}", session_id, prompt);
1309    tracing::debug!(
1310        "[{}] ========== END BASE SYSTEM PROMPT SNAPSHOT ==========",
1311        session_id
1312    );
1313}
1314
1315/// Map an execution result to a terminal error event.
1316pub fn terminal_error_event_for_result(result: &Result<(), AgentError>) -> Option<AgentEvent> {
1317    match result {
1318        Ok(_) => None,
1319        Err(error) if error.is_cancelled() => Some(AgentEvent::Error {
1320            message: "Agent execution cancelled by user".to_string(),
1321        }),
1322        Err(error) => Some(AgentEvent::Error {
1323            message: error.to_string(),
1324        }),
1325    }
1326}
1327
1328// Session metadata helpers (pure functions, no server dependency).
1329
1330fn system_prompt_for_session(session: &Session) -> Option<String> {
1331    session
1332        .messages
1333        .iter()
1334        .find(|message| matches!(message.role, bamboo_agent_core::Role::System))
1335        .map(|message| message.content.clone())
1336}
1337
1338/// Normalize legacy host-owned prompt sections before the debug logger can
1339/// observe persisted System text. The runner repeats this migration
1340/// idempotently before workspace-scoped setup.
1341fn system_prompt_for_debug_log(session: &mut Session) -> Option<String> {
1342    crate::runtime::runner::session_setup::migrate_legacy_workspace_prompt(session);
1343    system_prompt_for_session(session)
1344}
1345
1346fn initial_user_message_for_session(session: &Session) -> String {
1347    session
1348        .messages
1349        .last()
1350        .filter(|message| matches!(message.role, bamboo_agent_core::Role::User))
1351        .map(|message| message.content.clone())
1352        .unwrap_or_default()
1353}
1354
1355fn selected_skill_ids_for_session(session: &Session) -> Option<Vec<String>> {
1356    session
1357        .metadata
1358        .get("selected_skill_ids")
1359        .and_then(|raw| bamboo_skills::selection::parse_selected_skill_ids_metadata(raw))
1360}
1361
1362fn selected_skill_mode_for_session(session: &Session) -> Option<String> {
1363    let value = session
1364        .metadata
1365        .get("skill_mode")
1366        .or_else(|| session.metadata.get("mode"))?;
1367    let trimmed = value.trim();
1368    if trimmed.is_empty() {
1369        None
1370    } else {
1371        Some(trimmed.to_string())
1372    }
1373}
1374
1375pub(super) struct RootActorExecution {
1376    binding: bamboo_domain::RootActorExecutionBinding,
1377    stop_renewal: CancellationToken,
1378    renewal: Option<tokio::task::JoinHandle<()>>,
1379    response_events: Vec<AgentEvent>,
1380}
1381
1382impl RootActorExecution {
1383    pub(super) fn start(binding: bamboo_domain::RootActorExecutionBinding) -> Self {
1384        let stop_renewal = CancellationToken::new();
1385        let stop = stop_renewal.clone();
1386        let directory = binding.directory.clone();
1387        let fence = binding.owner.fence.clone();
1388        let duration = binding.lease_duration;
1389        let renewal = tokio::spawn(async move {
1390            let mut interval = tokio::time::interval(duration / 3);
1391            interval.tick().await;
1392            loop {
1393                tokio::select! {
1394                    _ = stop.cancelled() => break,
1395                    _ = interval.tick() => {
1396                        let now = chrono::Utc::now();
1397                        let expires = now + chrono::Duration::from_std(duration).expect("bounded Root lease");
1398                        if let Err(error) = directory.renew_activation(&fence, now, expires).await {
1399                            // An in-flight provider response may still arrive.
1400                            // Its next load/write/effect boundary rejects this
1401                            // fence; never retry a side effect or cancel a new owner.
1402                            tracing::warn!(actor_id = %fence.actor_id, run_id = %fence.run_id,
1403                                %error, "Root Actor renewal lost its execution authority");
1404                            break;
1405                        }
1406                    }
1407                }
1408            }
1409        });
1410        Self {
1411            binding,
1412            stop_renewal,
1413            renewal: Some(renewal),
1414            response_events: Vec::new(),
1415        }
1416    }
1417
1418    pub(super) fn persistence(
1419        &self,
1420    ) -> std::sync::Arc<dyn bamboo_domain::RuntimeSessionPersistence> {
1421        self.binding.persistence.clone()
1422    }
1423
1424    pub(super) async fn finish(mut self, outcome: bamboo_domain::ActorActivationFinish) {
1425        self.stop_renewal.cancel();
1426        if let Some(renewal) = self.renewal.take() {
1427            let _ = renewal.await;
1428        }
1429        let directory = self.binding.directory.clone();
1430        let fence = self.binding.owner.fence.clone();
1431        let finish = tokio::spawn(async move {
1432            if let Err(error) = directory
1433                .finish_activation(&fence, chrono::Utc::now(), outcome)
1434                .await
1435            {
1436                tracing::warn!(actor_id = %fence.actor_id, run_id = %fence.run_id,
1437                    %error, "Root Actor finish rejected its obsolete execution fence");
1438            }
1439        });
1440        self.binding.disarm_abandonment();
1441        let _ = finish.await;
1442    }
1443}
1444
1445impl Drop for RootActorExecution {
1446    fn drop(&mut self) {
1447        self.stop_renewal.cancel();
1448        // Binding Drop schedules exact-fence abandonment. Never abort a
1449        // started filesystem job: its actual physical guards own its lifetime.
1450    }
1451}
1452
1453#[cfg(test)]
1454mod reservation_tests {
1455    use super::*;
1456    use crate::runtime::execution::runner_state::AgentStatus;
1457
1458    struct CompletionAtFinalSave {
1459        directory: std::path::PathBuf,
1460        persistence: bamboo_storage::LockedSessionStore,
1461        cleared: std::sync::atomic::AtomicBool,
1462    }
1463
1464    impl CompletionAtFinalSave {
1465        async fn clear_wait(&self, id: &str) {
1466            let mut process = std::process::Command::new(std::env::current_exe().unwrap())
1467                .args(["--exact", "runtime::execution::agent_spawn::reservation_tests::inherited_child_wait_adopts_independent_completion_at_final_save", "--nocapture"])
1468                .env("BAMBOO_FINAL_WAIT_COMPLETION_DIR", &self.directory)
1469                .env("BAMBOO_FINAL_WAIT_COMPLETION_ID", id)
1470                .spawn().unwrap();
1471            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
1472            loop {
1473                if let Some(status) = process.try_wait().unwrap() {
1474                    assert!(status.success(), "completion process failed");
1475                    break;
1476                }
1477                if std::time::Instant::now() >= deadline {
1478                    let _ = process.kill();
1479                    let _ = process.wait();
1480                    panic!("bounded completion process deadline");
1481                }
1482                std::thread::sleep(std::time::Duration::from_millis(5));
1483            }
1484            self.cleared
1485                .store(true, std::sync::atomic::Ordering::SeqCst);
1486        }
1487    }
1488
1489    #[async_trait::async_trait]
1490    impl bamboo_domain::RuntimeSessionPersistence for CompletionAtFinalSave {
1491        async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1492            self.clear_wait(&session.id).await;
1493            self.persistence.merge_save_finalized_runtime(session).await
1494        }
1495        async fn save_finalized_runtime_with_inherited_child_wait(
1496            &self,
1497            session: &mut Session,
1498            inherited: &bamboo_domain::session::runtime_state::WaitingForChildrenState,
1499        ) -> std::io::Result<()> {
1500            self.clear_wait(&session.id).await;
1501            self.persistence
1502                .merge_save_inherited_child_wait_and_publish(session, inherited, |_, _| {})
1503                .await
1504        }
1505    }
1506
1507    #[tokio::test]
1508    async fn inherited_child_wait_adopts_independent_completion_at_final_save() {
1509        use bamboo_agent_core::storage::Storage;
1510        use bamboo_domain::session::runtime_state::{
1511            AgentRuntimeState, AgentStatusState, ChildWaitPolicy, WaitingForChildrenState,
1512        };
1513        if let Some(directory) = std::env::var_os("BAMBOO_FINAL_WAIT_COMPLETION_DIR") {
1514            let remote = bamboo_storage::SessionStoreV2::new(directory.into())
1515                .await
1516                .unwrap();
1517            let id = std::env::var("BAMBOO_FINAL_WAIT_COMPLETION_ID").unwrap();
1518            let mut latest = remote.load_session(&id).await.unwrap().unwrap();
1519            let runtime = latest.agent_runtime_state.as_mut().unwrap();
1520            runtime.waiting_for_children = None;
1521            runtime.status = AgentStatusState::Idle;
1522            runtime.suspension = None;
1523            latest.metadata.remove("runtime.suspend_reason");
1524            remote.save_runtime_state(&latest).await.unwrap();
1525            return;
1526        }
1527        let temp = tempfile::tempdir().unwrap();
1528        let local = Arc::new(
1529            bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
1530                .await
1531                .unwrap(),
1532        );
1533        // Independent Host persistence does not participate in the engine's
1534        // process-local resume mutex. Complete only when finalization arrives,
1535        // after the old helper's reload but before its final durable write.
1536        let mut runner = Session::new("cross-host-final-wait", "model");
1537        let inherited = WaitingForChildrenState::for_children(
1538            vec!["child".into()],
1539            ChildWaitPolicy::All,
1540            chrono::Utc::now(),
1541        );
1542        let mut runtime = AgentRuntimeState::new("run");
1543        runtime.waiting_for_children = Some(inherited.clone());
1544        runtime.status = AgentStatusState::Suspended;
1545        runner.agent_runtime_state = Some(runtime);
1546        runner.metadata.insert(
1547            "runtime.suspend_reason".into(),
1548            "waiting_for_children".into(),
1549        );
1550        runner.set_last_run_status("suspended");
1551        local.save_session(&runner).await.unwrap();
1552        let persistence = CompletionAtFinalSave {
1553            directory: temp.path().to_path_buf(),
1554            persistence: bamboo_storage::LockedSessionStore::new(local.clone()),
1555            cleared: std::sync::atomic::AtomicBool::new(false),
1556        };
1557        save_finalized_runtime_with_inherited_child_wait(
1558            &persistence,
1559            &mut runner,
1560            Some(&inherited),
1561        )
1562        .await
1563        .unwrap();
1564        assert!(persistence
1565            .cleared
1566            .load(std::sync::atomic::Ordering::SeqCst));
1567        let durable = local.load_session(&runner.id).await.unwrap().unwrap();
1568        for session in [&runner, &durable] {
1569            let runtime = session.agent_runtime_state.as_ref().unwrap();
1570            assert!(
1571                runtime.waiting_for_children.is_none(),
1572                "independent completion must not be resurrected"
1573            );
1574            assert_eq!(runtime.status, AgentStatusState::Idle);
1575            assert!(runtime.suspension.is_none());
1576            assert!(!session.metadata.contains_key("runtime.suspend_reason"));
1577            assert_eq!(session.last_run_status().as_deref(), Some("completed"));
1578        }
1579    }
1580
1581    #[tokio::test]
1582    async fn inherited_child_wait_rejects_aba_and_missing_birth_without_cache_publication() {
1583        use bamboo_agent_core::storage::Storage;
1584        use bamboo_domain::session::runtime_state::{
1585            AgentRuntimeState, ChildWaitPolicy, WaitingForChildrenState,
1586        };
1587        use bamboo_storage::{LockedSessionStore, SessionStoreV2};
1588
1589        for missing in [false, true] {
1590            let temp = tempfile::tempdir().unwrap();
1591            let storage = Arc::new(
1592                SessionStoreV2::new(temp.path().to_path_buf())
1593                    .await
1594                    .unwrap(),
1595            );
1596            let persistence = LockedSessionStore::new(storage.clone());
1597            let cache: SessionCache = Arc::default();
1598            storage
1599                .save_session(&Session::new("root", "model"))
1600                .await
1601                .unwrap();
1602            let mut stale = Session::new("nested-child-parent", "model");
1603            stale.kind = bamboo_domain::SessionKind::Child;
1604            stale.parent_session_id = Some("root".into());
1605            stale.root_session_id = "root".into();
1606            stale.spawn_depth = 1;
1607            let wait = WaitingForChildrenState::for_children(
1608                vec!["grandchild".into()],
1609                ChildWaitPolicy::All,
1610                chrono::Utc::now(),
1611            );
1612            let mut runtime = AgentRuntimeState::new("old-run");
1613            runtime.waiting_for_children = Some(wait.clone());
1614            stale.agent_runtime_state = Some(runtime);
1615            stale.add_message(bamboo_agent_core::Message::assistant("STALE", None));
1616            storage.save_session(&stale).await.unwrap();
1617
1618            let mut replacement = stale.clone();
1619            replacement.created_at += chrono::Duration::seconds(1);
1620            replacement.messages.clear();
1621            replacement.add_message(bamboo_agent_core::Message::assistant("REPLACEMENT", None));
1622            replacement
1623                .agent_runtime_state
1624                .as_mut()
1625                .unwrap()
1626                .waiting_for_children = None;
1627            storage.delete_session(&stale.id).await.unwrap();
1628            if !missing {
1629                storage.save_session(&replacement).await.unwrap();
1630            }
1631            if !missing {
1632                cache.insert(
1633                    replacement.id.clone(),
1634                    Arc::new(crate::SessionSnapshot::new(replacement.clone())),
1635                );
1636            }
1637
1638            let saved = save_finalized_runtime_with_inherited_child_wait(
1639                &persistence,
1640                &mut stale,
1641                Some(&wait),
1642            )
1643            .await;
1644            assert!(saved.is_err(), "stale incarnation must not be saved");
1645            // A nested Child uses the unbound publication path. A plain I/O
1646            // error used to pass its cache guard despite the durable rejection.
1647            publish_final_session_cache(&cache, stale, false, &saved);
1648            if missing {
1649                assert!(
1650                    read_cached_session(&cache, &replacement.id).is_none(),
1651                    "deleted incarnation must not be resurrected in cache"
1652                );
1653            } else {
1654                let cached = read_cached_session(&cache, &replacement.id).unwrap();
1655                assert_eq!(
1656                    cached.created_at, replacement.created_at,
1657                    "ABA rejection must retain replacement cache birth"
1658                );
1659                assert_eq!(
1660                    serde_json::to_value(&cached.messages).unwrap(),
1661                    serde_json::to_value(&replacement.messages).unwrap(),
1662                    "ABA rejection must retain replacement cache transcript"
1663                );
1664            }
1665            assert!(saved
1666                .as_ref()
1667                .unwrap_err()
1668                .get_ref()
1669                .is_some_and(|cause| cause.is::<bamboo_domain::SessionAuthorityConflict>()));
1670            let durable = storage.load_session(&replacement.id).await.unwrap();
1671            if missing {
1672                assert!(durable.is_none());
1673            } else {
1674                let durable = durable.unwrap();
1675                assert_eq!(durable.created_at, replacement.created_at);
1676                assert_eq!(
1677                    serde_json::to_value(&durable.messages).unwrap(),
1678                    serde_json::to_value(&replacement.messages).unwrap()
1679                );
1680            }
1681        }
1682    }
1683
1684    #[test]
1685    fn debug_prompt_snapshot_migrates_legacy_host_paths_before_logging() {
1686        let legacy_project = format!(
1687            "{}\nProject ID: legacy-project\nProject path: /private/legacy-project\nProject home: /private/legacy-home\n{}",
1688            crate::runtime::context::PROJECT_CONTEXT_START_MARKER,
1689            crate::runtime::context::PROJECT_CONTEXT_END_MARKER,
1690        );
1691        let legacy_workspace =
1692            crate::runtime::context::build_workspace_prompt_context("/private/legacy-workspace")
1693                .expect("legacy workspace context");
1694        let legacy_instruction = format!(
1695            "{}\nSource: /private/legacy-workspace/AGENTS.md\nlegacy policy\n{}",
1696            crate::runtime::context::instruction::INSTRUCTION_CONTEXT_START_MARKER,
1697            crate::runtime::context::instruction::INSTRUCTION_CONTEXT_END_MARKER,
1698        );
1699        let mut session = Session::new("legacy-debug-log", "model");
1700        session.add_message(bamboo_agent_core::Message::system(format!(
1701            "Base prompt\n\n{legacy_project}\n\n{legacy_workspace}\n\n{legacy_instruction}"
1702        )));
1703
1704        let prompt = system_prompt_for_debug_log(&mut session).expect("normalized System");
1705
1706        assert_eq!(prompt, "Base prompt");
1707        assert_eq!(
1708            session.workspace_path_meta().as_deref(),
1709            Some("/private/legacy-workspace")
1710        );
1711        for private_path in [
1712            "/private/legacy-project",
1713            "/private/legacy-home",
1714            "/private/legacy-workspace",
1715        ] {
1716            assert!(!prompt.contains(private_path));
1717        }
1718        assert!(!prompt.contains(crate::runtime::context::PROJECT_CONTEXT_START_MARKER));
1719        assert!(!prompt.contains(crate::runtime::context::WORKSPACE_CONTEXT_START_MARKER));
1720        assert!(!prompt
1721            .contains(crate::runtime::context::instruction::INSTRUCTION_CONTEXT_START_MARKER));
1722    }
1723
1724    #[test]
1725    fn reservation_target_requires_domain_id_and_exact_runner_registry() {
1726        let runners = Arc::new(RwLock::new(HashMap::new()));
1727        let other_runners = Arc::new(RwLock::new(HashMap::new()));
1728        let mut reservation = SessionExecutionReservation {
1729            session_id: "session-a".to_string(),
1730            run_id: "run-a".to_string(),
1731            cancel_token: CancellationToken::new(),
1732            runners: runners.clone(),
1733            activation: SessionExecutionActivationOwnership::Unrouted,
1734            root_actor: None,
1735            execution_persistence: None,
1736            persistence_bound: false,
1737            armed: true,
1738        };
1739
1740        assert!(reservation.matches_execution_target("session-a", "session-a", &runners));
1741        assert!(!reservation.matches_execution_target("session-b", "session-a", &runners));
1742        assert!(!reservation.matches_execution_target("session-a", "session-b", &runners));
1743        assert!(!reservation.matches_execution_target("session-a", "session-a", &other_runners));
1744        reservation.armed = false;
1745    }
1746
1747    #[tokio::test]
1748    async fn forced_same_run_registration_rejection_never_cancels_live_owner() {
1749        let runners = Arc::new(RwLock::new(HashMap::new()));
1750        let mut runner = AgentRunner::new();
1751        runner.status = AgentStatus::Running;
1752        let run_id = runner.run_id.clone();
1753        let live_cancel_token = runner.cancel_token.clone();
1754        runners.write().await.insert("same-run".to_string(), runner);
1755
1756        let router = SessionActivationRouter::new();
1757        let mut live_registration = router
1758            .register_run("same-run", &run_id)
1759            .await
1760            .expect("first registration owns the run");
1761        let mut rejected = SessionExecutionReservation {
1762            session_id: "same-run".to_string(),
1763            run_id: run_id.clone(),
1764            cancel_token: live_cancel_token.clone(),
1765            runners: runners.clone(),
1766            activation: SessionExecutionActivationOwnership::RegistrationPending(router.clone()),
1767            root_actor: None,
1768            execution_persistence: None,
1769            persistence_bound: false,
1770            armed: true,
1771        };
1772
1773        let error = match rejected.ensure_registered().await {
1774            Ok(()) => panic!("a duplicate registration for the same run must be rejected"),
1775            Err(error) => error,
1776        };
1777        assert_eq!(error.existing_run_id(), run_id);
1778        drop(rejected);
1779
1780        assert!(!live_cancel_token.is_cancelled());
1781        assert!(matches!(
1782            runners
1783                .read()
1784                .await
1785                .get("same-run")
1786                .map(|runner| &runner.status),
1787            Some(AgentStatus::Running)
1788        ));
1789        assert!(router.owns_run("same-run", &run_id).await);
1790        live_registration.begin_finalization().await;
1791        assert_eq!(live_registration.finish(0).await.unwrap(), None);
1792    }
1793}