1use std::collections::HashMap;
8use std::sync::{Arc, OnceLock, RwLock as StdRwLock};
9use std::time::Duration;
10
11use bamboo_domain::poison::PoisonRecover;
12
13use crate::execution::{
14 create_event_forwarder, finalize_runner, spawn_session_execution, try_reserve_runner,
15 AgentRunner, AgentStatus, ChildCompletion, ChildCompletionHandler, RunnerReservation,
16 SessionExecutionArgs,
17};
18use crate::runtime::config::{BashResumeHook, GuardianSpawner, BASH_COMPLETION_RESUME_KIND};
19use crate::runtime::guardian_state::{
20 parse_guardian_verdict, read_guardian_config, read_guardian_state, write_guardian_state,
21 GuardianVerdict,
22};
23use crate::Agent;
24use async_trait::async_trait;
25use bamboo_agent_core::storage::Storage;
26use bamboo_agent_core::tools::ToolExecutor;
27use bamboo_agent_core::{
28 AgentEvent, BashCompletionInfo, BashCompletionSink, Message, Role, Session,
29};
30use bamboo_domain::session::runtime_state::{
31 AgentRuntimeState, AgentStatusState, ChildWaitPolicy, SuspensionState, WaitingForChildrenState,
32};
33use bamboo_llm::{Config, ProviderModelRouter, ProviderRegistry};
34use bamboo_storage::LockedSessionStore;
35use chrono::Utc;
36use tokio::sync::{broadcast, RwLock};
37
38use crate::model_areas::resolve_global_area_models;
39use crate::model_config_helper::{
40 resolve_fast_model, resolve_gold_config, GOLD_CONFIG_METADATA_KEY,
41};
42use crate::session_app::provider_model::session_effective_model_ref;
43use crate::session_app::resume::{
44 resume_session_execution, ResumeExecutionPort, ResumeSpawnRequest,
45};
46use crate::session_app::types::{ResumeConfigSnapshot, ResumeOutcome};
47
48const AGENT_RUNTIME_STATE_METADATA_KEY: &str = "agent.runtime.state";
49const RUNTIME_RESUME_MESSAGE_HIDDEN_KEY: &str = "hidden_from_ui";
50const RUNTIME_RESUME_MESSAGE_KIND_KEY: &str = "runtime_kind";
51
52fn read_runtime_state(session: &Session) -> AgentRuntimeState {
53 session
54 .agent_runtime_state
55 .clone()
56 .or_else(|| {
57 session
58 .metadata
59 .get(AGENT_RUNTIME_STATE_METADATA_KEY)
60 .and_then(|raw| serde_json::from_str::<AgentRuntimeState>(raw).ok())
61 })
62 .unwrap_or_else(|| AgentRuntimeState::new(format!("{}-child-wait", session.id)))
63}
64
65fn write_runtime_state(session: &mut Session, runtime_state: &AgentRuntimeState) {
66 session.agent_runtime_state = Some(runtime_state.clone());
67 if let Ok(serialized) = serde_json::to_string(runtime_state) {
68 session
69 .metadata
70 .insert(AGENT_RUNTIME_STATE_METADATA_KEY.to_string(), serialized);
71 }
72}
73
74fn is_error_like(status: &str) -> bool {
75 matches!(status, "error" | "timeout" | "cancelled")
76}
77
78fn is_terminal_child_status(status: &str) -> bool {
80 matches!(
81 status,
82 "completed" | "error" | "timeout" | "cancelled" | "skipped"
83 )
84}
85
86async fn derive_completed_child_ids(
91 storage: &Arc<dyn Storage>,
92 parent_session_id: &str,
93 just_completed_child_id: &str,
94) -> Vec<String> {
95 let mut completed: Vec<String> = storage
96 .list_child_run_statuses(parent_session_id)
97 .await
98 .unwrap_or_default()
99 .into_iter()
100 .filter(|(_, status)| status.as_deref().is_some_and(is_terminal_child_status))
101 .map(|(id, _)| id)
102 .collect();
103 if !completed.iter().any(|id| id == just_completed_child_id) {
104 completed.push(just_completed_child_id.to_string());
105 }
106 completed.sort();
107 completed.dedup();
108 completed
109}
110
111fn read_config_snapshot(config: &Arc<RwLock<Config>>, cached_config: &StdRwLock<Config>) -> Config {
112 if let Ok(config_guard) = config.try_read() {
113 let snapshot = config_guard.clone();
114
115 if let Ok(mut cached_guard) = cached_config.try_write() {
116 *cached_guard = snapshot.clone();
117 }
118
119 snapshot
120 } else {
121 cached_config
122 .try_read()
123 .map(|guard| guard.clone())
124 .unwrap_or_default()
125 }
126}
127
128fn parent_locks() -> &'static std::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>> {
145 static LOCKS: OnceLock<std::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>> =
146 OnceLock::new();
147 LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
148}
149
150fn session_resume_lock(session_id: &str) -> Arc<tokio::sync::Mutex<()>> {
157 let mut map = parent_locks().lock().recover_poison();
158 map.entry(session_id.to_string())
159 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
160 .clone()
161}
162
163fn wait_policy_satisfied(
164 policy: ChildWaitPolicy,
165 wait_child_ids: &[String],
166 completed_child_ids: &[String],
167 latest_child_id: &str,
168 latest_status: &str,
169) -> bool {
170 if wait_child_ids.is_empty() {
171 return false;
172 }
173
174 match policy {
175 ChildWaitPolicy::All => wait_child_ids
176 .iter()
177 .all(|id| completed_child_ids.iter().any(|completed| completed == id)),
178 ChildWaitPolicy::Any => completed_child_ids
179 .iter()
180 .any(|id| wait_child_ids.iter().any(|wait_id| wait_id == id)),
181 ChildWaitPolicy::FirstError => {
182 (is_error_like(latest_status) && wait_child_ids.iter().any(|id| id == latest_child_id))
188 || wait_child_ids
189 .iter()
190 .all(|id| completed_child_ids.iter().any(|completed| completed == id))
191 }
192 }
193}
194
195fn child_final_assistant_text(child: &Session) -> Option<String> {
199 child
200 .messages
201 .iter()
202 .rev()
203 .find(|message| matches!(message.role, Role::Assistant))
204 .map(|message| message.content.clone())
205 .filter(|content| !content.trim().is_empty())
206}
207
208fn runtime_resume_message(
209 completion: &ChildCompletion,
210 remaining_children: usize,
211 child_final_response: Option<&str>,
212) -> Message {
213 let mut body = format!(
214 "Runtime notification: child session `{}` finished with status `{}`. Remaining child sessions: {}.",
215 completion.child_session_id, completion.status, remaining_children
216 );
217
218 let final_response = child_final_response.map(str::to_string);
224 if let Some(response) = final_response.as_deref() {
225 body.push_str("\n\nChild final response:\n");
226 body.push_str(response);
227 } else if let Some(error) = completion.error.as_deref() {
228 if !error.is_empty() {
229 body.push_str("\n\nChild error:\n");
230 body.push_str(error);
231 }
232 }
233
234 body.push_str(
235 "\n\nResume the parent task using this child result and continue from the previous plan. \
236 If you need the full child transcript, call SubAgent.get(child_session_id).",
237 );
238
239 let mut message = Message::user(body);
240 message.metadata = Some(serde_json::json!({
241 RUNTIME_RESUME_MESSAGE_HIDDEN_KEY: true,
242 RUNTIME_RESUME_MESSAGE_KIND_KEY: "child_completion_resume",
243 "child_session_id": completion.child_session_id,
244 "child_status": completion.status,
245 "child_error": completion.error,
246 "completed_at": completion.completed_at,
247 "child_final_response_included": final_response.is_some(),
248 }));
249 message.never_compress = false;
253 message
254}
255
256fn guardian_resume_message(completion: &ChildCompletion, verdict: &GuardianVerdict) -> Message {
261 let mut body = if verdict.approve {
262 String::from(
263 "Guardian review APPROVED: an independent reviewer verified the work and found no blocking issues. You may finalize the task.",
264 )
265 } else {
266 String::from(
267 "Guardian review REJECTED: an independent reviewer found issues. Address every finding below before completing — do NOT declare the task complete until they are resolved.",
268 )
269 };
270 if let Some(summary) = verdict.summary.as_deref().filter(|s| !s.trim().is_empty()) {
271 body.push_str("\n\nReviewer summary: ");
272 body.push_str(summary);
273 }
274 if !verdict.findings.is_empty() {
275 body.push_str("\n\nFindings:");
276 for (idx, finding) in verdict.findings.iter().enumerate() {
277 body.push_str(&format!("\n{}. {}", idx + 1, finding));
278 }
279 }
280 body.push_str(
281 "\n\nIf you need the full guardian transcript, call SubAgent.get(child_session_id).",
282 );
283
284 let mut message = Message::user(body);
285 message.metadata = Some(serde_json::json!({
286 RUNTIME_RESUME_MESSAGE_HIDDEN_KEY: true,
287 RUNTIME_RESUME_MESSAGE_KIND_KEY: "guardian_review_resume",
288 "child_session_id": completion.child_session_id,
289 "child_status": completion.status,
290 "guardian_approved": verdict.approve,
291 "completed_at": completion.completed_at,
292 }));
293 message.never_compress = false;
294 message
295}
296
297#[derive(Clone)]
298pub struct ChildCompletionCoordinator {
299 storage: Arc<dyn Storage>,
300 persistence: Arc<bamboo_storage::LockedSessionStore>,
301 sessions: crate::SessionCache,
302 agent_runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
303 session_event_senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
304 agent: Arc<Agent>,
305 config: Arc<RwLock<Config>>,
306 provider_registry: Arc<ProviderRegistry>,
307 provider_router: Arc<ProviderModelRouter>,
308 app_data_dir: std::path::PathBuf,
309 account_feed_inbox: Option<crate::execution::AccountFeedInbox>,
310 root_tools: Arc<RwLock<Option<Arc<dyn ToolExecutor>>>>,
311 guardian_spawner: Arc<RwLock<Option<Arc<dyn GuardianSpawner>>>>,
315}
316
317impl ChildCompletionCoordinator {
318 #[allow(clippy::too_many_arguments)]
319 pub fn new(
320 storage: Arc<dyn Storage>,
321 persistence: Arc<LockedSessionStore>,
322 sessions: crate::SessionCache,
323 agent_runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
324 session_event_senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
325 agent: Arc<Agent>,
326 config: Arc<RwLock<Config>>,
327 provider_registry: Arc<ProviderRegistry>,
328 provider_router: Arc<ProviderModelRouter>,
329 app_data_dir: std::path::PathBuf,
330 account_feed_inbox: Option<crate::execution::AccountFeedInbox>,
331 ) -> Self {
332 Self {
333 storage,
334 persistence,
335 sessions,
336 agent_runners,
337 session_event_senders,
338 agent,
339 config,
340 provider_registry,
341 provider_router,
342 app_data_dir,
343 account_feed_inbox,
344 root_tools: Arc::new(RwLock::new(None)),
345 guardian_spawner: Arc::new(RwLock::new(None)),
346 }
347 }
348
349 pub async fn set_root_tools(&self, tools: Arc<dyn ToolExecutor>) {
350 *self.root_tools.write().await = Some(tools);
351 }
352
353 pub async fn set_guardian_spawner(&self, spawner: Arc<dyn GuardianSpawner>) {
356 *self.guardian_spawner.write().await = Some(spawner);
357 }
358
359 fn build_resume_config(
360 &self,
361 session: &Session,
362 config_snapshot: &Config,
363 ) -> ResumeConfigSnapshot {
364 crate::session_app::resolution::resolve_resume_config_snapshot(
365 config_snapshot,
366 &self.provider_registry,
367 session,
368 None,
369 )
370 }
371
372 async fn resume_parent(&self, parent_session_id: String) -> ResumeOutcome {
380 for attempt in 0..=5u8 {
381 if attempt > 0 {
382 tokio::time::sleep(Duration::from_millis(250 * attempt as u64)).await;
383 }
384
385 let Some(session) = self.load_session(&parent_session_id).await else {
386 tracing::warn!(%parent_session_id, "cannot resume parent after child completion: session not found");
387 return ResumeOutcome::NotFound;
388 };
389 let config_snapshot = self.config.read().await.clone();
390 let resume_config = self.build_resume_config(&session, &config_snapshot);
391 let outcome = resume_session_execution(self, &parent_session_id, resume_config).await;
392 tracing::info!(
393 %parent_session_id,
394 attempt,
395 outcome = outcome.as_str(),
396 "child completion requested parent resume"
397 );
398
399 if !matches!(outcome, ResumeOutcome::AlreadyRunning { .. }) {
400 return outcome;
401 }
402 }
403 tracing::error!(
409 %parent_session_id,
410 "parent resume gave up after AlreadyRunning retry budget; \
411 relying on the child-wait watchdog backstop"
412 );
413 ResumeOutcome::AlreadyRunning {
414 run_id: String::new(),
415 }
416 }
417
418 async fn save_and_cache(&self, session: &mut Session) {
419 if let Err(error) = self.persistence.merge_save_runtime(session).await {
420 tracing::warn!(session_id = %session.id, %error, "failed to persist session");
421 }
422 self.sessions.insert(
423 session.id.clone(),
424 Arc::new(parking_lot::RwLock::new(session.clone())),
425 );
426 }
427}
428
429#[async_trait]
430impl ChildCompletionHandler for ChildCompletionCoordinator {
431 async fn on_child_completed(&self, completion: ChildCompletion) {
432 if !is_terminal_child_status(&completion.status) {
441 tracing::info!(
442 parent_session_id = %completion.parent_session_id,
443 child_session_id = %completion.child_session_id,
444 status = %completion.status,
445 "non-terminal child status; leaving the parent wait armed"
446 );
447 return;
448 }
449
450 let per_parent = session_resume_lock(&completion.parent_session_id);
455 let _per_parent_guard = per_parent.lock().await;
456
457 let Some(mut parent) = self.load_session(&completion.parent_session_id).await else {
458 tracing::warn!(
459 parent_session_id = %completion.parent_session_id,
460 child_session_id = %completion.child_session_id,
461 "child completion received for missing parent"
462 );
463 return;
464 };
465
466 let mut runtime_state = read_runtime_state(&parent);
472
473 let completed_child_ids = derive_completed_child_ids(
476 &self.storage,
477 &completion.parent_session_id,
478 &completion.child_session_id,
479 )
480 .await;
481
482 let mut should_resume = false;
483 let mut remaining_children = 0usize;
484 if let Some(wait) = runtime_state.waiting_for_children.clone() {
485 remaining_children = wait
486 .child_session_ids
487 .iter()
488 .filter(|id| !completed_child_ids.iter().any(|completed| completed == *id))
489 .count();
490 should_resume = wait_policy_satisfied(
491 wait.wait_for,
492 &wait.child_session_ids,
493 &completed_child_ids,
494 &completion.child_session_id,
495 &completion.status,
496 );
497 if should_resume {
498 runtime_state.waiting_for_children = None;
499 runtime_state.status = AgentStatusState::Idle;
500 runtime_state.suspension = None;
501 }
502 }
503
504 if should_resume {
505 parent.metadata.remove("runtime.suspend_reason");
506
507 let reported_child_owned = match self
519 .storage
520 .load_runtime_control_plane(&completion.child_session_id)
521 .await
522 {
523 Ok(Some(control_plane)) => completion_child_is_owned(
524 &completion.parent_session_id,
525 control_plane.parent_session_id.as_deref(),
526 ),
527 _ => false,
528 };
529
530 let loaded_child = if reported_child_owned {
535 match self
536 .storage
537 .load_session(&completion.child_session_id)
538 .await
539 {
540 Ok(child) => child,
541 Err(error) => {
542 tracing::warn!(
543 child_session_id = %completion.child_session_id,
544 %error,
545 "failed to load child session for runtime resume message"
546 );
547 None
548 }
549 }
550 } else {
551 tracing::warn!(
552 parent_session_id = %completion.parent_session_id,
553 child_session_id = %completion.child_session_id,
554 "completion child is not a child of this parent; resuming with a neutral \
555 message and NOT folding its content"
556 );
557 None
558 };
559
560 let reviewed_round = runtime_state.round.current_round;
566 let guardian_resume = loaded_child.as_ref().and_then(|child| {
567 if child.subagent_type().as_deref() != Some("guardian") {
568 return None;
569 }
570 let mut guardian_state = read_guardian_state(&parent)?;
571 if guardian_state.guardian_child_id.as_deref()
572 != Some(completion.child_session_id.as_str())
573 {
574 tracing::warn!(
577 parent_session_id = %completion.parent_session_id,
578 child_session_id = %completion.child_session_id,
579 expected = ?guardian_state.guardian_child_id,
580 "guardian completion does not match recorded guardian_child_id; using generic resume"
581 );
582 return None;
583 }
584 let verdict = child_final_assistant_text(child)
592 .and_then(|text| match parse_guardian_verdict(&text) {
593 Ok(verdict) => Some(verdict),
594 Err(error) => {
595 tracing::warn!(
596 child_session_id = %completion.child_session_id,
597 %error,
598 "guardian verdict unparseable; recording a synthetic reject"
599 );
600 None
601 }
602 })
603 .unwrap_or_else(|| {
604 GuardianVerdict::rejected(vec![
605 "The guardian reviewer did not return a usable verdict (it errored or \
606 emitted unparseable output); the work has NOT been independently \
607 verified."
608 .to_string(),
609 ])
610 });
611 let approved = verdict.approve;
612 let message = guardian_resume_message(&completion, &verdict);
613 guardian_state.record_verdict(verdict, reviewed_round);
614 write_guardian_state(&mut parent, guardian_state);
615 tracing::info!(
616 parent_session_id = %completion.parent_session_id,
617 child_session_id = %completion.child_session_id,
618 approved,
619 "guardian verdict recorded; resuming parent"
620 );
621 Some(message)
622 });
623
624 let resume_message = guardian_resume.unwrap_or_else(|| {
625 runtime_resume_message(
626 &completion,
627 remaining_children,
628 loaded_child
629 .as_ref()
630 .and_then(child_final_assistant_text)
631 .as_deref(),
632 )
633 });
634 parent.add_message(resume_message);
635 } else if runtime_state.waiting_for_children.is_some() {
636 runtime_state.status = AgentStatusState::Suspended;
637 runtime_state.suspension = Some(SuspensionState {
638 reason: "waiting_for_children".to_string(),
639 suspended_at: Utc::now(),
640 resumable: true,
641 hook_point: Some("ChildCompletion".to_string()),
642 });
643 }
644
645 parent.updated_at = Utc::now();
646 write_runtime_state(&mut parent, &runtime_state);
647 self.save_and_cache(&mut parent).await;
648
649 let resume_parent_id = parent.id.clone();
654 drop(_per_parent_guard);
655
656 if should_resume {
657 self.resume_parent(resume_parent_id).await;
658 }
659 }
660}
661
662#[async_trait]
663impl ResumeExecutionPort for ChildCompletionCoordinator {
664 async fn load_session(&self, session_id: &str) -> Option<Session> {
665 match self.storage.load_session(session_id).await {
666 Ok(Some(session)) => Some(session),
667 Ok(None) => self
668 .sessions
669 .get(session_id)
670 .map(|e| e.value().clone())
671 .map(|arc| arc.read().clone()),
672 Err(error) => {
673 tracing::warn!(%session_id, %error, "failed to load session from storage");
674 self.sessions
675 .get(session_id)
676 .map(|e| e.value().clone())
677 .map(|arc| arc.read().clone())
678 }
679 }
680 }
681
682 async fn save_and_cache_session(&self, session: &mut Session) {
683 self.save_and_cache(session).await;
684 }
685
686 async fn try_reserve_runner(
687 &self,
688 session_id: &str,
689 event_sender: &broadcast::Sender<AgentEvent>,
690 ) -> Option<RunnerReservation> {
691 try_reserve_runner(
692 &self.agent_runners,
693 &self.session_event_senders,
694 session_id,
695 event_sender,
696 )
697 .await
698 }
699
700 async fn get_existing_runner_run_id(&self, session_id: &str) -> Option<String> {
701 let runners = self.agent_runners.read().await;
702 runners.get(session_id).map(|r| r.run_id.clone())
703 }
704
705 async fn get_or_create_event_sender(&self, session_id: &str) -> broadcast::Sender<AgentEvent> {
706 crate::execution::session_events::get_or_create_event_sender(
707 &self.session_event_senders,
708 session_id,
709 )
710 .await
711 }
712
713 async fn spawn_resume_execution(&self, request: ResumeSpawnRequest) {
714 let ResumeSpawnRequest {
715 session_id,
716 session,
717 cancel_token,
718 run_id: _,
719 event_sender,
720 config,
721 } = request;
722
723 let Some(root_tools) = self.root_tools.read().await.clone() else {
724 tracing::error!(%session_id, "cannot resume parent after child completion: root tool surface is not initialized");
725 return;
726 };
727
728 let model = session.model.clone();
729 let resolved_provider_name = session_effective_model_ref(&session)
730 .map(|model_ref| model_ref.provider)
731 .unwrap_or(config.provider_name);
732 let provider_override = session_effective_model_ref(&session)
733 .and_then(|model_ref| match self.provider_router.route(&model_ref) {
734 Ok(provider) => Some(provider),
735 Err(error) => {
736 tracing::warn!(
737 session_id = %session_id,
738 provider = %model_ref.provider,
739 model = %model_ref.model,
740 error = %error,
741 "failed to resolve provider override for child-completion parent resume; falling back to runtime provider"
742 );
743 None
744 }
745 });
746 let config_snapshot = self.config.read().await.clone();
747 let resolved_fast_provider = resolve_fast_model(
748 &config_snapshot,
749 &resolved_provider_name,
750 &self.provider_registry,
751 )
752 .map(|model| model.provider);
753 let reasoning_effort = session.reasoning_effort;
754 let reasoning_effort_source = session
755 .metadata
756 .get("reasoning_effort_source")
757 .cloned()
758 .unwrap_or_default();
759 let gold_config = resolve_gold_config(
760 &config_snapshot,
761 session
762 .metadata
763 .get(GOLD_CONFIG_METADATA_KEY)
764 .map(String::as_str),
765 )
766 .or(config.gold_config.clone());
767
768 let (mpsc_tx, _forwarder) = create_event_forwarder(
769 session_id.clone(),
770 event_sender,
771 self.agent_runners.clone(),
772 self.account_feed_inbox.clone(),
773 );
774
775 let config_handle = self.config.clone();
776 let cached_config = Arc::new(StdRwLock::new(config_snapshot.clone()));
777 let provider_registry = self.provider_registry.clone();
778 let provider_name_for_aux = resolved_provider_name.clone();
779 let auxiliary_model_resolver = std::sync::Arc::new(move || {
780 let config_snapshot = read_config_snapshot(&config_handle, cached_config.as_ref());
781 let areas = resolve_global_area_models(
783 &config_snapshot,
784 &provider_name_for_aux,
785 &provider_registry,
786 );
787 crate::AuxiliaryModelConfig {
788 fast_model_name: areas.fast.as_ref().map(|m| m.model_name.clone()),
789 fast_model_provider: areas.fast.map(|m| m.provider),
790 background_model_name: areas.background.as_ref().map(|m| m.model_name.clone()),
791 planning_model_name: None,
792 search_model_name: None,
793 summarization_model_name: areas
794 .summarization
795 .as_ref()
796 .map(|m| m.model_name.clone()),
797 background_model_provider: areas.background.map(|m| m.provider),
798 summarization_model_provider: areas.summarization.map(|m| m.provider),
799 }
800 });
801 let model_roster = crate::ModelRoster {
802 model: Some(model),
803 provider_name: Some(resolved_provider_name),
804 provider_type: config.provider_type.clone(),
805 fast: crate::RoleModel::from_parts(config.fast_model, resolved_fast_provider),
806 background: crate::RoleModel::from_parts(
807 config.background_model,
808 config.background_model_provider,
809 ),
810 summarization: crate::RoleModel::from_parts(
811 config.summarization_model,
812 config.summarization_model_provider,
813 ),
814 };
815
816 let guardian_config = read_guardian_config(&session);
821 let guardian_spawner = self.guardian_spawner.read().await.clone();
822
823 spawn_session_execution(SessionExecutionArgs {
824 agent: self.agent.clone(),
825 session_id,
826 session,
827 tools_override: Some(root_tools),
828 provider_override,
829 model_roster,
830 reasoning_effort,
831 reasoning_effort_source,
832 auxiliary_model_resolver: Some(auxiliary_model_resolver),
833 disabled_filter_resolver: None,
836 disabled_tools: Some(config.disabled_tools),
837 disabled_skill_ids: Some(config.disabled_skill_ids),
838 selected_skill_ids: None,
839 selected_skill_mode: None,
840 cancel_token,
841 mpsc_tx,
842 image_fallback: config.image_fallback,
843 gold_config,
844 guardian_config,
845 guardian_spawner,
846 bash_resume_hook: {
847 let hook: Arc<dyn BashResumeHook> = Arc::new(self.clone());
848 Some(hook)
849 },
850 bash_completion_sink: {
851 let sink: Arc<dyn BashCompletionSink> = Arc::new(self.clone());
854 Some(sink)
855 },
856 app_data_dir: Some(self.app_data_dir.clone()),
857 run_budget: None,
860 runners: self.agent_runners.clone(),
861 sessions_cache: self.sessions.clone(),
862 on_complete: None,
863 child_completion_handler: Some(Arc::new(self.clone())),
867 });
868 }
869}
870
871fn bash_completion_resume_message(bash_ids: &[String], timed_out: bool) -> Message {
881 let body = if timed_out {
882 format!(
883 "Runtime notification: the background-Bash wait ceiling was reached while one or more \
884 shell(s) ({}) may still be running. The session is being resumed so it is not \
885 stranded; verify their actual status with BashOutput before assuming completion.",
886 bash_ids.join(", ")
887 )
888 } else {
889 format!(
890 "Runtime notification: all background Bash shell(s) ({}) have completed. \
891 Review their output with BashOutput and resume the task from where you left off.",
892 bash_ids.join(", ")
893 )
894 };
895 let mut message = Message::user(body);
896 message.metadata = Some(serde_json::json!({
897 RUNTIME_RESUME_MESSAGE_HIDDEN_KEY: true,
898 RUNTIME_RESUME_MESSAGE_KIND_KEY: BASH_COMPLETION_RESUME_KIND,
899 }));
900 message.never_compress = false;
901 message
902}
903
904fn bash_resume_should_retry(outcome: &ResumeOutcome, persisted_waiting_for_bash: bool) -> bool {
917 match outcome {
918 ResumeOutcome::Started { .. } | ResumeOutcome::NotFound => false,
919 ResumeOutcome::Completed | ResumeOutcome::AlreadyRunning { .. } => {
920 persisted_waiting_for_bash
921 }
922 }
923}
924
925fn bash_completion_should_resume(
933 loop_suspended_on_bash: bool,
934 all_waited_shells_done: bool,
935) -> bool {
936 loop_suspended_on_bash && all_waited_shells_done
937}
938
939fn apply_bash_resume_transition(session: &mut Session, resume_message: &Message) -> bool {
947 let mut runtime_state = read_runtime_state(session);
948 if runtime_state.waiting_for_bash.is_none() {
949 return false;
950 }
951 runtime_state.waiting_for_bash = None;
952 runtime_state.status = AgentStatusState::Idle;
953 runtime_state.suspension = None;
954 write_runtime_state(session, &runtime_state);
955 session.metadata.remove("runtime.suspend_reason");
956 session.add_message(resume_message.clone());
957 true
958}
959
960impl ChildCompletionCoordinator {
962 async fn bash_self_resume(&self, session_id: String, bash_ids: Vec<String>) {
978 let mut delay = Duration::from_secs(1);
979 let max_delay = Duration::from_secs(30);
980 let max_poll = Duration::from_secs(6 * 3600 + 600);
984 let deadline = tokio::time::Instant::now() + max_poll;
985
986 loop {
987 tokio::time::sleep(delay).await;
988
989 let Some(session) = self.load_session(&session_id).await else {
990 tracing::info!(%session_id, "bash self-resume backstop: session gone; nothing to do");
991 return;
992 };
993 if read_runtime_state(&session).waiting_for_bash.is_none() {
994 return;
997 }
998
999 let still_running =
1000 bamboo_tools::tools::bash_runtime::running_shells_for_session(&session_id);
1001 let timed_out = tokio::time::Instant::now() >= deadline;
1002 if still_running.is_empty() || timed_out {
1003 let guard = session_resume_lock(&session_id);
1008 let _held = guard.lock().await;
1009 tracing::warn!(
1010 %session_id,
1011 shell_count = bash_ids.len(),
1012 timed_out,
1013 "bash self-resume backstop engaged (push lost or wait ceiling reached)"
1014 );
1015 self.perform_bash_resume(
1016 &session_id,
1017 bash_completion_resume_message(&bash_ids, timed_out),
1018 )
1019 .await;
1020 return;
1021 }
1022
1023 delay = (delay * 2).min(max_delay);
1024 }
1025 }
1026
1027 async fn perform_bash_resume(&self, session_id: &str, resume_message: Message) {
1048 let retry_backoff = Duration::from_millis(200);
1049 const MAX_RESUME_ATTEMPTS: u8 = 5;
1050 for attempt in 0..MAX_RESUME_ATTEMPTS {
1051 if attempt > 0 {
1052 tokio::time::sleep(retry_backoff).await;
1053 }
1054
1055 let Some(mut session) = self.load_session(session_id).await else {
1056 tracing::warn!(%session_id, "bash resume: session not found; nothing to resume");
1057 return;
1058 };
1059
1060 if !apply_bash_resume_transition(&mut session, &resume_message) {
1061 tracing::info!(
1065 %session_id, attempt,
1066 "bash resume: persisted bash wait already cleared; nothing to resume"
1067 );
1068 return;
1069 }
1070 session.updated_at = Utc::now();
1071 self.save_and_cache(&mut session).await;
1072 tracing::info!(
1073 %session_id, attempt,
1074 "bash resume: cleared bash wait and appended resume message"
1075 );
1076
1077 let outcome = self.resume_parent(session_id.to_string()).await;
1078 match outcome {
1079 ResumeOutcome::Started { .. } => {
1080 tracing::info!(%session_id, attempt, "bash resume: resume fired");
1081 return;
1082 }
1083 ResumeOutcome::NotFound => {
1084 tracing::warn!(%session_id, "bash resume: session vanished during resume");
1085 return;
1086 }
1087 _ => {
1088 let clobbered = match self.load_session(session_id).await {
1094 Some(reloaded) => read_runtime_state(&reloaded).waiting_for_bash.is_some(),
1095 None => {
1096 tracing::warn!(%session_id, "bash resume: session vanished after resume");
1097 return;
1098 }
1099 };
1100 if bash_resume_should_retry(&outcome, clobbered) {
1101 tracing::warn!(
1102 %session_id, attempt,
1103 outcome = outcome.as_str(),
1104 "bash resume: persisted wait still set after resume (finalize-clobber); retrying"
1105 );
1106 continue;
1107 }
1108 tracing::info!(
1109 %session_id, attempt,
1110 outcome = outcome.as_str(),
1111 "bash resume: wait cleared and resume handled; stopping"
1112 );
1113 return;
1114 }
1115 }
1116 }
1117
1118 tracing::warn!(
1119 %session_id,
1120 attempts = MAX_RESUME_ATTEMPTS,
1121 "bash resume: exhausted clobber-retry budget without confirming resume; giving up"
1122 );
1123 }
1124}
1125
1126impl BashResumeHook for ChildCompletionCoordinator {
1127 fn arrange_bash_self_resume(&self, session_id: String, bash_ids: Vec<String>) {
1128 let coordinator = Arc::new(self.clone());
1129 tokio::spawn(async move {
1130 coordinator.bash_self_resume(session_id, bash_ids).await;
1131 });
1132 }
1133}
1134
1135fn bash_completion_injection_body(info: &BashCompletionInfo) -> String {
1140 let exit = match info.exit_code {
1141 Some(code) => code.to_string(),
1142 None => "none (signal/killed)".to_string(),
1143 };
1144 let mut body = format!(
1145 "Runtime notification: background shell `{}` (`{}`) finished — status {}, exit code {}.",
1146 info.bash_id, info.command, info.status, exit
1147 );
1148 if info.output_tail.trim().is_empty() {
1149 body.push_str(" It produced no captured output.");
1150 } else {
1151 body.push_str("\n\nOutput tail:\n");
1152 body.push_str(&info.output_tail);
1153 }
1154 body.push_str(&format!(
1155 "\n\nUse BashOutput with bash_id=\"{}\" for the full output, then continue the task.",
1156 info.bash_id
1157 ));
1158 body
1159}
1160
1161async fn enqueue_bash_completion_injection(
1184 persistence: &LockedSessionStore,
1185 info: &BashCompletionInfo,
1186) -> std::io::Result<Option<Session>> {
1187 let body = bash_completion_injection_body(info);
1188 let queued = serde_json::json!({
1189 "content": body,
1190 "created_at": Utc::now(),
1191 });
1192 persistence
1193 .update_runtime_config(&info.session_id, move |session| {
1194 let mut pending = session.pending_injected_messages().unwrap_or_default();
1195 pending.push(queued);
1196 session.set_pending_injected_messages(pending);
1197 })
1198 .await
1199}
1200
1201fn bash_resume_message_from_info(info: &BashCompletionInfo) -> Message {
1209 let mut message = Message::user(bash_completion_injection_body(info));
1210 message.metadata = Some(serde_json::json!({
1211 RUNTIME_RESUME_MESSAGE_HIDDEN_KEY: true,
1212 RUNTIME_RESUME_MESSAGE_KIND_KEY: BASH_COMPLETION_RESUME_KIND,
1213 }));
1214 message.never_compress = false;
1215 message
1216}
1217
1218impl ChildCompletionCoordinator {
1219 async fn deliver_bash_completion(&self, info: BashCompletionInfo) {
1232 let guard = session_resume_lock(&info.session_id);
1233 let _held = guard.lock().await;
1234
1235 let Some(session) = self.load_session(&info.session_id).await else {
1236 tracing::warn!(
1237 session_id = %info.session_id,
1238 bash_id = %info.bash_id,
1239 "background bash completion: owning session not found; nothing to notify"
1240 );
1241 return;
1242 };
1243
1244 let waiting = read_runtime_state(&session).waiting_for_bash.is_some();
1245 let all_shells_done =
1251 bamboo_tools::tools::bash_runtime::running_shells_for_session(&info.session_id)
1252 .is_empty();
1253
1254 if bash_completion_should_resume(waiting, all_shells_done) {
1255 tracing::info!(
1256 session_id = %info.session_id,
1257 bash_id = %info.bash_id,
1258 status = %info.status,
1259 "background bash completion: push-resuming suspended loop (event-driven)"
1260 );
1261 self.perform_bash_resume(&info.session_id, bash_resume_message_from_info(&info))
1262 .await;
1263 return;
1264 }
1265
1266 match enqueue_bash_completion_injection(&self.persistence, &info).await {
1267 Ok(Some(_)) => tracing::info!(
1268 session_id = %info.session_id,
1269 bash_id = %info.bash_id,
1270 status = %info.status,
1271 waiting,
1272 "background bash completion queued for injection at the next round boundary"
1273 ),
1274 Ok(None) => tracing::warn!(
1275 session_id = %info.session_id,
1276 bash_id = %info.bash_id,
1277 "background bash completion: owning session not found; nothing to notify"
1278 ),
1279 Err(error) => tracing::warn!(
1280 session_id = %info.session_id,
1281 bash_id = %info.bash_id,
1282 %error,
1283 "background bash completion: failed to queue injection"
1284 ),
1285 }
1286 }
1287}
1288
1289impl BashCompletionSink for ChildCompletionCoordinator {
1290 fn on_bash_completed(&self, info: BashCompletionInfo) {
1291 let coordinator = Arc::new(self.clone());
1295 tokio::spawn(async move {
1296 coordinator.deliver_bash_completion(info).await;
1297 });
1298 }
1299}
1300
1301const CHILD_WAIT_SWEEP_INTERVAL_SECS: u64 = 30;
1307const CHILD_WAIT_REGISTRATION_GRACE_SECS: i64 = 60;
1311const DEAD_CHILD_GRACE_SECS: i64 = 120;
1314const STALE_RUNNER_SLACK_SECS: i64 = 600;
1320
1321fn is_dead_child_candidate_status(status: Option<&str>) -> bool {
1326 match status {
1327 Some(status) => !is_terminal_child_status(status) && status != "suspended",
1330 None => true,
1331 }
1332}
1333
1334fn completion_child_is_owned(reported_parent: &str, child_parent_linkage: Option<&str>) -> bool {
1342 child_parent_linkage == Some(reported_parent)
1343}
1344
1345fn select_replay_child(terminal: &[(String, String)]) -> Option<&(String, String)> {
1349 terminal
1350 .iter()
1351 .find(|(_, status)| is_error_like(status))
1352 .or_else(|| terminal.last())
1353}
1354
1355fn child_wait_watchdog_resume_message(body: String) -> Message {
1356 let mut message = Message::user(body);
1357 message.metadata = Some(serde_json::json!({
1358 RUNTIME_RESUME_MESSAGE_HIDDEN_KEY: true,
1359 RUNTIME_RESUME_MESSAGE_KIND_KEY: "child_wait_watchdog_resume",
1360 }));
1361 message.never_compress = false;
1362 message
1363}
1364
1365fn empty_child_wait_message() -> Message {
1366 child_wait_watchdog_resume_message(
1367 "Runtime notification: this session was suspended waiting for child sessions, but the \
1368 wait tracked no children (internal inconsistency). The session has been resumed; use \
1369 SubAgent.list to inspect child state and continue the task."
1370 .to_string(),
1371 )
1372}
1373
1374fn child_wait_lease_expired_message(child_ids: &[String]) -> Message {
1375 child_wait_watchdog_resume_message(format!(
1376 "Runtime notification: the wait lease for child session(s) [{}] expired before they all \
1377 reported completion. They were NOT cancelled and may still be running or already \
1378 finished — verify their actual status with SubAgent.list / SubAgent.get before assuming \
1379 anything, then continue the task.",
1380 child_ids.join(", ")
1381 ))
1382}
1383
1384impl ChildCompletionCoordinator {
1400 pub fn spawn_child_wait_watchdog(self: &Arc<Self>) {
1403 let coordinator = Arc::clone(self);
1404 tokio::spawn(async move {
1405 use futures::FutureExt;
1406 if std::panic::AssertUnwindSafe(coordinator.reconcile_orphans_at_boot())
1407 .catch_unwind()
1408 .await
1409 .is_err()
1410 {
1411 tracing::error!("child-wait watchdog: boot reconciliation panicked");
1412 }
1413 let mut ticker = tokio::time::interval(std::time::Duration::from_secs(
1414 CHILD_WAIT_SWEEP_INTERVAL_SECS,
1415 ));
1416 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1417 ticker.tick().await;
1419 loop {
1420 ticker.tick().await;
1421 if std::panic::AssertUnwindSafe(coordinator.sweep_child_waits())
1422 .catch_unwind()
1423 .await
1424 .is_err()
1425 {
1426 tracing::error!("child-wait watchdog: sweep panicked; continuing");
1427 }
1428 }
1429 });
1430 }
1431
1432 async fn reconcile_orphans_at_boot(&self) {
1437 let cutoff = Utc::now();
1438
1439 let running = self
1445 .storage
1446 .list_sessions_by_run_status("running")
1447 .await
1448 .unwrap_or_default();
1449 for (child_id, parent_id) in running {
1450 let Some(parent_id) = parent_id else { continue };
1451 if self.runner_is_running(&child_id).await {
1452 continue;
1453 }
1454 let Some(control_plane) = self.load_control_plane(&child_id).await else {
1455 continue;
1456 };
1457 if control_plane.updated_at >= cutoff {
1460 continue;
1461 }
1462 tracing::warn!(
1463 child_session_id = %child_id,
1464 parent_session_id = %parent_id,
1465 "boot reconciliation: child was running when the process died; \
1466 marking it error and waking the parent"
1467 );
1468 self.synthesize_child_completion(
1469 &parent_id,
1470 &child_id,
1471 "error",
1472 Some(
1473 "orphaned by server restart: the process died while this child session \
1474 was running"
1475 .to_string(),
1476 ),
1477 )
1478 .await;
1479 }
1480
1481 let suspended = self
1485 .storage
1486 .list_sessions_by_run_status("suspended")
1487 .await
1488 .unwrap_or_default();
1489 for (session_id, _) in suspended {
1490 let Some(control_plane) = self.load_control_plane(&session_id).await else {
1491 continue;
1492 };
1493 if control_plane
1494 .metadata
1495 .get("runtime.suspend_reason")
1496 .map(String::as_str)
1497 != Some("waiting_for_bash")
1498 {
1499 continue;
1500 }
1501 if let Some(wait) = read_runtime_state(&control_plane).waiting_for_bash {
1502 tracing::warn!(
1503 %session_id,
1504 "boot reconciliation: re-arming bash self-resume backstop lost in restart"
1505 );
1506 let coordinator = self.clone();
1507 tokio::spawn(async move {
1508 coordinator
1509 .bash_self_resume(session_id, wait.bash_ids)
1510 .await;
1511 });
1512 }
1513 }
1514 }
1515
1516 async fn runner_is_running(&self, session_id: &str) -> bool {
1517 let runners = self.agent_runners.read().await;
1518 runners
1519 .get(session_id)
1520 .is_some_and(|runner| matches!(runner.status, AgentStatus::Running))
1521 }
1522
1523 async fn load_control_plane(&self, session_id: &str) -> Option<Session> {
1524 match self.storage.load_runtime_control_plane(session_id).await {
1525 Ok(session) => session,
1526 Err(error) => {
1527 tracing::warn!(
1528 %session_id,
1529 %error,
1530 "child-wait watchdog: failed to load session control plane"
1531 );
1532 None
1533 }
1534 }
1535 }
1536
1537 async fn sweep_child_waits(&self) {
1541 let suspended = match self.storage.list_sessions_by_run_status("suspended").await {
1542 Ok(entries) => entries,
1543 Err(error) => {
1544 tracing::warn!(%error, "child-wait watchdog: failed to list suspended sessions");
1545 return;
1546 }
1547 };
1548 for (session_id, _) in suspended {
1549 self.sweep_one_suspended_session(&session_id).await;
1550 }
1551 }
1552
1553 async fn sweep_one_suspended_session(&self, session_id: &str) {
1554 if self.runner_is_running(session_id).await {
1557 return;
1558 }
1559 let Some(session) = self.load_control_plane(session_id).await else {
1560 return;
1561 };
1562 let runtime_state = read_runtime_state(&session);
1563 let suspend_reason = session
1564 .metadata
1565 .get("runtime.suspend_reason")
1566 .map(String::as_str)
1567 .unwrap_or_default()
1568 .to_string();
1569 match (
1570 suspend_reason.as_str(),
1571 runtime_state.waiting_for_children.clone(),
1572 ) {
1573 ("waiting_for_bash", _)
1575 | ("awaiting_clarification", _)
1576 | ("awaiting_parent_approval", _) => {}
1577 (_, Some(wait)) => self.sweep_child_wait(session_id, wait).await,
1578 ("waiting_for_children", None) | ("", None) => {
1581 self.rescue_stranded_resume(session_id).await;
1582 }
1583 _ => {}
1584 }
1585 }
1586
1587 async fn sweep_child_wait(&self, parent_session_id: &str, wait: WaitingForChildrenState) {
1592 let now = Utc::now();
1593
1594 if wait.child_session_ids.is_empty() {
1595 tracing::warn!(
1596 %parent_session_id,
1597 "child-wait watchdog: wait armed over an empty child set; force-resuming"
1598 );
1599 self.force_resume_child_wait(parent_session_id, empty_child_wait_message())
1600 .await;
1601 return;
1602 }
1603
1604 if wait.timeout_at.is_some_and(|deadline| now >= deadline) {
1608 tracing::warn!(
1609 %parent_session_id,
1610 "child-wait watchdog: wait lease expired; force-resuming parent"
1611 );
1612 self.force_resume_child_wait(
1613 parent_session_id,
1614 child_wait_lease_expired_message(&wait.child_session_ids),
1615 )
1616 .await;
1617 return;
1618 }
1619
1620 if now.signed_duration_since(wait.registered_at).num_seconds()
1622 < CHILD_WAIT_REGISTRATION_GRACE_SECS
1623 {
1624 return;
1625 }
1626
1627 let statuses: HashMap<String, Option<String>> = self
1628 .storage
1629 .list_child_run_statuses(parent_session_id)
1630 .await
1631 .unwrap_or_default()
1632 .into_iter()
1633 .collect();
1634
1635 struct DeadChild {
1636 child_id: String,
1637 status: String,
1638 reason: String,
1639 owned: bool,
1643 }
1644
1645 let mut terminal: Vec<(String, String)> = Vec::new();
1646 let mut dead: Vec<DeadChild> = Vec::new();
1647 for child_id in &wait.child_session_ids {
1648 let status = statuses.get(child_id).and_then(|status| status.as_deref());
1649 if let Some(status) = status {
1650 if is_terminal_child_status(status) {
1651 terminal.push((child_id.clone(), status.to_string()));
1652 continue;
1653 }
1654 }
1655 if !is_dead_child_candidate_status(status) {
1656 continue;
1657 }
1658
1659 let control_plane = self.load_control_plane(child_id).await;
1666 let owned = control_plane
1667 .as_ref()
1668 .is_some_and(|cp| cp.parent_session_id.as_deref() == Some(parent_session_id));
1669 if !owned {
1670 dead.push(DeadChild {
1671 child_id: child_id.clone(),
1672 status: "error".to_string(),
1673 reason: if control_plane.is_some() {
1674 "waited-on session id is not a child of this session; clearing it \
1675 from the wait without touching that session"
1676 .to_string()
1677 } else {
1678 "waited-on child session does not exist".to_string()
1679 },
1680 owned: false,
1681 });
1682 continue;
1683 }
1684
1685 let runner = { self.agent_runners.read().await.get(child_id).cloned() };
1686 match runner {
1687 Some(runner) if matches!(runner.status, AgentStatus::Running) => {
1688 let last_activity = runner.last_event_at.unwrap_or(runner.started_at);
1694 let idle_secs = now.signed_duration_since(last_activity).num_seconds();
1695 let total_secs = now.signed_duration_since(runner.started_at).num_seconds();
1696 let policy = match &control_plane {
1697 Some(child) => {
1698 crate::runtime::execution::spawn::watchdog_policy_for_session(child)
1699 }
1700 None => Default::default(),
1701 };
1702 let idle_limit = policy.max_idle_secs.saturating_add(STALE_RUNNER_SLACK_SECS);
1703 let total_limit = policy
1704 .max_total_secs
1705 .saturating_add(STALE_RUNNER_SLACK_SECS);
1706 if idle_secs >= idle_limit || total_secs >= total_limit {
1707 runner.cancel_token.cancel();
1708 dead.push(DeadChild {
1709 child_id: child_id.clone(),
1710 status: "timeout".to_string(),
1711 reason: format!(
1712 "child runner stalled: no events for {idle_secs}s \
1713 (limit {idle_limit}s including watchdog slack); \
1714 force-finalized by the child-wait watchdog"
1715 ),
1716 owned: true,
1717 });
1718 }
1719 }
1720 _ => {
1721 let quiet_secs = control_plane
1725 .as_ref()
1726 .map(|child| now.signed_duration_since(child.updated_at).num_seconds())
1727 .unwrap_or(i64::MAX);
1728 if quiet_secs >= DEAD_CHILD_GRACE_SECS {
1729 dead.push(DeadChild {
1730 child_id: child_id.clone(),
1731 status: "error".to_string(),
1732 reason: format!(
1733 "child runner lost (crashed task, dropped spawn job, or \
1734 process restart): index status {status:?} with no live \
1735 runner driving it"
1736 ),
1737 owned: true,
1738 });
1739 }
1740 }
1741 }
1742 }
1743
1744 if !dead.is_empty() {
1745 for entry in dead {
1746 tracing::warn!(
1747 %parent_session_id,
1748 child_session_id = %entry.child_id,
1749 status = %entry.status,
1750 reason = %entry.reason,
1751 owned = entry.owned,
1752 "child-wait watchdog: synthesizing terminal completion for dead child"
1753 );
1754 if entry.owned {
1755 self.synthesize_child_completion(
1756 parent_session_id,
1757 &entry.child_id,
1758 &entry.status,
1759 Some(entry.reason),
1760 )
1761 .await;
1762 } else {
1763 self.publish_synthetic_completion(
1765 parent_session_id,
1766 &entry.child_id,
1767 &entry.status,
1768 Some(entry.reason),
1769 )
1770 .await;
1771 }
1772 }
1773 return;
1775 }
1776
1777 let terminal_ids: Vec<String> = terminal.iter().map(|(id, _)| id.clone()).collect();
1783 if let Some((child_id, status)) = select_replay_child(&terminal) {
1784 if wait_policy_satisfied(
1785 wait.wait_for,
1786 &wait.child_session_ids,
1787 &terminal_ids,
1788 child_id,
1789 status,
1790 ) {
1791 tracing::warn!(
1792 %parent_session_id,
1793 child_session_id = %child_id,
1794 "child-wait watchdog: wait already satisfied but parent still suspended \
1795 (lost wake); replaying the completion"
1796 );
1797 let error = self
1798 .load_control_plane(child_id)
1799 .await
1800 .and_then(|child| child.last_run_error());
1801 self.publish_synthetic_completion(parent_session_id, child_id, status, error)
1802 .await;
1803 }
1804 }
1805 }
1806
1807 async fn synthesize_child_completion(
1813 &self,
1814 parent_session_id: &str,
1815 child_session_id: &str,
1816 status: &str,
1817 error: Option<String>,
1818 ) {
1819 match self.storage.load_session(child_session_id).await {
1820 Ok(Some(mut child)) => {
1821 if child.parent_session_id.as_deref() != Some(parent_session_id) {
1826 tracing::warn!(
1827 %parent_session_id,
1828 child_session_id = %child.id,
1829 "child-wait watchdog: refusing to synthesize status onto a session \
1830 that is not a child of this parent"
1831 );
1832 self.publish_synthetic_completion(
1833 parent_session_id,
1834 child_session_id,
1835 status,
1836 error,
1837 )
1838 .await;
1839 return;
1840 }
1841 child.set_last_run_status(status);
1842 match &error {
1843 Some(message) => child.set_last_run_error(message.clone()),
1844 None => child.clear_last_run_error(),
1845 }
1846 child.updated_at = Utc::now();
1847 if let Err(save_error) = self.persistence.merge_save_runtime(&mut child).await {
1848 tracing::warn!(
1849 child_session_id = %child.id,
1850 %save_error,
1851 "child-wait watchdog: failed to persist synthesized terminal status"
1852 );
1853 }
1854 self.sessions
1855 .insert(child.id.clone(), Arc::new(parking_lot::RwLock::new(child)));
1856 }
1857 Ok(None) => {}
1858 Err(load_error) => {
1859 tracing::warn!(
1860 %child_session_id,
1861 %load_error,
1862 "child-wait watchdog: failed to load child for synthesized terminal status"
1863 );
1864 }
1865 }
1866 finalize_runner(
1867 &self.agent_runners,
1868 child_session_id,
1869 &Err(bamboo_agent_core::AgentError::LLM(
1870 error
1871 .clone()
1872 .unwrap_or_else(|| format!("synthesized {status}")),
1873 )),
1874 )
1875 .await;
1876 self.publish_synthetic_completion(parent_session_id, child_session_id, status, error)
1877 .await;
1878 }
1879
1880 async fn publish_synthetic_completion(
1881 &self,
1882 parent_session_id: &str,
1883 child_session_id: &str,
1884 status: &str,
1885 error: Option<String>,
1886 ) {
1887 let parent_tx = crate::execution::session_events::get_or_create_event_sender(
1888 &self.session_event_senders,
1889 parent_session_id,
1890 )
1891 .await;
1892 let handler: Arc<dyn ChildCompletionHandler> = Arc::new(self.clone());
1893 crate::runtime::execution::spawn::publish_child_completion_parts(
1894 &parent_tx,
1895 Some(handler),
1896 parent_session_id.to_string(),
1897 child_session_id.to_string(),
1898 status.to_string(),
1899 error,
1900 )
1901 .await;
1902 }
1903
1904 async fn rescue_stranded_resume(&self, session_id: &str) {
1910 let Some(session) = self.load_session(session_id).await else {
1911 return;
1912 };
1913 let pending_runtime_resume = session.messages.last().is_some_and(|message| {
1914 matches!(message.role, Role::User)
1915 && message
1916 .metadata
1917 .as_ref()
1918 .is_some_and(|meta| meta.get(RUNTIME_RESUME_MESSAGE_KIND_KEY).is_some())
1919 });
1920 if !pending_runtime_resume {
1921 return;
1922 }
1923 tracing::warn!(
1924 %session_id,
1925 "child-wait watchdog: stranded resume detected (wait cleared, resume never \
1926 spawned); resuming"
1927 );
1928 self.resume_parent(session_id.to_string()).await;
1929 }
1930
1931 async fn force_resume_child_wait(&self, session_id: &str, resume_message: Message) {
1937 const MAX_ATTEMPTS: u8 = 5;
1938 let lock = session_resume_lock(session_id);
1939 for attempt in 0..MAX_ATTEMPTS {
1940 if attempt > 0 {
1941 tokio::time::sleep(Duration::from_millis(200)).await;
1942 }
1943 {
1944 let _held = lock.lock().await;
1945 let Some(mut session) = self.load_session(session_id).await else {
1946 return;
1947 };
1948 let mut runtime_state = read_runtime_state(&session);
1949 if runtime_state.waiting_for_children.is_none() {
1950 return;
1952 }
1953 runtime_state.waiting_for_children = None;
1954 runtime_state.status = AgentStatusState::Idle;
1955 runtime_state.suspension = None;
1956 write_runtime_state(&mut session, &runtime_state);
1957 session.metadata.remove("runtime.suspend_reason");
1958 session.add_message(resume_message.clone());
1959 session.updated_at = Utc::now();
1960 self.save_and_cache(&mut session).await;
1961 }
1962 let outcome = self.resume_parent(session_id.to_string()).await;
1963 match outcome {
1964 ResumeOutcome::Started { .. } | ResumeOutcome::NotFound => return,
1965 ResumeOutcome::Completed | ResumeOutcome::AlreadyRunning { .. } => {
1966 let clobbered = self
1970 .load_session(session_id)
1971 .await
1972 .map(|session| read_runtime_state(&session).waiting_for_children.is_some())
1973 .unwrap_or(false);
1974 if !clobbered {
1975 return;
1976 }
1977 }
1978 }
1979 }
1980 tracing::error!(
1981 %session_id,
1982 "child-wait watchdog: force-resume exhausted its clobber-retry budget"
1983 );
1984 }
1985}
1986
1987#[cfg(test)]
1988mod tests {
1989 use super::*;
1990 use bamboo_agent_core::Message;
1991
1992 #[test]
1995 fn dead_child_candidate_status_matrix() {
1996 assert!(is_dead_child_candidate_status(None));
1998 assert!(is_dead_child_candidate_status(Some("running")));
2001 assert!(is_dead_child_candidate_status(Some("pending")));
2002 assert!(!is_dead_child_candidate_status(Some("suspended")));
2004 for status in ["completed", "error", "timeout", "cancelled", "skipped"] {
2006 assert!(!is_dead_child_candidate_status(Some(status)), "{status}");
2007 }
2008 }
2009
2010 #[test]
2011 fn completion_child_ownership_gates_content_fold() {
2012 assert!(completion_child_is_owned("parent-1", Some("parent-1")));
2014 assert!(!completion_child_is_owned("parent-1", Some("parent-2")));
2017 assert!(!completion_child_is_owned("parent-1", None));
2019 }
2020
2021 #[test]
2022 fn replay_child_prefers_error_like_for_first_error_policy() {
2023 let terminal = vec![
2024 ("c-ok".to_string(), "completed".to_string()),
2025 ("c-err".to_string(), "timeout".to_string()),
2026 ("c-late".to_string(), "completed".to_string()),
2027 ];
2028 let (id, status) = select_replay_child(&terminal).expect("non-empty");
2029 assert_eq!(id, "c-err");
2030 assert_eq!(status, "timeout");
2031
2032 let all_ok = vec![
2033 ("c-1".to_string(), "completed".to_string()),
2034 ("c-2".to_string(), "completed".to_string()),
2035 ];
2036 let (id, _) = select_replay_child(&all_ok).expect("non-empty");
2037 assert_eq!(id, "c-2");
2038
2039 assert!(select_replay_child(&[]).is_none());
2040 }
2041
2042 #[test]
2043 fn watchdog_resume_messages_are_hidden_runtime_messages() {
2044 for message in [
2045 empty_child_wait_message(),
2046 child_wait_lease_expired_message(&["c-1".to_string(), "c-2".to_string()]),
2047 ] {
2048 assert!(matches!(message.role, Role::User));
2049 let meta = message.metadata.expect("hidden runtime metadata");
2050 assert_eq!(meta[RUNTIME_RESUME_MESSAGE_HIDDEN_KEY], true);
2051 assert_eq!(
2052 meta[RUNTIME_RESUME_MESSAGE_KIND_KEY],
2053 "child_wait_watchdog_resume"
2054 );
2055 }
2056 let lease = child_wait_lease_expired_message(&["c-1".to_string()]);
2057 assert!(lease.content.contains("NOT cancelled"));
2059 assert!(lease.content.contains("c-1"));
2060 }
2061
2062 #[test]
2065 fn non_terminal_statuses_never_satisfy_wait_policies() {
2066 assert!(!is_terminal_child_status("suspended"));
2069 assert!(!is_terminal_child_status("running"));
2070 assert!(!is_terminal_child_status("pending"));
2071 }
2072
2073 fn make_completion(status: &str) -> ChildCompletion {
2074 ChildCompletion {
2075 parent_session_id: "parent-1".to_string(),
2076 child_session_id: "child-1".to_string(),
2077 status: status.to_string(),
2078 error: None,
2079 completed_at: Utc::now(),
2080 }
2081 }
2082
2083 struct StubChildIndex {
2086 children: Vec<(String, Option<String>)>,
2087 }
2088
2089 #[async_trait]
2090 impl Storage for StubChildIndex {
2091 async fn save_session(&self, _session: &Session) -> std::io::Result<()> {
2092 Ok(())
2093 }
2094 async fn load_session(&self, _id: &str) -> std::io::Result<Option<Session>> {
2095 Ok(None)
2096 }
2097 async fn delete_session(&self, _id: &str) -> std::io::Result<bool> {
2098 Ok(false)
2099 }
2100 async fn list_child_run_statuses(
2101 &self,
2102 _parent_session_id: &str,
2103 ) -> std::io::Result<Vec<(String, Option<String>)>> {
2104 Ok(self.children.clone())
2105 }
2106 }
2107
2108 #[tokio::test]
2109 async fn derive_completed_only_includes_terminal_children() {
2110 let storage: Arc<dyn Storage> = Arc::new(StubChildIndex {
2111 children: vec![
2112 ("a".into(), Some("completed".into())),
2113 ("b".into(), Some("running".into())),
2114 ("c".into(), Some("error".into())),
2115 ("d".into(), None),
2116 ],
2117 });
2118 let completed = derive_completed_child_ids(&storage, "parent-1", "b").await;
2119 assert_eq!(
2121 completed,
2122 vec!["a".to_string(), "b".to_string(), "c".to_string()]
2123 );
2124 }
2125
2126 #[tokio::test]
2127 async fn derive_completed_folds_in_just_completed_when_index_lags() {
2128 let storage: Arc<dyn Storage> = Arc::new(StubChildIndex {
2130 children: vec![("only".into(), Some("running".into()))],
2131 });
2132 let completed = derive_completed_child_ids(&storage, "parent-1", "only").await;
2133 assert_eq!(completed, vec!["only".to_string()]);
2134 }
2135
2136 #[test]
2137 fn wait_policy_all_uses_derived_completed_set() {
2138 let waited = vec!["a".to_string(), "b".to_string()];
2139 assert!(!wait_policy_satisfied(
2140 ChildWaitPolicy::All,
2141 &waited,
2142 &["a".to_string()],
2143 "a",
2144 "completed"
2145 ));
2146 assert!(wait_policy_satisfied(
2147 ChildWaitPolicy::All,
2148 &waited,
2149 &["a".to_string(), "b".to_string()],
2150 "b",
2151 "completed"
2152 ));
2153 }
2154
2155 #[test]
2156 fn wait_policy_first_error_requires_tracked_membership() {
2157 let waited = vec!["a".to_string(), "b".to_string()];
2158 assert!(wait_policy_satisfied(
2160 ChildWaitPolicy::FirstError,
2161 &waited,
2162 &["a".to_string()],
2163 "a",
2164 "error"
2165 ));
2166 assert!(!wait_policy_satisfied(
2169 ChildWaitPolicy::FirstError,
2170 &waited,
2171 &["a".to_string()],
2172 "stray-child",
2173 "timeout"
2174 ));
2175 assert!(wait_policy_satisfied(
2177 ChildWaitPolicy::FirstError,
2178 &waited,
2179 &["a".to_string(), "b".to_string()],
2180 "stray-child",
2181 "completed"
2182 ));
2183 }
2184
2185 #[test]
2186 fn child_final_assistant_text_returns_last_assistant() {
2187 let mut session = Session::new("child-1", "gpt-4");
2188 session.messages.push(Message::user("hi"));
2189 session
2190 .messages
2191 .push(Message::assistant("first answer", None));
2192 session.messages.push(Message::user("again"));
2193 session
2194 .messages
2195 .push(Message::assistant("final answer", None));
2196
2197 assert_eq!(
2198 child_final_assistant_text(&session).as_deref(),
2199 Some("final answer")
2200 );
2201 }
2202
2203 #[test]
2204 fn child_final_assistant_text_returns_none_when_blank() {
2205 let mut session = Session::new("child-1", "gpt-4");
2206 session.messages.push(Message::assistant(" ", None));
2207 assert!(child_final_assistant_text(&session).is_none());
2208 }
2209
2210 #[test]
2211 fn child_final_assistant_text_returns_none_when_no_assistant() {
2212 let mut session = Session::new("child-1", "gpt-4");
2213 session.messages.push(Message::user("hi"));
2214 assert!(child_final_assistant_text(&session).is_none());
2215 }
2216
2217 #[test]
2218 fn runtime_resume_message_folds_full_response_without_truncation() {
2219 let completion = make_completion("completed");
2222 let long: String = "a".repeat(10_000);
2223 let message = runtime_resume_message(&completion, 0, Some(&long));
2224 assert!(message.content.contains(&long));
2225 assert!(!message.content.contains("truncated"));
2226 }
2227
2228 #[test]
2229 fn runtime_resume_message_includes_child_response_when_provided() {
2230 let completion = make_completion("completed");
2231 let message = runtime_resume_message(&completion, 0, Some("the answer is 42"));
2232
2233 assert!(matches!(message.role, Role::User));
2234 assert!(!message.never_compress);
2237 assert!(message.content.contains("Child final response:"));
2238 assert!(message.content.contains("the answer is 42"));
2239
2240 let metadata = message.metadata.expect("metadata present");
2241 assert_eq!(
2242 metadata.get("hidden_from_ui").and_then(|v| v.as_bool()),
2243 Some(true)
2244 );
2245 assert_eq!(
2246 metadata.get("runtime_kind").and_then(|v| v.as_str()),
2247 Some("child_completion_resume")
2248 );
2249 assert_eq!(
2250 metadata
2251 .get("child_final_response_included")
2252 .and_then(|v| v.as_bool()),
2253 Some(true)
2254 );
2255 }
2256
2257 #[test]
2258 fn runtime_resume_message_falls_back_to_error_when_no_response() {
2259 let mut completion = make_completion("error");
2260 completion.error = Some("boom".to_string());
2261
2262 let message = runtime_resume_message(&completion, 1, None);
2263 assert!(message.content.contains("Child error:"));
2264 assert!(message.content.contains("boom"));
2265 let metadata = message.metadata.expect("metadata present");
2266 assert_eq!(
2267 metadata
2268 .get("child_final_response_included")
2269 .and_then(|v| v.as_bool()),
2270 Some(false)
2271 );
2272 }
2273
2274 #[test]
2275 fn runtime_resume_message_minimal_when_no_response_and_no_error() {
2276 let completion = make_completion("completed");
2277 let message = runtime_resume_message(&completion, 2, None);
2278 assert!(!message.content.contains("Child final response:"));
2279 assert!(!message.content.contains("Child error:"));
2280 assert!(message.content.contains("Resume the parent task"));
2281 }
2282
2283 #[test]
2284 fn read_config_snapshot_refreshes_cached_snapshot_from_live_config() {
2285 let runtime = tokio::runtime::Runtime::new().expect("runtime");
2286
2287 runtime.block_on(async {
2288 let config = Arc::new(RwLock::new(Config::default()));
2289 config.write().await.provider = "copilot".to_string();
2290 let cached_config = StdRwLock::new(Config::default());
2291
2292 let snapshot = read_config_snapshot(&config, &cached_config);
2293
2294 assert_eq!(snapshot.provider, "copilot");
2295 assert_eq!(
2296 cached_config.read().expect("cached snapshot lock").provider,
2297 "copilot"
2298 );
2299 });
2300 }
2301
2302 #[test]
2303 fn read_config_snapshot_uses_cached_snapshot_when_live_lock_is_busy() {
2304 let runtime = tokio::runtime::Runtime::new().expect("runtime");
2305
2306 runtime.block_on(async {
2307 let cached_snapshot = Config {
2308 provider: "cached-provider".to_string(),
2309 ..Default::default()
2310 };
2311
2312 let config = Arc::new(RwLock::new(Config::default()));
2313 let cached_config = StdRwLock::new(cached_snapshot);
2314 let _write_guard = config.write().await;
2315
2316 let snapshot = read_config_snapshot(&config, &cached_config);
2317
2318 assert_eq!(snapshot.provider, "cached-provider");
2319 });
2320 }
2321
2322 #[test]
2325 fn bash_completion_resume_message_normal_announces_completion() {
2326 let ids = vec!["bg-1".to_string(), "bg-2".to_string()];
2327 let message = bash_completion_resume_message(&ids, false);
2328 assert!(
2330 message.content.contains("have completed"),
2331 "normal resume message must announce completion: {}",
2332 message.content
2333 );
2334 let metadata = message.metadata.expect("metadata present");
2336 assert_eq!(
2337 metadata
2338 .get(RUNTIME_RESUME_MESSAGE_HIDDEN_KEY)
2339 .and_then(|v| v.as_bool()),
2340 Some(true),
2341 "resume message must be hidden from the UI"
2342 );
2343 assert_eq!(
2344 metadata
2345 .get(RUNTIME_RESUME_MESSAGE_KIND_KEY)
2346 .and_then(|v| v.as_str()),
2347 Some(BASH_COMPLETION_RESUME_KIND),
2348 "resume message must carry the bash-completion kind discriminant"
2349 );
2350 }
2351
2352 #[test]
2353 fn bash_completion_resume_message_deadline_does_not_claim_completion() {
2354 let ids = vec!["bg-long".to_string()];
2358 let message = bash_completion_resume_message(&ids, true);
2359 assert!(
2360 !message.content.contains("have completed"),
2361 "deadline resume message must NOT claim the shells completed: {}",
2362 message.content
2363 );
2364 assert!(
2365 message.content.contains("may still be running"),
2366 "deadline resume message must warn shells may still be running: {}",
2367 message.content
2368 );
2369 assert!(
2370 message.content.contains("BashOutput"),
2371 "deadline resume message must direct verification via BashOutput: {}",
2372 message.content
2373 );
2374 let metadata = message.metadata.expect("metadata present");
2376 assert_eq!(
2377 metadata
2378 .get(RUNTIME_RESUME_MESSAGE_KIND_KEY)
2379 .and_then(|v| v.as_str()),
2380 Some(BASH_COMPLETION_RESUME_KIND)
2381 );
2382 }
2383
2384 #[test]
2385 fn bash_resume_should_retry_matrix() {
2386 assert!(!bash_resume_should_retry(
2392 &ResumeOutcome::Started { run_id: "r".into() },
2393 true
2394 ));
2395 assert!(!bash_resume_should_retry(
2396 &ResumeOutcome::Started { run_id: "r".into() },
2397 false
2398 ));
2399
2400 assert!(!bash_resume_should_retry(&ResumeOutcome::NotFound, true));
2402 assert!(!bash_resume_should_retry(&ResumeOutcome::NotFound, false));
2403
2404 assert!(bash_resume_should_retry(&ResumeOutcome::Completed, true));
2406 assert!(!bash_resume_should_retry(&ResumeOutcome::Completed, false));
2409
2410 assert!(bash_resume_should_retry(
2413 &ResumeOutcome::AlreadyRunning { run_id: "r".into() },
2414 true
2415 ));
2416 assert!(!bash_resume_should_retry(
2418 &ResumeOutcome::AlreadyRunning { run_id: "r".into() },
2419 false
2420 ));
2421 }
2422
2423 #[test]
2426 fn injection_body_includes_status_exit_command_and_tail() {
2427 let info = BashCompletionInfo {
2428 session_id: "s".into(),
2429 bash_id: "abc123".into(),
2430 command: "make build".into(),
2431 exit_code: Some(0),
2432 status: "completed".into(),
2433 output_tail: "BUILD OK".into(),
2434 };
2435 let body = bash_completion_injection_body(&info);
2436 assert!(body.contains("abc123"), "body: {body}");
2437 assert!(body.contains("make build"), "body: {body}");
2438 assert!(body.contains("completed"), "body: {body}");
2439 assert!(body.contains("exit code 0"), "body: {body}");
2440 assert!(body.contains("BUILD OK"), "body: {body}");
2441 assert!(body.contains("BashOutput"), "body: {body}");
2443 assert!(body.contains("bash_id=\"abc123\""), "body: {body}");
2444 }
2445
2446 #[test]
2447 fn injection_body_handles_no_output_and_signal_kill() {
2448 let info = BashCompletionInfo {
2449 session_id: "s".into(),
2450 bash_id: "xyz".into(),
2451 command: "sleep 99".into(),
2452 exit_code: None,
2453 status: "killed".into(),
2454 output_tail: String::new(),
2455 };
2456 let body = bash_completion_injection_body(&info);
2457 assert!(body.contains("killed"), "body: {body}");
2458 assert!(body.contains("none (signal/killed)"), "body: {body}");
2459 assert!(body.contains("no captured output"), "body: {body}");
2460 assert!(!body.contains("Output tail:"), "body: {body}");
2462 }
2463
2464 async fn temp_store() -> (tempfile::TempDir, Arc<dyn Storage>, LockedSessionStore) {
2465 let temp = tempfile::tempdir().unwrap();
2466 let storage: Arc<dyn Storage> = Arc::new(
2467 bamboo_storage::v2::SessionStoreV2::new(temp.path().to_path_buf())
2468 .await
2469 .expect("storage init"),
2470 );
2471 let persistence = LockedSessionStore::new(storage.clone());
2472 (temp, storage, persistence)
2473 }
2474
2475 #[tokio::test]
2476 async fn enqueue_writes_pending_injection_and_preserves_messages() {
2477 let (_temp, storage, persistence) = temp_store().await;
2478
2479 let mut session = Session::new("sess-enq", "test-model");
2480 session.add_message(Message::user("do the build"));
2481 storage.save_session(&session).await.unwrap();
2482
2483 let info = BashCompletionInfo {
2484 session_id: "sess-enq".into(),
2485 bash_id: "sh-1".into(),
2486 command: "make".into(),
2487 exit_code: Some(0),
2488 status: "completed".into(),
2489 output_tail: "done".into(),
2490 };
2491 let saved = enqueue_bash_completion_injection(&persistence, &info)
2492 .await
2493 .expect("enqueue io ok")
2494 .expect("session exists");
2495
2496 let pending = saved
2497 .pending_injected_messages()
2498 .expect("pending injection present");
2499 assert_eq!(pending.len(), 1);
2500 let content = pending[0].get("content").and_then(|v| v.as_str()).unwrap();
2501 assert!(content.contains("sh-1"), "content: {content}");
2502 assert!(content.contains("make"), "content: {content}");
2503 assert!(content.contains("done"), "content: {content}");
2504 assert_eq!(saved.messages.len(), 1);
2506 }
2507
2508 #[tokio::test]
2509 async fn enqueue_returns_none_for_missing_session() {
2510 let (_temp, _storage, persistence) = temp_store().await;
2511 let info = BashCompletionInfo {
2512 session_id: "does-not-exist".into(),
2513 bash_id: "x".into(),
2514 command: "true".into(),
2515 exit_code: Some(0),
2516 status: "completed".into(),
2517 output_tail: String::new(),
2518 };
2519 let result = enqueue_bash_completion_injection(&persistence, &info)
2520 .await
2521 .expect("io ok");
2522 assert!(result.is_none(), "no session → nothing enqueued");
2523 }
2524
2525 #[test]
2533 fn apply_bash_resume_transition_clears_wait_and_appends_message() {
2534 use bamboo_domain::session::runtime_state::WaitingForBashState;
2535
2536 let mut session = Session::new("sess-resume", "test-model");
2537 session.add_message(Message::user("kick off the build"));
2538 let mut rt = read_runtime_state(&session);
2539 rt.status = AgentStatusState::Running;
2540 rt.waiting_for_bash = Some(WaitingForBashState::for_bash(
2541 vec!["sh-1".into()],
2542 Utc::now(),
2543 ));
2544 write_runtime_state(&mut session, &rt);
2545 session.metadata.insert(
2546 "runtime.suspend_reason".to_string(),
2547 "waiting_for_bash".to_string(),
2548 );
2549
2550 let resume = bash_completion_resume_message(&["sh-1".to_string()], false);
2551 let did = apply_bash_resume_transition(&mut session, &resume);
2552
2553 assert!(did, "a suspended session must transition");
2554 let after = read_runtime_state(&session);
2555 assert!(
2556 after.waiting_for_bash.is_none(),
2557 "bash wait must be cleared"
2558 );
2559 assert_eq!(after.status, AgentStatusState::Idle, "runtime must be Idle");
2560 assert!(
2561 !session.metadata.contains_key("runtime.suspend_reason"),
2562 "suspend-reason marker must be removed"
2563 );
2564 assert_eq!(session.messages.len(), 2, "resume message must be appended");
2565 assert!(matches!(
2566 session.messages.last().map(|m| &m.role),
2567 Some(Role::User)
2568 ));
2569 }
2570
2571 #[test]
2575 fn apply_bash_resume_transition_noops_when_not_waiting() {
2576 let mut session = Session::new("sess-live", "test-model");
2577 session.add_message(Message::user("hi"));
2578
2579 let resume = bash_completion_resume_message(&["sh-1".to_string()], false);
2580 let did = apply_bash_resume_transition(&mut session, &resume);
2581
2582 assert!(!did, "a non-waiting session must not transition");
2583 assert_eq!(session.messages.len(), 1, "no resume message appended");
2584 }
2585
2586 #[test]
2590 fn bash_completion_should_resume_only_when_suspended_and_all_done() {
2591 assert!(bash_completion_should_resume(true, true));
2592 assert!(!bash_completion_should_resume(true, false)); assert!(!bash_completion_should_resume(false, true)); assert!(!bash_completion_should_resume(false, false));
2595 }
2596
2597 #[test]
2601 fn bash_resume_message_from_info_carries_bashid_tail_and_kind() {
2602 let info = BashCompletionInfo {
2603 session_id: "s".into(),
2604 bash_id: "sh-42".into(),
2605 command: "cargo test".into(),
2606 exit_code: Some(0),
2607 status: "completed".into(),
2608 output_tail: "test result: ok".into(),
2609 };
2610 let msg = bash_resume_message_from_info(&info);
2611
2612 assert!(matches!(msg.role, Role::User));
2613 assert!(msg.content.contains("sh-42"), "content: {}", msg.content);
2614 assert!(
2615 msg.content.contains("cargo test"),
2616 "content: {}",
2617 msg.content
2618 );
2619 assert!(
2620 msg.content.contains("test result: ok"),
2621 "content: {}",
2622 msg.content
2623 );
2624 assert!(
2625 msg.content.contains("BashOutput"),
2626 "content: {}",
2627 msg.content
2628 );
2629 let meta = serde_json::to_string(&msg.metadata).unwrap();
2630 assert!(
2631 meta.contains(BASH_COMPLETION_RESUME_KIND),
2632 "resume message must be tagged as a bash-completion resume: {meta}"
2633 );
2634 }
2635}