1use 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 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#[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 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 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 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
227struct 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 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 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 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 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 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 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 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 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 crate::session_app::execute::consume_pending_clarification_resume(&mut owned_session);
949 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 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 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 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 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 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 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 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 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 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 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 let errors = lock(&self.coordinator_errors).clone();
1383 if errors.is_empty() {
1384 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}