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_exact, reserve_runner_core,
28    reserve_runner_for_registration, 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. Queued internal launches use this state; normal manual
48    /// entries register before creating their raw runner.
49    RegistrationPending(Arc<SessionActivationRouter>),
50    /// Manual/server reservation acquired router ownership before any
51    /// execution-specific mutation or external side effect.
52    Registered(Box<SessionRunRegistration>),
53}
54
55/// One exact runner reservation plus its logical-session activation ownership.
56///
57/// Callers must obtain this through [`reserve_session_execution`]. The only
58/// exception is the router's own activation launcher, which uses the
59/// crate-private placeholder constructor while assembling its two-phase launch.
60pub struct SessionExecutionReservation {
61    session_id: String,
62    run_id: String,
63    cancel_token: CancellationToken,
64    runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
65    activation: SessionExecutionActivationOwnership,
66    root_actor: Option<Box<RootActorExecution>>,
67    child_startup: Option<Box<ChildHostStartup>>,
68    child_closure: Option<tokio::task::JoinHandle<()>>,
69    physical_admission: Option<super::root_execution_lifetime::RootRunAdmission>,
70    execution_persistence: Option<Arc<dyn bamboo_domain::RuntimeSessionPersistence>>,
71    persistence_bound: bool,
72    selected_input: bool,
73    armed: bool,
74}
75
76impl SessionExecutionReservation {
77    pub fn session_id(&self) -> &str {
78        &self.session_id
79    }
80
81    pub fn run_id(&self) -> &str {
82        &self.run_id
83    }
84
85    pub fn cancel_token(&self) -> &CancellationToken {
86        &self.cancel_token
87    }
88
89    /// Retain the no-execution cleanup until the exact reservation reaches the
90    /// common spawn handoff. Dropping an HTTP future must not strand its bound
91    /// Child run, and cleanup must not release a successor's identity.
92    pub async fn arm_child_host_startup(
93        &mut self,
94        agent: &Agent,
95        expected: &Session,
96        cache: SessionCache,
97        events: tokio::sync::broadcast::Sender<AgentEvent>,
98    ) -> std::io::Result<()> {
99        self.arm_child_host_startup_inner(agent, expected, cache, Some(events))
100            .await
101    }
102
103    async fn arm_child_host_startup_inner(
104        &mut self,
105        agent: &Agent,
106        expected: &Session,
107        cache: SessionCache,
108        events: Option<tokio::sync::broadcast::Sender<AgentEvent>>,
109    ) -> std::io::Result<()> {
110        if expected.kind != bamboo_domain::SessionKind::Child || expected.id != self.session_id {
111            return Err(std::io::Error::other("Child startup target mismatch"));
112        }
113        if self.child_startup.is_none() {
114            let publication = self
115                .runners
116                .read()
117                .await
118                .get(&self.session_id)
119                .filter(|runner| runner.run_id == self.run_id)
120                .map(|runner| runner.event_publication.clone())
121                .ok_or_else(|| std::io::Error::other("Child startup runner was superseded"))?;
122            let sent = std::sync::atomic::AtomicBool::new(false);
123            self.child_startup = Some(Box::new(ChildHostStartup {
124                storage: agent.storage().clone(),
125                expected: expected.clone(),
126                run_id: self.run_id.clone(),
127                publish: Arc::new(move |saved| {
128                    publication.publish(|| {
129                        cache.insert(
130                            saved.id.clone(),
131                            Arc::new(crate::SessionSnapshot::new(saved.clone())),
132                        );
133                        if !sent.swap(true, std::sync::atomic::Ordering::AcqRel) {
134                            if let Some(events) = &events {
135                                let _ = events.send(AgentEvent::Error {
136                                    message: saved.last_run_error().unwrap_or_else(|| {
137                                        "Child startup failed before execution".into()
138                                    }),
139                                });
140                            }
141                        }
142                    });
143                }),
144            }));
145        }
146        Ok(())
147    }
148
149    pub async fn fail_child_host_startup(&mut self, detail: &str) {
150        if let Some(startup) = self.child_startup.take() {
151            let detail = detail.to_string();
152            // Once started, retain the cleanup even if this waiter is cancelled.
153            self.child_closure = Some(tokio::spawn(async move {
154                startup.close(&detail).await;
155            }));
156        }
157        if let Some(closure) = self.child_closure.as_mut() {
158            let _ = closure.await;
159        }
160        self.child_closure = None;
161    }
162
163    /// Bind before an adapter persists execution state or admits a provider.
164    /// A duplicate call on this exact reservation retains the same capability.
165    pub async fn bind_root_actor(
166        &mut self,
167        agent: &Agent,
168        session: &Session,
169    ) -> std::io::Result<()> {
170        self.bind_root_actor_inner(agent, session, false).await
171    }
172
173    async fn bind_root_actor_inner(
174        &mut self,
175        agent: &Agent,
176        session: &Session,
177        for_compaction: bool,
178    ) -> std::io::Result<()> {
179        if self.root_actor.is_none() {
180            if self.session_id != session.id {
181                return Err(std::io::Error::new(
182                    std::io::ErrorKind::InvalidInput,
183                    "Root binding target mismatch",
184                ));
185            }
186            let persistence = agent.persistence();
187            let binding = if for_compaction {
188                persistence
189                    .bind_root_actor_compaction(session, &self.run_id)
190                    .await?
191            } else {
192                persistence
193                    .bind_root_actor_execution(session, &self.run_id)
194                    .await?
195            };
196            if let Some(binding) = binding {
197                self.root_actor = Some(Box::new(RootActorExecution::start(binding)));
198            }
199        }
200        self.bind_execution_persistence(agent, session)
201    }
202
203    fn bind_execution_persistence(
204        &mut self,
205        agent: &Agent,
206        session: &Session,
207    ) -> std::io::Result<()> {
208        if self.session_id != session.id {
209            return Err(std::io::Error::other(
210                "execution persistence target mismatch",
211            ));
212        }
213        if self.persistence_bound {
214            return Ok(());
215        }
216        let persistence = self
217            .root_actor
218            .as_ref()
219            .map_or_else(|| agent.persistence().clone(), |owner| owner.persistence());
220        if let Some(inherited) = persistence
221            .inherited_child_wait()
222            .or_else(|| bamboo_domain::InheritedChildWait::capture(session))
223        {
224            inherited.validate_session(session)?;
225            self.execution_persistence = Some(persistence.bind_inherited_child_wait(inherited)?);
226        } else if self.root_actor.is_some() {
227            self.execution_persistence = Some(persistence);
228        }
229        self.persistence_bound = true;
230        Ok(())
231    }
232
233    pub fn execution_persistence(
234        &self,
235    ) -> Option<Arc<dyn bamboo_domain::RuntimeSessionPersistence>> {
236        self.execution_persistence.clone()
237    }
238
239    /// Retain one exact input selector in the same private persistence view
240    /// moved through admission, Ready and spawn. This cannot mint a writer.
241    pub fn bind_root_inbox_receipt(
242        &mut self,
243        receipt: bamboo_domain::SessionInboxReceipt,
244    ) -> std::io::Result<()> {
245        if !self.selected_input || self.root_actor.is_none() || self.cancel_token.is_cancelled() {
246            return Err(std::io::Error::other(
247                "Root receipt requires a live bound reservation",
248            ));
249        }
250        let persistence = self
251            .execution_persistence
252            .as_ref()
253            .ok_or_else(|| std::io::Error::other("Root execution persistence unavailable"))?;
254        self.execution_persistence = Some(persistence.bind_root_inbox_receipt(receipt)?);
255        Ok(())
256    }
257
258    /// Opt only a selected, actual Root execution into finite visible output
259    /// replay. No public request data can supply its writer or runner identity.
260    pub async fn retain_selected_run_output(&self) -> std::io::Result<bool> {
261        if !self.selected_input {
262            return Ok(false);
263        }
264        let owner = self
265            .root_actor_writer()
266            .ok_or_else(|| std::io::Error::other("Selected output requires its Root writer"))?;
267        let receipt = self
268            .execution_persistence
269            .as_ref()
270            .and_then(|p| p.root_inbox_receipt())
271            .cloned()
272            .ok_or_else(|| std::io::Error::other("Selected output requires its bound receipt"))?;
273        if owner.fence.actor_id != self.session_id
274            || owner.fence.run_id != self.run_id
275            || self.cancel_token.is_cancelled()
276        {
277            return Err(std::io::Error::other(
278                "Selected output reservation is not live",
279            ));
280        }
281        let runners = self.runners.read().await;
282        let runner = runners
283            .get(&self.session_id)
284            .filter(|r| r.run_id == self.run_id && !r.cancel_token.is_cancelled())
285            .ok_or_else(|| std::io::Error::other("Selected output runner is unavailable"))?;
286        runner
287            .visible_messages
288            .retain_selected_output(&owner, receipt)?;
289        Ok(true)
290    }
291
292    pub fn root_actor_writer(&self) -> Option<bamboo_domain::RootActorRuntimeWrite> {
293        self.root_actor
294            .as_ref()
295            .map(|owner| owner.binding.owner.clone())
296    }
297
298    /// Response frames travel with the exact reserved Root until its detached
299    /// adapter can publish them through the existing fenced event forwarder.
300    pub fn queue_root_response_event(&mut self, event: AgentEvent) -> Option<AgentEvent> {
301        let Some(owner) = self.root_actor.as_mut() else {
302            return Some(event);
303        };
304        owner.response_events.push(event);
305        None
306    }
307
308    pub fn take_root_response_events(&mut self) -> Vec<AgentEvent> {
309        self.root_actor
310            .as_mut()
311            .map(|owner| std::mem::take(&mut owner.response_events))
312            .unwrap_or_default()
313    }
314
315    /// Finish a valid adapter-owned pause only after its actual history
316    /// publication barrier. Abandonment remains the rejected-startup fallback.
317    pub async fn finish_root_actor(&mut self, outcome: bamboo_domain::ActorActivationFinish) {
318        if let Some(owner) = self.root_actor.take() {
319            (*owner).finish(outcome).await;
320        }
321    }
322
323    /// Build the handoff owned by a router activation launch.
324    ///
325    /// The value starts unpublished. Its launch closure must call
326    /// [`mark_activation_published`](Self::mark_activation_published) after the
327    /// router commits the matching zero-registration owner.
328    pub(crate) fn from_activation_placeholder(
329        session_id: impl Into<String>,
330        reservation: RunnerReservation,
331        router: Arc<SessionActivationRouter>,
332        runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
333    ) -> Self {
334        Self {
335            session_id: session_id.into(),
336            run_id: reservation.run_id,
337            cancel_token: reservation.cancel_token,
338            runners,
339            activation: SessionExecutionActivationOwnership::UnpublishedActivation(router),
340            root_actor: None,
341            physical_admission: reservation.physical_admission,
342            execution_persistence: None,
343            persistence_bound: false,
344            child_startup: None,
345            child_closure: None,
346            selected_input: false,
347            armed: true,
348        }
349    }
350
351    /// Build a manual/queued execution handoff immediately after reserving its
352    /// raw runner. If a router exists, Drop can safely wait for and adopt an
353    /// activation placeholder that observed this exact runner.
354    pub(crate) fn from_pending_registration(
355        session_id: impl Into<String>,
356        reservation: RunnerReservation,
357        router: Option<Arc<SessionActivationRouter>>,
358        runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
359    ) -> Self {
360        let activation = match router {
361            Some(router) => SessionExecutionActivationOwnership::RegistrationPending(router),
362            None => SessionExecutionActivationOwnership::Unrouted,
363        };
364        Self {
365            session_id: session_id.into(),
366            run_id: reservation.run_id,
367            cancel_token: reservation.cancel_token,
368            runners,
369            activation,
370            root_actor: None,
371            physical_admission: reservation.physical_admission,
372            execution_persistence: None,
373            persistence_bound: false,
374            child_startup: None,
375            child_closure: None,
376            selected_input: false,
377            armed: true,
378        }
379    }
380
381    /// Mark that the router has published this exact owner and invoked its
382    /// launch closure.
383    pub(crate) fn mark_activation_published(&mut self) {
384        let activation = std::mem::replace(
385            &mut self.activation,
386            SessionExecutionActivationOwnership::Unrouted,
387        );
388        self.activation = match activation {
389            SessionExecutionActivationOwnership::UnpublishedActivation(router) => {
390                SessionExecutionActivationOwnership::RegistrationPending(router)
391            }
392            other => other,
393        };
394    }
395
396    /// Roll back an external runner whose router launch was never published.
397    ///
398    /// The router reservation lease exclusively owns token release and retry
399    /// ordering here, so this path removes only the exact raw runner slot.
400    pub(crate) async fn rollback_unpublished_activation(mut self) {
401        self.armed = false;
402        self.cancel_token.cancel();
403        self.finish_root_actor(bamboo_domain::ActorActivationFinish::Cancelled)
404            .await;
405        let activation = std::mem::replace(
406            &mut self.activation,
407            SessionExecutionActivationOwnership::Unrouted,
408        );
409        debug_assert!(matches!(
410            activation,
411            SessionExecutionActivationOwnership::UnpublishedActivation(_)
412        ));
413        remove_runner_exact(&self.runners, &self.session_id, &self.run_id).await;
414    }
415
416    /// Adopt a router-published activation placeholder before any adapter-side
417    /// tool replay, workspace mutation, persistence, relay, or spawned task.
418    ///
419    /// Reservations returned by [`reserve_session_execution`] are already
420    /// registered, so calling this on the normal manual/server path is a no-op.
421    pub async fn ensure_registered(&mut self) -> Result<(), SessionRunRegistrationError> {
422        let router = match &self.activation {
423            SessionExecutionActivationOwnership::RegistrationPending(router) => router.clone(),
424            SessionExecutionActivationOwnership::UnpublishedActivation(_) => {
425                unreachable!("unpublished activation entered an execution adapter")
426            }
427            SessionExecutionActivationOwnership::Unrouted
428            | SessionExecutionActivationOwnership::Registered(_) => return Ok(()),
429        };
430        match register_reserved_activation(
431            router,
432            &self.runners,
433            &self.session_id,
434            &self.run_id,
435            self.selected_input,
436        )
437        .await
438        {
439            Ok(registration) => {
440                self.activation =
441                    SessionExecutionActivationOwnership::Registered(Box::new(registration));
442                Ok(())
443            }
444            Err(error) => {
445                // The helper already terminalized only a distinct attempted
446                // runner. Disarm Drop here as part of the shared API so every
447                // adapter preserves a same-run live owner's cancellation
448                // token, even when it returns immediately on the collision.
449                self.disarm_after_registration_rejection(error.existing_run_id());
450                Err(error)
451            }
452        }
453    }
454
455    /// Release this exact runner/router reservation before a failed startup is
456    /// reported as retryable. The cleanup owns its state in a detached task so
457    /// cancellation of this explicit wait cannot strand either ownership plane.
458    pub async fn abandon(mut self) {
459        self.armed = false;
460        self.cancel_token.cancel();
461        if let Some(owner) = self.root_actor.take() {
462            (*owner)
463                .finish(bamboo_domain::ActorActivationFinish::Cancelled)
464                .await;
465        }
466        let activation = std::mem::replace(
467            &mut self.activation,
468            SessionExecutionActivationOwnership::Unrouted,
469        );
470        let runners = self.runners.clone();
471        let session_id = self.session_id.clone();
472        let run_id = self.run_id.clone();
473        let cleanup_session_id = session_id.clone();
474        let cleanup_run_id = run_id.clone();
475        let selected_input = self.selected_input;
476        let child_startup = self.child_startup.take();
477        let child_closure = self.child_closure.take();
478        let cleanup = tokio::spawn(async move {
479            if let Some(closure) = child_closure {
480                let _ = closure.await;
481            }
482            if let Some(startup) = child_startup {
483                startup
484                    .close("startup reservation abandoned before execution")
485                    .await;
486            }
487            cleanup_execution_reservation(
488                activation,
489                runners,
490                cleanup_session_id,
491                cleanup_run_id,
492                selected_input,
493            )
494            .await;
495        });
496        if let Err(error) = cleanup.await {
497            tracing::error!(
498                %session_id,
499                %run_id,
500                %error,
501                "detached execution-reservation cleanup failed"
502            );
503        }
504    }
505
506    fn disarm_after_registration_rejection(&mut self, existing_run_id: &str) {
507        if existing_run_id != self.run_id {
508            self.cancel_token.cancel();
509        }
510        self.armed = false;
511        if let Some(startup) = self.child_startup.take() {
512            if existing_run_id != self.run_id {
513                tokio::spawn(async move {
514                    startup
515                        .close("startup registration rejected before execution")
516                        .await;
517                });
518            }
519        }
520    }
521
522    pub(crate) fn matches_execution_target(
523        &self,
524        session_id: &str,
525        domain_session_id: &str,
526        runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
527    ) -> bool {
528        self.session_id == session_id
529            && domain_session_id == session_id
530            && Arc::ptr_eq(&self.runners, runners)
531    }
532
533    pub(crate) fn disarm_for_execution(
534        &mut self,
535    ) -> (CancellationToken, Option<SessionRunRegistration>) {
536        self.armed = false;
537        self.child_startup = None;
538        let registration = match std::mem::replace(
539            &mut self.activation,
540            SessionExecutionActivationOwnership::Unrouted,
541        ) {
542            SessionExecutionActivationOwnership::Registered(registration) => Some(*registration),
543            SessionExecutionActivationOwnership::Unrouted => None,
544            SessionExecutionActivationOwnership::UnpublishedActivation(_) => {
545                unreachable!("unpublished activation cannot transfer to execution")
546            }
547            SessionExecutionActivationOwnership::RegistrationPending(_) => {
548                unreachable!("ensure_registered adopts every pending router registration")
549            }
550        };
551        (self.cancel_token.clone(), registration)
552    }
553}
554
555impl Drop for SessionExecutionReservation {
556    fn drop(&mut self) {
557        if !self.armed {
558            return;
559        }
560        self.armed = false;
561        self.cancel_token.cancel();
562        let activation = std::mem::replace(
563            &mut self.activation,
564            SessionExecutionActivationOwnership::Unrouted,
565        );
566        let runners = self.runners.clone();
567        let session_id = self.session_id.clone();
568        let run_id = self.run_id.clone();
569        let selected_input = self.selected_input;
570        let child_startup = self.child_startup.take();
571        let child_closure = self.child_closure.take();
572        if let Ok(runtime) = tokio::runtime::Handle::try_current() {
573            runtime.spawn(async move {
574                if let Some(closure) = child_closure {
575                    let _ = closure.await;
576                }
577                if let Some(startup) = child_startup {
578                    startup
579                        .close("startup reservation dropped before execution")
580                        .await;
581                }
582                cleanup_execution_reservation(
583                    activation,
584                    runners,
585                    session_id,
586                    run_id,
587                    selected_input,
588                )
589                .await;
590            });
591        }
592    }
593}
594
595struct ChildHostStartup {
596    storage: Arc<dyn bamboo_domain::Storage>,
597    expected: Session,
598    run_id: String,
599    publish: bamboo_domain::storage::RootActorRuntimePublisher,
600}
601
602impl ChildHostStartup {
603    async fn close(self, detail: &str) {
604        if let Err(error) = self
605            .storage
606            .fail_child_host_startup(&self.expected, &self.run_id, detail, self.publish)
607            .await
608        {
609            tracing::warn!(session_id = %self.expected.id, run_id = %self.run_id, %error, "Child pre-execution closure unconfirmed");
610        }
611    }
612}
613
614/// A fenced history preparation, deliberately unable to spawn an execution.
615/// Its private logical owner never installs or retires a shared runner.
616pub struct SessionPreparationReservation {
617    reservation: Option<SessionExecutionReservation>,
618}
619
620impl SessionPreparationReservation {
621    pub async fn bind_root_actor(
622        &mut self,
623        agent: &Agent,
624        session: &Session,
625    ) -> std::io::Result<()> {
626        self.reservation
627            .as_mut()
628            .expect("live preparation")
629            .bind_root_actor(agent, session)
630            .await
631    }
632
633    pub async fn bind_root_actor_compaction(
634        &mut self,
635        agent: &Agent,
636        session: &Session,
637    ) -> std::io::Result<()> {
638        self.reservation
639            .as_mut()
640            .expect("live preparation")
641            .bind_root_actor_inner(agent, session, true)
642            .await
643    }
644
645    pub fn root_actor_writer(&self) -> Option<bamboo_domain::RootActorRuntimeWrite> {
646        self.reservation
647            .as_ref()
648            .and_then(SessionExecutionReservation::root_actor_writer)
649    }
650
651    pub async fn finish(mut self, outcome: bamboo_domain::ActorActivationFinish) {
652        let reservation = self.reservation.take().expect("live preparation");
653        let _ = Self::release(reservation, outcome).await;
654    }
655
656    pub async fn abandon(self) {
657        self.finish(bamboo_domain::ActorActivationFinish::Cancelled)
658            .await;
659    }
660
661    fn release(
662        mut reservation: SessionExecutionReservation,
663        outcome: bamboo_domain::ActorActivationFinish,
664    ) -> tokio::task::JoinHandle<()> {
665        // Cancellation of the HTTP waiter cannot release the router ahead of
666        // its actual Root writer, or interrupt exact-fence cleanup midway.
667        tokio::spawn(async move {
668            reservation.finish_root_actor(outcome).await;
669            reservation.abandon().await;
670        })
671    }
672}
673
674impl Drop for SessionPreparationReservation {
675    fn drop(&mut self) {
676        if let Some(reservation) = self.reservation.take() {
677            if tokio::runtime::Handle::try_current().is_ok() {
678                drop(Self::release(
679                    reservation,
680                    bamboo_domain::ActorActivationFinish::Cancelled,
681                ));
682            }
683        }
684    }
685}
686
687/// Acquire only logical ownership for a durable preparation. A following real
688/// execution, not this operation, decides when to replace retained output.
689pub async fn reserve_session_preparation(
690    agent: &Arc<Agent>,
691    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
692    session_id: &str,
693) -> std::io::Result<SessionPreparationReservation> {
694    let router = agent.activation_router().cloned().ok_or_else(|| {
695        std::io::Error::new(
696            std::io::ErrorKind::Unsupported,
697            "Preparation requires its activation router",
698        )
699    })?;
700    let busy = || {
701        std::io::Error::new(
702            std::io::ErrorKind::WouldBlock,
703            "Session has an active runner or logical owner",
704        )
705    };
706    if runners.read().await.get(session_id).is_some_and(|runner| {
707        runner.root_reset_admission_reserved()
708            || matches!(
709                runner.status,
710                super::runner_state::AgentStatus::Pending
711                    | super::runner_state::AgentStatus::Running
712            )
713    }) {
714        return Err(busy());
715    }
716    // Restrict release to genuine producer wakeups; preparing history must
717    // not itself dispatch unrelated durable Inbox backlog.
718    let reservation = reserve_logical_session(router, runners, session_id, true)
719        .await
720        .map_err(|_| busy())?;
721    let preparation = SessionPreparationReservation {
722        reservation: Some(reservation),
723    };
724    if runners.read().await.get(session_id).is_some_and(|runner| {
725        runner.root_reset_admission_reserved()
726            || matches!(
727                runner.status,
728                super::runner_state::AgentStatus::Pending
729                    | super::runner_state::AgentStatus::Running
730            )
731    }) {
732        preparation.abandon().await;
733        return Err(busy());
734    }
735    Ok(preparation)
736}
737
738async fn reserve_logical_session(
739    router: Arc<SessionActivationRouter>,
740    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
741    session_id: &str,
742    selected_input: bool,
743) -> Result<SessionExecutionReservation, SessionRunRegistrationError> {
744    let mut reservation = SessionExecutionReservation {
745        session_id: session_id.to_owned(),
746        run_id: uuid::Uuid::new_v4().to_string(),
747        cancel_token: CancellationToken::new(),
748        runners: runners.clone(),
749        activation: SessionExecutionActivationOwnership::Unrouted,
750        root_actor: None,
751        physical_admission: None,
752        execution_persistence: None,
753        persistence_bound: false,
754        child_startup: None,
755        child_closure: None,
756        selected_input: false,
757        armed: true,
758    };
759    let registration = register_reserved_activation(
760        router,
761        runners,
762        session_id,
763        &reservation.run_id,
764        selected_input,
765    )
766    .await?;
767    reservation.activation =
768        SessionExecutionActivationOwnership::Registered(Box::new(registration));
769    Ok(reservation)
770}
771
772/// Combined runner/router reservation result.
773pub enum SessionExecutionReserveOutcome {
774    Reserved(SessionExecutionReservation),
775    AlreadyRunning { run_id: String },
776}
777
778async fn register_reserved_activation(
779    router: Arc<SessionActivationRouter>,
780    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
781    session_id: &str,
782    run_id: &str,
783    selected_input: bool,
784) -> Result<SessionRunRegistration, SessionRunRegistrationError> {
785    let mut registration = match router.register_run(session_id, run_id).await {
786        Ok(registration) => registration,
787        Err(error) => {
788            let collision = Err(AgentError::LLM(error.to_string()));
789            finalize_rejected_runner_if_distinct(
790                runners,
791                session_id,
792                error.existing_run_id(),
793                run_id,
794                &collision,
795            )
796            .await;
797            return Err(error);
798        }
799    };
800    if selected_input {
801        registration.restrict_to_selected_input();
802    }
803    let cleanup_runners = runners.clone();
804    let cleanup_session_id = session_id.to_string();
805    let cleanup_run_id = run_id.to_string();
806    registration.set_abort_cleanup(move || async move {
807        let abandoned = Err(AgentError::Cancelled);
808        finalize_runner_exact(
809            &cleanup_runners,
810            &cleanup_session_id,
811            &cleanup_run_id,
812            &abandoned,
813        )
814        .await;
815    });
816    Ok(registration)
817}
818
819async fn cleanup_execution_reservation(
820    activation: SessionExecutionActivationOwnership,
821    runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
822    session_id: String,
823    run_id: String,
824    selected_input: bool,
825) {
826    match activation {
827        SessionExecutionActivationOwnership::Registered(registration) => {
828            (*registration).abandon().await;
829        }
830        SessionExecutionActivationOwnership::RegistrationPending(router) => {
831            match register_reserved_activation(
832                router,
833                &runners,
834                &session_id,
835                &run_id,
836                selected_input,
837            )
838            .await
839            {
840                Ok(registration) => registration.abandon().await,
841                Err(error) => {
842                    tracing::debug!(
843                        %session_id,
844                        %run_id,
845                        existing_run_id = %error.existing_run_id(),
846                        "abandoned activation placeholder was already adopted or superseded"
847                    );
848                }
849            }
850        }
851        SessionExecutionActivationOwnership::UnpublishedActivation(_) => {
852            remove_runner_exact(&runners, &session_id, &run_id).await;
853        }
854        SessionExecutionActivationOwnership::Unrouted => {
855            let abandoned = Err(AgentError::Cancelled);
856            finalize_runner_exact(&runners, &session_id, &run_id, &abandoned).await;
857        }
858    }
859}
860
861async fn remove_runner_exact(
862    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
863    session_id: &str,
864    run_id: &str,
865) -> bool {
866    let mut runners = runners.write().await;
867    if runners
868        .get(session_id)
869        .is_some_and(|runner| runner.run_id == run_id)
870    {
871        super::runner_lifecycle::remove_runner_entry(&mut runners, session_id).await;
872        true
873    } else {
874        false
875    }
876}
877
878/// Reserve the shared runner slot and logical-session router as one handoff.
879///
880/// The raw runner reservation remains the common low-level primitive used by
881/// the router's own two-phase activation protocol. Every manual/server entry
882/// point uses this wrapper so an SDK owner or another entry surface collides
883/// before approved tools, workspace state, persistence, relays, or a
884/// background execution task can run.
885pub async fn reserve_session_execution(
886    agent: &Arc<Agent>,
887    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
888    senders: &Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
889    session_id: &str,
890    event_sender: &broadcast::Sender<AgentEvent>,
891) -> SessionExecutionReserveOutcome {
892    reserve_session_execution_inner(agent, runners, senders, session_id, event_sender, false).await
893}
894
895/// Selected Native input activation uses the same reservation protocol, while
896/// its release only inherits explicitly requested producer wakeups.
897pub async fn reserve_selected_input_execution(
898    agent: &Arc<Agent>,
899    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
900    senders: &Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
901    session_id: &str,
902    event_sender: &broadcast::Sender<AgentEvent>,
903) -> SessionExecutionReserveOutcome {
904    reserve_session_execution_inner(agent, runners, senders, session_id, event_sender, true).await
905}
906
907async fn reserve_session_execution_inner(
908    agent: &Arc<Agent>,
909    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
910    senders: &Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
911    session_id: &str,
912    event_sender: &broadcast::Sender<AgentEvent>,
913    selected_input: bool,
914) -> SessionExecutionReserveOutcome {
915    if let Some(router) = agent.activation_router().cloned() {
916        if let Some(run_id) = runners
917            .read()
918            .await
919            .get(session_id)
920            .filter(|runner| {
921                runner.root_reset_admission_reserved()
922                    || matches!(runner.status, super::runner_state::AgentStatus::Running)
923            })
924            .map(|runner| runner.run_id.clone())
925        {
926            return SessionExecutionReserveOutcome::AlreadyRunning { run_id };
927        }
928        // Collide before touching the retained predecessor. This also excludes
929        // real Execute/respond while a history-only preparation owns the Root.
930        let mut reservation =
931            match reserve_logical_session(router, runners, session_id, selected_input).await {
932                Ok(reservation) => reservation,
933                Err(error) => {
934                    return SessionExecutionReserveOutcome::AlreadyRunning {
935                        run_id: error.existing_run_id().to_owned(),
936                    }
937                }
938            };
939        let identity = RunnerReservation {
940            run_id: reservation.run_id.clone(),
941            cancel_token: reservation.cancel_token.clone(),
942            physical_admission: None,
943        };
944        match reserve_runner_for_registration(runners, senders, session_id, event_sender, identity)
945            .await
946        {
947            ReserveOutcome::Reserved(raw) => {
948                reservation.physical_admission = raw.physical_admission;
949                reservation.selected_input = selected_input;
950                return SessionExecutionReserveOutcome::Reserved(reservation);
951            }
952            ReserveOutcome::AlreadyRunning(run_id) => {
953                reservation.abandon().await;
954                return SessionExecutionReserveOutcome::AlreadyRunning { run_id };
955            }
956        }
957    }
958
959    let reservation = match reserve_runner_core(runners, senders, session_id, event_sender).await {
960        ReserveOutcome::AlreadyRunning(run_id) => {
961            return SessionExecutionReserveOutcome::AlreadyRunning { run_id };
962        }
963        ReserveOutcome::Reserved(reservation) => reservation,
964    };
965
966    let execution_reservation = SessionExecutionReservation {
967        session_id: session_id.to_string(),
968        run_id: reservation.run_id,
969        cancel_token: reservation.cancel_token,
970        runners: runners.clone(),
971        activation: SessionExecutionActivationOwnership::Unrouted,
972        root_actor: None,
973        physical_admission: reservation.physical_admission,
974        execution_persistence: None,
975        persistence_bound: false,
976        child_startup: None,
977        child_closure: None,
978        selected_input,
979        armed: true,
980    };
981    SessionExecutionReserveOutcome::Reserved(execution_reservation)
982}
983
984/// Read a consistent session snapshot without acquiring a session or index
985/// lock. Returns `None` on a cache miss.
986///
987/// This is the single canonical cache-read used everywhere a caller holds a
988/// `SessionCache` (HTTP handlers, server tools, the app-state loader). It
989/// replaced ~13 verbatim copies of the
990/// `cache.get(id).map(|e| e.value().clone()).map(|a| a.read().clone())` idiom.
991pub fn read_cached_session(cache: &SessionCache, id: &str) -> Option<bamboo_agent_core::Session> {
992    cache
993        .get(id)
994        .map(|e| e.value().clone())
995        .map(|a| a.read().clone())
996}
997
998const SKILL_CONTEXT_START_MARKER: &str = "<!-- BAMBOO_SKILL_CONTEXT_START -->";
999const TOOL_GUIDE_START_MARKER: &str = "<!-- BAMBOO_TOOL_GUIDE_START -->";
1000const EXTERNAL_MEMORY_START_MARKER: &str = "<!-- BAMBOO_EXTERNAL_MEMORY_START -->";
1001const TASK_LIST_START_MARKER: &str = "<!-- BAMBOO_TASK_LIST_START -->";
1002
1003/// Outcome of an agent execution, handed to an optional
1004/// [`SessionCompletionHook`].
1005///
1006/// Deliberately decoupled from the runtime's internal error type so the hook
1007/// API stays stable across crates and callers don't need to match on engine
1008/// error variants.
1009pub struct SessionExecutionOutcome {
1010    /// The run finished without error.
1011    pub success: bool,
1012    /// The run ended because it was cancelled (a non-success subset).
1013    pub cancelled: bool,
1014    /// Stringified error, present when `!success`.
1015    pub error: Option<String>,
1016}
1017
1018impl SessionExecutionOutcome {
1019    fn from_result(result: &Result<(), AgentError>) -> Self {
1020        match result {
1021            Ok(()) => Self {
1022                success: true,
1023                cancelled: false,
1024                error: None,
1025            },
1026            Err(error) => Self {
1027                success: false,
1028                cancelled: error.is_cancelled(),
1029                error: Some(error.to_string()),
1030            },
1031        }
1032    }
1033}
1034
1035/// Optional post-execution hook for [`spawn_session_execution`].
1036///
1037/// Invoked after the runner is finalized but **before** the session is
1038/// persisted, so a caller can record bespoke terminal bookkeeping (e.g. a
1039/// scheduled-run status) and/or append a closing message that is then saved
1040/// with the session. Receives the execution outcome plus a mutable handle to
1041/// the session. This is how callers with extra finalization (the schedule
1042/// manager) route through the single canonical execution path instead of
1043/// forking their own spawn + `execute` + persist sequence.
1044pub type SessionCompletionHook = Box<
1045    dyn for<'a> FnOnce(
1046            SessionExecutionOutcome,
1047            &'a mut Session,
1048        ) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>>
1049        + Send,
1050>;
1051
1052/// Arguments for spawning a background agent execution.
1053///
1054/// This is the crate-agnostic equivalent of the server's `SpawnAgentExecution`.
1055/// It holds everything needed to run the agent loop and persist the result,
1056/// without depending on HTTP types or `AppState`.
1057pub struct SessionExecutionArgs {
1058    // Core execution.
1059    pub agent: Arc<Agent>,
1060    pub session_id: String,
1061    pub session: Session,
1062    /// Exact shared runner/router ownership acquired before caller-side
1063    /// execution mutations.
1064    pub execution_reservation: SessionExecutionReservation,
1065
1066    // Execution parameters.
1067    pub tools_override: Option<Arc<dyn ToolExecutor>>,
1068    pub provider_override: Option<Arc<dyn LLMProvider>>,
1069    /// Cohesive primary + auxiliary model/provider selection. The primary
1070    /// `model` is required for a spawn (see [`ModelRoster::model`]); the three
1071    /// auxiliary roles default to their `Config::get_*` fallbacks when `None`.
1072    pub model_roster: ModelRoster,
1073    pub reasoning_effort: Option<ReasoningEffort>,
1074    pub reasoning_effort_source: String,
1075    pub auxiliary_model_resolver:
1076        Option<Arc<dyn Fn() -> crate::runtime::config::AuxiliaryModelConfig + Send + Sync>>,
1077    /// Optional per-round live resolver for the disabled tool/skill sets (#136).
1078    /// When `None` the per-run snapshot is used (sub-agent spawns pass `None`, so
1079    /// short-lived children keep the spawn-time snapshot — by design).
1080    pub disabled_filter_resolver: Option<DisabledFilterResolver>,
1081    pub disabled_tools: Option<BTreeSet<String>>,
1082    pub disabled_skill_ids: Option<BTreeSet<String>>,
1083    pub selected_skill_ids: Option<Vec<String>>,
1084    pub selected_skill_mode: Option<String>,
1085    pub mpsc_tx: mpsc::Sender<AgentEvent>,
1086    /// Acknowledges that the forwarder actually published the terminal durable
1087    /// history barrier before this runner becomes replaceable.
1088    pub history_commit_barrier: HistoryCommitBarrier,
1089    pub image_fallback: Option<ImageFallbackConfig>,
1090    pub gold_config: Option<GoldConfig>,
1091    /// Optional guardian adversarial-review gate configuration.
1092    pub guardian_config: Option<GuardianConfig>,
1093    /// Late-bound guardian reviewer spawner (server-provided; the runner cannot
1094    /// construct a child directly).
1095    pub guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
1096    /// Late-bound bash self-resume hook (issue #84 Phase 2b).
1097    pub bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
1098    /// Late-bound bash completion sink (issue #84 Phase 2b follow-up).
1099    pub bash_completion_sink: Option<Arc<dyn BashCompletionSink>>,
1100    pub app_data_dir: Option<std::path::PathBuf>,
1101    /// Per-run resource guardrail override (issue #221). `None` uses the
1102    /// config-level `Config::run_budget` default unmodified.
1103    pub run_budget: Option<bamboo_config::RunBudgetConfig>,
1104
1105    // Post-execution resources.
1106    pub runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
1107    pub sessions_cache: SessionCache,
1108
1109    /// Optional bespoke finalization, run after the runner is finalized and
1110    /// before the session is persisted. See [`SessionCompletionHook`].
1111    pub on_complete: Option<SessionCompletionHook>,
1112
1113    /// Child-completion publisher for CHILD sessions driven through this path
1114    /// (issue #546). The canonical first run of a child goes through
1115    /// `run_child_spawn`, which publishes its own terminal completion — but a
1116    /// child RESUMED later (after an approval, a clarification answer, or a
1117    /// nested child-parent woken by its own children) runs through
1118    /// `spawn_session_execution` and previously published nothing, so the
1119    /// waiting parent was never woken. When set and `session.kind == Child`
1120    /// with a parent id, the terminal block invokes this handler after the
1121    /// final persist + runner finalization. Non-terminal suspends publish the
1122    /// non-terminal "suspended" status, which the coordinator's terminality
1123    /// guard ignores.
1124    pub child_completion_handler: Option<Arc<dyn super::ChildCompletionHandler>>,
1125}
1126
1127/// The per-request parameter subset of [`SessionExecutionArgs`] — everything
1128/// that maps onto an [`ExecuteRequest`], minus the three required positional
1129/// fields (`initial_message`, `event_tx`, `cancel_token`) and the post-execution
1130/// resources (runners, sessions cache, completion hook).
1131///
1132/// Grouping these here lets [`build_execute_request`] perform the
1133/// `SessionExecutionArgs` → [`ExecuteRequest`] mapping through the canonical
1134/// [`ExecuteRequestBuilder`] in one place, instead of a hand-written struct
1135/// literal that must be kept field-aligned with [`ExecuteRequest`] by hand.
1136struct ExecuteRequestParams {
1137    tools: Option<Arc<dyn ToolExecutor>>,
1138    provider_override: Option<Arc<dyn LLMProvider>>,
1139    model_roster: ModelRoster,
1140    reasoning_effort: Option<ReasoningEffort>,
1141    auxiliary_model_resolver: Option<Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync>>,
1142    disabled_filter_resolver: Option<DisabledFilterResolver>,
1143    disabled_tools: Option<BTreeSet<String>>,
1144    disabled_skill_ids: Option<BTreeSet<String>>,
1145    selected_skill_ids: Option<Vec<String>>,
1146    selected_skill_mode: Option<String>,
1147    image_fallback: Option<ImageFallbackConfig>,
1148    gold_config: Option<GoldConfig>,
1149    guardian_config: Option<GuardianConfig>,
1150    guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
1151    bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
1152    bash_completion_sink: Option<Arc<dyn BashCompletionSink>>,
1153    app_data_dir: Option<std::path::PathBuf>,
1154    run_budget: Option<bamboo_config::RunBudgetConfig>,
1155}
1156
1157/// Assemble an [`ExecuteRequest`] from the resolved spawn parameters via the
1158/// canonical [`ExecuteRequestBuilder`].
1159///
1160/// Centralizing this mapping keeps every optional field threaded with exactly
1161/// the same value the old struct literal carried (the builder defaults each
1162/// unset field to `None`), while removing the field-by-field duplication.
1163fn build_execute_request(
1164    initial_message: String,
1165    event_tx: mpsc::Sender<AgentEvent>,
1166    cancel_token: CancellationToken,
1167    params: ExecuteRequestParams,
1168) -> ExecuteRequest {
1169    let ExecuteRequestParams {
1170        tools,
1171        provider_override,
1172        model_roster,
1173        reasoning_effort,
1174        auxiliary_model_resolver,
1175        disabled_filter_resolver,
1176        disabled_tools,
1177        disabled_skill_ids,
1178        selected_skill_ids,
1179        selected_skill_mode,
1180        image_fallback,
1181        gold_config,
1182        guardian_config,
1183        guardian_spawner,
1184        bash_resume_hook,
1185        bash_completion_sink,
1186        app_data_dir,
1187        run_budget,
1188    } = params;
1189
1190    let mut builder = ExecuteRequestBuilder::new(initial_message, event_tx, cancel_token)
1191        .model_roster(model_roster)
1192        .gold_config(gold_config)
1193        .guardian_config(guardian_config)
1194        .guardian_spawner(guardian_spawner)
1195        .bash_resume_hook(bash_resume_hook)
1196        .bash_completion_sink(bash_completion_sink);
1197
1198    if let Some(run_budget) = run_budget {
1199        builder = builder.run_budget(run_budget);
1200    }
1201
1202    if let Some(tools) = tools {
1203        builder = builder.tools(tools);
1204    }
1205    if let Some(provider_override) = provider_override {
1206        builder = builder.provider_override(provider_override);
1207    }
1208    if let Some(reasoning_effort) = reasoning_effort {
1209        builder = builder.reasoning_effort(reasoning_effort);
1210    }
1211    if let Some(disabled_filter_resolver) = disabled_filter_resolver {
1212        builder = builder.disabled_filter_resolver(disabled_filter_resolver);
1213    }
1214    if let Some(auxiliary_model_resolver) = auxiliary_model_resolver {
1215        builder = builder.auxiliary_model_resolver(auxiliary_model_resolver);
1216    }
1217    if let Some(disabled_tools) = disabled_tools {
1218        builder = builder.disabled_tools(disabled_tools);
1219    }
1220    if let Some(disabled_skill_ids) = disabled_skill_ids {
1221        builder = builder.disabled_skill_ids(disabled_skill_ids);
1222    }
1223    if let Some(selected_skill_ids) = selected_skill_ids {
1224        builder = builder.selected_skill_ids(selected_skill_ids);
1225    }
1226    if let Some(selected_skill_mode) = selected_skill_mode {
1227        builder = builder.selected_skill_mode(selected_skill_mode);
1228    }
1229    if let Some(image_fallback) = image_fallback {
1230        builder = builder.image_fallback(image_fallback);
1231    }
1232    if let Some(app_data_dir) = app_data_dir {
1233        builder = builder.app_data_dir(app_data_dir);
1234    }
1235
1236    builder.build()
1237}
1238
1239/// Spawn a background agent execution task.
1240///
1241/// This function spawns a tokio task that:
1242/// 1. Executes the agent loop via `agent.execute()`
1243/// 2. Sends a terminal error event if the execution fails
1244/// 3. Finalizes the runner status
1245/// 4. Persists the session via merge-save (preserves concurrent UI title/pin edits)
1246/// 5. Updates the in-memory session cache
1247pub fn spawn_session_execution(args: SessionExecutionArgs) {
1248    spawn_session_execution_with_inputs(args, None);
1249}
1250
1251/// Forward only explicitly supplied caller data through the existing spawned
1252/// body. The reservation and public argument layout remain unchanged.
1253pub fn spawn_session_execution_with_inputs(
1254    args: SessionExecutionArgs,
1255    inputs: Option<crate::runtime::config::UntrustedExecutionInputs>,
1256) {
1257    spawn_session_execution_with_upstream_retry(args, inputs, Default::default());
1258}
1259
1260/// An explicit per-execution initial-request retry option. Existing admission,
1261/// reservation, cancellation, messages and completion ownership are unchanged.
1262pub fn spawn_session_execution_with_upstream_retry(
1263    args: SessionExecutionArgs,
1264    inputs: Option<crate::runtime::config::UntrustedExecutionInputs>,
1265    upstream_retry: bamboo_llm::retry::UpstreamRetry,
1266) {
1267    let span_session_id = args.session_id.clone();
1268    let session_span = tracing::info_span!("agent_execution", session_id = %span_session_id);
1269    let is_root = args.session.kind == bamboo_domain::SessionKind::Root;
1270    let outer = if is_root {
1271        match args
1272            .execution_reservation
1273            .physical_admission
1274            .as_ref()
1275            .map(|admission| admission.attach(&args.session))
1276            .transpose()
1277        {
1278            Ok(outer) => outer,
1279            Err(error) => {
1280                // This new run cannot pass an obsolete/duplicate reset handoff.
1281                // Abandon only its exact existing reservation; no other work.
1282                tokio::spawn(async move {
1283                    let _ = args
1284                        .mpsc_tx
1285                        .send(AgentEvent::Error {
1286                            message: error.to_string(),
1287                        })
1288                        .await;
1289                    args.execution_reservation.abandon().await;
1290                });
1291                return;
1292            }
1293        }
1294    } else {
1295        None
1296    };
1297    let task_outer = outer.clone();
1298
1299    let task = tokio::spawn(
1300        async move {
1301            let SessionExecutionArgs {
1302                agent,
1303                session_id,
1304                mut session,
1305                mut execution_reservation,
1306                tools_override,
1307                provider_override,
1308                model_roster,
1309                reasoning_effort,
1310                reasoning_effort_source,
1311                auxiliary_model_resolver,
1312                disabled_filter_resolver,
1313                disabled_tools,
1314                disabled_skill_ids,
1315                selected_skill_ids,
1316                selected_skill_mode,
1317                mpsc_tx,
1318                mut history_commit_barrier,
1319                image_fallback,
1320                gold_config,
1321                guardian_config,
1322                guardian_spawner,
1323                bash_resume_hook,
1324                bash_completion_sink,
1325                app_data_dir,
1326                run_budget,
1327                runners,
1328                sessions_cache,
1329                on_complete,
1330                child_completion_handler,
1331            } = args;
1332
1333            if !execution_reservation.matches_execution_target(&session_id, &session.id, &runners) {
1334                tracing::error!(
1335                    %session_id,
1336                    domain_session_id = %session.id,
1337                    reservation_session_id = %execution_reservation.session_id(),
1338                    run_id = %execution_reservation.run_id(),
1339                    same_runner_registry = Arc::ptr_eq(&execution_reservation.runners, &runners),
1340                    "refusing mismatched session execution reservation"
1341                );
1342                execution_reservation.abandon().await;
1343                return;
1344            }
1345            if let Err(error) = execution_reservation.ensure_registered().await {
1346                tracing::warn!(
1347                    %session_id,
1348                    run_id = %execution_reservation.run_id(),
1349                    %error,
1350                    "session execution could not adopt its router activation owner"
1351                );
1352                return;
1353            }
1354            if execution_reservation.root_actor.is_none()
1355                && agent.persistence().root_actor_execution_required(&session)
1356            {
1357                let message =
1358                    "Root execution route requires a bound Actor writer and event handoff";
1359                tracing::warn!(%session_id, "{message}");
1360                let _ = mpsc_tx
1361                    .send(AgentEvent::Error {
1362                        message: message.into(),
1363                    })
1364                    .await;
1365                execution_reservation.abandon().await;
1366                return;
1367            }
1368            // Bind before disarming: every pre-spawn and runtime checkpoint
1369            // uses one private view, and binding failure abandons this runner.
1370            if let Err(error) = execution_reservation.bind_execution_persistence(&agent, &session) {
1371                let _ = mpsc_tx
1372                    .send(AgentEvent::Error {
1373                        message: error.to_string(),
1374                    })
1375                    .await;
1376                execution_reservation.abandon().await;
1377                return;
1378            }
1379            let ownership = match &execution_reservation.activation {
1380                SessionExecutionActivationOwnership::Registered(run) => Some(run.ownership()),
1381                _ => None,
1382            };
1383            let admission_persistence = execution_reservation
1384                .execution_persistence()
1385                .unwrap_or_else(|| agent.persistence().clone());
1386            if let (Some(router), Some(ownership)) = (agent.activation_router(), ownership.as_ref())
1387            {
1388                if !router.bind_run_lifetime(ownership, &session).await {
1389                    let _ = mpsc_tx
1390                        .send(AgentEvent::Error {
1391                            message: "registered run Session lifetime differs".into(),
1392                        })
1393                        .await;
1394                    execution_reservation.abandon().await;
1395                    return;
1396                }
1397            }
1398            if let Err(error) = crate::runtime::counted_loop::admit_registered(
1399                &mut session,
1400                ownership.as_ref(),
1401                agent.activation_router(),
1402                admission_persistence.as_ref(),
1403            )
1404            .await
1405            {
1406                let _ = mpsc_tx
1407                    .send(AgentEvent::Error {
1408                        message: error.to_string(),
1409                    })
1410                    .await;
1411                execution_reservation.abandon().await;
1412                return;
1413            }
1414            let mut root_actor = execution_reservation.root_actor.take().map(|owner| *owner);
1415            let root_actor_bound = root_actor.is_some();
1416            let persistence = execution_reservation
1417                .execution_persistence()
1418                .unwrap_or_else(|| agent.persistence().clone());
1419            let agent = Arc::new(agent.with_execution_persistence(persistence));
1420            let activation_run_id = execution_reservation.run_id().to_string();
1421            if session.kind == bamboo_agent_core::SessionKind::Child {
1422                // Non-HTTP resumed Child entrypoints need the same retained
1423                // startup closure. Their existing mpsc forwarder owns errors;
1424                // the guarded callback only publishes the exact cache state.
1425                if let Err(error) = execution_reservation
1426                    .arm_child_host_startup_inner(&agent, &session, sessions_cache.clone(), None)
1427                    .await
1428                {
1429                    let _ = mpsc_tx
1430                        .send(AgentEvent::Error {
1431                            message: error.to_string(),
1432                        })
1433                        .await;
1434                    execution_reservation.abandon().await;
1435                    return;
1436                }
1437                if let Err(error) = agent
1438                    .storage()
1439                    .begin_host_run(&session, &activation_run_id)
1440                    .await
1441                {
1442                    let _ = mpsc_tx
1443                        .send(AgentEvent::Error {
1444                            message: error.to_string(),
1445                        })
1446                        .await;
1447                    execution_reservation
1448                        .fail_child_host_startup(&error.to_string())
1449                        .await;
1450                    execution_reservation.abandon().await;
1451                    return;
1452                }
1453                session.set_host_run_id(&activation_run_id);
1454                session.set_last_run_status("running");
1455            }
1456            let prior_message_ids = session.messages.iter().map(|m| m.id.clone()).collect();
1457            let (cancel_token, mut activation_registration) =
1458                execution_reservation.disarm_for_execution();
1459
1460            // The primary model is required for a spawn; the roster stores it as
1461            // `Option<String>` for uniformity, so recover the owned String here
1462            // for session attribution / logging (same value the caller set).
1463            let model = model_roster.model.clone().unwrap_or_default();
1464
1465            let initial_message = initial_user_message_for_session(&session);
1466            let selected_skill_ids =
1467                selected_skill_ids.or_else(|| selected_skill_ids_for_session(&session));
1468            let selected_skill_mode =
1469                selected_skill_mode.or_else(|| selected_skill_mode_for_session(&session));
1470
1471            tracing::info!(
1472                "[{}] Using resolved session model: {}, reasoning_effort={}, reasoning_source={}",
1473                session_id,
1474                model,
1475                reasoning_effort
1476                    .map(ReasoningEffort::as_str)
1477                    .unwrap_or("none"),
1478                reasoning_effort_source
1479            );
1480
1481            // Set the resolved model via the single authoritative pre-execution
1482            // mutation point. The caller already placed the system prompt on the
1483            // session, so pass `None` for `system_prompt` (the subsequent
1484            // `system_prompt_for_session` read below sees the caller's message).
1485            // This must run before that read / logging so the observable
1486            // sequence (model set, then prompt snapshot) is identical.
1487            crate::session_app::execution_prep::prepare_session_for_execution(
1488                &mut session,
1489                None,
1490                Some(&model),
1491            );
1492
1493            let system_prompt = system_prompt_for_debug_log(&mut session);
1494            if let Some(prompt) = system_prompt.as_ref() {
1495                log_base_system_prompt_snapshot(&session_id, prompt);
1496            }
1497
1498            // A carried untagged wait has already been persisted. Unlike a
1499            // newly armed safety-net wait, it must not resurrect a completed
1500            // child wait. Tagged tool waits are reconciled by the final writer.
1501            let inherited_child_wait = agent.persistence().inherited_child_wait();
1502            let execute_request = build_execute_request(
1503                initial_message,
1504                mpsc_tx.clone(),
1505                cancel_token,
1506                ExecuteRequestParams {
1507                    tools: tools_override,
1508                    provider_override,
1509                    model_roster,
1510                    reasoning_effort,
1511                    auxiliary_model_resolver,
1512                    disabled_filter_resolver,
1513                    disabled_tools,
1514                    disabled_skill_ids,
1515                    selected_skill_ids,
1516                    selected_skill_mode,
1517                    image_fallback,
1518                    gold_config,
1519                    guardian_config,
1520                    guardian_spawner,
1521                    bash_resume_hook,
1522                    bash_completion_sink,
1523                    app_data_dir,
1524                    run_budget,
1525                },
1526            );
1527
1528            // Panic containment (issue #546): everything below — the terminal
1529            // status persist, finalize_runner, and the child-completion
1530            // publish — only runs if this task survives execution. An
1531            // unwinding panic would leave a zombie Running runner entry (which
1532            // blinds liveness checks) and, for a child session, strand its
1533            // waiting parent. Map a panic to a terminal error instead.
1534            let result = {
1535                use futures::FutureExt;
1536                match std::panic::AssertUnwindSafe(agent.execute_with_inputs_and_upstream_retry(
1537                    &mut session,
1538                    execute_request,
1539                    inputs,
1540                    upstream_retry,
1541                ))
1542                .catch_unwind()
1543                .await
1544                {
1545                    Ok(result) => result,
1546                    Err(panic) => {
1547                        if let Some(outer) = &task_outer {
1548                            outer
1549                                .unconfirmed("Root loop unwound; physical effects are unconfirmed");
1550                        }
1551                        let message = panic
1552                            .downcast_ref::<&str>()
1553                            .map(|s| (*s).to_string())
1554                            .or_else(|| panic.downcast_ref::<String>().cloned())
1555                            .unwrap_or_else(|| "non-string panic payload".to_string());
1556                        tracing::error!(
1557                            "[{}] agent execution panicked; finalizing as terminal error: {}",
1558                            session_id,
1559                            message
1560                        );
1561                        Err(AgentError::LLM(format!(
1562                            "agent execution panicked: {message}"
1563                        )))
1564                    }
1565                }
1566            };
1567
1568            // Send terminal event for all error cases (including cancellation).
1569            if let Some(error_event) = terminal_error_event_for_result(&result) {
1570                let _ = mpsc_tx.send(error_event).await;
1571            }
1572
1573            // Record the terminal run status on the session BEFORE persisting so
1574            // the session summary reports a real `last_run_status`. Top-level
1575            // sessions otherwise never set it, so summaries show
1576            // `last_run_status: null`; the frontend then cannot confirm the run
1577            // finished and falls back on a ~5s optimistic-settle window, leaving
1578            // a phantom "thinking" indicator after the reply is already done
1579            // (notably on a session's first turn). Same Ok/cancelled/error
1580            // mapping `status_from_execution_result` applies to the runner.
1581            //
1582            // A suspended run also returns `Ok(())` but is NOT terminal: it
1583            // stamped `runtime.suspend_reason` (awaiting_clarification /
1584            // waiting_for_children / waiting_for_bash / awaiting_parent_approval)
1585            // and will resume later (which removes the reason — see respond.rs /
1586            // child_completion_coordinator). Mark it "suspended" rather than
1587            // "completed" so a session waiting on the user or on children isn't
1588            // reported as finished (mirrors the child path in `sdk::spawn`).
1589            let suspended_non_terminal = result.is_ok() && session.has_runtime_wait();
1590            match &result {
1591                Ok(()) if suspended_non_terminal => {
1592                    session.set_last_run_status("suspended");
1593                    session.clear_last_run_error();
1594                }
1595                Ok(()) => {
1596                    session.set_last_run_status("completed");
1597                    session.clear_last_run_error();
1598                }
1599                Err(error) if error.is_cancelled() => {
1600                    session.set_last_run_status("cancelled");
1601                    session.set_last_run_error(error.to_string());
1602                }
1603                Err(error) => {
1604                    session.set_last_run_status("error");
1605                    session.set_last_run_error(error.to_string());
1606                }
1607            }
1608
1609            session.synchronize_terminal_runtime_status();
1610
1611            // Bespoke terminal bookkeeping (e.g. a scheduled-run status) runs
1612            // here — before persistence — so any closing message the hook
1613            // appends is saved with the session below.
1614            if let Some(on_complete) = on_complete {
1615                on_complete(SessionExecutionOutcome::from_result(&result), &mut session).await;
1616            }
1617
1618            // Freeze the generation actually consumed by provider reasoning.
1619            // Terminal compatibility migration below may enqueue newer work,
1620            // but must never make that work look executed.
1621            let executed_admitted_generation = session
1622                .session_inbox_admission()
1623                .map_or(0, |state| state.last_admitted_sequence);
1624            let legacy_migration =
1625                crate::runtime::runner::state_bridge::migrate_legacy_pending_only(
1626                    &mut session,
1627                    Some(agent.storage()),
1628                    Some(agent.persistence()),
1629                    agent.session_inbox(),
1630                )
1631                .await;
1632            let pending_boundary_generation = session
1633                .session_inbox_admission()
1634                .and_then(|state| state.pending_activation_generation());
1635            let pending_generation = match (
1636                pending_boundary_generation,
1637                legacy_migration.highest_generation,
1638            ) {
1639                (Some(left), Some(right)) => Some(left.max(right)),
1640                (left, right) => left.or(right),
1641            };
1642            if let (Some(generation), Some(router)) =
1643                (pending_generation, agent.activation_router())
1644            {
1645                let activation_ready = if let Some(inbox) = agent.session_inbox() {
1646                    match inbox
1647                        .mark_activation_eligible(
1648                            &session_id,
1649                            generation,
1650                            bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
1651                        )
1652                        .await
1653                    {
1654                        Ok(()) => true,
1655                        Err(error) => {
1656                            tracing::error!(
1657                                session_id = %session_id,
1658                                %error,
1659                                "failed to persist unadmitted SessionInbox activation watermark"
1660                            );
1661                            false
1662                        }
1663                    }
1664                } else {
1665                    false
1666                };
1667                if activation_ready {
1668                    if let Err(error) = bamboo_domain::SessionActivationPort::request_activation(
1669                        router.as_ref(),
1670                        &session_id,
1671                        generation,
1672                    )
1673                    .await
1674                    {
1675                        tracing::error!(
1676                            session_id = %session_id,
1677                            %error,
1678                            "failed to hand unadmitted SessionInbox generation to activation router"
1679                        );
1680                    }
1681                }
1682            }
1683
1684            // Close the save→terminal race before the final persistence write:
1685            // a delivery from this point onward is coalesced for a successor
1686            // rather than being "notified" to a loop that has already exited.
1687            if let Some(registration) = activation_registration.as_mut() {
1688                registration.begin_finalization().await;
1689            }
1690
1691            // Save session via merge-save so any concurrent UI edits to
1692            // title / title_generated / pinned / title_version are preserved (the runtime is not
1693            // an authoritative title writer).
1694            super::child_completion::ChildCompletionSource::prepare(
1695                &mut session,
1696                &activation_run_id,
1697                &prior_message_ids,
1698            );
1699            let saved = save_finalized_runtime_with_inherited_child_wait(
1700                agent.persistence().as_ref(),
1701                &mut session,
1702                inherited_child_wait
1703                    .as_ref()
1704                    .map(|inherited| inherited.wait()),
1705            )
1706            .await;
1707            let history_committed = saved.is_ok();
1708            let completion_source =
1709                super::child_completion::ChildCompletionSource::after_final_save(
1710                    &session,
1711                    history_committed,
1712                );
1713            let completed_at = chrono::Utc::now();
1714            if let Err(error) = &saved {
1715                if let Some(outer) = &task_outer {
1716                    outer.unconfirmed(format!(
1717                        "Root final persistence outcome unconfirmed: {error}"
1718                    ));
1719                }
1720                tracing::warn!("[{}] Failed to save session: {}", session_id, error);
1721            }
1722
1723            // `Complete` intentionally closes the low-latency token stream as
1724            // soon as generation ends. Publish a separate durable barrier only
1725            // after the final runtime snapshot has been saved, while this run's
1726            // publication fence still owns event ordering. Account-feed clients
1727            // can now reconcile `/history` without racing the checkpoint.
1728            if history_committed
1729                && !history_commit_barrier
1730                    .send_and_wait(&mpsc_tx, session_id.clone())
1731                    .await
1732            {
1733                if let Some(outer) = &task_outer {
1734                    outer.unconfirmed("Root history publication barrier unconfirmed");
1735                }
1736                tracing::warn!(
1737                    session_id = %session_id,
1738                    "history commit barrier could not be published before runner finalization"
1739                );
1740            }
1741
1742            // Flip the runner registry to a terminal status (which makes session
1743            // summaries report `is_running: false`) ONLY AFTER the run status is
1744            // persisted above, so `is_running` and `last_run_status` become
1745            // visible together and the frontend settles immediately instead of
1746            // lingering in its optimistic-settle window.
1747            finalize_runner_exact(&runners, &session_id, &activation_run_id, &result).await;
1748
1749            #[cfg(test)]
1750            if let Some(outer) = &task_outer {
1751                outer.pause_before_actor_finish().await;
1752            }
1753
1754            if let Some(owner) = root_actor.take() {
1755                let outcome = if result.as_ref().is_err_and(|error| error.is_cancelled()) {
1756                    bamboo_domain::ActorActivationFinish::Cancelled
1757                } else if result.is_err() || !history_committed {
1758                    bamboo_domain::ActorActivationFinish::Failed
1759                } else {
1760                    bamboo_domain::ActorActivationFinish::Succeeded
1761                };
1762                let finished = owner.finish_observed(outcome).await;
1763                if let Err(error) = &finished {
1764                    tracing::warn!(%session_id, %error, "Root Actor closure unconfirmed");
1765                }
1766                if let Some(outer) = &task_outer {
1767                    outer.actor_finished(finished);
1768                }
1769            }
1770
1771            let finalization = if let Some(registration) = activation_registration.take() {
1772                registration.finish(executed_admitted_generation).await
1773            } else {
1774                Ok(None)
1775            };
1776            if let Err(error) = finalization {
1777                if let Some(outer) = &task_outer {
1778                    outer.unconfirmed(format!(
1779                        "Root registration finalization unconfirmed: {error}"
1780                    ));
1781                }
1782                // Delivery is durable even if activation infrastructure is
1783                // temporarily unavailable; startup/backlog reconciliation
1784                // can retry without loss.
1785                tracing::error!(
1786                    session_id = %session_id,
1787                    %error,
1788                    "failed to activate successor for finalization-racing SessionInbox delivery"
1789                );
1790            }
1791
1792            // A CHILD session finishing through this path (a resumed child, or
1793            // a nested child-parent woken by its own children) must wake its
1794            // waiting parent (issue #546). Publish AFTER the final persist and
1795            // runner finalization so the coordinator reads the child's settled
1796            // terminal state — the resume message folds in the child's final
1797            // assistant content from storage. The status mirrors the
1798            // `last_run_status` mapping above; a non-terminal "suspended" is
1799            // published too and ignored by the coordinator's terminality guard.
1800            let child_completion = child_completion_handler.filter(|_| {
1801                session.kind == bamboo_agent_core::SessionKind::Child
1802                    && session.parent_session_id.is_some()
1803            });
1804            let parent_session_id = session.parent_session_id.clone();
1805            let child_status = session.last_run_status();
1806            let child_error = session.last_run_error();
1807
1808            publish_final_session_cache(&sessions_cache, session, root_actor_bound, &saved);
1809
1810            if let (Some(handler), Some(parent_session_id), Some(status)) =
1811                (child_completion, parent_session_id, child_status)
1812            {
1813                use futures::FutureExt;
1814                let completion = ChildCompletion {
1815                    parent_session_id: parent_session_id.clone(),
1816                    child_session_id: session_id.clone(),
1817                    status,
1818                    error: child_error,
1819                    completed_at,
1820                    source: completion_source,
1821                };
1822                if std::panic::AssertUnwindSafe(handler.on_child_completed(completion))
1823                    .catch_unwind()
1824                    .await
1825                    .is_err()
1826                {
1827                    tracing::error!(
1828                        %parent_session_id,
1829                        child_session_id = %session_id,
1830                        "child completion handler panicked on resumed-child terminal"
1831                    );
1832                }
1833            }
1834
1835            tracing::info!("[{}] Agent execution completed", session_id);
1836            if let Some(outer) = &task_outer {
1837                outer.tail_completed();
1838            }
1839        }
1840        .instrument(session_span),
1841    );
1842    if let Some(outer) = outer {
1843        outer.retain(task);
1844    } else if is_root {
1845        // Legacy/custom composition has no supported Root issuer. Still join
1846        // this actual task, but do not invent an observable physical receipt.
1847        tokio::spawn(async move {
1848            let _ = task.await;
1849        });
1850    }
1851}
1852
1853// Keep the final writer and its rejection path directly regression-testable.
1854async fn save_finalized_runtime_with_inherited_child_wait(
1855    persistence: &dyn bamboo_domain::RuntimeSessionPersistence,
1856    session: &mut Session,
1857    inherited_child_wait: Option<&bamboo_domain::session::runtime_state::WaitingForChildrenState>,
1858) -> std::io::Result<()> {
1859    if let Some(inherited) = inherited_child_wait.filter(|wait| {
1860        session
1861            .agent_runtime_state
1862            .as_ref()
1863            .and_then(|runtime| runtime.waiting_for_children.as_ref())
1864            == Some(*wait)
1865    }) {
1866        persistence
1867            .save_finalized_runtime_with_inherited_child_wait(session, inherited)
1868            .await
1869    } else {
1870        persistence.save_finalized_runtime_session(session).await
1871    }
1872}
1873
1874fn publish_final_session_cache(
1875    cache: &SessionCache,
1876    session: Session,
1877    root_actor_bound: bool,
1878    saved: &std::io::Result<()>,
1879) {
1880    let authority_conflict = saved.as_ref().err().is_some_and(|error| {
1881        error
1882            .get_ref()
1883            .is_some_and(|cause| cause.is::<bamboo_domain::SessionAuthorityConflict>())
1884    });
1885    // Preserve unrelated I/O failure behavior, but never publish a rejected
1886    // incarnation. Bound Root executions publish through their owned writer.
1887    if !authority_conflict && !root_actor_bound {
1888        cache.insert(
1889            session.id.clone(),
1890            Arc::new(crate::SessionSnapshot::new(session)),
1891        );
1892    }
1893}
1894
1895/// Log a snapshot of the base system prompt for debugging.
1896pub fn log_base_system_prompt_snapshot(session_id: &str, prompt: &str) {
1897    tracing::info!(
1898        "[{}] Base system prompt snapshot: len={} chars, has_skill={}, has_tool_guide={}, has_external_memory={}, has_task_list={}",
1899        session_id,
1900        prompt.len(),
1901        prompt.contains(SKILL_CONTEXT_START_MARKER),
1902        prompt.contains(TOOL_GUIDE_START_MARKER),
1903        prompt.contains(EXTERNAL_MEMORY_START_MARKER),
1904        prompt.contains(TASK_LIST_START_MARKER),
1905    );
1906
1907    tracing::debug!(
1908        "[{}] ========== BASE SYSTEM PROMPT SNAPSHOT ==========",
1909        session_id
1910    );
1911    tracing::debug!("[{}] Snapshot length: {} chars", session_id, prompt.len());
1912    tracing::debug!("[{}] -----------------------------------", session_id);
1913    tracing::debug!("[{}] {}", session_id, prompt);
1914    tracing::debug!(
1915        "[{}] ========== END BASE SYSTEM PROMPT SNAPSHOT ==========",
1916        session_id
1917    );
1918}
1919
1920/// Map an execution result to a terminal error event.
1921pub fn terminal_error_event_for_result(result: &Result<(), AgentError>) -> Option<AgentEvent> {
1922    match result {
1923        Ok(_) => None,
1924        Err(error) if error.is_cancelled() => Some(AgentEvent::Error {
1925            message: "Agent execution cancelled by user".to_string(),
1926        }),
1927        Err(error) => Some(AgentEvent::Error {
1928            message: error.to_string(),
1929        }),
1930    }
1931}
1932
1933// Session metadata helpers (pure functions, no server dependency).
1934
1935fn system_prompt_for_session(session: &Session) -> Option<String> {
1936    session
1937        .messages
1938        .iter()
1939        .find(|message| matches!(message.role, bamboo_agent_core::Role::System))
1940        .map(|message| message.content.clone())
1941}
1942
1943/// Normalize legacy host-owned prompt sections before the debug logger can
1944/// observe persisted System text. The runner repeats this migration
1945/// idempotently before workspace-scoped setup.
1946fn system_prompt_for_debug_log(session: &mut Session) -> Option<String> {
1947    crate::runtime::runner::session_setup::migrate_legacy_workspace_prompt(session);
1948    system_prompt_for_session(session)
1949}
1950
1951fn initial_user_message_for_session(session: &Session) -> String {
1952    session
1953        .messages
1954        .last()
1955        .filter(|message| matches!(message.role, bamboo_agent_core::Role::User))
1956        .map(|message| message.content.clone())
1957        .unwrap_or_default()
1958}
1959
1960fn selected_skill_ids_for_session(session: &Session) -> Option<Vec<String>> {
1961    session
1962        .metadata
1963        .get("selected_skill_ids")
1964        .and_then(|raw| bamboo_skills::selection::parse_selected_skill_ids_metadata(raw))
1965}
1966
1967fn selected_skill_mode_for_session(session: &Session) -> Option<String> {
1968    let value = session
1969        .metadata
1970        .get("skill_mode")
1971        .or_else(|| session.metadata.get("mode"))?;
1972    let trimmed = value.trim();
1973    if trimmed.is_empty() {
1974        None
1975    } else {
1976        Some(trimmed.to_string())
1977    }
1978}
1979
1980pub(super) struct RootActorExecution {
1981    binding: bamboo_domain::RootActorExecutionBinding,
1982    stop_renewal: CancellationToken,
1983    renewal: Option<tokio::task::JoinHandle<()>>,
1984    response_events: Vec<AgentEvent>,
1985}
1986
1987impl RootActorExecution {
1988    pub(super) fn start(binding: bamboo_domain::RootActorExecutionBinding) -> Self {
1989        let stop_renewal = CancellationToken::new();
1990        let stop = stop_renewal.clone();
1991        let directory = binding.directory.clone();
1992        let fence = binding.owner.fence.clone();
1993        let duration = binding.lease_duration;
1994        let renewal = tokio::spawn(async move {
1995            let mut interval = tokio::time::interval(duration / 3);
1996            interval.tick().await;
1997            loop {
1998                tokio::select! {
1999                    _ = stop.cancelled() => break,
2000                    _ = interval.tick() => {
2001                        let now = chrono::Utc::now();
2002                        let expires = now + chrono::Duration::from_std(duration).expect("bounded Root lease");
2003                        if let Err(error) = directory.renew_activation(&fence, now, expires).await {
2004                            // A transient storage failure (lock contention, IO
2005                            // hiccup) only delays this renewal; the next tick
2006                            // retries. An authority rejection (fence taken over
2007                            // or activation finished) is final: an in-flight
2008                            // provider response may still arrive, but its next
2009                            // load/write/effect boundary rejects this fence;
2010                            // never retry a side effect or cancel a new owner.
2011                            if matches!(error, bamboo_domain::ActorDirectoryError::Storage(_)) {
2012                                tracing::warn!(actor_id = %fence.actor_id, run_id = %fence.run_id,
2013                                    %error, "Root Actor renewal hit transient storage failure; retrying next tick");
2014                                continue;
2015                            }
2016                            tracing::warn!(actor_id = %fence.actor_id, run_id = %fence.run_id,
2017                                %error, "Root Actor renewal lost its execution authority");
2018                            break;
2019                        }
2020                    }
2021                }
2022            }
2023        });
2024        Self {
2025            binding,
2026            stop_renewal,
2027            renewal: Some(renewal),
2028            response_events: Vec::new(),
2029        }
2030    }
2031
2032    pub(super) fn persistence(
2033        &self,
2034    ) -> std::sync::Arc<dyn bamboo_domain::RuntimeSessionPersistence> {
2035        self.binding.persistence.clone()
2036    }
2037
2038    pub(super) async fn finish(self, outcome: bamboo_domain::ActorActivationFinish) {
2039        if let Err(error) = self.finish_observed(outcome).await {
2040            tracing::warn!(%error, "Root Actor closure unconfirmed");
2041        }
2042    }
2043
2044    async fn finish_observed(
2045        mut self,
2046        outcome: bamboo_domain::ActorActivationFinish,
2047    ) -> std::io::Result<()> {
2048        self.stop_renewal.cancel();
2049        let renewal = match self.renewal.take() {
2050            Some(renewal) => renewal.await.map_err(std::io::Error::other),
2051            None => Ok(()),
2052        };
2053        let directory = self.binding.directory.clone();
2054        let owner = self.binding.owner.clone();
2055        let publish = self.binding.terminal_publisher.clone();
2056        let finish = tokio::spawn(async move {
2057            directory
2058                .finish_root_activation_observed(&owner, chrono::Utc::now(), outcome, publish)
2059                .await
2060                .map(|_| ())
2061                .map_err(std::io::Error::other)
2062        });
2063        self.binding.disarm_abandonment();
2064        finish.await.map_err(std::io::Error::other)??;
2065        renewal
2066    }
2067}
2068
2069impl Drop for RootActorExecution {
2070    fn drop(&mut self) {
2071        self.stop_renewal.cancel();
2072        // Binding Drop schedules exact-fence abandonment. Never abort a
2073        // started filesystem job: its actual physical guards own its lifetime.
2074    }
2075}
2076
2077#[cfg(test)]
2078#[path = "agent_spawn_peer_privacy_tests.rs"]
2079mod peer_privacy_tests;
2080
2081#[cfg(test)]
2082mod reservation_tests {
2083    use super::*;
2084    use crate::runtime::execution::runner_state::AgentStatus;
2085
2086    struct CompletionAtFinalSave {
2087        directory: std::path::PathBuf,
2088        persistence: bamboo_storage::LockedSessionStore,
2089        cleared: std::sync::atomic::AtomicBool,
2090    }
2091
2092    impl CompletionAtFinalSave {
2093        async fn clear_wait(&self, id: &str) {
2094            let mut process = std::process::Command::new(std::env::current_exe().unwrap())
2095                .args(["--exact", "runtime::execution::agent_spawn::reservation_tests::inherited_child_wait_adopts_independent_completion_at_final_save", "--nocapture"])
2096                .env("BAMBOO_FINAL_WAIT_COMPLETION_DIR", &self.directory)
2097                .env("BAMBOO_FINAL_WAIT_COMPLETION_ID", id)
2098                .spawn().unwrap();
2099            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
2100            loop {
2101                if let Some(status) = process.try_wait().unwrap() {
2102                    assert!(status.success(), "completion process failed");
2103                    break;
2104                }
2105                if std::time::Instant::now() >= deadline {
2106                    let _ = process.kill();
2107                    let _ = process.wait();
2108                    panic!("bounded completion process deadline");
2109                }
2110                std::thread::sleep(std::time::Duration::from_millis(5));
2111            }
2112            self.cleared
2113                .store(true, std::sync::atomic::Ordering::SeqCst);
2114        }
2115    }
2116
2117    #[async_trait::async_trait]
2118    impl bamboo_domain::RuntimeSessionPersistence for CompletionAtFinalSave {
2119        async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
2120            self.clear_wait(&session.id).await;
2121            self.persistence.merge_save_finalized_runtime(session).await
2122        }
2123        async fn save_finalized_runtime_with_inherited_child_wait(
2124            &self,
2125            session: &mut Session,
2126            inherited: &bamboo_domain::session::runtime_state::WaitingForChildrenState,
2127        ) -> std::io::Result<()> {
2128            self.clear_wait(&session.id).await;
2129            self.persistence
2130                .merge_save_inherited_child_wait_and_publish(session, inherited, |_, _| {})
2131                .await
2132        }
2133    }
2134
2135    #[tokio::test]
2136    async fn inherited_child_wait_adopts_independent_completion_at_final_save() {
2137        use bamboo_agent_core::storage::Storage;
2138        use bamboo_domain::session::runtime_state::{
2139            AgentRuntimeState, AgentStatusState, ChildWaitPolicy, WaitingForChildrenState,
2140        };
2141        if let Some(directory) = std::env::var_os("BAMBOO_FINAL_WAIT_COMPLETION_DIR") {
2142            let remote = bamboo_storage::SessionStoreV2::new(directory.into())
2143                .await
2144                .unwrap();
2145            let id = std::env::var("BAMBOO_FINAL_WAIT_COMPLETION_ID").unwrap();
2146            let mut latest = remote.load_session(&id).await.unwrap().unwrap();
2147            let runtime = latest.agent_runtime_state.as_mut().unwrap();
2148            runtime.waiting_for_children = None;
2149            runtime.status = AgentStatusState::Idle;
2150            runtime.suspension = None;
2151            latest.metadata.remove("runtime.suspend_reason");
2152            remote.save_runtime_state(&latest).await.unwrap();
2153            return;
2154        }
2155        let temp = tempfile::tempdir().unwrap();
2156        let local = Arc::new(
2157            bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
2158                .await
2159                .unwrap(),
2160        );
2161        // Independent Host persistence does not participate in the engine's
2162        // process-local resume mutex. Complete only when finalization arrives,
2163        // after the old helper's reload but before its final durable write.
2164        let mut runner = Session::new("cross-host-final-wait", "model");
2165        let inherited = WaitingForChildrenState::for_children(
2166            vec!["child".into()],
2167            ChildWaitPolicy::All,
2168            chrono::Utc::now(),
2169        );
2170        let mut runtime = AgentRuntimeState::new("run");
2171        runtime.waiting_for_children = Some(inherited.clone());
2172        runtime.status = AgentStatusState::Suspended;
2173        runner.agent_runtime_state = Some(runtime);
2174        runner.metadata.insert(
2175            "runtime.suspend_reason".into(),
2176            "waiting_for_children".into(),
2177        );
2178        runner.set_last_run_status("suspended");
2179        local.save_session(&runner).await.unwrap();
2180        let persistence = CompletionAtFinalSave {
2181            directory: temp.path().to_path_buf(),
2182            persistence: bamboo_storage::LockedSessionStore::new(local.clone()),
2183            cleared: std::sync::atomic::AtomicBool::new(false),
2184        };
2185        save_finalized_runtime_with_inherited_child_wait(
2186            &persistence,
2187            &mut runner,
2188            Some(&inherited),
2189        )
2190        .await
2191        .unwrap();
2192        assert!(persistence
2193            .cleared
2194            .load(std::sync::atomic::Ordering::SeqCst));
2195        let durable = local.load_session(&runner.id).await.unwrap().unwrap();
2196        for session in [&runner, &durable] {
2197            let runtime = session.agent_runtime_state.as_ref().unwrap();
2198            assert!(
2199                runtime.waiting_for_children.is_none(),
2200                "independent completion must not be resurrected"
2201            );
2202            assert_eq!(runtime.status, AgentStatusState::Idle);
2203            assert!(runtime.suspension.is_none());
2204            assert!(!session.metadata.contains_key("runtime.suspend_reason"));
2205            assert_eq!(session.last_run_status().as_deref(), Some("completed"));
2206        }
2207    }
2208
2209    #[tokio::test]
2210    async fn inherited_child_wait_rejects_aba_and_missing_birth_without_cache_publication() {
2211        use bamboo_agent_core::storage::Storage;
2212        use bamboo_domain::session::runtime_state::{
2213            AgentRuntimeState, ChildWaitPolicy, WaitingForChildrenState,
2214        };
2215        use bamboo_storage::{LockedSessionStore, SessionStoreV2};
2216
2217        for missing in [false, true] {
2218            let temp = tempfile::tempdir().unwrap();
2219            let storage = Arc::new(
2220                SessionStoreV2::new(temp.path().to_path_buf())
2221                    .await
2222                    .unwrap(),
2223            );
2224            let persistence = LockedSessionStore::new(storage.clone());
2225            let cache: SessionCache = Arc::default();
2226            storage
2227                .save_session(&Session::new("root", "model"))
2228                .await
2229                .unwrap();
2230            let mut stale = Session::new("nested-child-parent", "model");
2231            stale.kind = bamboo_domain::SessionKind::Child;
2232            stale.parent_session_id = Some("root".into());
2233            stale.root_session_id = "root".into();
2234            stale.spawn_depth = 1;
2235            let wait = WaitingForChildrenState::for_children(
2236                vec!["grandchild".into()],
2237                ChildWaitPolicy::All,
2238                chrono::Utc::now(),
2239            );
2240            let mut runtime = AgentRuntimeState::new("old-run");
2241            runtime.waiting_for_children = Some(wait.clone());
2242            stale.agent_runtime_state = Some(runtime);
2243            stale.add_message(bamboo_agent_core::Message::assistant("STALE", None));
2244            storage.save_session(&stale).await.unwrap();
2245
2246            let mut replacement = stale.clone();
2247            replacement.created_at += chrono::Duration::seconds(1);
2248            replacement.messages.clear();
2249            replacement.add_message(bamboo_agent_core::Message::assistant("REPLACEMENT", None));
2250            replacement
2251                .agent_runtime_state
2252                .as_mut()
2253                .unwrap()
2254                .waiting_for_children = None;
2255            storage.delete_session(&stale.id).await.unwrap();
2256            if !missing {
2257                storage.save_session(&replacement).await.unwrap();
2258            }
2259            if !missing {
2260                cache.insert(
2261                    replacement.id.clone(),
2262                    Arc::new(crate::SessionSnapshot::new(replacement.clone())),
2263                );
2264            }
2265
2266            let saved = save_finalized_runtime_with_inherited_child_wait(
2267                &persistence,
2268                &mut stale,
2269                Some(&wait),
2270            )
2271            .await;
2272            assert!(saved.is_err(), "stale incarnation must not be saved");
2273            // A nested Child uses the unbound publication path. A plain I/O
2274            // error used to pass its cache guard despite the durable rejection.
2275            publish_final_session_cache(&cache, stale, false, &saved);
2276            if missing {
2277                assert!(
2278                    read_cached_session(&cache, &replacement.id).is_none(),
2279                    "deleted incarnation must not be resurrected in cache"
2280                );
2281            } else {
2282                let cached = read_cached_session(&cache, &replacement.id).unwrap();
2283                assert_eq!(
2284                    cached.created_at, replacement.created_at,
2285                    "ABA rejection must retain replacement cache birth"
2286                );
2287                assert_eq!(
2288                    serde_json::to_value(&cached.messages).unwrap(),
2289                    serde_json::to_value(&replacement.messages).unwrap(),
2290                    "ABA rejection must retain replacement cache transcript"
2291                );
2292            }
2293            assert!(saved
2294                .as_ref()
2295                .unwrap_err()
2296                .get_ref()
2297                .is_some_and(|cause| cause.is::<bamboo_domain::SessionAuthorityConflict>()));
2298            let durable = storage.load_session(&replacement.id).await.unwrap();
2299            if missing {
2300                assert!(durable.is_none());
2301            } else {
2302                let durable = durable.unwrap();
2303                assert_eq!(durable.created_at, replacement.created_at);
2304                assert_eq!(
2305                    serde_json::to_value(&durable.messages).unwrap(),
2306                    serde_json::to_value(&replacement.messages).unwrap()
2307                );
2308            }
2309        }
2310    }
2311
2312    #[test]
2313    fn debug_prompt_snapshot_migrates_legacy_host_paths_before_logging() {
2314        let legacy_project = format!(
2315            "{}\nProject ID: legacy-project\nProject path: /private/legacy-project\nProject home: /private/legacy-home\n{}",
2316            crate::runtime::context::PROJECT_CONTEXT_START_MARKER,
2317            crate::runtime::context::PROJECT_CONTEXT_END_MARKER,
2318        );
2319        let legacy_workspace =
2320            crate::runtime::context::build_workspace_prompt_context("/private/legacy-workspace")
2321                .expect("legacy workspace context");
2322        let legacy_instruction = format!(
2323            "{}\nSource: /private/legacy-workspace/AGENTS.md\nlegacy policy\n{}",
2324            crate::runtime::context::instruction::INSTRUCTION_CONTEXT_START_MARKER,
2325            crate::runtime::context::instruction::INSTRUCTION_CONTEXT_END_MARKER,
2326        );
2327        let mut session = Session::new("legacy-debug-log", "model");
2328        session.add_message(bamboo_agent_core::Message::system(format!(
2329            "Base prompt\n\n{legacy_project}\n\n{legacy_workspace}\n\n{legacy_instruction}"
2330        )));
2331
2332        let prompt = system_prompt_for_debug_log(&mut session).expect("normalized System");
2333
2334        assert_eq!(prompt, "Base prompt");
2335        assert_eq!(
2336            session.workspace_path_meta().as_deref(),
2337            Some("/private/legacy-workspace")
2338        );
2339        for private_path in [
2340            "/private/legacy-project",
2341            "/private/legacy-home",
2342            "/private/legacy-workspace",
2343        ] {
2344            assert!(!prompt.contains(private_path));
2345        }
2346        assert!(!prompt.contains(crate::runtime::context::PROJECT_CONTEXT_START_MARKER));
2347        assert!(!prompt.contains(crate::runtime::context::WORKSPACE_CONTEXT_START_MARKER));
2348        assert!(!prompt
2349            .contains(crate::runtime::context::instruction::INSTRUCTION_CONTEXT_START_MARKER));
2350    }
2351
2352    #[test]
2353    fn reservation_target_requires_domain_id_and_exact_runner_registry() {
2354        let runners = Arc::new(RwLock::new(HashMap::new()));
2355        let other_runners = Arc::new(RwLock::new(HashMap::new()));
2356        let mut reservation = SessionExecutionReservation {
2357            session_id: "session-a".to_string(),
2358            run_id: "run-a".to_string(),
2359            cancel_token: CancellationToken::new(),
2360            runners: runners.clone(),
2361            activation: SessionExecutionActivationOwnership::Unrouted,
2362            root_actor: None,
2363            physical_admission: None,
2364            execution_persistence: None,
2365            persistence_bound: false,
2366            child_startup: None,
2367            child_closure: None,
2368            selected_input: false,
2369            armed: true,
2370        };
2371
2372        assert!(reservation.matches_execution_target("session-a", "session-a", &runners));
2373        assert!(!reservation.matches_execution_target("session-b", "session-a", &runners));
2374        assert!(!reservation.matches_execution_target("session-a", "session-b", &runners));
2375        assert!(!reservation.matches_execution_target("session-a", "session-a", &other_runners));
2376        reservation.armed = false;
2377    }
2378
2379    #[tokio::test]
2380    async fn forced_same_run_registration_rejection_never_cancels_live_owner() {
2381        let runners = Arc::new(RwLock::new(HashMap::new()));
2382        let mut runner = AgentRunner::new();
2383        runner.status = AgentStatus::Running;
2384        let run_id = runner.run_id.clone();
2385        let live_cancel_token = runner.cancel_token.clone();
2386        runners.write().await.insert("same-run".to_string(), runner);
2387
2388        let router = SessionActivationRouter::new();
2389        let mut live_registration = router
2390            .register_run("same-run", &run_id)
2391            .await
2392            .expect("first registration owns the run");
2393        let mut rejected = SessionExecutionReservation {
2394            session_id: "same-run".to_string(),
2395            run_id: run_id.clone(),
2396            cancel_token: live_cancel_token.clone(),
2397            runners: runners.clone(),
2398            activation: SessionExecutionActivationOwnership::RegistrationPending(router.clone()),
2399            root_actor: None,
2400            physical_admission: None,
2401            execution_persistence: None,
2402            persistence_bound: false,
2403            child_startup: None,
2404            child_closure: None,
2405            selected_input: false,
2406            armed: true,
2407        };
2408
2409        let error = match rejected.ensure_registered().await {
2410            Ok(()) => panic!("a duplicate registration for the same run must be rejected"),
2411            Err(error) => error,
2412        };
2413        assert_eq!(error.existing_run_id(), run_id);
2414        drop(rejected);
2415
2416        assert!(!live_cancel_token.is_cancelled());
2417        assert!(matches!(
2418            runners
2419                .read()
2420                .await
2421                .get("same-run")
2422                .map(|runner| &runner.status),
2423            Some(AgentStatus::Running)
2424        ));
2425        assert!(router.owns_run("same-run", &run_id).await);
2426        live_registration.begin_finalization().await;
2427        assert_eq!(live_registration.finish(0).await.unwrap(), None);
2428    }
2429}