Skip to main content

bamboo_engine/external_agents/
local_task.rs

1//! Native Child execution in the Host process. Identity, input, history and
2//! terminal publication still belong to the canonical Actor/Session stores.
3//! There is no broker, Worker IPC, or remote fallback in this backend.
4use super::runtime::NativeToolCeilingSource;
5use crate::execution::spawn::{ExternalChildRunner, SessionInboxRuntimeBinding, SpawnJob};
6use crate::runtime::{Agent, ExecuteRequestBuilder};
7use async_trait::async_trait;
8use bamboo_agent_core::tools::ToolExecutor;
9use bamboo_agent_core::{AgentError, AgentEvent, Session};
10use bamboo_domain::{
11    ActorActivationClaim, ActorActivationFinish, ActorDirectoryError, ActorDirectoryPort,
12    ChildActorRuntimeWrite, RuntimeSessionPersistence, SessionKind, Storage,
13};
14use bamboo_llm::{Config, ProviderModelRouter};
15use futures::FutureExt;
16use std::{
17    collections::HashMap,
18    future::Future,
19    path::PathBuf,
20    sync::{
21        atomic::{AtomicBool, Ordering},
22        Arc, Mutex, OnceLock,
23    },
24    time::Duration,
25};
26use tokio::sync::{mpsc, Semaphore};
27use tokio_util::sync::CancellationToken;
28
29#[derive(Clone)]
30pub struct LocalTaskRuntime {
31    pub agent: Arc<Agent>,
32    /// The Host's complete permission-checking base chain, without the Root role fence.
33    pub tools: Arc<dyn ToolExecutor>,
34    pub provider_router: Option<Arc<ProviderModelRouter>>,
35    pub app_data_dir: Option<PathBuf>,
36    pub sessions_cache: crate::SessionCache,
37}
38
39mod local_skill_binding;
40pub use local_skill_binding::{
41    LocalChildSkillAuthority, LocalChildSkillBinding, LocalChildSkillFactory,
42};
43
44#[derive(Default)]
45struct Bindings {
46    skills: Option<Arc<dyn LocalChildSkillFactory>>,
47    runtime: Option<LocalTaskRuntime>,
48    inbox: Option<SessionInboxRuntimeBinding>,
49    store: Option<Arc<bamboo_storage::SessionStoreV2>>,
50    tickets: Option<Arc<bamboo_tickets::TicketService>>,
51    subagent: Option<Arc<dyn bamboo_agent_core::tools::Tool>>,
52    observer: Option<Arc<dyn super::actor_event_stream::ActorEventObserver>>,
53}
54
55/// Constructed only after the actual Agent task was joined and its owned
56/// resources drained. A request to cancel, a PID, and a durable status cannot
57/// manufacture this value.
58#[derive(Clone, Debug)]
59pub struct ConfirmedTaskStop {
60    session_id: String,
61    child_birth: chrono::DateTime<chrono::Utc>,
62    run_id: String,
63    instance_id: uuid::Uuid,
64}
65impl ConfirmedTaskStop {
66    pub(crate) fn session_id(&self) -> &str {
67        &self.session_id
68    }
69    pub(crate) fn child_birth(&self) -> chrono::DateTime<chrono::Utc> {
70        self.child_birth
71    }
72    pub(crate) fn run_id(&self) -> &str {
73        &self.run_id
74    }
75    pub(crate) fn instance_id(&self) -> uuid::Uuid {
76        self.instance_id
77    }
78}
79
80struct ExecutionOutput {
81    session: Session,
82    result: crate::runtime::runner::Result<()>,
83    resources: std::io::Result<bamboo_tools::DrainedLocalTaskResources>,
84}
85#[derive(Clone, Copy, PartialEq, Eq)]
86enum LaunchState {
87    Queued,
88    Preparing,
89    Running,
90    Stopping,
91    Stopped,
92}
93
94#[derive(Default)]
95struct LiveTaskSignals {
96    entered_at: OnceLock<chrono::DateTime<chrono::Utc>>,
97    renewal_started_at: Mutex<Option<chrono::DateTime<chrono::Utc>>>,
98}
99
100impl LiveTaskSignals {
101    async fn observe_renewal<T, F>(
102        &self,
103        renew: impl FnOnce(chrono::DateTime<chrono::Utc>) -> F,
104    ) -> Result<T, ActorDirectoryError>
105    where
106        F: Future<Output = Result<T, ActorDirectoryError>>,
107    {
108        // A queued renewal may finish after entry. Capture entry before the
109        // request and publish its timestamp only after successful renewal.
110        let entered_at = self.entered_at.get().copied();
111        let now = chrono::Utc::now();
112        let receipt = renew(now).await?;
113        if entered_at.is_some_and(|at| at <= now) {
114            *lock(&self.renewal_started_at) = Some(now);
115        }
116        Ok(receipt)
117    }
118}
119
120struct Execution {
121    owner: ChildActorRuntimeWrite,
122    persistence: Arc<dyn RuntimeSessionPersistence>,
123    cancel: CancellationToken,
124    resources: bamboo_tools::LocalTaskResources,
125    signals: Arc<LiveTaskSignals>,
126    task: tokio::sync::Mutex<Option<tokio::task::JoinHandle<ExecutionOutput>>>,
127    stop: Mutex<Option<ConfirmedTaskStop>>,
128    finalized: AtomicBool,
129    launch: Mutex<LaunchState>,
130    admission: Mutex<Option<tokio::sync::OwnedSemaphorePermit>>,
131    slot: Mutex<Option<tokio::sync::OwnedSemaphorePermit>>,
132    launch_handoff: tokio::sync::watch::Sender<bool>,
133    heartbeat_stop: CancellationToken,
134    heartbeat: tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
135}
136
137impl Execution {
138    async fn persist_stop_unconfirmed(&self) -> Result<Session, String> {
139        // The same birth/fence-bound writer owns this narrow observation. A
140        // task panic must never checkpoint its partially mutated transcript.
141        let mut current = self
142            .persistence
143            .load_runtime_control_plane(&self.owner.fence.actor_id)
144            .await
145            .map_err(|e| e.to_string())?
146            .ok_or("Child missing while recording unconfirmed stop")?;
147        let same_observation = current
148            .metadata
149            .get("runtime.local_task.state")
150            .map(String::as_str)
151            == Some("stop_unconfirmed")
152            && current.metadata.get("runtime.local_task.run_id") == Some(&self.owner.fence.run_id)
153            && current.metadata.get("runtime.local_task.child_birth")
154                == Some(&self.owner.created_at.to_rfc3339())
155            && current
156                .metadata
157                .get("runtime.local_task.reason")
158                .map(String::as_str)
159                == Some("execution_stop_unconfirmed")
160            && current
161                .metadata
162                .get("runtime.local_task.observed_at")
163                .is_some_and(|value| chrono::DateTime::parse_from_rfc3339(value).is_ok());
164        if !same_observation {
165            record_local_task_observation(
166                &mut current,
167                &self.owner.fence.run_id,
168                self.owner.created_at,
169                "stop_unconfirmed",
170            );
171            current.metadata.insert(
172                "runtime.local_task.reason".into(),
173                "execution_stop_unconfirmed".into(),
174            );
175        }
176        current.metadata.remove("runtime.local_task.instance");
177        self.persistence
178            .save_runtime_control_plane(&mut current)
179            .await
180            .map_err(|e| e.to_string())?;
181        let readback = self
182            .persistence
183            .load_runtime_control_plane(&self.owner.fence.actor_id)
184            .await
185            .map_err(|e| e.to_string())?
186            .ok_or("Child missing after unconfirmed-stop checkpoint")?;
187        if LOCAL_TASK_OBSERVATION_KEYS
188            .iter()
189            .any(|key| current.metadata.get(*key) != readback.metadata.get(*key))
190        {
191            return Err("unconfirmed-stop checkpoint readback differs".into());
192        }
193        Ok(readback)
194    }
195
196    async fn wait_for_handoff(&self) {
197        let mut handoff = self.launch_handoff.subscribe();
198        while !*handoff.borrow_and_update() {
199            // This Execution owns the sender, so cancellation of any other
200            // waiter cannot close it or consume another waiter's wakeup.
201            handoff
202                .changed()
203                .await
204                .expect("execution retains handoff sender");
205        }
206    }
207}
208
209pub struct LocalTaskChildRunner {
210    bindings: Mutex<Bindings>,
211    executions: Mutex<HashMap<String, Arc<Execution>>>,
212    coordinators: Mutex<Vec<CoordinatorHandle>>,
213    coordinator_errors: Mutex<Vec<String>>,
214    concurrency: Arc<Semaphore>,
215    admission: Arc<Semaphore>,
216    preparations: Arc<Semaphore>,
217    closed: AtomicBool,
218    ceiling: Option<Arc<dyn NativeToolCeilingSource>>,
219    permission: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
220    remote_roles: Vec<String>,
221    approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
222    approval_reviewer: Option<Arc<dyn super::actor_adapter::ChildApprovalReviewer>>,
223}
224
225type CoordinatorHandle = tokio::task::JoinHandle<Result<(), String>>;
226
227/// Shutdown may be cancelled while a publication tail still owns a writer.
228/// Keep the real join handle available to the next shutdown attempt.
229struct RetainedCoordinatorJoin<'a> {
230    queue: &'a Mutex<Vec<CoordinatorHandle>>,
231    handle: Option<CoordinatorHandle>,
232}
233impl Drop for RetainedCoordinatorJoin<'_> {
234    fn drop(&mut self) {
235        if let Some(handle) = self.handle.take() {
236            lock(self.queue).push(handle);
237        }
238    }
239}
240
241fn failure(error: impl std::fmt::Display) -> AgentError {
242    AgentError::LLM(format!("local task backend: {error}"))
243}
244const LOCAL_TASK_OBSERVATION_KEYS: [&str; 6] = [
245    "runtime.local_task.state",
246    "runtime.local_task.run_id",
247    "runtime.local_task.child_birth",
248    "runtime.local_task.observed_at",
249    "runtime.local_task.reason",
250    "runtime.local_task.instance",
251];
252
253fn record_local_task_observation(
254    session: &mut Session,
255    run_id: &str,
256    child_birth: chrono::DateTime<chrono::Utc>,
257    state: &str,
258) {
259    for key in LOCAL_TASK_OBSERVATION_KEYS {
260        session.metadata.remove(key);
261    }
262    session
263        .metadata
264        .insert("runtime.local_task.state".into(), state.into());
265    session
266        .metadata
267        .insert("runtime.local_task.run_id".into(), run_id.into());
268    session.metadata.insert(
269        "runtime.local_task.child_birth".into(),
270        child_birth.to_rfc3339(),
271    );
272    session.metadata.insert(
273        "runtime.local_task.observed_at".into(),
274        chrono::Utc::now().to_rfc3339(),
275    );
276}
277
278fn copy_local_task_observation(from: &Session, to: &mut Session) {
279    for key in LOCAL_TASK_OBSERVATION_KEYS {
280        match from.metadata.get(key) {
281            Some(value) => {
282                to.metadata.insert(key.into(), value.clone());
283            }
284            None => {
285                to.metadata.remove(key);
286            }
287        }
288    }
289}
290
291fn record_stop_metadata(session: &mut Session, proof: &ConfirmedTaskStop) {
292    record_local_task_observation(session, &proof.run_id, proof.child_birth, "stopped");
293    session.metadata.insert(
294        "runtime.local_task.instance".into(),
295        proof.instance_id.to_string(),
296    );
297    session
298        .metadata
299        .insert("runtime.local_task.run_id".into(), proof.run_id.clone());
300    if session
301        .metadata
302        .get(bamboo_subagent::proto::WORKFLOW_USAGE_REQUESTED_KEY)
303        .is_some_and(|v| v == "true")
304    {
305        session.metadata.insert(
306            bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY.into(),
307            proof.run_id.clone(),
308        );
309    }
310}
311
312fn reconcile_question_handoff(session: &mut Session, previous_request: Option<&str>) {
313    let issued_here = session
314        .metadata
315        .get(bamboo_domain::PARENT_QUESTION_REQUEST_KEY)
316        .is_some_and(|request| Some(request.as_str()) != previous_request);
317    if issued_here {
318        // Preserve this run's handoff even if a fast parent answer already
319        // cleared pending_question before the Agent returned.
320        session.metadata.insert(
321            "runtime.actor_parent_question_handoff".into(),
322            "true".into(),
323        );
324    } else {
325        session
326            .metadata
327            .remove("runtime.actor_parent_question_handoff");
328    }
329}
330
331fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
332    mutex.lock().unwrap_or_else(|error| error.into_inner())
333}
334
335impl LocalTaskChildRunner {
336    fn retain_shutdown_error(&self, error: String) {
337        let mut errors = lock(&self.coordinator_errors);
338        if !errors.contains(&error) {
339            errors.push(error);
340        }
341    }
342
343    pub fn new(
344        config: &Config,
345        ceiling: Option<Arc<dyn NativeToolCeilingSource>>,
346        permission: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
347    ) -> Self {
348        Self {
349            bindings: Mutex::new(Bindings::default()),
350            executions: Mutex::new(HashMap::new()),
351            coordinators: Mutex::new(Vec::new()),
352            coordinator_errors: Mutex::new(Vec::new()),
353            concurrency: Arc::new(Semaphore::new(
354                config
355                    .subagents()
356                    .max_concurrent
357                    .unwrap_or(super::actor_adapter::DEFAULT_MAX_CONCURRENT_ACTORS)
358                    .max(1),
359            )),
360            admission: Arc::new(Semaphore::new(
361                config
362                    .subagents()
363                    .max_concurrent
364                    .unwrap_or(super::actor_adapter::DEFAULT_MAX_CONCURRENT_ACTORS)
365                    .max(1)
366                    .saturating_add(128),
367            )),
368            preparations: Arc::new(Semaphore::new(128)),
369            closed: AtomicBool::new(false),
370            ceiling,
371            permission,
372            approval_registry: None,
373            approval_reviewer: None,
374            remote_roles: config
375                .subagents()
376                .remote_placements
377                .iter()
378                .map(|p| p.role.clone())
379                .chain(
380                    config
381                        .subagents()
382                        .schedulable_placements
383                        .iter()
384                        .map(|p| p.role.clone()),
385                )
386                .collect(),
387        }
388    }
389    pub fn with_approval(
390        mut self,
391        registry: Option<super::approval_registry::SharedApprovalRegistry>,
392        reviewer: Option<Arc<dyn super::actor_adapter::ChildApprovalReviewer>>,
393    ) -> Self {
394        self.approval_registry = registry;
395        self.approval_reviewer = reviewer;
396        self
397    }
398    async fn stop_unstarted(
399        &self,
400        execution: &Execution,
401        session: &mut Session,
402        run_id: &str,
403    ) -> Result<(), String> {
404        {
405            let mut state = lock(&execution.launch);
406            match *state {
407                LaunchState::Queued | LaunchState::Preparing => *state = LaunchState::Stopping,
408                LaunchState::Stopped => return Ok(()),
409                _ => return Err("local execution launch or stop is already in progress".into()),
410            }
411        }
412        let drained = execution
413            .resources
414            .close_and_drain()
415            .await
416            .map_err(|e| e.to_string())?;
417        let identity = drained.identity();
418        if identity.session_id != session.id
419            || identity.run_id != run_id
420            || identity.child_birth != session.created_at
421        {
422            return Err("local resource identity changed during rollback".into());
423        }
424        let proof = ConfirmedTaskStop {
425            session_id: session.id.clone(),
426            child_birth: session.created_at,
427            run_id: run_id.into(),
428            instance_id: identity.owner_id,
429        };
430        record_stop_metadata(session, &proof);
431        *lock(&execution.stop) = Some(proof);
432        *lock(&execution.launch) = LaunchState::Stopped;
433        Ok(())
434    }
435    fn execution(&self, session: &Session, run_id: &str) -> Result<Arc<Execution>, String> {
436        let execution = lock(&self.executions)
437            .get(&session.id)
438            .cloned()
439            .ok_or("local execution ownership is missing")?;
440        if execution.owner.created_at != session.created_at
441            || execution.owner.fence.run_id != run_id
442        {
443            return Err("local execution birth or generation changed".into());
444        }
445        Ok(execution)
446    }
447    fn runtime(&self) -> Result<LocalTaskRuntime, String> {
448        lock(&self.bindings)
449            .runtime
450            .clone()
451            .ok_or("Host local task runtime is unavailable".into())
452    }
453}
454
455#[async_trait]
456impl ExternalChildRunner for LocalTaskChildRunner {
457    fn native_task_start(
458        &self,
459        session_id: &str,
460        run_id: &str,
461        child_birth: chrono::DateTime<chrono::Utc>,
462    ) -> Option<crate::execution::spawn::NativeTaskStart> {
463        self.native_task_liveness(session_id, run_id, child_birth)
464            .map(|live| live.start)
465    }
466
467    fn native_task_liveness(
468        &self,
469        session_id: &str,
470        run_id: &str,
471        child_birth: chrono::DateTime<chrono::Utc>,
472    ) -> Option<crate::execution::spawn::NativeTaskLiveness> {
473        if self.closed.load(Ordering::Acquire) {
474            return None;
475        }
476        let execution = lock(&self.executions).get(session_id)?.clone();
477        let entered_at = *execution.signals.entered_at.get()?;
478        let renewal_started_at = *lock(&execution.signals.renewal_started_at);
479        // Sample first so a renewal confirmed during closure cannot pass a
480        // lifecycle check that preceded the sample.
481        if self.closed.load(Ordering::Acquire)
482            || execution.owner.fence.run_id != run_id
483            || execution.owner.created_at != child_birth
484            || execution.owner.fence.actor_id != session_id
485            || execution.cancel.is_cancelled()
486            || *lock(&execution.launch) != LaunchState::Running
487            || lock(&execution.slot).is_none()
488        {
489            return None;
490        }
491        Some(crate::execution::spawn::NativeTaskLiveness {
492            start: crate::execution::spawn::NativeTaskStart {
493                session_id: session_id.into(),
494                child_birth,
495                run_id: run_id.into(),
496                instance_id: execution.resources.identity().owner_id,
497                entered_at,
498            },
499            renewal_started_at,
500        })
501    }
502
503    fn supports_native_ticket_budget(&self) -> bool {
504        true
505    }
506
507    fn set_local_child_skill_factory(&self, factory: Option<Arc<dyn LocalChildSkillFactory>>) {
508        lock(&self.bindings).skills = factory;
509    }
510    fn set_local_task_runtime(&self, runtime: Option<LocalTaskRuntime>) {
511        lock(&self.bindings).runtime = runtime;
512    }
513    fn set_session_inbox_runtime(&self, inbox: Option<SessionInboxRuntimeBinding>) {
514        lock(&self.bindings).inbox = inbox;
515    }
516    fn set_actor_directory_store(&self, store: Option<Arc<bamboo_storage::SessionStoreV2>>) {
517        lock(&self.bindings).store = store;
518    }
519    fn set_ticket_service(&self, service: Option<Arc<bamboo_tickets::TicketService>>) {
520        let mut bindings = lock(&self.bindings);
521        // Serialize this check with the successful drain's cache release. A
522        // late scope publisher must not reattach a writer to a closed runner.
523        if service.is_some() && self.closed.load(Ordering::Acquire) {
524            return;
525        }
526        bindings.tickets = service;
527    }
528    fn set_canonical_subagent_tool(&self, tool: Option<Arc<dyn bamboo_agent_core::tools::Tool>>) {
529        lock(&self.bindings).subagent = tool;
530    }
531    fn set_actor_event_observer(
532        &self,
533        observer: Option<Arc<dyn super::actor_event_stream::ActorEventObserver>>,
534    ) {
535        lock(&self.bindings).observer = observer;
536    }
537
538    async fn should_handle(&self, session: &Session) -> bool {
539        session.kind == SessionKind::Child
540            && session
541                .metadata
542                .get("external.agent_id")
543                .is_none_or(|id| id == super::config::LOCAL_ACTOR_AGENT_ID)
544            && !self
545                .remote_roles
546                .iter()
547                .any(|role| session.metadata.get("subagent_type") == Some(role))
548    }
549    async fn validate_required_child_context_route(&self, session: &Session) -> Result<(), String> {
550        // Admission probes use a synthetic Child before its durable birth.
551        // Check only its configured route here; execution separately proves
552        // the canonical Child, lineage and activation ownership.
553        if session
554            .metadata
555            .get("external.agent_id")
556            .is_some_and(|id| id != super::config::LOCAL_ACTOR_AGENT_ID)
557            || self
558                .remote_roles
559                .iter()
560                .any(|role| session.metadata.get("subagent_type") == Some(role))
561            || self.ceiling.is_none()
562        {
563            return Err(
564                "required_child_context_unsupported: local native ceiling unavailable".into(),
565            );
566        }
567        self.runtime()?;
568        let bindings = lock(&self.bindings);
569        if bindings.store.is_none() || bindings.inbox.is_none() {
570            return Err("canonical local Child storage is unavailable".into());
571        }
572        Ok(())
573    }
574
575    async fn prepare_child_execution(
576        &self,
577        session: &Session,
578        run_id: &str,
579        cancel: CancellationToken,
580    ) -> Result<Option<Arc<dyn RuntimeSessionPersistence>>, String> {
581        let _preparation = self
582            .preparations
583            .clone()
584            .try_acquire_owned()
585            .map_err(|_| "local Child preparation capacity exhausted")?;
586        if self.closed.load(Ordering::Acquire) {
587            return Err("local task backend is shutting down".into());
588        }
589        // Keep scheduler backpressure: an accepted setup waits for bounded
590        // capacity instead of detaching an unbounded pending coordinator.
591        let admission = tokio::select! {
592            biased;
593            _ = cancel.cancelled() => return Err("local Child cancelled before admission".into()),
594            permit = self.admission.clone().acquire_owned() => permit.map_err(|_| "local task backend is shutting down")?,
595        };
596        let runtime = self.runtime()?;
597        let (store, inbox) = {
598            let bindings = lock(&self.bindings);
599            (
600                bindings
601                    .store
602                    .clone()
603                    .ok_or("canonical local Actor store unavailable")?,
604                bindings
605                    .inbox
606                    .clone()
607                    .ok_or("canonical local Inbox unavailable")?,
608            )
609        };
610        if !inbox.router.owns_run(&session.id, run_id).await {
611            return Err("local Child activation is not owned by this Host run".into());
612        }
613        if lock(&self.executions).contains_key(&session.id) {
614            return Err("previous local execution has not been finalized".into());
615        }
616        let durable = store
617            .load_session(&session.id)
618            .await
619            .map_err(|e| e.to_string())?
620            .ok_or("canonical Child is missing")?;
621        if durable.created_at != session.created_at
622            || durable.parent_created_at != session.parent_created_at
623            || durable.parent_session_id != session.parent_session_id
624            || durable.root_session_id != session.root_session_id
625            || durable.project_id_meta() != session.project_id_meta()
626        {
627            return Err("canonical Child birth or lineage changed".into());
628        }
629        if durable
630            .metadata
631            .get("runtime.local_task.state")
632            .is_some_and(|state| state != "stopped")
633        {
634            return Err("previous local task has no confirmed stop; recovery is blocked".into());
635        }
636        store
637            .ensure_actor(&session.id)
638            .await
639            .map_err(|e| e.to_string())?;
640        store
641            .bind_control_plane(
642                &session.id,
643                bamboo_domain::ActorControlPlaneBinding {
644                    policy_revision: self.permission.as_ref().map(|p| p.policy_revision()),
645                    placement_intent: Some(bamboo_domain::ActorPlacementIntent::Local),
646                },
647            )
648            .await
649            .map_err(|e| e.to_string())?;
650        let locked = inbox
651            .parent_question_lock
652            .as_ref()
653            .ok_or("canonical local write coordinator unavailable")?;
654        let now = chrono::Utc::now();
655        let claimed = store
656            .claim_activation(&ActorActivationClaim {
657                actor_id: session.id.clone(),
658                run_id: run_id.into(),
659                lease_owner: format!("host-task-{}", uuid::Uuid::new_v4()),
660                lease_expires_at: now + chrono::Duration::seconds(60),
661                inbox_generation: session
662                    .session_inbox_admission()
663                    .map_or(0, |state| state.last_admitted_sequence),
664                placement_ref: None,
665                now,
666            })
667            .await
668            .map_err(|e| e.to_string())?;
669        let setup = async {
670            let owner = ChildActorRuntimeWrite::from_session(claimed.fence(), session)
671                .map_err(|e| e.to_string())?;
672            let cache = runtime.sessions_cache.clone();
673            let persistence: Arc<dyn RuntimeSessionPersistence> = Arc::new(
674                locked
675                    .bind_child_actor_writer(
676                        owner.clone(),
677                        store.clone(),
678                        Arc::new(move |saved| {
679                            cache.insert(
680                                saved.id.clone(),
681                                Arc::new(crate::SessionSnapshot::new(saved.clone())),
682                            );
683                        }),
684                    )
685                    .map_err(|e| e.to_string())?,
686            );
687            store
688                .start_activation(&owner.fence, now)
689                .await
690                .map_err(|e| e.to_string())?;
691            Ok::<_, String>((owner, persistence))
692        }
693        .await;
694        let (owner, persistence) = match setup {
695            Ok(bound) => bound,
696            Err(error) => {
697                return match store
698                    .finish_activation(
699                        &claimed.fence(),
700                        chrono::Utc::now(),
701                        ActorActivationFinish::Failed,
702                    )
703                    .await
704                {
705                    Ok(_) => Err(error),
706                    Err(rollback) => Err(format!(
707                        "{error}; exact claim rollback unconfirmed: {rollback}"
708                    )),
709                };
710            }
711        };
712        let signals = Arc::new(LiveTaskSignals::default());
713        let heartbeat_stop = CancellationToken::new();
714        let heartbeat = {
715            let store = store.clone();
716            let fence = owner.fence.clone();
717            let done = heartbeat_stop.clone();
718            let cancel = cancel.clone();
719            let signals = signals.clone();
720            tokio::spawn(async move {
721                let mut interval = tokio::time::interval(Duration::from_secs(5));
722                interval.tick().await;
723                loop {
724                    tokio::select! { _ = done.cancelled() => break, _ = interval.tick() => {} }
725                    match signals
726                        .observe_renewal(|now| {
727                            store.renew_activation(&fence, now, now + chrono::Duration::seconds(60))
728                        })
729                        .await
730                    {
731                        Ok(_) => {}
732                        // Transient storage failures only delay one heartbeat;
733                        // the next tick retries before the lease can expire.
734                        Err(error @ ActorDirectoryError::Storage(_)) => {
735                            tracing::warn!(actor_id = %fence.actor_id, run_id = %fence.run_id,
736                                %error, "local task heartbeat hit transient storage failure; retrying next tick");
737                        }
738                        Err(_) => {
739                            cancel.cancel();
740                            break;
741                        }
742                    }
743                }
744            })
745        };
746        let execution = Arc::new(Execution {
747            owner,
748            persistence: persistence.clone(),
749            cancel,
750            signals,
751            resources: bamboo_tools::LocalTaskResources::new(
752                &session.id,
753                run_id,
754                session.created_at,
755            ),
756            task: tokio::sync::Mutex::new(None),
757            stop: Mutex::new(None),
758            finalized: AtomicBool::new(false),
759            launch: Mutex::new(LaunchState::Queued),
760            admission: Mutex::new(Some(admission)),
761            slot: Mutex::new(None),
762            launch_handoff: tokio::sync::watch::channel(false).0,
763            heartbeat_stop,
764            heartbeat: tokio::sync::Mutex::new(Some(heartbeat)),
765        });
766        lock(&self.executions).insert(session.id.clone(), execution);
767        Ok(Some(persistence))
768    }
769
770    async fn execute_external_child(
771        &self,
772        session: &mut Session,
773        job: &SpawnJob,
774        event_tx: mpsc::Sender<AgentEvent>,
775        cancel_token: CancellationToken,
776    ) -> crate::runtime::runner::Result<()> {
777        let runtime = self.runtime().map_err(failure)?;
778        let (binding, tickets) = {
779            let b = lock(&self.bindings);
780            (
781                b.inbox
782                    .clone()
783                    .ok_or_else(|| failure("Inbox unavailable"))?,
784                b.tickets.clone(),
785            )
786        };
787        let run_id = binding
788            .router
789            .current_run_id(&session.id)
790            .await
791            .ok_or_else(|| failure("activation missing"))?;
792        let execution = self.execution(session, &run_id).map_err(failure)?;
793        {
794            let mut state = lock(&execution.launch);
795            if *state != LaunchState::Queued {
796                return Err(failure("duplicate or closed local task launch"));
797            }
798            *state = LaunchState::Preparing;
799        }
800        let result = async {
801        let slot = tokio::select! { biased; _ = cancel_token.cancelled() => return Err(AgentError::Cancelled), slot = self.concurrency.clone().acquire_owned() => slot.map_err(failure)? };
802        *lock(&execution.slot) = Some(slot);
803        crate::session_app::child_session::named_profile::validate_named_profile(session)
804            .map_err(failure)?;
805        if let (Some(policy), Some(workspace)) = (&self.permission, &session.workspace) {
806            policy.register_session_workspace(session.id.clone(), workspace);
807        }
808        let strict = skill_host::strict_child(session)?;
809        let names = if strict { match &self.ceiling {
810            Some(source) => Some(source.observe(session).await.map_err(failure)?),
811            None => return Err(failure("strict native tool ceiling unavailable")),
812        }} else { None };
813        let mut ticket_plan = None;
814        if session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY) {
815            let service = tickets.clone().ok_or_else(||failure("Ticket authority unavailable"))?;
816            crate::ticket_runtime::admit_ticket_run(&service, &binding, session, &run_id).await.map_err(failure)?;
817            let assignment = session.metadata[crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY].clone();
818            let plan = Arc::new(crate::ticket_worker_plan::TicketWorkerPlan::from_runtime_receipt(service, &assignment).map_err(failure)?);
819            session.agent_runtime_state.get_or_insert_with(Default::default).run_id = run_id.clone();
820            plan.bind_session(session).map_err(failure)?;
821            ticket_plan = Some(plan);
822        }
823        let mut tools: Arc<dyn ToolExecutor> = runtime.tools.clone();
824        let store = lock(&self.bindings).store.clone().ok_or_else(||failure("local store unavailable"))?;
825        let observer = store.supervisor_observer_activation(session).await.map_err(failure)?;
826        let factory = if strict || observer { None } else { lock(&self.bindings).skills.clone() };
827        let mut child_skill_host = None;
828        let mut child_skill_authority = None;
829        if let Some(factory) = factory {
830            let authority = LocalChildSkillAuthority::bind(store.clone(), &execution);
831            let binding = factory.bind(authority.clone(), session, tools).await?;
832            child_skill_authority = Some(authority);
833            tools = binding.tools;
834            child_skill_host = Some(binding.host);
835        }
836        let names = names.unwrap_or_else(||tools.list_tools().into_iter().map(|s|s.function.name).collect());
837        let local_tools = tool_policy::LocalChildTools::new(tools, names.clone(), session, store.clone(), execution.owner.clone(), job.disabled_tools.as_deref())?.with_canonical_subagent(lock(&self.bindings).subagent.clone());
838        tools = Arc::new(if observer { local_tools.restrict_to_observer() } else { local_tools });
839        if session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY) {
840            tools = Arc::new(crate::ticket_worker_plan::files::LocalFileExecutor::new(
841                tools, tickets.clone().ok_or_else(||failure("Ticket authority unavailable"))?, session.clone(), run_id.clone(), &names
842            ).map_err(failure)?);
843        }
844        if let Some(authority) = child_skill_authority { authority.finalize_tools(tools.as_ref()); }
845        let (local_event_tx, local_event_rx) = mpsc::channel(256);
846        let mut request = ExecuteRequestBuilder::new("", local_event_tx.clone(), cancel_token.clone())
847            .tools(tools)
848            .model(job.model.clone());
849        if strict || observer { request = request.selected_skill_ids(Vec::new()); }
850        else {
851            if let Some(ids) = session.metadata.get("selected_skill_ids").and_then(|raw|bamboo_skills::selection::parse_selected_skill_ids_metadata(raw)) { request = request.selected_skill_ids(ids); }
852            if let Some(mode) = session.metadata.get("skill_mode").or_else(||session.metadata.get("mode")).map(|v|v.trim()).filter(|v|!v.is_empty()) { request = request.selected_skill_mode(mode); }
853        }
854        if let Some(data_dir) = runtime.app_data_dir.clone() {
855            request = request.app_data_dir(data_dir);
856        }
857        if let Some(disabled) = &job.disabled_tools {
858            request = request.disabled_tools(disabled.iter().cloned().collect());
859        }
860        if observer {
861            request = request.run_budget(bamboo_config::RunBudgetConfig {
862                max_rounds: Some(6),
863                max_tool_calls: Some(8),
864                ..Default::default()
865            });
866        }
867        if let Some(reference) = crate::session_app::provider_model::session_effective_model_ref(session) {
868            if let Some(router) = &runtime.provider_router {
869                request = request.model(reference.model.clone())
870                    .provider_override(router.route(&reference).map_err(failure)?)
871                    .provider_name(reference.provider.clone());
872                if let Some(kind) = router.provider_type_for(&reference) {
873                    request = request.provider_type(kind);
874                }
875            }
876        }
877        if let Some(effort) = session.reasoning_effort {
878            request = request.reasoning_effort(effort);
879        }
880        if let Some(plan) = ticket_plan {
881            // Admission above binds this exact canonical Child/Run/dispatch.
882            // Carry its Host ceiling into the request even if queued execution
883            // observes a later relaxation of the global configuration.
884            if let Some(cap) = crate::ticket_runtime::read_dispatch(session).map_err(failure)?.and_then(|binding| binding.max_rounds) {
885                if cap == 0 { return Err(failure("zero Host round ceiling")); }
886                request = request.run_budget(bamboo_config::RunBudgetConfig { max_rounds: Some(cap), ..Default::default() });
887            }
888            request = request.ticket_worker_plan(plan);
889        }
890        if let Some(budget) = crate::ticket_token_budget::NativeWorkTokenBudget::capture(
891            tickets.clone(), store.clone(), session
892        ).await.map_err(failure)? {
893            request = request.ticket_token_budget(budget);
894        }
895        let mut owned_session = session.clone();
896        // The question fact survives its answer for replay/deduplication. The
897        // handoff is only for the run that issued that fact, not every later
898        // run that happens to retain it in canonical history.
899        let parent_question_at_start = owned_session.metadata.get(bamboo_domain::PARENT_QUESTION_REQUEST_KEY).cloned();
900        owned_session.metadata.remove("runtime.actor_parent_question_handoff");
901        owned_session.metadata.remove(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY);
902        owned_session.metadata.remove(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY);
903        record_local_task_observation(&mut owned_session, &run_id, execution.owner.created_at, "running");
904        execution
905            .persistence
906            .checkpoint_runtime_session(&mut owned_session)
907            .await
908            .map_err(failure)?;
909        let mut agent = runtime
910            .agent
911            .with_execution_persistence(execution.persistence.clone());
912        if strict {
913            agent = agent.with_skill_execution_host(skill_host::StrictChildSkills::bind(store, &execution));
914        } else if let Some(host) = child_skill_host {
915            agent = agent.with_skill_execution_host(host);
916        }
917        let resources = execution.resources.clone();
918        let task_cancel = cancel_token.clone();
919        let mut handle = execution.task.lock().await;
920        {
921            let mut state = lock(&execution.launch);
922            if *state != LaunchState::Preparing { return Err(failure("local launch gate is closed")); }
923            if cancel_token.is_cancelled() || self.closed.load(Ordering::Acquire) { return Err(AgentError::Cancelled); }
924            *state = LaunchState::Running;
925        }
926        let approval_proxy: Option<Arc<dyn bamboo_tools::ApprovalProxy>> = match (&self.permission, &self.approval_reviewer) {
927            (Some(policy), Some(reviewer)) => Some(Arc::new(approval::LocalApprovalProxy::new(approval::LocalApprovalContext {
928                store: lock(&self.bindings).store.clone().expect("prepared store"), owner: execution.owner.clone(), router: binding.router.clone(), cancel: cancel_token.clone(), policy: policy.clone(), reviewer: reviewer.clone(), registry: self.approval_registry.clone(),
929            }))),
930            _ => None,
931        };
932        let (event_store, event_observer) = { let bindings=lock(&self.bindings); (bindings.store.clone().expect("prepared store"), bindings.observer.clone()) };
933        let event_owner = execution.owner.clone();
934        let event_cancel = cancel_token.clone();
935        let signals = execution.signals.clone();
936        let task = tokio::spawn(async move {
937            let forward = tokio::spawn(events::forward(event_store, event_owner, event_observer, local_event_rx, event_tx, event_cancel));
938            let result = std::panic::AssertUnwindSafe(
939                resources.scope(bamboo_tools::with_approval_proxy(approval_proxy, async {
940                    if let Some(audit) = bamboo_domain::PermissionAuditSnapshot::from_metadata(&owned_session.metadata) {
941                        local_event_tx.send(AgentEvent::PermissionPostureActivated { session_id: owned_session.id.clone(), policy_revision: audit.policy_revision, requested_mode: audit.resolution.requested.as_str().to_owned(), effective_mode: audit.resolution.effective.as_str().to_owned(), executor_mapping: audit.executor_mapping }).await.map_err(failure)?;
942                    }
943                    drop(local_event_tx);
944                    // This owned execution has now taken over the admitted
945                    // resume. Mirror the normal Root execution seam: retained
946                    // answer/retry handoff markers are not future pending work.
947                    // Do not consume them while queued or during preparation.
948                    crate::session_app::execute::consume_pending_clarification_resume(&mut owned_session);
949                    // This physical Task has reached Agent execution with its
950                    // native slot and exact owner. Preparation/Actor start is
951                    // earlier and cannot set this once-only, non-persisted fact.
952                    let _ = signals.entered_at.set(chrono::Utc::now());
953                    agent.execute(&mut owned_session, request.build()).await
954                })),
955            )
956            .catch_unwind()
957            .await;
958            // This is a private execution token. Finalization cancels remaining
959            // safe auxiliary requests even when the main loop returned normally.
960            task_cancel.cancel();
961            if result.is_err() { resources.record_unconfirmed("Agent task panicked; physical effects remain unconfirmed"); }
962            let mut drained = resources.close_and_drain().await;
963            let mut result = match result {
964                Ok(result) => result,
965                Err(_) => {
966                    // The loop itself may have unwound a Tokio filesystem or
967                    // custom-tool future outside registered builtin resources.
968                    // Joining the task proves exit, not those physical effects.
969                    drained = Err(std::io::Error::other("Agent task panicked; physical effects remain unconfirmed"));
970                    Err(failure("Agent task panicked"))
971                },
972            };
973            match forward.await {
974                Ok(Ok(())) => {},
975                Ok(Err(error)) => result = Err(failure(format!("local event publication failed: {error}"))),
976                Err(error) => { drained = Err(std::io::Error::other(format!("local event owner panicked: {error}"))); result = Err(failure(error)); },
977            }
978            reconcile_question_handoff(&mut owned_session, parent_question_at_start.as_deref());
979            ExecutionOutput {
980                session: owned_session,
981                result,
982                resources: drained,
983            }
984        });
985        *handle = Some(task);
986        let joined = handle.as_mut().expect("owned task").await;
987        handle.take();
988        let mut output = joined.map_err(|error| failure(format!("Agent task join failed: {error}")))?;
989        *session = output.session;
990        let drained = output
991            .resources
992            .map_err(|error| failure(format!("resource stop unconfirmed: {error}")))?;
993        let identity = drained.identity();
994        if identity.session_id != session.id
995            || identity.run_id != run_id
996            || identity.child_birth != session.created_at
997        {
998            return Err(failure("resource owner changed"));
999        }
1000        let proof = ConfirmedTaskStop {
1001            session_id: session.id.clone(),
1002            child_birth: session.created_at,
1003            run_id: run_id.clone(),
1004            instance_id: identity.owner_id,
1005        };
1006        record_stop_metadata(session, &proof);
1007        // A Host-limited Ticket which exhausted its rounds did not complete
1008        // its contract. Preserve the real stop proof, then use the existing
1009        // failure delivery path instead of attempting a success Submission
1010        // from a summary turn which may contain only more tool calls.
1011        if output.result.is_ok()
1012            && session.metadata.get("runtime.completion_reason").map(String::as_str)
1013                == Some("max_rounds_reached")
1014            && crate::ticket_runtime::read_dispatch(session).map_err(failure)?
1015                .is_some_and(|dispatch| dispatch.max_rounds.is_some())
1016        {
1017            output.result = Err(AgentError::Budget("Ticket run round budget exhausted".into()));
1018        }
1019        if session.metadata.get(bamboo_subagent::proto::WORKFLOW_USAGE_REQUESTED_KEY).is_some_and(|v|v == "true") {
1020            if let Some(runtime) = session.agent_runtime_state.as_ref().filter(|runtime|runtime.run_id == run_id) {
1021                let usage = bamboo_subagent::proto::WorkflowAgentUsage { kind: bamboo_subagent::proto::WorkflowAgentUsage::TYPE.into(), activation_run_id: run_id.clone(), child_session_id: session.id.clone(), child_created_at: session.created_at, prompt_tokens: runtime.round.total_prompt_tokens, completion_tokens: runtime.round.total_completion_tokens };
1022                session.metadata.insert(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY.into(), serde_json::to_string(&usage).map_err(failure)?);
1023            }
1024        }
1025        *lock(&execution.stop) = Some(proof.clone());
1026        *lock(&execution.launch) = LaunchState::Stopped;
1027        let stopped_tickets = lock(&self.bindings).tickets.clone();
1028        if let Some(tickets) = stopped_tickets {
1029            if session
1030                .metadata
1031                .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
1032            {
1033                crate::ticket_runtime::checkpoint_owned_task_stop(
1034                    &tickets,
1035                    &binding,
1036                    session,
1037                    &run_id,
1038                    &proof,
1039                    output.result.is_ok(),
1040                )
1041                .await
1042                .map_err(failure)?;
1043            }
1044        }
1045        output.result
1046        }.await;
1047        // No-work stop is proved by permanently closing the launch gate and
1048        // draining the resource domain, never by inventing a PID or a task join.
1049        let result = async {
1050            let unstarted = *lock(&execution.launch) == LaunchState::Preparing;
1051            if unstarted {
1052                self.stop_unstarted(&execution, session, &run_id)
1053                    .await
1054                    .map_err(failure)?;
1055            }
1056            if unstarted
1057                && crate::ticket_runtime::read_dispatch(session)
1058                    .ok()
1059                    .flatten()
1060                    .is_some_and(|d| d.receipt.is_some())
1061            {
1062                let proof = lock(&execution.stop)
1063                    .clone()
1064                    .ok_or_else(|| failure("local stop proof missing"))?;
1065                if let Some(tickets) = tickets {
1066                    crate::ticket_runtime::checkpoint_owned_task_stop(
1067                        &tickets, &binding, session, &run_id, &proof, false,
1068                    )
1069                    .await
1070                    .map_err(failure)?;
1071                }
1072            }
1073            result
1074        }
1075        .await;
1076        match result {
1077            Err(error) if lock(&execution.stop).is_none() => {
1078                match execution.persist_stop_unconfirmed().await {
1079                    Ok(observed) => {
1080                        copy_local_task_observation(&observed, session);
1081                        Err(error)
1082                    }
1083                    Err(checkpoint) => Err(failure(format!(
1084                        "{error}; unconfirmed-stop observation was not durably confirmed: {checkpoint}"
1085                    ))),
1086                }
1087            }
1088            result => result,
1089        }
1090    }
1091
1092    async fn abandon_child_execution(&self, session: &Session, run_id: &str) -> Result<(), String> {
1093        let execution = self.execution(session, run_id)?;
1094        execution.cancel.cancel();
1095        execution.launch_handoff.send_replace(true);
1096        let mut stopped = session.clone();
1097        if let Err(error) = self.stop_unstarted(&execution, &mut stopped, run_id).await {
1098            return match execution.persist_stop_unconfirmed().await {
1099                Ok(_) => Err(error),
1100                Err(checkpoint) => Err(format!(
1101                    "{error}; unconfirmed-stop observation was not durably confirmed: {checkpoint}"
1102                )),
1103            };
1104        }
1105        stopped.set_last_run_status("error");
1106        stopped.set_last_run_error("initial local Child checkpoint failed");
1107        stopped.synchronize_terminal_runtime_status();
1108        execution
1109            .persistence
1110            .checkpoint_runtime_session(&mut stopped)
1111            .await
1112            .map_err(|e| e.to_string())?;
1113        self.finish_child_execution(&stopped, run_id, true).await
1114    }
1115
1116    fn quarantine_child_execution(&self, session: &Session, run_id: &str) {
1117        if let Ok(execution) = self.execution(session, run_id) {
1118            execution.cancel.cancel();
1119            let mut state = lock(&execution.launch);
1120            if matches!(*state, LaunchState::Queued | LaunchState::Preparing) {
1121                *state = LaunchState::Stopping;
1122            }
1123            execution.launch_handoff.send_replace(true);
1124        }
1125    }
1126
1127    fn track_child_task(
1128        &self,
1129        session_id: &str,
1130        run_id: &str,
1131        handle: tokio::task::JoinHandle<()>,
1132    ) -> Option<tokio::task::JoinHandle<()>> {
1133        let execution = lock(&self.executions).get(session_id).cloned();
1134        if let Some(execution) = execution.filter(|e| e.owner.fence.run_id == run_id) {
1135            // Admission remains occupied through the SDK publication tail.
1136            // The retained supervisor owns and joins the actual coordinator;
1137            // completed permits are returned without waiting for a later spawn.
1138            let permit = lock(&execution.admission).take();
1139            let retained_execution = execution.clone();
1140            let supervisor = tokio::spawn(async move {
1141                let mut result = handle.await.map_err(|error| error.to_string());
1142                if !retained_execution.finalized.load(Ordering::Acquire) {
1143                    // A coordinator exiting does not make an unconfirmed or
1144                    // unpublished execution eligible for replacement.
1145                    if let Some(permit) = permit {
1146                        *lock(&retained_execution.admission) = Some(permit);
1147                    }
1148                    if lock(&retained_execution.stop).is_none() {
1149                        retained_execution.cancel.cancel();
1150                        // The coordinator may have unwound while its separate
1151                        // Agent task still owned a canonical writer. Cancellation
1152                        // alone does not stop those writes: join that task and
1153                        // all registered resources before recording the final
1154                        // unknown observation. This recovery does not mint a
1155                        // normal execution/publication stop receipt.
1156                        let mut orphaned_task = retained_execution.task.lock().await;
1157                        if let Some(task) = orphaned_task.take() {
1158                            if let Err(error) = task.await {
1159                                retained_execution.resources.record_unconfirmed(format!(
1160                                    "orphaned Agent task join failed: {error}"
1161                                ));
1162                            }
1163                        }
1164                        drop(orphaned_task);
1165                        let _ = retained_execution.resources.close_and_drain().await;
1166                        if let Err(error) = retained_execution.persist_stop_unconfirmed().await {
1167                            result = Err(format!(
1168                                "{}unconfirmed-stop observation was not durably confirmed: {error}",
1169                                result
1170                                    .err()
1171                                    .map(|error| format!("{error}; "))
1172                                    .unwrap_or_default()
1173                            ));
1174                        }
1175                    }
1176                }
1177                result
1178            });
1179            let mut handles = lock(&self.coordinators);
1180            let mut pending = Vec::with_capacity(handles.len() + 1);
1181            for mut handle in handles.drain(..) {
1182                if handle.is_finished() {
1183                    match (&mut handle).now_or_never() {
1184                        Some(Err(error)) => lock(&self.coordinator_errors).push(error.to_string()),
1185                        Some(Ok(Err(error))) => lock(&self.coordinator_errors).push(error),
1186                        Some(Ok(Ok(()))) => {}
1187                        None => pending.push(handle),
1188                    }
1189                } else {
1190                    pending.push(handle);
1191                }
1192            }
1193            pending.push(supervisor);
1194            *handles = pending;
1195            execution.launch_handoff.send_replace(true);
1196            None
1197        } else {
1198            Some(handle)
1199        }
1200    }
1201
1202    async fn prepare_durable_child_delivery(
1203        &self,
1204        session: &Session,
1205        run_id: &str,
1206    ) -> Result<bool, String> {
1207        if !self.should_handle(session).await {
1208            return Ok(false);
1209        }
1210        let execution = self.execution(session, run_id)?;
1211        if lock(&execution.stop).is_none() {
1212            return Err("local execution stop is unconfirmed; terminal publication blocked".into());
1213        }
1214        Ok(true)
1215    }
1216    async fn commit_durable_child_delivery(
1217        &self,
1218        session: &Session,
1219        run_id: &str,
1220    ) -> Result<(), String> {
1221        if !self.should_handle(session).await {
1222            return Ok(());
1223        }
1224        let execution = self.execution(session, run_id)?;
1225        if lock(&execution.stop).is_none() {
1226            return Err("local task stop receipt missing".into());
1227        }
1228        let mut final_session = session.clone();
1229        crate::execution::ChildCompletionSource::publish_after_durable_delivery(
1230            &mut final_session,
1231            run_id,
1232        )?;
1233        execution
1234            .persistence
1235            .checkpoint_runtime_session(&mut final_session)
1236            .await
1237            .map_err(|e| e.to_string())?;
1238        let store = lock(&self.bindings)
1239            .store
1240            .clone()
1241            .ok_or("local store unavailable")?;
1242        let readback = store
1243            .load_session(&session.id)
1244            .await
1245            .map_err(|e| e.to_string())?
1246            .ok_or("Child missing after terminal checkpoint")?;
1247        if readback.created_at != session.created_at
1248            || readback
1249                .metadata
1250                .get("runtime.local_task.state")
1251                .map(String::as_str)
1252                != Some("stopped")
1253            || readback.metadata.get("runtime.local_task.instance")
1254                != final_session.metadata.get("runtime.local_task.instance")
1255            || readback.metadata.get("runtime.local_task.run_id") != Some(&run_id.to_string())
1256            || crate::execution::ChildCompletionSource::from_committed_session(&readback)
1257                != crate::execution::ChildCompletionSource::from_committed_session(&final_session)
1258        {
1259            return Err("local terminal checkpoint readback differs".into());
1260        }
1261        let tickets = lock(&self.bindings).tickets.clone();
1262        if let Some(tickets) = tickets.filter(|_| {
1263            crate::ticket_runtime::read_dispatch(&readback)
1264                .ok()
1265                .flatten()
1266                .is_some_and(|d| d.receipt.is_some())
1267        }) {
1268            crate::ticket_runtime::checkpoint_ticket_result(
1269                &tickets,
1270                store.as_ref(),
1271                &readback,
1272                run_id,
1273            )
1274            .await
1275            .map_err(|e| e.to_string())?;
1276        }
1277        Ok(())
1278    }
1279    async fn finish_child_execution(
1280        &self,
1281        session: &Session,
1282        run_id: &str,
1283        history_committed: bool,
1284    ) -> Result<(), String> {
1285        let execution = self.execution(session, run_id)?;
1286        // SDK registers the real coordinator immediately after spawning it.
1287        // Keep this record visible until its handle has another strong owner.
1288        execution.wait_for_handoff().await;
1289        if !history_committed || lock(&execution.stop).is_none() {
1290            return Err("local Child finalization remains unconfirmed".into());
1291        }
1292        execution.heartbeat_stop.cancel();
1293        if let Some(handle) = execution.heartbeat.lock().await.take() {
1294            handle.await.map_err(|e| e.to_string())?;
1295        }
1296        let store = lock(&self.bindings)
1297            .store
1298            .clone()
1299            .ok_or("local store unavailable")?;
1300        let outcome = match session.last_run_status().as_deref() {
1301            Some("completed" | "suspended") => ActorActivationFinish::Succeeded,
1302            Some("cancelled") => ActorActivationFinish::Cancelled,
1303            _ => ActorActivationFinish::Failed,
1304        };
1305        store
1306            .finish_activation(&execution.owner.fence, chrono::Utc::now(), outcome)
1307            .await
1308            .map_err(|e| e.to_string())?;
1309        lock(&execution.slot).take();
1310        lock(&self.executions).remove(&session.id);
1311        execution.finalized.store(true, Ordering::Release);
1312        Ok(())
1313    }
1314    async fn shutdown(&self) -> Result<(), String> {
1315        self.closed.store(true, Ordering::Release);
1316        self.admission.close();
1317        // Preparations take a permit before observing closed. Holding all of
1318        // them here closes the snapshot race with an already admitted claim
1319        // and serializes shutdown callers through all joins and cache release.
1320        let _preparations = self
1321            .preparations
1322            .acquire_many(128)
1323            .await
1324            .map_err(|e| e.to_string())?;
1325        let executions: Vec<_> = lock(&self.executions).values().cloned().collect();
1326        for execution in &executions {
1327            execution.cancel.cancel();
1328        }
1329        for execution in &executions {
1330            execution.wait_for_handoff().await;
1331        }
1332        loop {
1333            let next = lock(&self.coordinators).pop();
1334            let Some(coordinator) = next else { break };
1335            let mut retained = RetainedCoordinatorJoin {
1336                queue: &self.coordinators,
1337                handle: Some(coordinator),
1338            };
1339            let result = retained
1340                .handle
1341                .as_mut()
1342                .expect("retained coordinator")
1343                .await;
1344            retained.handle.take();
1345            // Record each observed error before the next await, so cancelling
1346            // this caller cannot discard a completed coordinator's failure.
1347            match result {
1348                Ok(Ok(())) => {}
1349                Ok(Err(error)) => self.retain_shutdown_error(error),
1350                Err(error) => self.retain_shutdown_error(error.to_string()),
1351            }
1352        }
1353        for execution in executions {
1354            execution.wait_for_handoff().await;
1355            execution.heartbeat_stop.cancel();
1356            if let Some(heartbeat) = execution.heartbeat.lock().await.take() {
1357                if let Err(error) = heartbeat.await {
1358                    self.retain_shutdown_error(error.to_string());
1359                }
1360            }
1361            if let Some(task) = execution.task.lock().await.take() {
1362                if let Err(error) = task.await {
1363                    self.retain_shutdown_error(error.to_string());
1364                }
1365            }
1366            if let Err(error) = execution.resources.close_and_drain().await {
1367                self.retain_shutdown_error(error.to_string());
1368            }
1369            if lock(&execution.stop).is_none() {
1370                self.retain_shutdown_error(format!(
1371                    "{} local stop remains unconfirmed",
1372                    execution.owner.fence.actor_id
1373                ));
1374            }
1375        }
1376        // A setup accepted before shutdown may harvest an older coordinator
1377        // while handing off. Read this queue only after all handoffs/joins, so
1378        // its retained error cannot be lost between the two snapshots.
1379        // A consumed failed join cannot be re-confirmed by a later empty
1380        // queue. Keep observed drain failures on this closed runtime, including
1381        // across cancellation; repeated shutdown is not permission to forget.
1382        let errors = lock(&self.coordinator_errors).clone();
1383        if errors.is_empty() {
1384            // Only a confirmed physical drain, including publication-tail
1385            // joins, can relinquish this cached canonical writer. Unknown stop
1386            // or a failed drain keeps the original service and its real lock.
1387            let retired_ticket_service = lock(&self.bindings).tickets.take();
1388            drop(retired_ticket_service);
1389            Ok(())
1390        } else {
1391            Err(errors.join("; "))
1392        }
1393    }
1394}
1395
1396mod approval;
1397mod events;
1398mod skill_host;
1399mod tool_policy;
1400
1401#[cfg(test)]
1402mod tests;
1403
1404#[cfg(test)]
1405mod question_handoff_tests {
1406    use super::*;
1407
1408    #[test]
1409    fn answered_question_fact_does_not_suspend_successor_run() {
1410        let mut session = Session::new("child", "model");
1411        session.metadata.insert(
1412            bamboo_domain::PARENT_QUESTION_REQUEST_KEY.into(),
1413            "durable-question".into(),
1414        );
1415        session.metadata.insert(
1416            "runtime.actor_parent_question_handoff".into(),
1417            "true".into(),
1418        );
1419        reconcile_question_handoff(&mut session, Some("durable-question"));
1420        assert!(!session
1421            .metadata
1422            .contains_key("runtime.actor_parent_question_handoff"));
1423    }
1424
1425    #[test]
1426    fn newly_issued_question_keeps_handoff_after_fast_answer() {
1427        let mut session = Session::new("child", "model");
1428        session.metadata.insert(
1429            bamboo_domain::PARENT_QUESTION_REQUEST_KEY.into(),
1430            "durable-question".into(),
1431        );
1432        assert!(session.pending_question.is_none());
1433        reconcile_question_handoff(&mut session, None);
1434        assert_eq!(
1435            session
1436                .metadata
1437                .get("runtime.actor_parent_question_handoff")
1438                .map(String::as_str),
1439            Some("true")
1440        );
1441    }
1442}