Skip to main content

bamboo_engine/session_app/
child_completion_coordinator.rs

1//! Child-session completion coordinator.
2//!
3//! Receives terminal child runner notifications from `bamboo-engine`, updates
4//! durable parent wait state, and resumes the parent when the configured wait
5//! policy is satisfied.
6
7use 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
78/// Terminal child run statuses, as mirrored into the session index.
79fn is_terminal_child_status(status: &str) -> bool {
80    matches!(
81        status,
82        "completed" | "error" | "timeout" | "cancelled" | "skipped"
83    )
84}
85
86/// Reconstruct the set of completed child session ids for a parent from the
87/// session index (the single source of truth), folding in the child whose
88/// completion event is being processed so a momentarily-lagging index can never
89/// stall the parent's resume.
90async 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
128/// Per-parent async locks that serialize concurrent `on_child_completed`
129/// invocations for the same parent session.
130///
131/// Race eliminated: when `wait_for=Any` and two child sessions complete
132/// simultaneously, both invocations load the parent with
133/// `waiting_for_children=Some` before either persists the cleared state, so
134/// both pass `wait_policy_satisfied`, both clear `waiting_for_children`, add a
135/// duplicate resume message, and call `resume_parent` — a double resume.
136/// Holding this per-parent `tokio::sync::Mutex` across the load-check-save
137/// critical section makes the second caller observe the already-cleared state.
138///
139/// The inner `std::sync::Mutex` guards only the brief HashMap lookup/insert
140/// (no await inside); the per-parent `tokio::sync::Mutex` is the one held
141/// across the async critical section. Entries accumulate but are small
142/// (`Arc<tokio::sync::Mutex<()>>` ≈ 24 bytes) and bounded by the number of
143/// distinct parent sessions.
144fn 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
150/// Fetch (or create) the per-session async lock from [`parent_locks`]. Held
151/// across the load-check-clear-resume critical section so the three resume
152/// sources for one session — child completion, the loop-facing bash **push**
153/// ([`BashCompletionSink::on_bash_completed`]), and the bash **backstop** poll
154/// ([`ChildCompletionCoordinator::bash_self_resume`]) — can never double-resume.
155/// The inner sync `Mutex` guards only the brief map lookup (no await inside).
156fn 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            // The error short-circuit only counts a completion from a child
183            // this wait actually tracks (issue #546): a stray/duplicate
184            // completion from an untracked child — e.g. a frozen runner's
185            // task waking up after the watchdog already synthesized its
186            // timeout, in a later run's wait — must not resume the parent.
187            (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
195/// Extract the child session's last assistant content, if any. Returns `None`
196/// when the child produced no assistant message (e.g. errored before the first
197/// model response, or only emitted tool messages).
198fn 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    // Fold the child's full final response back into the parent — no
219    // truncation. Sub-agents are first-class agents whose complete conclusion
220    // should be available to the parent without an extra `SubAgent.get` round
221    // trip. The message is left compressible (see `never_compress` below) so a
222    // long transcript can still be reclaimed under parent compaction.
223    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    // Allow parent-side compaction to reclaim this (now untruncated) message if
250    // the parent context grows — important once children nest and fold full
251    // results upward. The `SubAgent.get` hint preserves recoverability.
252    message.never_compress = false;
253    message
254}
255
256/// The hidden resume message for a completed **guardian** review: a directive,
257/// verdict-tailored note that carries the reviewer's findings straight into the
258/// parent (so it can act without a `SubAgent.get`), mirroring
259/// [`runtime_resume_message`]'s hidden/compressible shape.
260fn 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    /// Late-bound guardian reviewer spawner, set post-construction by the server
312    /// (mirrors `root_tools`). Re-injected into resumed runs so a guardian's
313    /// reject→fix verdict can be re-reviewed across the suspend/resume boundary.
314    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    /// Wire the guardian reviewer spawner (server-provided), so resumed runs can
354    /// re-spawn a guardian to re-review a fix after a reject verdict.
355    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    /// Drive a parent-resume and return the final [`ResumeOutcome`] so callers
373    /// can distinguish a successful spawn (`Started`) from a gate-blocked
374    /// attempt (`Completed`). The bash self-resume poll task uses this to
375    /// detect the finalize-clobber case — its appended resume message was
376    /// reverted by the suspending runner's final `merge_save_runtime`, so the
377    /// resume port's `has_pending_user_message` gate fails and nothing spawns —
378    /// and retry the clear→append→resume (see [`Self::bash_self_resume`]).
379    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        // Exhausted the AlreadyRunning retry budget; surface the final state.
404        // The wait state was already cleared and the resume message persisted,
405        // so nothing event-driven will retry — the child-wait watchdog is the
406        // backstop that picks this stranded parent up (it resumes suspended
407        // sessions that hold a pending runtime resume message but no runner).
408        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        // Terminality guard: a child that reports a NON-terminal status (e.g.
433        // "suspended" — awaiting parent approval, its own bash wait, or its own
434        // grandchildren) is not done. It must never satisfy the parent's wait:
435        // `derive_completed_child_ids` folds the just-reported child in
436        // unconditionally, so without this guard a suspending child would
437        // resume the parent with a premature "finished with status
438        // `suspended`" message. The child will publish a real terminal
439        // completion when it later resumes and finishes.
440        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        // Acquire the per-session async lock to eliminate the concurrent
451        // double-resume race (see `parent_locks` for the full scenario). The
452        // inner std::sync::Mutex is released immediately so no sync lock is
453        // held across the await that follows.
454        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        // A parent may itself be a child (nested sub-agents): the rest of this
467        // handler is kind-agnostic — it operates on `completion.parent_session_id`,
468        // inspects that session's own `waiting_for_children` runtime state, and
469        // resumes it. (Previously this bailed unless the parent was Root, which
470        // silently dropped grandchild completions.)
471        let mut runtime_state = read_runtime_state(&parent);
472
473        // Single source of truth: reconstruct the completed-child set from the
474        // session index rather than from a denormalized copy on the parent file.
475        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            // READ-SIDE OWNERSHIP GUARD (issue #546): `SubAgent.wait` ids are
508            // model-provided and unvalidated, and the watchdog unstrands a wait
509            // over a FOREIGN/unknown id by publishing a synthetic completion
510            // here. We must resume the parent (so it is not stranded) but MUST
511            // NOT fold that foreign session's transcript into the parent — that
512            // would be a cross-session disclosure primitive. Decide ownership
513            // from the child's OWN parent linkage (control-plane only, no
514            // messages loaded), and only load its full content when it is truly
515            // this parent's child. An unowned id resumes with the neutral/error
516            // message (`runtime_resume_message` falls back to `completion.error`
517            // when no child content is supplied).
518            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            // Load the completed child once, ONLY when owned. The guardian
531            // branch inspects its subagent_type + final verdict; the generic
532            // path folds its final assistant content into the hidden resume
533            // message (avoiding an extra `SubAgent.get` round trip after resume).
534            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            // Guardian branch: a completing guardian reviewer that matches the
561            // parent's recorded review advances GuardianState (phase → Reviewed)
562            // and resumes with a verdict-tailored, findings-carrying message. Any
563            // id mismatch or unparseable verdict falls through to the generic
564            // resume, so the parent is never stranded.
565            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                    // A *different* guardian is legitimately still in flight —
575                    // leave its Pending state intact and use the generic resume.
576                    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                // This IS the guardian we dispatched, so we MUST advance the
585                // phase out of `Pending` — otherwise the next terminal gate's
586                // `Pending => return None` would let the run complete unreviewed.
587                // A reviewer that errored or produced unparseable output is
588                // treated as a SYNTHETIC REJECT (never a silent pass), so the
589                // budgeted re-review loop governs the outcome: fail-closed, but
590                // still bounded by `max_reviews`.
591                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        // Capture before releasing the per-parent lock so the borrow checker
650        // is satisfied; `resume_parent` has its own retry loop and should not
651        // hold the per-parent lock (it would block other completions for the
652        // same parent, and the state is already durably settled above).
653        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            // Auxiliary models are global (config-derived), never session-bound.
782            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        // Re-inject guardian state on resume so a reject→fix verdict can be
817        // re-reviewed: config from the session (persisted at first spawn),
818        // spawner from the coordinator-held handle. Absent guardian config this
819        // stays `None`, and the approve→complete path is unchanged.
820        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            // Resumed child runs keep the spawn-time disabled snapshot (#136 lives
834            // on the long-running main agent path; children are short-lived).
835            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                // Resumed runs keep the push wired too, so a background shell
852                // launched after resume still notifies the loop.
853                let sink: Arc<dyn BashCompletionSink> = Arc::new(self.clone());
854                Some(sink)
855            },
856            app_data_dir: Some(self.app_data_dir.clone()),
857            // Resume does not carry a fresh per-request override; the
858            // config-level default (issue #221) still applies.
859            run_budget: None,
860            runners: self.agent_runners.clone(),
861            sessions_cache: self.sessions.clone(),
862            on_complete: None,
863            // A resumed session that is itself a CHILD (nested sub-agents)
864            // must publish its terminal completion so ITS parent is woken
865            // in turn (issue #546).
866            child_completion_handler: Some(Arc::new(self.clone())),
867        });
868    }
869}
870
871/// Hidden resume message for a bash-completion self-resume (issue #84 Phase 2b).
872/// Mirrors [`runtime_resume_message`]'s hidden/compressible shape so the resume
873/// port's `has_pending_user_message` gate is satisfied.
874///
875/// `timed_out` selects the wording: the normal path (all shells finished)
876/// announces completion; the deadline path (the 6h+10m wait ceiling was hit with
877/// shells STILL running) must NOT claim the shells completed — it says they may
878/// still be running so the model verifies with BashOutput instead of assuming
879/// success on a false premise.
880fn 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
904/// Decide whether the bash self-resume should retry its clear→append→resume
905/// sequence after a resume attempt returned `outcome`, given that the persisted
906/// bash wait is (`true`) / is not (`false`) still set on reload.
907///
908/// Retry **only** when the resume did NOT spawn (`Completed` — no pending user
909/// message, i.e. our resume message was dropped — or `AlreadyRunning`) AND the
910/// persisted bash wait is still set: the signature of the finalize-clobber, where
911/// the suspending runner's one-shot final `merge_save_runtime` lands after our
912/// save and reverts `waiting_for_bash=Some` while dropping our resume message, so
913/// `has_pending_user_message` fails and nothing spawns. `Started` (resume fired)
914/// and `NotFound` (session gone) never retry. Pure helper so the clobber
915/// detection is unit-testable in isolation from async I/O.
916fn 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
925/// Whether a background-shell completion push should **resume** the owning loop
926/// (vs merely enqueue an injection). Resume only when the loop is actually
927/// suspended on a bash wait AND every shell it was waiting on has now finished —
928/// resuming while other waited shells are still running would drop them back into
929/// a foreground turn prematurely. The last shell to finish (or the backstop)
930/// drives the resume; earlier ones enqueue their notice. Pure so the invariant is
931/// unit-testable in isolation.
932fn 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
939/// Apply the bash-resume state transition to a loaded session **in place**: clear
940/// the `waiting_for_bash` wait, mark the runtime Idle, drop the suspension +
941/// `runtime.suspend_reason`, and append `resume_message`. Returns `false` (a
942/// no-op) when the session was not actually waiting on bash — the double-resume
943/// guard shared by the push and the backstop. Pure (no I/O) so both
944/// [`ChildCompletionCoordinator::perform_bash_resume`] and unit tests exercise the
945/// exact same transition.
946fn 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
960/// Bash self-resume support (issue #84 Phase 2b; push follow-up).
961impl ChildCompletionCoordinator {
962    /// **Backstop** for a session suspended on `waiting_for_bash`. The primary,
963    /// event-driven wake is the loop-facing push
964    /// ([`BashCompletionSink::on_bash_completed`] → [`Self::deliver_bash_completion`]):
965    /// the shell's completion task fires it the instant the process exits, and it
966    /// resumes the loop directly. This task exists ONLY to catch a **lost push** —
967    /// the completion landing in the window before the suspend was persisted (so
968    /// the push saw no `waiting_for_bash` and only queued an injection), or a
969    /// configuration with no sink wired — and to honour the wait ceiling.
970    ///
971    /// So it is deliberately NOT a hot spin: a coarse backoff (1 s → 30 s) that
972    /// **yields to the push**. In the happy path the push has already cleared
973    /// `waiting_for_bash` before the first check fires, so this returns after one
974    /// cheap load with no registry polling at all. It only performs a resume when
975    /// the shell(s) have finished but the loop is somehow still suspended, or the
976    /// 6 h wait ceiling is reached.
977    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        // Hard ceiling: the wait lease (6 h) + the registry GC TTL (5 min) +
981        // margin. After this the shells are gone from the registry regardless,
982        // so force-resume to avoid stranding the session on a GC edge case.
983        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                // The push (or another path) already resumed. This is the common
995                // case — the backstop yields silently after a single load.
996                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                // The shell(s) finished but the loop is still suspended → the push
1004                // was lost (pre-persist window / no sink), or the ceiling hit.
1005                // Resume under the shared per-session lock so we never race the
1006                // push or a concurrent child-completion resume.
1007                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    /// Clear a session's `waiting_for_bash` state, append `resume_message`, and
1028    /// drive the parent resume — the shared clear→append→resume used by BOTH the
1029    /// event-driven push ([`Self::deliver_bash_completion`]) and the backstop poll
1030    /// ([`Self::bash_self_resume`]).
1031    ///
1032    /// **The caller MUST hold the [`session_resume_lock`] for `session_id`** so the
1033    /// load-check-clear-resume critical section is serialized against every other
1034    /// resume source (no double resume). No-op when the persisted wait was already
1035    /// cleared (another source handled it first).
1036    ///
1037    /// The clear→append→resume is a **bounded retry loop** that closes the
1038    /// finalize-clobber strand. The suspending runner's `finalize_task_context`
1039    /// runs a full `save_runtime_session` (same `merge_save_runtime`, which
1040    /// overwrites the whole `messages` array) that can land AFTER our save,
1041    /// reverting `waiting_for_bash=Some` and dropping our resume message, so
1042    /// `has_pending_user_message` fails and `resume_parent` returns `Completed`
1043    /// without spawning. We detect that (persisted wait still set after a
1044    /// non-`Started` outcome) and re-clear/re-append/re-resume. It converges
1045    /// because the runner's finalize persist is one-shot: once landed, our retry's
1046    /// save is the last writer, the message sticks, and resume fires.
1047    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                // Double-resume guard: the wait was already cleared by another
1062                // source (the push, the backstop, or a user-driven resume). Do
1063                // not append a duplicate message or request a redundant resume.
1064                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                    // Completed (no pending user message ⇒ our resume message was
1089                    // dropped by the runner's finalize persist) or AlreadyRunning.
1090                    // Decide via the persisted bash wait: still set ⇒
1091                    // finalize-clobber ⇒ retry; cleared ⇒ the session is being
1092                    // handled (by us or a concurrent resume) ⇒ stop.
1093                    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
1135/// Build the injected user-message body for a completed background shell — a
1136/// concise notice plus a bounded output tail — so the model can act on the
1137/// result without a mandatory `BashOutput` round-trip (issue #84 Phase 2b
1138/// follow-up).
1139fn 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
1161/// Loop-facing background-Bash completion delivery (issue #84 Phase 2b
1162/// follow-up). Pushes a completed shell's result into its owning session's loop,
1163/// mirroring how a sub-agent completion reaches its parent — but via the
1164/// running-loop channel, which children never exercise (a parent waiting on
1165/// children is always suspended when one completes; bash is the first completion
1166/// source that can land on a *live, iterating* loop).
1167///
1168/// The push enqueues onto `pending_injected_messages`, the same round-boundary
1169/// steering channel `send_message` uses. That covers every reachable loop state:
1170/// an actively-looping session drains it at its next round
1171/// (`merge_pending_injected_messages`); a session suspended on `waiting_for_bash`
1172/// drains it when the durable end-of-turn poll backstop (`bash_resume_hook`)
1173/// resumes it at round 0. A wired-sink session is never idle-with-a-running-shell
1174/// (ending a turn with one suspends), so no separate idle-wake path is needed —
1175/// keeping the push a pure latency optimization that never races the backstop.
1176/// Enqueue a completed shell's summary as a pending injected message on the
1177/// owning session. Race-safe: `update_runtime_config` loads the freshest session
1178/// under the per-session lock and re-saves, so it can never revert a message the
1179/// live loop appended concurrently — unlike `merge_save_runtime` (which writes
1180/// the caller's whole `messages` snapshot verbatim). Free fn so it is unit-
1181/// testable without constructing a full coordinator. Returns the saved session,
1182/// or `None` if the owning session no longer exists.
1183async 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
1201/// Build the hidden, compressible resume message for a completed background
1202/// shell — the same rich notice body used for a live-loop injection
1203/// ([`bash_completion_injection_body`]), but tagged as a resume message so it
1204/// satisfies the `has_pending_user_message` gate that lets a suspended session
1205/// spawn. This is what the **push** appends when it wakes a suspended loop, so
1206/// the model gets the shell's status + output tail in one shot without a
1207/// separate `BashOutput` round-trip.
1208fn 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    /// Loop-facing delivery of a completed background shell. Two paths, chosen
1220    /// under the per-session resume lock so we never race the backstop poll or a
1221    /// concurrent child-completion resume:
1222    ///
1223    /// - **Suspended loop** (the model ended its turn with the shell running, so
1224    ///   `waiting_for_bash` is set) AND every waited shell has now finished →
1225    ///   **resume the loop directly**, event-driven, appending the rich completion
1226    ///   notice as the resume message. This is the push's whole point: no polling.
1227    /// - Otherwise (a live/iterating loop, or a suspend still waiting on OTHER
1228    ///   shells) → **enqueue** the notice as a pending injected message, drained at
1229    ///   the next round boundary (a live loop) or folded into the eventual resume
1230    ///   when the last shell finishes.
1231    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        // The producer flips the shell's `running` flag false BEFORE firing this
1246        // push, so a now-empty per-session registry means every shell the loop was
1247        // waiting on has finished — safe to resume. If OTHER waited shells are
1248        // still running, fall through to the enqueue path and let the last one (or
1249        // the backstop) drive the resume.
1250        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        // Best-effort, off the shell's completion-poll task: hand the delivery to
1292        // a detached task so the producer is never blocked (mirrors
1293        // `arrange_bash_self_resume`).
1294        let coordinator = Arc::new(self.clone());
1295        tokio::spawn(async move {
1296            coordinator.deliver_bash_completion(info).await;
1297        });
1298    }
1299}
1300
1301// ---------------------------------------------------------------------------
1302// Child-wait watchdog (issue #546)
1303// ---------------------------------------------------------------------------
1304
1305/// How often the child-wait watchdog sweeps suspended sessions.
1306const CHILD_WAIT_SWEEP_INTERVAL_SECS: u64 = 30;
1307/// Leave a freshly registered wait alone for this long so the event-driven
1308/// completion push always gets the first shot (and just-enqueued spawn jobs
1309/// have time to persist their running marker).
1310const CHILD_WAIT_REGISTRATION_GRACE_SECS: i64 = 60;
1311/// A waited child with NO live runner and a non-terminal index status is
1312/// declared dead once its control-plane has been quiet for this long.
1313const DEAD_CHILD_GRACE_SECS: i64 = 120;
1314/// Slack on top of the per-child liveness policy before a Running-but-frozen
1315/// runner entry (dead task) is force-finalized by the sweeper. The per-child
1316/// watchdog cancels at `max_idle`/`max_total` and the child then publishes its
1317/// own timeout; an entry frozen this far PAST those limits proves that
1318/// machinery is dead.
1319const STALE_RUNNER_SLACK_SECS: i64 = 600;
1320
1321/// Whether a waited child's index status means the sweeper must consider it
1322/// DEAD when nothing is driving it: non-terminal and not legitimately
1323/// suspended. `None` = never ran (created-but-never-started, or a spawn that
1324/// was lost before its running marker persisted).
1325fn is_dead_child_candidate_status(status: Option<&str>) -> bool {
1326    match status {
1327        // "suspended" children wait on a human / their own children / bash —
1328        // their wake has its own driver; never declare them dead here.
1329        Some(status) => !is_terminal_child_status(status) && status != "suspended",
1330        None => true,
1331    }
1332}
1333
1334/// Whether a reported completion's child id is genuinely a child of the parent
1335/// it claims (issue #546 read-side disclosure guard). `SubAgent.wait` ids are
1336/// model-provided, and the watchdog resolves an unowned id by publishing a
1337/// synthetic completion so the parent is unstranded — but the parent must NEVER
1338/// receive a FOREIGN session's content folded into its transcript.
1339/// `child_parent_linkage` is that session's own `parent_session_id` (`None`
1340/// when the session does not exist). Pure so the rule is unit-testable.
1341fn completion_child_is_owned(reported_parent: &str, child_parent_linkage: Option<&str>) -> bool {
1342    child_parent_linkage == Some(reported_parent)
1343}
1344
1345/// Pick which terminal child's completion to replay when the wait is already
1346/// satisfied but the parent is still suspended (lost wake). Prefer an
1347/// error-like child so a `FirstError` policy re-evaluates truthfully.
1348fn 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
1384/// Child-wait watchdog (issue #546): the heartbeat backstop for parents
1385/// suspended on `waiting_for_children`.
1386///
1387/// The primary wake is the event-driven completion push (child terminal →
1388/// [`ChildCompletionHandler::on_child_completed`] → resume). This sweeper
1389/// exists because ANY break in that chain — a panicked child task, a dead
1390/// spawn scheduler, a process restart, a clobbered/exhausted resume, a wait
1391/// registered over an already-terminal child — previously stranded the parent
1392/// forever. It mirrors the bash backstop's philosophy: coarse, cheap, yields
1393/// to the push, and only acts when the durable state proves nothing else can.
1394///
1395/// All wake decisions funnel through the SAME machinery the push uses
1396/// (synthetic/replayed completions → `on_child_completed`, per-parent
1397/// serialization via [`session_resume_lock`]), so there is exactly one resume
1398/// implementation.
1399impl ChildCompletionCoordinator {
1400    /// Spawn the watchdog: one boot-time reconciliation pass, then a sweep
1401    /// every [`CHILD_WAIT_SWEEP_INTERVAL_SECS`]. Call once at server startup.
1402    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            // Skip the immediate tick (boot reconciliation just ran).
1418            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    /// One-shot startup reconciliation: a process restart kills every in-flight
1433    /// child task AND the in-memory bash backstop polls, but the durable state
1434    /// (child index status `running`, parents suspended on bash) survives —
1435    /// previously stranding those parents forever.
1436    async fn reconcile_orphans_at_boot(&self) {
1437        let cutoff = Utc::now();
1438
1439        // (1) Children left `running` by the previous process: no task in THIS
1440        // process is driving them, so no completion will ever fire. Mark them
1441        // terminal and wake their parents through the canonical path. (Root
1442        // sessions left `running` are user-visible and user-recoverable; only
1443        // children hold a suspended parent hostage.)
1444        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            // A child that started AFTER boot is alive by definition — the
1458            // cutoff guards the tiny window where a fresh spawn races this scan.
1459            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        // (2) Sessions suspended on `waiting_for_bash`: their backstop poll was
1482        // an in-memory task that died with the process. Re-arm it — with the
1483        // shell registry empty after a restart it resumes on its first check.
1484        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    /// One sweep: inspect every session whose index status is `suspended`.
1538    /// Cheap in the common case — the candidate list is small and each check is
1539    /// a sidecar (control-plane) load.
1540    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        // A live runner means the session already resumed (the index status
1555        // only advances at its next terminal) — nothing to do.
1556        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            // Human-gated or bash-owned waits: not ours to time out.
1574            ("waiting_for_bash", _)
1575            | ("awaiting_clarification", _)
1576            | ("awaiting_parent_approval", _) => {}
1577            (_, Some(wait)) => self.sweep_child_wait(session_id, wait).await,
1578            // Suspended with NO armed wait: either the coordinator cleared the
1579            // wait but its resume never spawned, or state is half-cleared.
1580            ("waiting_for_children", None) | ("", None) => {
1581                self.rescue_stranded_resume(session_id).await;
1582            }
1583            _ => {}
1584        }
1585    }
1586
1587    /// Evaluate one armed child wait against reality and act on what the
1588    /// durable state proves: dead children are synthesized terminal, an
1589    /// already-satisfied wait gets its lost wake replayed, an expired lease or
1590    /// empty wait force-resumes the parent.
1591    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        // The 6h lease (previously written but never read). Expiry does not
1605        // kill children — child runners own child liveness; the parent is
1606        // resumed with a verify-don't-assume note.
1607        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        // Yield to the event-driven push on fresh waits.
1621        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            /// `false` = the id does not name a child of THIS parent (wait ids
1640            /// are model-provided and unvalidated): publish the wake but never
1641            /// persist onto / cancel / finalize the named session.
1642            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            // Ownership check BEFORE anything destructive: `SubAgent.wait` ids
1660            // are model-provided and unvalidated, so an id absent from the
1661            // parent-scoped status map may name a REAL session in another tree
1662            // (a grandchild, a foreign root). Such a session must never be
1663            // mutated, cancelled, or finalized here — but the parent's bogus
1664            // wait entry still needs a synthetic completion to clear it.
1665            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                    // A live-looking runner: only intervene when it is frozen
1689                    // far PAST the per-child liveness limits — which proves the
1690                    // per-child watchdog machinery itself is dead (task
1691                    // panicked or lost), because it would have cancelled and
1692                    // published a timeout long before.
1693                    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                    // Nothing is driving this child, yet its index status will
1722                    // never advance by itself. The grace covers the enqueue →
1723                    // running-marker window and slow spawn queues.
1724                    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                    // Foreign / nonexistent id: wake the parent only.
1764                    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            // The publishes above re-evaluate the wait policy themselves.
1774            return;
1775        }
1776
1777        // No dead children — but if the terminal set ALREADY satisfies the
1778        // policy, the original wake was lost (clobbered resume / retry budget
1779        // exhausted / completion raced the wait registration). Replay one real
1780        // completion through the canonical path; `on_child_completed` is
1781        // idempotent for an already-cleared wait.
1782        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    /// Persist a synthesized terminal status on the child (so the index flips
1808    /// and nothing re-detects or re-suspends on it), finalize any lingering
1809    /// runner entry (so a future re-run can reserve), then publish through the
1810    /// canonical completion path — broadcast + `on_child_completed`, exactly
1811    /// like a real child terminal.
1812    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                // Ownership guard (defense in depth — callers check too): only
1822                // a session that IS a child of this parent may be mutated. An
1823                // arbitrary session id named in a wait still wakes the parent
1824                // via the publish below, but its own state stays untouched.
1825                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    /// A parent whose wait was already cleared (resume message appended) but
1905    /// whose resume never spawned — retry-budget exhaustion, root-tools not
1906    /// yet initialized, or a restart between clear and spawn. Detected by: no
1907    /// live runner, no armed wait, and a pending hidden runtime resume message
1908    /// as the LAST message. Resume is all that's left to do.
1909    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    /// Clear the parent's child wait, append `resume_message`, and drive the
1932    /// resume — with a bounded clobber-retry mirroring
1933    /// [`Self::perform_bash_resume`]: a suspending runner's one-shot finalize
1934    /// save can land after ours and revert the wait while dropping the
1935    /// message; we detect the re-armed wait and re-clear.
1936    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                    // Another source already resumed this parent.
1951                    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                    // Only retry when the persisted wait was clobbered back to
1967                    // armed; if it stayed cleared with the message intact, the
1968                    // next sweep's stranded-resume rescue finishes the job.
1969                    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    // ── child-wait watchdog pure helpers (issue #546) ────────────────────
1993
1994    #[test]
1995    fn dead_child_candidate_status_matrix() {
1996        // Never ran / lost before the running marker: dead candidate.
1997        assert!(is_dead_child_candidate_status(None));
1998        // Actively-reported non-terminal statuses: dead candidates when nothing
1999        // is driving them.
2000        assert!(is_dead_child_candidate_status(Some("running")));
2001        assert!(is_dead_child_candidate_status(Some("pending")));
2002        // Legitimately quiescent: waiting on a human / own children / bash.
2003        assert!(!is_dead_child_candidate_status(Some("suspended")));
2004        // Terminal statuses can never be "dead" — they are already done.
2005        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        // Owned: the child's own parent linkage matches the reporting parent.
2013        assert!(completion_child_is_owned("parent-1", Some("parent-1")));
2014        // Foreign: a real session that belongs to a DIFFERENT parent — its
2015        // content must never be folded into parent-1's transcript.
2016        assert!(!completion_child_is_owned("parent-1", Some("parent-2")));
2017        // Root/unparented session, or a nonexistent id (linkage None).
2018        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        // The lease message must never claim the children finished.
2058        assert!(lease.content.contains("NOT cancelled"));
2059        assert!(lease.content.contains("c-1"));
2060    }
2061
2062    // ── on_child_completed terminality guard (issue #546) ────────────────
2063
2064    #[test]
2065    fn non_terminal_statuses_never_satisfy_wait_policies() {
2066        // The guard keys on `is_terminal_child_status`; "suspended" (and any
2067        // unknown non-terminal string) must not count toward any policy.
2068        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    // ── ② derive completed children from the index ──────────────────────
2084
2085    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        // Terminal from index: a, c. Plus the just-completed child b folded in.
2120        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        // Index hasn't caught up — reports the child as still running.
2129        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        // An error from a TRACKED child resumes immediately.
2159        assert!(wait_policy_satisfied(
2160            ChildWaitPolicy::FirstError,
2161            &waited,
2162            &["a".to_string()],
2163            "a",
2164            "error"
2165        ));
2166        // An error-like completion from an UNTRACKED child (e.g. a zombie
2167        // task from an earlier run waking up late) must not resume the wait.
2168        assert!(!wait_policy_satisfied(
2169            ChildWaitPolicy::FirstError,
2170            &waited,
2171            &["a".to_string()],
2172            "stray-child",
2173            "timeout"
2174        ));
2175        // The all-complete fallback still applies regardless of the reporter.
2176        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        // A very long child final response is folded in verbatim (no 4000-char
2220        // cap, no truncation marker).
2221        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        // Folded child results are now compressible so the parent context can
2235        // reclaim them under compaction.
2236        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    // ── Bash self-resume (issue #84 Phase 2b): deadline message + clobber-retry ──
2323
2324    #[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        // Normal path: the shells genuinely finished.
2329        assert!(
2330            message.content.contains("have completed"),
2331            "normal resume message must announce completion: {}",
2332            message.content
2333        );
2334        // Hidden + compressible so the resume gate sees it but the UI hides it.
2335        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        // The 6h+10m deadline force-breaks with shells STILL running. The message
2355        // must NOT say "have completed" — that would let the model assume success
2356        // on a false premise. It must direct the model to verify with BashOutput.
2357        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        // Same hidden/kind shape so the resume gate is satisfied identically.
2375        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        // The finalize-clobber retry predicate (issue #84 Phase 2b). Retry only
2387        // when the resume did NOT spawn (Completed / AlreadyRunning) AND the
2388        // persisted bash wait is still set on reload — the clobber signature.
2389
2390        // Started: the resume fired — never retry, regardless of persisted state.
2391        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        // NotFound: session gone — never retry.
2401        assert!(!bash_resume_should_retry(&ResumeOutcome::NotFound, true));
2402        assert!(!bash_resume_should_retry(&ResumeOutcome::NotFound, false));
2403
2404        // Completed + persisted wait still set ⇒ finalize-clobber ⇒ retry.
2405        assert!(bash_resume_should_retry(&ResumeOutcome::Completed, true));
2406        // Completed + persisted wait cleared ⇒ handled (our message stuck, or a
2407        // concurrent resume finished) ⇒ stop.
2408        assert!(!bash_resume_should_retry(&ResumeOutcome::Completed, false));
2409
2410        // AlreadyRunning + persisted wait still set ⇒ clobbered while a runner is
2411        // (stale-)active ⇒ retry to re-establish the resume message.
2412        assert!(bash_resume_should_retry(
2413            &ResumeOutcome::AlreadyRunning { run_id: "r".into() },
2414            true
2415        ));
2416        // AlreadyRunning + wait cleared ⇒ a runner owns the session ⇒ stop.
2417        assert!(!bash_resume_should_retry(
2418            &ResumeOutcome::AlreadyRunning { run_id: "r".into() },
2419            false
2420        ));
2421    }
2422
2423    // ── bash completion injection body (Phase 2b follow-up) ──────────────
2424
2425    #[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        // The model is pointed at BashOutput for the full log.
2442        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        // No output tail section when there is nothing to show.
2461        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        // The pre-existing conversation is untouched (no clobber).
2505        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    // ── push-driven resume: the state transition + decision the push applies ──
2526
2527    /// A session suspended on `waiting_for_bash`, given the rich completion
2528    /// message, is transitioned to a resumable state: the wait is cleared, the
2529    /// runtime is Idle, the suspend-reason marker is gone, and the resume message
2530    /// is appended. This is exactly what the PUSH does to wake the loop
2531    /// event-driven (vs the old backstop poll).
2532    #[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    /// The double-resume guard: a session NOT waiting on bash is a no-op — no
2572    /// message appended, nothing mutated. This is what makes the backstop poll
2573    /// harmlessly yield once the push has already resumed (and vice versa).
2574    #[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    /// The resume invariant: push-resume fires ONLY when the loop is suspended on
2587    /// bash AND every waited shell has finished. A still-running sibling shell
2588    /// keeps it on the enqueue path.
2589    #[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)); // other shells still running
2593        assert!(!bash_completion_should_resume(false, true)); // live loop, not suspended
2594        assert!(!bash_completion_should_resume(false, false));
2595    }
2596
2597    /// The push's resume message carries the shell's identity + status + output
2598    /// tail (so the model needs no `BashOutput` round-trip) and is tagged as a
2599    /// bash-completion resume so it satisfies the `has_pending_user_message` gate.
2600    #[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}