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