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, 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 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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}