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