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 notifies the parent of each terminal outcome.
5//! The configured wait policy independently decides when its wait is released.
6
7use std::collections::HashMap;
8use std::sync::{Arc, Mutex as StdMutex, OnceLock, RwLock as StdRwLock, Weak};
9use std::time::Duration;
10
11use bamboo_domain::poison::PoisonRecover;
12use bamboo_domain::{
13    AgentHookPoint, HookPayload, HookToolOutcome, SessionActivationPolicy, SessionChildOutcome,
14    SessionMessageBody, SessionMessageContent, SessionMessageEnvelope, SessionMessageId,
15    SessionMessageKind, SessionMessageSource, SessionProviderMessage,
16};
17
18use crate::execution::{
19    finalize_runner, reserve_runner_core, reserve_session_execution, spawn_session_execution,
20    AgentRunner, AgentStatus, ChildCompletion, ChildCompletionHandler, ChildCompletionSource,
21    ReserveOutcome, SessionExecutionArgs, SessionExecutionReservation,
22    SessionExecutionReserveOutcome, SpawnJob, SpawnScheduler,
23};
24use crate::runtime::config::{BashResumeHook, GuardianSpawner, BASH_COMPLETION_RESUME_KIND};
25use crate::runtime::guardian_state::{
26    parse_guardian_verdict, read_guardian_config, read_guardian_state, write_guardian_state,
27    GuardianVerdict,
28};
29use crate::Agent;
30use async_trait::async_trait;
31use bamboo_agent_core::storage::Storage;
32use bamboo_agent_core::tools::ToolExecutor;
33use bamboo_agent_core::{
34    AgentEvent, BashCompletionInfo, BashCompletionSink, Message, Role, Session, SessionKind,
35};
36use bamboo_domain::session::runtime_state::{
37    AgentRuntimeState, AgentStatusState, ChildWaitPolicy, WaitingForChildrenState,
38};
39use bamboo_llm::{Config, ProviderModelRouter, ProviderRegistry};
40use bamboo_storage::LockedSessionStore;
41use chrono::Utc;
42use sha2::{Digest, Sha256};
43use tokio::sync::{broadcast, RwLock};
44
45use crate::model_areas::resolve_global_area_models;
46use crate::model_config_helper::{
47    resolve_fast_model, resolve_gold_config, resolve_provider_routing_key, GOLD_CONFIG_METADATA_KEY,
48};
49use crate::session_activation::{
50    SessionActivationLaunch, SessionActivationReserveOutcome, SessionActivationSpawner,
51};
52use crate::session_app::child_session::result_projection::{
53    child_result_identity, decode_report, fits_child_result_budget, plain_child_report_message,
54    project_child_report, unavailable_child_result,
55};
56use crate::session_app::execute::consume_pending_clarification_resume;
57use crate::session_app::provider_model::{persist_model_ref, session_effective_model_ref};
58use crate::session_app::resume::{
59    resume_session_execution, ResumeExecutionPort, ResumeSpawnRequest,
60};
61use crate::session_app::types::{ResumeConfigSnapshot, ResumeOutcome};
62
63const AGENT_RUNTIME_STATE_METADATA_KEY: &str = "agent.runtime.state";
64const RUNTIME_RESUME_MESSAGE_HIDDEN_KEY: &str = "hidden_from_ui";
65const RUNTIME_RESUME_MESSAGE_KIND_KEY: &str = "runtime_kind";
66const CHILD_COMPLETION_INLINE_FIELD_BYTES: usize = 48 * 1024;
67const CHILD_COMPLETION_OVERSIZE_TAIL_BYTES: usize = 8 * 1024;
68
69fn read_runtime_state(session: &Session) -> AgentRuntimeState {
70    session
71        .agent_runtime_state
72        .clone()
73        .or_else(|| {
74            session
75                .metadata
76                .get(AGENT_RUNTIME_STATE_METADATA_KEY)
77                .and_then(|raw| serde_json::from_str::<AgentRuntimeState>(raw).ok())
78        })
79        .unwrap_or_else(|| AgentRuntimeState::new(format!("{}-child-wait", session.id)))
80}
81
82fn write_runtime_state(session: &mut Session, runtime_state: &AgentRuntimeState) {
83    session.agent_runtime_state = Some(runtime_state.clone());
84    if let Ok(serialized) = serde_json::to_string(runtime_state) {
85        session
86            .metadata
87            .insert(AGENT_RUNTIME_STATE_METADATA_KEY.to_string(), serialized);
88    }
89}
90
91/// Re-read and prepare an activation target under the same per-session
92/// persistence lock that commits the generic suspension clear.
93///
94/// The boolean is false for an unanswered human question or a respected
95/// child/Bash wait in the latest durable snapshot; leave activation unreserved.
96async fn prepare_session_inbox_activation(
97    persistence: &LockedSessionStore,
98    session_id: &str,
99    interrupt_specific_wait: bool,
100) -> std::io::Result<Option<(Session, bool)>> {
101    let ready = Arc::new(std::sync::atomic::AtomicBool::new(false));
102    let ready_for_mutation = ready.clone();
103    let saved = persistence
104        .update_runtime_config(session_id, move |latest| {
105            // Inbox steering never answers a human question. Check under the
106            // same final writer lock, including when an earlier user envelope
107            // has already advanced the prefix's interrupt watermark.
108            if latest.has_pending_question() {
109                return;
110            }
111            let mut runtime_state = read_runtime_state(latest);
112            let specifically_waiting = runtime_state.waiting_for_children.is_some()
113                || runtime_state.waiting_for_bash.is_some();
114            if specifically_waiting && !interrupt_specific_wait {
115                return;
116            }
117            // Explicit steering interrupts only this reasoning gate. The
118            // durable child/Bash wait remains owned so later terminal events
119            // still have exactly one coordinator and can authorize their
120            // staged outcomes. End-of-run bookkeeping re-suspends if that wait
121            // is still present.
122            runtime_state.status = AgentStatusState::Idle;
123            runtime_state.suspension = None;
124            write_runtime_state(latest, &runtime_state);
125            latest.metadata.remove("runtime.suspend_reason");
126            latest.updated_at = Utc::now();
127            ready_for_mutation.store(true, std::sync::atomic::Ordering::Release);
128        })
129        .await?;
130    Ok(saved.map(|session| (session, ready.load(std::sync::atomic::Ordering::Acquire))))
131}
132
133fn is_error_like(status: &str) -> bool {
134    matches!(status, "error" | "timeout" | "cancelled")
135}
136
137/// Terminal child run statuses, as mirrored into the session index.
138fn is_terminal_child_status(status: &str) -> bool {
139    matches!(
140        status,
141        "completed" | "error" | "timeout" | "cancelled" | "skipped"
142    )
143}
144
145fn child_completion_envelope(
146    completion: &ChildCompletion,
147    wait_registered_at: chrono::DateTime<Utc>,
148    result: Option<String>,
149    provider_message: &Message,
150) -> SessionMessageEnvelope {
151    let envelope = build_child_completion_envelope(
152        completion,
153        wait_registered_at,
154        result.as_deref(),
155        provider_message,
156        false,
157    );
158    if serde_json::to_vec(&envelope).map_or(true, |bytes| {
159        bytes.len() > bamboo_domain::SessionInboxLimits::default().max_payload_bytes
160    }) {
161        // UTF-8 byte caps do not bound JSON escaping or duplicate fields in
162        // ChildOutcome/provider metadata. Fall back to digest-only summaries
163        // before admission so an escaped or mixed result/error cannot strand
164        // the parent wait at the SessionInbox payload limit.
165        return build_child_completion_envelope(
166            completion,
167            wait_registered_at,
168            result.as_deref(),
169            provider_message,
170            true,
171        );
172    }
173    envelope
174}
175
176fn child_completion_id(
177    completion: &ChildCompletion,
178    wait_registered_at: chrono::DateTime<Utc>,
179    result: Option<&str>,
180    legacy_wait_identity: bool,
181) -> SessionMessageId {
182    fn identity(value: Option<&str>) -> serde_json::Value {
183        match value {
184            Some(value) if value.len() > CHILD_COMPLETION_INLINE_FIELD_BYTES => serde_json::json!({
185                "oversized": true, "utf8_bytes": value.len(),
186                "sha256": hex::encode(Sha256::digest(value.as_bytes())),
187            }),
188            value => serde_json::json!(value),
189        }
190    }
191    SessionMessageId::stable(
192        "session_child_completion",
193        &serde_json::json!({
194            "parent_session_id": completion.parent_session_id,
195            "child_session_id": completion.child_session_id,
196            "status": completion.status,
197            "error": identity(completion.error.as_deref()), "result": identity(result),
198            "wait_registered_at": (legacy_wait_identity || completion.source.is_none()).then_some(wait_registered_at),
199            "terminal_source": completion.source,
200        }),
201    )
202}
203
204fn build_child_completion_envelope(
205    completion: &ChildCompletion,
206    wait_registered_at: chrono::DateTime<Utc>,
207    result: Option<&str>,
208    provider_message: &Message,
209    compact: bool,
210) -> SessionMessageEnvelope {
211    fn bounded_terminal_field(
212        label: &str,
213        child_session_id: &str,
214        value: Option<&str>,
215        compact: bool,
216    ) -> (Option<String>, serde_json::Value) {
217        let Some(value) = value else {
218            return (None, serde_json::Value::Null);
219        };
220        let oversized = value.len() > CHILD_COMPLETION_INLINE_FIELD_BYTES;
221        let identity = if oversized {
222            serde_json::json!({
223                "oversized": true,
224                "utf8_bytes": value.len(),
225                "sha256": hex::encode(Sha256::digest(value.as_bytes())),
226            })
227        } else {
228            serde_json::Value::String(value.to_string())
229        };
230        if !oversized && !compact {
231            return (Some(value.to_string()), identity);
232        }
233        let digest = hex::encode(Sha256::digest(value.as_bytes()));
234        let retrieval_view = if label == "error" {
235            "diagnostics"
236        } else {
237            "result"
238        };
239        let inspection = serde_json::json!({
240            "intent": "inspect", "target": child_session_id, "message": retrieval_view,
241        });
242        let recovery = if label == "error" {
243            format!(
244                "Check observed status and recovery diagnostics by calling SubAgent with {inspection}. \
245                 Detailed execution errors remain available to the authenticated inspector."
246            )
247        } else {
248            format!(
249                "Read it in bounded slices by calling SubAgent with {inspection}; use message=\"messages\" for transcript previews. \
250                 Follow returned cursors with a JSON-encoded view/cursor query in message, omitting unused query fields."
251            )
252        };
253        let mut summary = format!(
254            "Child {label} could not fit in the durable inline envelope ({} UTF-8 bytes, sha256={digest}). {recovery}",
255            value.len()
256        );
257        if !compact {
258            let mut tail_start = value
259                .len()
260                .saturating_sub(CHILD_COMPLETION_OVERSIZE_TAIL_BYTES);
261            while tail_start < value.len() && !value.is_char_boundary(tail_start) {
262                tail_start += 1;
263            }
264            summary.push_str("\n\nBounded tail:\n");
265            summary.push_str(&value[tail_start..]);
266        }
267        (Some(summary), identity)
268    }
269
270    let (stored_result, _) =
271        bounded_terminal_field("result", &completion.child_session_id, result, compact);
272    let (stored_error, _) = bounded_terminal_field(
273        "error",
274        &completion.child_session_id,
275        completion.error.as_deref(),
276        compact,
277    );
278    let mut bounded_provider_message = provider_message.clone();
279    let provider_oversized = serde_json::to_vec(provider_message)
280        .map(|bytes| bytes.len() > CHILD_COMPLETION_INLINE_FIELD_BYTES)
281        .unwrap_or(true);
282    if provider_oversized || compact {
283        // A short provider presentation can carry an important instruction or
284        // guardian verdict. Keep it when only the typed fields' JSON escaping
285        // forced a compact envelope.
286        let mut content = if provider_oversized {
287            format!(
288                "Runtime notification: child session `{}` finished with status `{}`.",
289                completion.child_session_id, completion.status
290            )
291        } else {
292            bounded_provider_message.content.clone()
293        };
294        if let Some(result) = stored_result.as_deref() {
295            content.push_str("\n\n");
296            content.push_str(result);
297        }
298        if let Some(error) = stored_error.as_deref() {
299            content.push_str("\n\n");
300            content.push_str(error);
301        }
302        bounded_provider_message.content = content;
303        if provider_oversized {
304            bounded_provider_message.content_parts = None;
305        }
306        // The runtime resume also carries the raw error in metadata. Bounding
307        // only its visible content leaves the durable inbox envelope oversized
308        // and strands the parent's wait when admission rejects it.
309        if compact {
310            let original = bounded_provider_message
311                .metadata
312                .as_ref()
313                .and_then(serde_json::Value::as_object);
314            let mut metadata = serde_json::Map::new();
315            for key in [
316                RUNTIME_RESUME_MESSAGE_HIDDEN_KEY,
317                RUNTIME_RESUME_MESSAGE_KIND_KEY,
318                "child_session_id",
319                "child_status",
320                "child_final_response_included",
321                "child_completion_source",
322                "child_completion_source_sha256",
323                "guardian_approved",
324            ] {
325                if let Some(value) = original.and_then(|metadata| metadata.get(key)) {
326                    metadata.insert(key.to_string(), value.clone());
327                }
328            }
329            if original.is_some_and(|metadata| metadata.contains_key("child_error")) {
330                metadata.insert(
331                    "child_error".to_string(),
332                    stored_error
333                        .as_ref()
334                        .map_or(serde_json::Value::Null, |error| {
335                            serde_json::Value::String(error.clone())
336                        }),
337                );
338            }
339            bounded_provider_message.metadata = Some(serde_json::Value::Object(metadata));
340        } else if let Some(child_error) = bounded_provider_message
341            .metadata
342            .as_mut()
343            .and_then(serde_json::Value::as_object_mut)
344            .and_then(|metadata| metadata.get_mut("child_error"))
345        {
346            *child_error = stored_error
347                .as_ref()
348                .map_or(serde_json::Value::Null, |error| {
349                    serde_json::Value::String(error.clone())
350                });
351        }
352    }
353    let body = SessionMessageBody::ChildOutcome(SessionChildOutcome {
354        child_session_id: completion.child_session_id.clone(),
355        status: completion.status.clone(),
356        result: stored_result,
357        error: stored_error,
358        provider_message: Some(session_provider_message(&bounded_provider_message)),
359    });
360    SessionMessageEnvelope {
361        // A committed source keeps one identity across wait release and re-arm.
362        // Synthetic recovery completions remain tied to their authorizing wait.
363        id: child_completion_id(completion, wait_registered_at, result, false),
364        source: SessionMessageSource::Runtime {
365            subsystem: "child_completion_coordinator".to_string(),
366        },
367        target_session_id: completion.parent_session_id.clone(),
368        kind: SessionMessageKind::ChildOutcome,
369        body,
370        created_at: completion.completed_at,
371        thread_id: None,
372        in_reply_to: None,
373        attempt: None,
374        correlation_id: Some(format!("child_completion:{}", completion.child_session_id)),
375    }
376}
377
378fn session_provider_message(message: &Message) -> SessionProviderMessage {
379    SessionProviderMessage {
380        content: SessionMessageContent {
381            text: message.content.clone(),
382            parts: message.content_parts.clone().unwrap_or_default(),
383            skill_request: None,
384        },
385        metadata: message
386            .metadata
387            .as_ref()
388            .and_then(serde_json::Value::as_object)
389            .cloned()
390            .unwrap_or_default(),
391        never_compress: message.never_compress,
392    }
393}
394
395/// Reconstruct the set of completed child session ids for a parent from the
396/// session index (the single source of truth), folding in the child whose
397/// completion event is being processed so a momentarily-lagging index can never
398/// stall the parent's resume.
399async fn derive_completed_child_ids(
400    storage: &Arc<dyn Storage>,
401    parent_session_id: &str,
402    just_completed_child_id: &str,
403) -> Vec<String> {
404    let mut completed: Vec<String> = storage
405        .list_child_run_statuses(parent_session_id)
406        .await
407        .unwrap_or_default()
408        .into_iter()
409        .filter(|(_, status)| status.as_deref().is_some_and(is_terminal_child_status))
410        .map(|(id, _)| id)
411        .collect();
412    if !completed.iter().any(|id| id == just_completed_child_id) {
413        completed.push(just_completed_child_id.to_string());
414    }
415    completed.sort();
416    completed.dedup();
417    completed
418}
419
420fn read_config_snapshot(config: &Arc<RwLock<Config>>, cached_config: &StdRwLock<Config>) -> Config {
421    if let Ok(config_guard) = config.try_read() {
422        let snapshot = config_guard.clone();
423
424        if let Ok(mut cached_guard) = cached_config.try_write() {
425            *cached_guard = snapshot.clone();
426        }
427
428        snapshot
429    } else {
430        cached_config
431            .try_read()
432            .map(|guard| guard.clone())
433            .unwrap_or_default()
434    }
435}
436
437/// Per-parent async locks that serialize concurrent `on_child_completed`
438/// invocations for the same parent session.
439///
440/// Race eliminated: when `wait_for=Any` and two child sessions complete
441/// simultaneously, both invocations load the parent with
442/// `waiting_for_children=Some` before either persists the cleared state, so
443/// both pass `wait_policy_satisfied`, both clear `waiting_for_children`, add a
444/// duplicate resume message, and call `resume_parent` — a double resume.
445/// Holding this per-parent `tokio::sync::Mutex` across the load-check-save
446/// critical section makes the second caller observe the already-cleared state.
447///
448/// The inner `std::sync::Mutex` guards only the brief HashMap lookup/insert
449/// (no await inside); the per-parent `tokio::sync::Mutex` is the one held
450/// across the async critical section. Entries exist only while a holder or
451/// waiter owns a `SessionResumeLock`; historical parent IDs are reclaimed.
452fn parent_locks() -> &'static std::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>> {
453    static LOCKS: OnceLock<std::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>> =
454        OnceLock::new();
455    LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
456}
457
458/// Fetch (or create) the per-session async lock from [`parent_locks`]. Held
459/// across the load-check-clear-resume critical section so the three resume
460/// sources for one session — child completion, the loop-facing bash **push**
461/// ([`BashCompletionSink::on_bash_completed`]), and the bash **backstop** poll
462/// ([`ChildCompletionCoordinator::bash_self_resume`]) — can never double-resume.
463/// The inner sync `Mutex` guards only the brief map lookup (no await inside).
464pub(crate) fn session_resume_lock(session_id: &str) -> SessionResumeLock {
465    let mut map = parent_locks().lock().recover_poison();
466    let lock = map
467        .entry(session_id.to_string())
468        .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
469        .clone();
470    SessionResumeLock {
471        session_id: session_id.to_string(),
472        lock: Some(lock),
473    }
474}
475
476/// The lease is constructed before awaiting the mutex, so cancelled waiters
477/// also reclaim their registration. Lookup and last-owner removal use the same
478/// brief registry lock; two live mutexes can never exist for the same ID.
479pub(crate) struct SessionResumeLock {
480    session_id: String,
481    lock: Option<Arc<tokio::sync::Mutex<()>>>,
482}
483
484impl std::ops::Deref for SessionResumeLock {
485    type Target = tokio::sync::Mutex<()>;
486    fn deref(&self) -> &Self::Target {
487        self.lock.as_ref().expect("live resume-lock lease")
488    }
489}
490
491impl Drop for SessionResumeLock {
492    fn drop(&mut self) {
493        self.lock.take();
494        let mut map = parent_locks().lock().recover_poison();
495        if map
496            .get(&self.session_id)
497            .is_some_and(|lock| Arc::strong_count(lock) == 1)
498        {
499            map.remove(&self.session_id);
500        }
501    }
502}
503
504fn wait_policy_satisfied(
505    policy: ChildWaitPolicy,
506    wait_child_ids: &[String],
507    completed_child_ids: &[String],
508    latest_child_id: &str,
509    latest_status: &str,
510) -> bool {
511    if wait_child_ids.is_empty() {
512        return false;
513    }
514
515    match policy {
516        ChildWaitPolicy::All => wait_child_ids
517            .iter()
518            .all(|id| completed_child_ids.iter().any(|completed| completed == id)),
519        ChildWaitPolicy::Any => completed_child_ids
520            .iter()
521            .any(|id| wait_child_ids.iter().any(|wait_id| wait_id == id)),
522        ChildWaitPolicy::FirstError => {
523            // The error short-circuit only counts a completion from a child
524            // this wait actually tracks (issue #546): a stray/duplicate
525            // completion from an untracked child — e.g. a frozen runner's
526            // task waking up after the watchdog already synthesized its
527            // timeout, in a later run's wait — must not resume the parent.
528            (is_error_like(latest_status) && wait_child_ids.iter().any(|id| id == latest_child_id))
529                || wait_child_ids
530                    .iter()
531                    .all(|id| completed_child_ids.iter().any(|completed| completed == id))
532        }
533    }
534}
535
536/// Extract the child session's last assistant content, if any. Returns `None`
537/// when the child produced no assistant message (e.g. errored before the first
538/// model response, or only emitted tool messages).
539#[cfg(test)]
540fn child_final_assistant_text(child: &Session) -> Option<String> {
541    child
542        .messages
543        .iter()
544        .rev()
545        .find(|message| matches!(message.role, Role::Assistant))
546        .map(|message| message.content.clone())
547        .filter(|content| !content.trim().is_empty())
548}
549
550fn runtime_resume_message(
551    completion: &ChildCompletion,
552    remaining_children: usize,
553    child_final_response: Option<&str>,
554) -> Message {
555    let mut body = format!(
556        "Runtime notification: child session `{}` finished with status `{}`. Remaining child sessions: {}.",
557        completion.child_session_id, completion.status, remaining_children
558    );
559
560    // Fold the child's full final response back into the parent — no
561    // truncation. Sub-agents are first-class agents whose complete conclusion
562    // should be available to the parent without an extra SubAgent inspection round
563    // trip. The message is left compressible (see `never_compress` below) so a
564    // long transcript can still be reclaimed under parent compaction.
565    let final_response = child_final_response.map(str::to_string);
566    if let Some(response) = final_response.as_deref() {
567        body.push_str("\n\nChild final response:\n");
568        body.push_str(response);
569    } else if let Some(error) = completion.error.as_deref() {
570        if !error.is_empty() {
571            body.push_str("\n\nChild error:\n");
572            body.push_str(error);
573        }
574    }
575
576    let inspection = serde_json::json!({
577        "intent": "inspect", "target": completion.child_session_id, "message": "messages",
578    });
579    body.push_str(&format!(
580        "\n\nResume the parent task using this child result and continue from the previous plan. \
581         If you need transcript evidence, call SubAgent with {inspection}. \
582         Follow returned cursors with a JSON-encoded view/cursor query in message; use view=\"message\" and message_id inside that query for a selected full message. Omit unused query fields.",
583    ));
584
585    let mut message = Message::user(body);
586    message.metadata = Some(serde_json::json!({
587        RUNTIME_RESUME_MESSAGE_HIDDEN_KEY: true,
588        RUNTIME_RESUME_MESSAGE_KIND_KEY: "child_completion_resume",
589        "child_session_id": completion.child_session_id,
590        "child_status": completion.status,
591        "child_error": completion.error,
592        "child_final_response_included": final_response.is_some(),
593        "child_completion_source": completion.source,
594    }));
595    // Allow parent-side compaction to reclaim this (now untruncated) message if
596    // the parent context grows — important once children nest and fold full
597    // results upward. The compact inspection hint preserves recoverability.
598    message.never_compress = false;
599    message
600}
601
602/// A current sealed required-packet completion always uses the bounded route.
603/// Unusable reports are unavailable, never a reason to import legacy raw text.
604/// Unbound children, Guardian verdicts and resident completions retain their paths.
605fn sealed_child_report(
606    completion: &ChildCompletion,
607    child: &Session,
608    parent: &Session,
609) -> Option<serde_json::Value> {
610    if child.subagent_type().as_deref() == Some("guardian")
611        || child
612            .metadata
613            .get("lifecycle")
614            .is_some_and(|v| v == "resident")
615    {
616        return None;
617    }
618    let source = completion.source.as_ref()?;
619    if !source.matches_completion(completion)
620        || ChildCompletionSource::from_committed_session(child).as_ref() != Some(source)
621        || !source.matches_parent(child, parent)
622    {
623        return None;
624    }
625    let binding = bamboo_domain::ChildContextBinding::from_session(child).ok()??;
626    let Some(message) = child
627        .messages
628        .iter()
629        .find(|m| Some(&m.id) == source.result_message_id.as_ref())
630    else {
631        let reason = if source.last_assistant.is_some() {
632            "report_not_current_final"
633        } else {
634            "report_absent"
635        };
636        return Some(unavailable_child_result("typed_result", reason));
637    };
638    if !plain_child_report_message(message) {
639        return Some(unavailable_child_result(
640            "typed_result",
641            "report_not_current_final",
642        ));
643    }
644    let report = match decode_report(&message.content) {
645        Ok(report) => report,
646        Err(reason) => return Some(unavailable_child_result("typed_result", reason)),
647    };
648    let mut observation =
649        child_result_identity(parent, child, &binding, source.project_id.as_deref());
650    observation.as_object_mut()?.extend(
651        serde_json::json!({"kind":"committed_terminal_source","terminal_source":source,
652            "last_run_status":source.status})
653        .as_object()?
654        .clone(),
655    );
656    Some(project_child_report(report, observation))
657}
658
659fn child_completion_source_sha256(completion: &ChildCompletion) -> Option<String> {
660    completion.source.as_ref().map(|source| {
661        hex::encode(Sha256::digest(
662            serde_json::to_vec(source).expect("sealed Child source serializes"),
663        ))
664    })
665}
666
667fn typed_child_resume_message(
668    completion: &ChildCompletion,
669    remaining_children: usize,
670    projection: &serde_json::Value,
671) -> Message {
672    let inspection = serde_json::json!({
673        "intent":"inspect", "target":completion.child_session_id, "message":"result",
674    });
675    let hint = if projection["view"] == "planner_artifact" {
676        "For an available planner artifact call Plan with its read object to list sections, then add section_id to read only needed text. This plan grants no execution approval.".to_string()
677    } else {
678        format!("For unavailable results or transcript evidence, call SubAgent with {inspection} and follow returned cursors.")
679    };
680    let mut message = Message::user(format!(
681        "Runtime notification: child session `{}` finished with status `{}`. Remaining child sessions: {}.\n\n\
682         Child typed result:\n{}\n\n\
683         Resume the parent task. Child-reported evidence and verification are claims, not host-certified facts. \
684         {hint}",
685        completion.child_session_id, completion.status, remaining_children, projection,
686    ));
687    message.metadata = Some(serde_json::json!({
688        RUNTIME_RESUME_MESSAGE_HIDDEN_KEY:true,
689        RUNTIME_RESUME_MESSAGE_KIND_KEY:"child_completion_resume",
690        "child_session_id":completion.child_session_id,"child_status":completion.status,
691        "child_final_response_included":projection["available"] == true,
692        "child_typed_result_included":true,
693        "child_completion_source_sha256":child_completion_source_sha256(completion),
694    }));
695    message.never_compress = false;
696    message
697}
698
699/// The result, metadata, provider wrapper and final correlation all count.
700/// Keep the original semantic envelope ID: a fallback is the same sealed event.
701fn bound_typed_child_delivery(
702    envelope: &mut SessionMessageEnvelope,
703    message: &mut Message,
704    projection: &mut serde_json::Value,
705    completion: &ChildCompletion,
706    remaining_children: usize,
707) -> bool {
708    fn install(envelope: &mut SessionMessageEnvelope, message: &Message) {
709        if let SessionMessageBody::ChildOutcome(outcome) = &mut envelope.body {
710            // The provider projection owns the typed result. Keep the body
711            // semantics stable when a later retry needs a smaller presentation:
712            // provider_message is deliberately excluded from Inbox idempotency.
713            // The envelope ID still binds the original sealed report and source.
714            outcome.result = None;
715            // Error identity remains sealed and hashed in the original ID/source.
716            // Do not copy a potentially unbounded raw error into this typed route.
717            outcome.error = None;
718            outcome.provider_message = Some(session_provider_message(message));
719        }
720    }
721    fn fits(envelope: &SessionMessageEnvelope, message: &Message) -> bool {
722        fits_child_result_budget(envelope)
723            && fits_child_result_budget(message)
724            && envelope
725                .to_provider_message()
726                .is_ok_and(|actual| fits_child_result_budget(&actual))
727    }
728    install(envelope, message);
729    if fits(envelope, message) {
730        return true;
731    }
732    *projection = unavailable_child_result("typed_result", "result_budget_exceeded");
733    *message = typed_child_resume_message(completion, remaining_children, projection);
734    install(envelope, message);
735    fits(envelope, message)
736}
737
738/// The hidden resume message for a completed **guardian** review: a directive,
739/// verdict-tailored note that carries the reviewer's findings straight into the
740/// parent (so it can act without a SubAgent inspection), mirroring
741/// [`runtime_resume_message`]'s hidden/compressible shape.
742fn guardian_resume_message(completion: &ChildCompletion, verdict: &GuardianVerdict) -> Message {
743    let mut body = if verdict.approve {
744        String::from(
745            "Guardian review APPROVED: an independent reviewer verified the work and found no blocking issues. You may finalize the task.",
746        )
747    } else {
748        String::from(
749            "Guardian review REJECTED: an independent reviewer found issues. Address every finding below before completing — do NOT declare the task complete until they are resolved.",
750        )
751    };
752    if let Some(summary) = verdict.summary.as_deref().filter(|s| !s.trim().is_empty()) {
753        body.push_str("\n\nReviewer summary: ");
754        body.push_str(summary);
755    }
756    if !verdict.findings.is_empty() {
757        body.push_str("\n\nFindings:");
758        for (idx, finding) in verdict.findings.iter().enumerate() {
759            body.push_str(&format!("\n{}. {}", idx + 1, finding));
760        }
761    }
762    let inspection = serde_json::json!({
763        "intent": "inspect", "target": completion.child_session_id, "message": "messages",
764    });
765    body.push_str(&format!(
766        "\n\nIf you need guardian transcript evidence, call SubAgent with {inspection}. \
767         Follow returned cursors with a JSON-encoded view/cursor query in message, omitting unused query fields.",
768    ));
769
770    let mut message = Message::user(body);
771    message.metadata = Some(serde_json::json!({
772        RUNTIME_RESUME_MESSAGE_HIDDEN_KEY: true,
773        RUNTIME_RESUME_MESSAGE_KIND_KEY: "guardian_review_resume",
774        "child_session_id": completion.child_session_id,
775        "child_status": completion.status,
776        "guardian_approved": verdict.approve,
777        "child_completion_source": completion.source,
778    }));
779    message.never_compress = false;
780    message
781}
782
783/// Observe the existing registered Root surface without retaining its owner.
784pub type RootToolSurfaceObserver = Arc<
785    dyn Fn() -> futures::future::BoxFuture<'static, Option<Arc<dyn ToolExecutor>>> + Send + Sync,
786>;
787
788pub type ReservedRootExecutionAdapter = Arc<
789    dyn Fn(
790            Arc<Agent>,
791            &Session,
792            &SessionExecutionReservation,
793            Arc<dyn ToolExecutor>,
794        ) -> Result<(Arc<Agent>, Arc<dyn ToolExecutor>), bamboo_agent_core::AgentError>
795        + Send
796        + Sync,
797>;
798
799#[derive(Clone)]
800pub struct ChildCompletionCoordinator {
801    storage: Arc<dyn Storage>,
802    persistence: Arc<bamboo_storage::LockedSessionStore>,
803    sessions: crate::SessionCache,
804    agent_runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
805    session_event_senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
806    agent: Arc<Agent>,
807    config: Arc<RwLock<Config>>,
808    provider_registry: Arc<ProviderRegistry>,
809    provider_router: Arc<ProviderModelRouter>,
810    app_data_dir: std::path::PathBuf,
811    account_feed_inbox: Option<crate::execution::AccountFeedInbox>,
812    root_account_sink: Option<Arc<crate::events::AccountEventSink>>,
813    root_tools: Arc<RwLock<Option<Arc<dyn ToolExecutor>>>>,
814    reserved_root_adapter: Arc<StdRwLock<Option<ReservedRootExecutionAdapter>>>,
815    /// Late-bound guardian reviewer spawner, set post-construction by the server
816    /// (mirrors `root_tools`). Re-injected into resumed runs so a guardian's
817    /// reject→fix verdict can be re-reviewed across the suspend/resume boundary.
818    guardian_spawner: Arc<RwLock<Option<Arc<dyn GuardianSpawner>>>>,
819    /// Weak late binding avoids the scheduler -> completion handler ->
820    /// scheduler ownership cycle while still routing idle child activation
821    /// through the canonical placement-aware spawn core.
822    spawn_scheduler: Arc<RwLock<Weak<SpawnScheduler>>>,
823}
824
825impl ChildCompletionCoordinator {
826    pub fn with_root_account_sink(mut self, sink: Arc<crate::events::AccountEventSink>) -> Self {
827        self.root_account_sink = Some(sink);
828        self
829    }
830
831    #[allow(clippy::too_many_arguments)]
832    pub fn new(
833        storage: Arc<dyn Storage>,
834        persistence: Arc<LockedSessionStore>,
835        sessions: crate::SessionCache,
836        agent_runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
837        session_event_senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
838        agent: Arc<Agent>,
839        config: Arc<RwLock<Config>>,
840        provider_registry: Arc<ProviderRegistry>,
841        provider_router: Arc<ProviderModelRouter>,
842        app_data_dir: std::path::PathBuf,
843        account_feed_inbox: Option<crate::execution::AccountFeedInbox>,
844    ) -> Self {
845        Self {
846            storage,
847            persistence,
848            sessions,
849            agent_runners,
850            session_event_senders,
851            agent,
852            config,
853            provider_registry,
854            provider_router,
855            app_data_dir,
856            account_feed_inbox,
857            root_account_sink: None,
858            root_tools: Arc::new(RwLock::new(None)),
859            reserved_root_adapter: Arc::new(StdRwLock::new(None)),
860            guardian_spawner: Arc::new(RwLock::new(None)),
861            spawn_scheduler: Arc::new(RwLock::new(Weak::new())),
862        }
863    }
864
865    /// Register a host producer at the existing genuine reserved Root seam.
866    /// This is optional; independent workers and embeddings retain None.
867    pub fn set_reserved_root_execution_adapter(&self, adapter: ReservedRootExecutionAdapter) {
868        *self
869            .reserved_root_adapter
870            .write()
871            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(adapter);
872    }
873
874    pub fn root_tool_surface_observer(&self) -> RootToolSurfaceObserver {
875        let source = Arc::downgrade(&self.root_tools);
876        Arc::new(move || {
877            let source = source.clone();
878            Box::pin(async move {
879                let source = source.upgrade()?;
880                let current = source.read().await.clone();
881                current
882            })
883        })
884    }
885
886    pub async fn set_root_tools(&self, tools: Arc<dyn ToolExecutor>) {
887        *self.root_tools.write().await = Some(tools);
888    }
889
890    pub async fn set_spawn_scheduler(&self, scheduler: &Arc<SpawnScheduler>) {
891        *self.spawn_scheduler.write().await = Arc::downgrade(scheduler);
892    }
893
894    /// Wire the guardian reviewer spawner (server-provided), so resumed runs can
895    /// re-spawn a guardian to re-review a fix after a reject verdict.
896    pub async fn set_guardian_spawner(&self, spawner: Arc<dyn GuardianSpawner>) {
897        *self.guardian_spawner.write().await = Some(spawner);
898    }
899
900    fn build_resume_config(
901        &self,
902        session: &Session,
903        config_snapshot: &Config,
904    ) -> ResumeConfigSnapshot {
905        crate::session_app::resolution::resolve_resume_config_snapshot(
906            config_snapshot,
907            &self.provider_registry,
908            session,
909            None,
910        )
911    }
912
913    /// Drive a parent-resume and return the final [`ResumeOutcome`] so callers
914    /// can distinguish a successful spawn (`Started`) from a gate-blocked
915    /// attempt (`Completed`). The bash self-resume poll task uses this to
916    /// detect the finalize-clobber case — its appended resume message was
917    /// reverted by the suspending runner's final `merge_save_runtime`, so the
918    /// resume port's `has_pending_user_message` gate fails and nothing spawns —
919    /// and retry the clear→append→resume (see [`Self::bash_self_resume`]).
920    async fn resume_parent(&self, parent_session_id: String) -> ResumeOutcome {
921        for attempt in 0..=5u8 {
922            if attempt > 0 {
923                tokio::time::sleep(Duration::from_millis(250 * attempt as u64)).await;
924            }
925
926            let Some(session) = self.load_session(&parent_session_id).await else {
927                tracing::warn!(%parent_session_id, "cannot resume parent after child completion: session not found");
928                return ResumeOutcome::NotFound;
929            };
930            let config_snapshot = self.config.read().await.clone();
931            let resume_config = self.build_resume_config(&session, &config_snapshot);
932            let outcome = resume_session_execution(self, &parent_session_id, resume_config).await;
933            tracing::info!(
934                %parent_session_id,
935                attempt,
936                outcome = outcome.as_str(),
937                "child completion requested parent resume"
938            );
939
940            if !matches!(outcome, ResumeOutcome::AlreadyRunning { .. }) {
941                return outcome;
942            }
943        }
944        // Exhausted the AlreadyRunning retry budget; surface the final state.
945        // The wait state was already cleared and the resume message persisted,
946        // so nothing event-driven will retry — the child-wait watchdog is the
947        // backstop that picks this stranded parent up (it resumes suspended
948        // sessions that hold a pending runtime resume message but no runner).
949        tracing::error!(
950            %parent_session_id,
951            "parent resume gave up after AlreadyRunning retry budget; \
952             relying on the child-wait watchdog backstop"
953        );
954        ResumeOutcome::AlreadyRunning {
955            run_id: String::new(),
956        }
957    }
958
959    async fn save_and_cache(&self, session: &mut Session) {
960        if let Err(error) = self
961            .persistence
962            .merge_save_runtime_and_publish(session, |saved, _| {
963                self.sessions.insert(
964                    saved.id.clone(),
965                    Arc::new(crate::SessionSnapshot::new(saved.clone())),
966                );
967            })
968            .await
969        {
970            tracing::warn!(session_id = %session.id, %error, "failed to persist session");
971        }
972    }
973
974    async fn reuse_legacy_child_delivery(
975        &self,
976        envelope: &mut SessionMessageEnvelope,
977        completion: &ChildCompletion,
978        wait: Option<&WaitingForChildrenState>,
979        result: Option<&str>,
980    ) -> Result<(), bamboo_domain::SessionInboxError> {
981        if let (Some(wait), Some(inbox), Some(_)) =
982            (wait, self.agent.session_inbox(), completion.source.as_ref())
983        {
984            let mut legacy = envelope.clone();
985            legacy.id = child_completion_id(completion, wait.registered_at, result, true);
986            if inbox.existing_staged_delivery(&legacy).await?.is_some() {
987                *envelope = legacy;
988            }
989        }
990        Ok(())
991    }
992
993    async fn legacy_child_delivery(
994        &self,
995        parent: &Session,
996        completion: &ChildCompletion,
997    ) -> Result<Option<(SessionMessageEnvelope, bool)>, bamboo_domain::SessionInboxError> {
998        let (Some(source), Some(inbox)) = (completion.source.as_ref(), self.agent.session_inbox())
999        else {
1000            return Ok(None);
1001        };
1002        let source_sha256 = child_completion_source_sha256(completion);
1003        let source = serde_json::to_value(source).expect("sealed Child source serializes");
1004        let matches_source = |message: &Message, envelope: &SessionMessageEnvelope| {
1005            if !matches!(&envelope.source, SessionMessageSource::Runtime { subsystem }
1006                if subsystem == "child_completion_coordinator")
1007                || envelope.target_session_id != parent.id
1008                || !matches!(&envelope.body, SessionMessageBody::ChildOutcome(outcome)
1009                    if outcome.child_session_id == completion.child_session_id && outcome.status == completion.status)
1010                || !bamboo_domain::is_matching_session_message(message, envelope)
1011            {
1012                return false;
1013            }
1014            let Some(metadata) = message.metadata.as_ref() else {
1015                return false;
1016            };
1017            // Old typed deliveries predate source metadata. Only a bounded,
1018            // available projection carries enough proof to match its generation.
1019            // Unavailable historical projections without a source are not guessed.
1020            let typed_source = message
1021                .content
1022                .split_once("\n\nChild typed result:\n")
1023                .and_then(|(_, text)| text.lines().next())
1024                .filter(|line| line.len() <= 8192)
1025                .and_then(|line| serde_json::from_str::<serde_json::Value>(line).ok())
1026                .and_then(|projection| {
1027                    projection
1028                        .get("host_observation")?
1029                        .get("terminal_source")
1030                        .cloned()
1031                });
1032            metadata.get("child_completion_source") == Some(&source)
1033                || source_sha256.as_ref().is_some_and(|digest| {
1034                    metadata
1035                        .get("child_completion_source_sha256")
1036                        .and_then(serde_json::Value::as_str)
1037                        == Some(digest.as_str())
1038                })
1039                || typed_source.as_ref() == Some(&source)
1040        };
1041        for message in &parent.messages {
1042            let Some(marker) = message
1043                .metadata
1044                .as_ref()
1045                .and_then(|m| m.get("session_message"))
1046            else {
1047                continue;
1048            };
1049            let Ok(envelope) = serde_json::from_value::<SessionMessageEnvelope>(marker.clone())
1050            else {
1051                continue;
1052            };
1053            if matches_source(message, &envelope)
1054                && inbox.was_admitted(&parent.id, &envelope.id).await?
1055            {
1056                return Ok(Some((envelope, true)));
1057            }
1058        }
1059        // An old writer may have cleared its wait after staging this exact
1060        // source, before a reasoning turn committed and ACKed its presentation.
1061        // Reuse that pending envelope rather than inventing a second event ID.
1062        for envelope in inbox
1063            .pending_child_outcomes(&parent.id, &completion.child_session_id)
1064            .await?
1065        {
1066            if envelope
1067                .to_provider_message()
1068                .is_ok_and(|message| matches_source(&message, &envelope))
1069            {
1070                return Ok(Some((envelope, false)));
1071            }
1072        }
1073        Ok(None)
1074    }
1075}
1076
1077#[async_trait]
1078impl ChildCompletionHandler for ChildCompletionCoordinator {
1079    async fn on_child_completed(&self, completion: ChildCompletion) {
1080        // Terminality guard: a child that reports a NON-terminal status (e.g.
1081        // "suspended" — awaiting parent approval, its own bash wait, or its own
1082        // grandchildren) is not done. It must never satisfy the parent's wait:
1083        // `derive_completed_child_ids` folds the just-reported child in
1084        // unconditionally, so without this guard a suspending child would
1085        // resume the parent with a premature "finished with status
1086        // `suspended`" message. The child will publish a real terminal
1087        // completion when it later resumes and finishes.
1088        if !is_terminal_child_status(&completion.status) {
1089            tracing::info!(
1090                parent_session_id = %completion.parent_session_id,
1091                child_session_id = %completion.child_session_id,
1092                status = %completion.status,
1093                "non-terminal child status; leaving the parent wait armed"
1094            );
1095            return;
1096        }
1097
1098        // Acquire the per-session async lock to eliminate the concurrent
1099        // double-resume race (see `parent_locks` for the full scenario). The
1100        // inner std::sync::Mutex is released immediately so no sync lock is
1101        // held across the await that follows.
1102        let per_parent = session_resume_lock(&completion.parent_session_id);
1103        let _per_parent_guard = per_parent.lock().await;
1104
1105        let (parent, should_resume, messenger, child_admission, child_admissions) = loop {
1106            let Some(mut parent) = self.load_session(&completion.parent_session_id).await else {
1107                tracing::warn!(
1108                    parent_session_id = %completion.parent_session_id,
1109                    child_session_id = %completion.child_session_id,
1110                    "child completion received for missing parent"
1111                );
1112                return;
1113            };
1114
1115            let expected_parent = parent.clone();
1116
1117            // A parent may itself be a child (nested sub-agents): the rest of this
1118            // handler is kind-agnostic — it operates on `completion.parent_session_id`,
1119            // inspects that session's own `waiting_for_children` runtime state, and
1120            // resumes it. (Previously this bailed unless the parent was Root, which
1121            // silently dropped grandchild completions.)
1122            let mut runtime_state = read_runtime_state(&parent);
1123
1124            // Single source of truth: reconstruct the completed-child set from the
1125            // session index rather than from a denormalized copy on the parent file.
1126            let mut completed_child_ids = derive_completed_child_ids(
1127                &self.storage,
1128                &completion.parent_session_id,
1129                &completion.child_session_id,
1130            )
1131            .await;
1132
1133            let mut should_resume = false;
1134            let mut remaining_children = 0usize;
1135            let active_wait = runtime_state.waiting_for_children.clone();
1136            let tracked_wait = active_wait.as_ref().is_some_and(|wait| {
1137                wait.child_session_ids
1138                    .contains(&completion.child_session_id)
1139            });
1140            if !tracked_wait {
1141                // Registration owns the physical writer while it fresh-checks
1142                // Child terminality. Our no-wait decision must use that same
1143                // boundary, so a callback cannot disappear before its arm lands.
1144                let mut unchanged = parent.clone();
1145                match self
1146                    .persistence
1147                    .compare_exchange_child_wait_and_publish(
1148                        &parent,
1149                        &mut unchanged,
1150                        true,
1151                        Arc::new(|_| {}),
1152                    )
1153                    .await
1154                {
1155                    Ok(false) => continue,
1156                    Ok(true) if completion.source.is_none() => return,
1157                    Ok(true) => {}
1158                    Err(error) => {
1159                        tracing::warn!(parent_session_id = %parent.id, %error,
1160                            "child completion could not settle no-wait observation");
1161                        return;
1162                    }
1163                }
1164            }
1165            // Independent Stores can have stale child-status indexes. Refresh
1166            // this arm's known children from their durable control planes.
1167            if let Some(wait) = active_wait.as_ref() {
1168                for id in &wait.child_session_ids {
1169                    if id == &completion.child_session_id {
1170                        continue;
1171                    }
1172                    if let Ok(Some(child)) = self
1173                        .storage
1174                        .load_child_wait_session(&parent, id, false)
1175                        .await
1176                    {
1177                        completed_child_ids.retain(|completed| completed != id);
1178                        if completion_child_is_owned(&parent.id, child.parent_session_id.as_deref())
1179                            && child
1180                                .last_run_status()
1181                                .as_deref()
1182                                .is_some_and(is_terminal_child_status)
1183                        {
1184                            completed_child_ids.push(id.clone());
1185                        }
1186                    }
1187                }
1188            }
1189            if let Some(wait) = active_wait.as_ref() {
1190                remaining_children = wait
1191                    .child_session_ids
1192                    .iter()
1193                    .filter(|id| !completed_child_ids.iter().any(|completed| completed == *id))
1194                    .count();
1195                should_resume = tracked_wait
1196                    && wait_policy_satisfied(
1197                        wait.wait_for,
1198                        &wait.child_session_ids,
1199                        &completed_child_ids,
1200                        &completion.child_session_id,
1201                        &completion.status,
1202                    );
1203            }
1204
1205            // READ-SIDE OWNERSHIP GUARD (issue #546): `SubAgent.wait` ids are
1206            // model-provided and unvalidated, and the watchdog unstrands a wait
1207            // over a FOREIGN/unknown id by publishing a synthetic completion
1208            // here. We must resume the parent (so it is not stranded) but MUST
1209            // NOT fold that foreign session's transcript into the parent — that
1210            // would be a cross-session disclosure primitive. Decide ownership
1211            // from the child's OWN parent linkage (control-plane only, no
1212            // messages loaded), and only load its full content when it is truly
1213            // this parent's child. An unowned id resumes with the neutral/error
1214            // message (`runtime_resume_message` falls back to `completion.error`
1215            // when no child content is supplied).
1216            let reported_child_owned = match self
1217                .storage
1218                .load_child_wait_session(&parent, &completion.child_session_id, false)
1219                .await
1220            {
1221                Ok(Some(control_plane)) => completion_child_is_owned(
1222                    &completion.parent_session_id,
1223                    control_plane.parent_session_id.as_deref(),
1224                ),
1225                _ => false,
1226            };
1227
1228            // Load the completed child once, ONLY when owned. The guardian
1229            // branch inspects its subagent_type + final verdict; the generic
1230            // path folds its final assistant content into the hidden resume
1231            // message (avoiding an extra `SubAgent.get` round trip after resume).
1232            let loaded_child = if reported_child_owned {
1233                match self
1234                    .storage
1235                    .load_child_wait_session(&parent, &completion.child_session_id, true)
1236                    .await
1237                {
1238                    Ok(child) => child,
1239                    Err(error) => {
1240                        tracing::warn!(
1241                            child_session_id = %completion.child_session_id,
1242                            %error,
1243                            "failed to load child session for runtime resume message"
1244                        );
1245                        None
1246                    }
1247                }
1248            } else {
1249                tracing::warn!(
1250                    parent_session_id = %completion.parent_session_id,
1251                    child_session_id = %completion.child_session_id,
1252                    "completion child is not a child of this parent; resuming with a neutral \
1253                     message and NOT folding its content"
1254                );
1255                None
1256            };
1257
1258            // An old committed callback cannot count a successor, stage another
1259            // outcome, or clear a newer wait. Source-less synthetic completions may
1260            // unblock recovery, but never borrow the latest transcript answer.
1261            if let Some(source) = completion.source.as_ref() {
1262                if loaded_child
1263                    .as_ref()
1264                    .and_then(ChildCompletionSource::from_committed_session)
1265                    .as_ref()
1266                    != Some(source)
1267                    || !source.matches_completion(&completion)
1268                    || loaded_child
1269                        .as_ref()
1270                        .is_none_or(|child| !source.matches_parent(child, &parent))
1271                {
1272                    tracing::warn!(child_session_id = %completion.child_session_id,
1273                    "stale/unavailable committed child completion source; leaving wait armed");
1274                    return;
1275                }
1276            }
1277            if !tracked_wait
1278                && crate::ticket_runtime::is_independent_ticket_child(
1279                    self.storage.as_ref(),
1280                    &parent,
1281                    &completion.child_session_id,
1282                )
1283                .await
1284            {
1285                return;
1286            }
1287            let previous_delivery = match self.legacy_child_delivery(&parent, &completion).await {
1288                Ok(envelope) => envelope,
1289                Err(error) => {
1290                    tracing::warn!(parent_session_id = %parent.id, %error,
1291                        "cannot verify historical Child delivery; leaving replay recoverable");
1292                    return;
1293                }
1294            };
1295            let previously_admitted = previous_delivery
1296                .as_ref()
1297                .is_some_and(|(_, admitted)| *admitted);
1298            let child_final_response = loaded_child
1299                .as_ref()
1300                .and_then(|child| completion.source.as_ref()?.result(child, &parent));
1301            // Select the exact provider-facing resume message before durable
1302            // admission. The typed body carries its content/parts and safe runtime
1303            // metadata, so the canonical path is semantically identical to the
1304            // rolling-upgrade transcript fallback.
1305            let guardian_resume = if completion.source.is_some() || should_resume {
1306                let reviewed_round = runtime_state.round.current_round;
1307                loaded_child.as_ref().and_then(|child| {
1308                if child.subagent_type().as_deref() != Some("guardian") {
1309                    return None;
1310                }
1311                let mut guardian_state = read_guardian_state(&parent)?;
1312                if guardian_state.guardian_child_id.as_deref()
1313                    != Some(completion.child_session_id.as_str())
1314                {
1315                    // A *different* guardian is legitimately still in flight —
1316                    // leave its Pending state intact and use the generic resume.
1317                    tracing::warn!(
1318                        parent_session_id = %completion.parent_session_id,
1319                        child_session_id = %completion.child_session_id,
1320                        expected = ?guardian_state.guardian_child_id,
1321                        "guardian completion does not match recorded guardian_child_id; using generic resume"
1322                    );
1323                    return None;
1324                }
1325                if guardian_state.phase.is_reviewed() {
1326                    // Replaying the same sealed completion must not mutate a
1327                    // newer reasoning round or repeat the control transition.
1328                    return guardian_state.last_verdict.as_ref()
1329                        .map(|verdict| guardian_resume_message(&completion, verdict));
1330                }
1331                // This IS the guardian we dispatched, so we MUST advance the
1332                // phase out of `Pending` — otherwise the next terminal gate's
1333                // `Pending => return None` would let the run complete unreviewed.
1334                // A reviewer that errored or produced unparseable output is
1335                // treated as a SYNTHETIC REJECT (never a silent pass), so the
1336                // budgeted re-review loop governs the outcome: fail-closed, but
1337                // still bounded by `max_reviews`.
1338                let verdict = child_final_response.clone()
1339                    .and_then(|text| match parse_guardian_verdict(&text) {
1340                        Ok(verdict) => Some(verdict),
1341                        Err(error) => {
1342                            tracing::warn!(
1343                                child_session_id = %completion.child_session_id,
1344                                %error,
1345                                "guardian verdict unparseable; recording a synthetic reject"
1346                            );
1347                            None
1348                        }
1349                    })
1350                    .unwrap_or_else(|| {
1351                        GuardianVerdict::rejected(vec![
1352                            "The guardian reviewer did not return a usable verdict (it errored or \
1353                             emitted unparseable output); the work has NOT been independently \
1354                             verified."
1355                                .to_string(),
1356                        ])
1357                    });
1358                let approved = verdict.approve;
1359                let message = guardian_resume_message(&completion, &verdict);
1360                guardian_state.record_verdict(verdict, reviewed_round);
1361                write_guardian_state(&mut parent, guardian_state);
1362                tracing::info!(
1363                    parent_session_id = %completion.parent_session_id,
1364                    child_session_id = %completion.child_session_id,
1365                    approved,
1366                    "guardian verdict recorded; resuming parent"
1367                );
1368                Some(message)
1369            })
1370            } else {
1371                None
1372            };
1373            let guardian_control_changed = parent.metadata.get("guardian.state")
1374                != expected_parent.metadata.get("guardian.state");
1375            // A permanent receipt suppresses another notification, but it does
1376            // not prove that the corresponding Guardian verdict was committed.
1377            if previously_admitted && !should_resume && !guardian_control_changed {
1378                return;
1379            }
1380            let activation_policy = if should_resume || guardian_control_changed {
1381                SessionActivationPolicy::RespectSpecificWait
1382            } else {
1383                SessionActivationPolicy::InterruptSpecificWait
1384            };
1385            let mut typed_result = if let Some(child) = loaded_child.as_ref() {
1386                match super::child_session::planner_artifact::publish_completion(
1387                    &self.app_data_dir,
1388                    &completion,
1389                    child,
1390                    &parent,
1391                )
1392                .await
1393                {
1394                    Ok(Some(projection)) => Some(projection),
1395                    Ok(None) => sealed_child_report(&completion, child, &parent),
1396                    Err(error) => {
1397                        tracing::warn!(%error, child_session_id = %child.id,
1398                            "planner artifact publication failed; leaving wait armed");
1399                        return;
1400                    }
1401                }
1402            } else {
1403                None
1404            };
1405            let mut resume_message = if let Some(projection) = typed_result.as_mut() {
1406                let message =
1407                    typed_child_resume_message(&completion, remaining_children, projection);
1408                if fits_child_result_budget(&message) {
1409                    message
1410                } else {
1411                    *projection =
1412                        unavailable_child_result("typed_result", "result_budget_exceeded");
1413                    typed_child_resume_message(&completion, remaining_children, projection)
1414                }
1415            } else {
1416                guardian_resume.unwrap_or_else(|| {
1417                    runtime_resume_message(
1418                        &completion,
1419                        remaining_children,
1420                        child_final_response.as_deref(),
1421                    )
1422                })
1423            };
1424            if typed_result.is_some() && !fits_child_result_budget(&resume_message) {
1425                tracing::warn!(child_session_id = %completion.child_session_id,
1426                "typed child resume identity exceeds budget; leaving wait armed");
1427                return;
1428            }
1429
1430            // Notification permission belongs to this exact outcome, not the
1431            // whole queue prefix or the parent's wait release. A parent may
1432            // reason on this result while its unfinished wait remains armed.
1433            let messenger = self.agent.session_messenger().cloned();
1434            let mut child_admission = if let Some(messenger) = messenger.as_ref() {
1435                let mut envelope = child_completion_envelope(
1436                    &completion,
1437                    active_wait
1438                        .as_ref()
1439                        .map_or(completion.completed_at, |wait| wait.registered_at),
1440                    child_final_response.clone(),
1441                    &resume_message,
1442                );
1443                if let Some(router) = self.agent.activation_router() {
1444                    let previous_fenced_run = previous_delivery
1445                        .as_ref()
1446                        .and_then(|(envelope, _)| envelope.correlation_id.as_deref())
1447                        .and_then(|value| value.strip_prefix("child_completion_after_run:"));
1448                    if let Some(run_id) = router
1449                        .child_completion_successor_run(
1450                            &completion.parent_session_id,
1451                            active_wait.is_some() || guardian_control_changed,
1452                            previous_fenced_run,
1453                        )
1454                        .await
1455                    {
1456                        envelope.correlation_id =
1457                            Some(format!("child_completion_after_run:{run_id}"));
1458                    }
1459                }
1460                if let Some(projection) = typed_result.as_mut() {
1461                    if !bound_typed_child_delivery(
1462                        &mut envelope,
1463                        &mut resume_message,
1464                        projection,
1465                        &completion,
1466                        remaining_children,
1467                    ) {
1468                        tracing::warn!(child_session_id = %completion.child_session_id,
1469                        "typed child outcome wrapper exceeds budget; leaving wait armed");
1470                        return;
1471                    }
1472                }
1473                if let Some((previous, _)) = previous_delivery.as_ref() {
1474                    envelope = previous.clone();
1475                } else if let Err(error) = self
1476                    .reuse_legacy_child_delivery(
1477                        &mut envelope,
1478                        &completion,
1479                        active_wait.as_ref(),
1480                        child_final_response.as_deref(),
1481                    )
1482                    .await
1483                {
1484                    tracing::warn!(parent_session_id = %parent.id, %error,
1485                        "cannot verify existing Child delivery; leaving wait armed");
1486                    return;
1487                }
1488                match messenger.admit(envelope).await {
1489                    Ok(admission) => {
1490                        if !should_resume && !guardian_control_changed {
1491                            let mut observed = expected_parent.clone();
1492                            match self
1493                                .persistence
1494                                .compare_exchange_child_wait_and_publish(
1495                                    &expected_parent,
1496                                    &mut observed,
1497                                    true,
1498                                    Arc::new(|_| {}),
1499                                )
1500                                .await
1501                            {
1502                                Ok(false) => continue,
1503                                Ok(true) => {}
1504                                Err(error) => {
1505                                    tracing::warn!(parent_session_id = %parent.id, %error,
1506                                        "partial Child delivery lost its parent authority; leaving staged outcome inert");
1507                                    return;
1508                                }
1509                            }
1510                        }
1511                        // A partial Guardian verdict must commit before any
1512                        // reasoning owner can consume its presentation. Its
1513                        // still-armed wait lets the watchdog recover this gap.
1514                        if should_resume || !guardian_control_changed {
1515                            if let Err(error) = messenger
1516                                .prepare_message_activation(&admission, activation_policy)
1517                                .await
1518                            {
1519                                tracing::warn!(parent_session_id = %completion.parent_session_id,
1520                                    child_session_id = %completion.child_session_id, %error,
1521                                    "child outcome notification permission failed; leaving wait armed");
1522                                return;
1523                            }
1524                        }
1525                        Some(admission)
1526                    }
1527                    Err(error) => {
1528                        tracing::warn!(
1529                            parent_session_id = %completion.parent_session_id,
1530                            child_session_id = %completion.child_session_id,
1531                            %error,
1532                            "child outcome SessionInbox admission failed; leaving parent wait armed"
1533                        );
1534                        return;
1535                    }
1536                }
1537            } else {
1538                None
1539            };
1540
1541            let mut child_admissions = child_admission.iter().cloned().collect::<Vec<_>>();
1542
1543            // The durable Child index can reach terminal state before its
1544            // completion callback acquires this parent lock. Clearing an already
1545            // satisfied wait after staging only the current callback would drop
1546            // every later callback at the no-wait guard above. Stage those exact
1547            // waited-for terminal siblings first, using the same wait identity and
1548            // Inbox protocol. Any/FirstError still do not wait for running siblings.
1549            if should_resume {
1550                if let (Some(wait), Some(messenger)) = (active_wait.as_ref(), messenger.as_ref()) {
1551                    let parent_run_id = if let Some(router) = self.agent.activation_router() {
1552                        router
1553                            .child_completion_successor_run(
1554                                &completion.parent_session_id,
1555                                true,
1556                                None,
1557                            )
1558                            .await
1559                    } else {
1560                        None
1561                    };
1562                    for child_id in &wait.child_session_ids {
1563                        if child_id == &completion.child_session_id
1564                            || !completed_child_ids.contains(child_id)
1565                        {
1566                            continue;
1567                        }
1568                        // Check lineage before reading transcript content. The
1569                        // index is parent-scoped, but ownership must still come
1570                        // from this child's own durable control plane.
1571                        let owned = match self
1572                            .storage
1573                            .load_child_wait_session(&parent, child_id, false)
1574                            .await
1575                        {
1576                            Ok(Some(child)) => completion_child_is_owned(
1577                                &completion.parent_session_id,
1578                                child.parent_session_id.as_deref(),
1579                            ),
1580                            _ => false,
1581                        };
1582                        let sibling = if owned {
1583                            self.storage
1584                                .load_child_wait_session(&parent, child_id, true)
1585                                .await
1586                        } else {
1587                            tracing::warn!(%child_id, "terminal sibling ownership unavailable; leaving parent wait armed");
1588                            return;
1589                        };
1590                        let sibling = match sibling {
1591                            Ok(Some(child))
1592                                if completion_child_is_owned(
1593                                    &completion.parent_session_id,
1594                                    child.parent_session_id.as_deref(),
1595                                ) && child
1596                                    .last_run_status()
1597                                    .as_deref()
1598                                    .is_some_and(is_terminal_child_status) =>
1599                            {
1600                                child
1601                            }
1602                            _ => {
1603                                tracing::warn!(%child_id, "terminal sibling snapshot unavailable; leaving parent wait armed");
1604                                return;
1605                            }
1606                        };
1607                        if ChildCompletionSource::has_source_record(&sibling)
1608                            && ChildCompletionSource::from_committed_session(&sibling).is_none()
1609                        {
1610                            tracing::warn!(%child_id, "terminal sibling source is invalid; leaving wait armed");
1611                            return;
1612                        }
1613                        let sibling_completion = ChildCompletion {
1614                            parent_session_id: completion.parent_session_id.clone(),
1615                            child_session_id: child_id.clone(),
1616                            status: sibling.last_run_status().expect("terminal status checked"),
1617                            error: sibling.last_run_error(),
1618                            completed_at: sibling.updated_at,
1619                            source: ChildCompletionSource::from_committed_session(&sibling),
1620                        };
1621                        if sibling_completion
1622                            .source
1623                            .as_ref()
1624                            .is_some_and(|source| !source.matches_parent(&sibling, &parent))
1625                        {
1626                            tracing::warn!(%child_id, "terminal sibling parent context is stale; leaving wait armed");
1627                            return;
1628                        }
1629                        let result = sibling_completion
1630                            .source
1631                            .as_ref()
1632                            .and_then(|source| source.result(&sibling, &parent));
1633                        let mut typed_result =
1634                            match super::child_session::planner_artifact::publish_completion(
1635                                &self.app_data_dir,
1636                                &sibling_completion,
1637                                &sibling,
1638                                &parent,
1639                            )
1640                            .await
1641                            {
1642                                Ok(Some(projection)) => Some(projection),
1643                                Ok(None) => {
1644                                    sealed_child_report(&sibling_completion, &sibling, &parent)
1645                                }
1646                                Err(error) => {
1647                                    tracing::warn!(%error, %child_id,
1648                                    "sibling planner artifact publication failed; leaving wait armed");
1649                                    return;
1650                                }
1651                            };
1652                        let mut presentation = if let Some(projection) = typed_result.as_ref() {
1653                            typed_child_resume_message(
1654                                &sibling_completion,
1655                                remaining_children,
1656                                projection,
1657                            )
1658                        } else {
1659                            runtime_resume_message(
1660                                &sibling_completion,
1661                                remaining_children,
1662                                result.as_deref(),
1663                            )
1664                        };
1665                        let mut envelope = child_completion_envelope(
1666                            &sibling_completion,
1667                            wait.registered_at,
1668                            result.clone(),
1669                            &presentation,
1670                        );
1671                        if let Some(run_id) = parent_run_id.as_ref() {
1672                            envelope.correlation_id =
1673                                Some(format!("child_completion_after_run:{run_id}"));
1674                        }
1675                        if let Some(projection) = typed_result.as_mut() {
1676                            if !bound_typed_child_delivery(
1677                                &mut envelope,
1678                                &mut presentation,
1679                                projection,
1680                                &sibling_completion,
1681                                remaining_children,
1682                            ) {
1683                                tracing::warn!(%child_id, "typed terminal sibling wrapper exceeds budget; leaving wait armed");
1684                                return;
1685                            }
1686                        }
1687                        let previous_delivery = match self
1688                            .legacy_child_delivery(&parent, &sibling_completion)
1689                            .await
1690                        {
1691                            Ok(delivery) => delivery,
1692                            Err(error) => {
1693                                tracing::warn!(%child_id, %error,
1694                                    "cannot verify historical sibling delivery; leaving wait armed");
1695                                return;
1696                            }
1697                        };
1698                        if let Some((previous, _)) = previous_delivery {
1699                            envelope = previous;
1700                        } else if let Err(error) = self
1701                            .reuse_legacy_child_delivery(
1702                                &mut envelope,
1703                                &sibling_completion,
1704                                Some(wait),
1705                                result.as_deref(),
1706                            )
1707                            .await
1708                        {
1709                            tracing::warn!(%child_id, %error,
1710                                "cannot verify existing sibling delivery; leaving wait armed");
1711                            return;
1712                        }
1713                        match messenger.admit(envelope).await {
1714                            Ok(admission) => {
1715                                if let Err(error) = messenger
1716                                    .prepare_message_activation(
1717                                        &admission,
1718                                        SessionActivationPolicy::RespectSpecificWait,
1719                                    )
1720                                    .await
1721                                {
1722                                    tracing::warn!(%child_id, %error,
1723                                        "terminal sibling notification permission failed; leaving wait armed");
1724                                    return;
1725                                }
1726                                child_admissions.push(admission.clone());
1727                                // Keep every permission separately, while one
1728                                // request coalesces the batch's activation.
1729                                if child_admission.as_ref().is_none_or(|current| {
1730                                    admission.delivery.generation > current.delivery.generation
1731                                }) {
1732                                    child_admission = Some(admission);
1733                                }
1734                            }
1735                            Err(error) => {
1736                                tracing::warn!(%child_id, %error, "terminal sibling admission failed; leaving parent wait armed");
1737                                return;
1738                            }
1739                        }
1740                    }
1741                }
1742            }
1743
1744            // Partial notifications do not own status/suspension writes. In
1745            // particular, do not stamp Suspended over a parent already reasoning
1746            // on an earlier result. The original wait is left byte-for-byte intact.
1747            if !should_resume && !guardian_control_changed && child_admission.is_some() {
1748                break (parent, true, messenger, child_admission, child_admissions);
1749            }
1750
1751            if should_resume {
1752                runtime_state.waiting_for_children = None;
1753                runtime_state.status = AgentStatusState::Idle;
1754                runtime_state.suspension = None;
1755                parent.metadata.remove("runtime.suspend_reason");
1756
1757                if child_admission.is_none() {
1758                    // Rolling-upgrade fallback only. The canonical path keeps the
1759                    // child outcome solely in SessionInbox until the next safe
1760                    // reasoning boundary.
1761                    parent.add_message(resume_message);
1762                }
1763            }
1764
1765            parent.updated_at = Utc::now();
1766            write_runtime_state(&mut parent, &runtime_state);
1767            // Canonical ChildOutcome admission owns the provider-visible message;
1768            // this transaction only changes the parent's wait/control state. A
1769            // concurrent Root turn may already have advanced its model-context
1770            // ledger, so use the control-plane writer, which adopts that durable
1771            // ledger under the session lock. The rolling-upgrade fallback above
1772            // appends a message and still requires an append-safe checkpoint.
1773            let runtime_only = child_admission.is_some();
1774            let sessions = self.sessions.clone();
1775            let history = expected_parent.clone();
1776            let save = self
1777                .persistence
1778                .compare_exchange_child_wait_and_publish(
1779                    &expected_parent,
1780                    &mut parent,
1781                    runtime_only,
1782                    Arc::new(move |saved| {
1783                        if runtime_only {
1784                            if let Some(cached) = sessions.get(&saved.id) {
1785                                cached.update(|current| {
1786                                    if current.created_at != saved.created_at {
1787                                        *current = saved.clone();
1788                                        current.messages = history.messages.clone();
1789                                        current.provider_transcript =
1790                                            history.provider_transcript.clone();
1791                                        current
1792                                            .runtime_metadata
1793                                            .get_or_insert_with(Default::default)
1794                                            .session_inbox_admission =
1795                                            history.session_inbox_admission().cloned();
1796                                        return;
1797                                    }
1798
1799                                    let messages = current.messages.clone();
1800                                    let provider_transcript = current.provider_transcript.clone();
1801                                    let admission =
1802                                        current.runtime_metadata.as_ref().and_then(|metadata| {
1803                                            metadata.session_inbox_admission.clone()
1804                                        });
1805                                    *current = saved.clone();
1806                                    current.messages = messages;
1807                                    current.provider_transcript = provider_transcript;
1808                                    current
1809                                        .runtime_metadata
1810                                        .get_or_insert_with(Default::default)
1811                                        .session_inbox_admission = admission;
1812                                });
1813                            } else {
1814                                let mut snapshot = saved.clone();
1815                                snapshot.messages = history.messages.clone();
1816                                snapshot.provider_transcript = history.provider_transcript.clone();
1817                                snapshot
1818                                    .runtime_metadata
1819                                    .get_or_insert_with(Default::default)
1820                                    .session_inbox_admission = history
1821                                    .runtime_metadata
1822                                    .as_ref()
1823                                    .and_then(|metadata| metadata.session_inbox_admission.clone());
1824                                sessions.insert(
1825                                    snapshot.id.clone(),
1826                                    Arc::new(crate::SessionSnapshot::new(snapshot)),
1827                                );
1828                            }
1829                        } else {
1830                            sessions.insert(
1831                                saved.id.clone(),
1832                                Arc::new(crate::SessionSnapshot::new(saved.clone())),
1833                            );
1834                        }
1835                    }),
1836                )
1837                .await;
1838            match save {
1839                Ok(false) => {
1840                    // Membership or policy changed, even if first-arm tag/time
1841                    // stayed the same. Reload and recompute; never replay a clear.
1842                    continue;
1843                }
1844                Ok(true) => {
1845                    break (
1846                        parent,
1847                        should_resume || (guardian_control_changed && !previously_admitted),
1848                        messenger,
1849                        child_admission,
1850                        child_admissions,
1851                    )
1852                }
1853                Err(error) => {
1854                    tracing::warn!(parent_session_id = %completion.parent_session_id,
1855                    child_session_id = %completion.child_session_id, %error,
1856                    "child outcome is durable but parent wait transition failed; leaving activation deferred");
1857                    return;
1858                }
1859            }
1860        };
1861
1862        // Capture before releasing the per-parent lock so the borrow checker
1863        // is satisfied; `resume_parent` has its own retry loop and should not
1864        // hold the per-parent lock (it would block other completions for the
1865        // same parent, and the state is already durably settled above).
1866        let resume_parent_id = parent.id.clone();
1867        drop(_per_parent_guard);
1868
1869        if should_resume {
1870            if let (Some(messenger), Some(admission)) = (messenger, child_admission) {
1871                // Control transitions have now committed. Upgrade each exact
1872                // outcome, including a lower generation below an ACKed sibling,
1873                // so a concurrently rearmed wait cannot suppress this batch.
1874                for outcome in &child_admissions {
1875                    if let Err(error) = messenger
1876                        .prepare_message_activation(
1877                            outcome,
1878                            SessionActivationPolicy::InterruptSpecificWait,
1879                        )
1880                        .await
1881                    {
1882                        tracing::warn!(parent_session_id = %resume_parent_id, %error,
1883                            "child control transition committed but notification permission failed");
1884                        return;
1885                    }
1886                }
1887                if let Err(error) = messenger.activate_prepared(&admission).await {
1888                    tracing::warn!(
1889                        parent_session_id = %resume_parent_id,
1890                        %error,
1891                        "child outcome and wait transition are durable but activation failed"
1892                    );
1893                }
1894            } else {
1895                self.resume_parent(resume_parent_id).await;
1896            }
1897        }
1898    }
1899}
1900
1901#[async_trait]
1902impl ResumeExecutionPort for ChildCompletionCoordinator {
1903    async fn load_session(&self, session_id: &str) -> Option<Session> {
1904        match self.storage.load_session(session_id).await {
1905            Ok(Some(session)) => Some(session),
1906            Ok(None) => self
1907                .sessions
1908                .get(session_id)
1909                .map(|e| e.value().clone())
1910                .map(|arc| arc.read().clone()),
1911            Err(error) => {
1912                tracing::warn!(%session_id, %error, "failed to load session from storage");
1913                self.sessions
1914                    .get(session_id)
1915                    .map(|e| e.value().clone())
1916                    .map(|arc| arc.read().clone())
1917            }
1918        }
1919    }
1920
1921    async fn save_and_cache_session(&self, session: &mut Session) {
1922        self.save_and_cache(session).await;
1923    }
1924
1925    async fn reserve_session_execution(
1926        &self,
1927        session_id: &str,
1928        event_sender: &broadcast::Sender<AgentEvent>,
1929    ) -> SessionExecutionReserveOutcome {
1930        reserve_session_execution(
1931            &self.agent,
1932            &self.agent_runners,
1933            &self.session_event_senders,
1934            session_id,
1935            event_sender,
1936        )
1937        .await
1938    }
1939
1940    async fn get_or_create_event_sender(&self, session_id: &str) -> broadcast::Sender<AgentEvent> {
1941        crate::execution::session_events::get_or_create_event_sender(
1942            &self.session_event_senders,
1943            session_id,
1944        )
1945        .await
1946    }
1947
1948    fn dispatch_resume_execution(
1949        &self,
1950        request: ResumeSpawnRequest,
1951    ) -> Result<(), ResumeSpawnRequest> {
1952        let owner = self.clone();
1953        tokio::spawn(async move {
1954            ResumeExecutionPort::spawn_resume_execution(&owner, request).await;
1955        });
1956        Ok(())
1957    }
1958
1959    async fn spawn_resume_execution(&self, request: ResumeSpawnRequest) {
1960        let ResumeSpawnRequest {
1961            session_id,
1962            mut session,
1963            mut execution_reservation,
1964            event_sender,
1965            config,
1966        } = request;
1967        if let Err(error) = execution_reservation.ensure_registered().await {
1968            tracing::warn!(
1969                %session_id,
1970                run_id = %execution_reservation.run_id(),
1971                %error,
1972                "cannot resume after child completion without exact router ownership"
1973            );
1974            return;
1975        }
1976
1977        if self
1978            .agent
1979            .persistence()
1980            .root_actor_execution_required(&session)
1981            && self.root_account_sink.is_none()
1982        {
1983            tracing::warn!(%session_id, "Root resume has no actual account sink; rejecting before Actor claim");
1984            execution_reservation.abandon().await;
1985            return;
1986        }
1987        if let Err(error) = execution_reservation
1988            .bind_root_actor(&self.agent, &session)
1989            .await
1990        {
1991            tracing::warn!(%session_id, %error, "Root coordinator resume Actor binding rejected");
1992            execution_reservation.abandon().await;
1993            return;
1994        }
1995        let root_publication = execution_reservation.root_actor_writer().map(|owner| {
1996            crate::events::RootActorEventPublication::new(
1997                self.storage.clone(),
1998                self.root_account_sink
1999                    .clone()
2000                    .expect("required Root actual account sink checked before claim"),
2001                owner,
2002            )
2003        });
2004
2005        let Some(root_tools) = self.root_tools.read().await.clone() else {
2006            tracing::error!(%session_id, "cannot resume parent after child completion: root tool surface is not initialized");
2007            return;
2008        };
2009
2010        let adapter = self
2011            .reserved_root_adapter
2012            .read()
2013            .unwrap_or_else(std::sync::PoisonError::into_inner)
2014            .clone();
2015        let (agent, root_tools) = if let Some(adapter) = adapter {
2016            // A registered Root producer freezes its finite Source here.
2017            // Resolve the existing execution context first, while the exact
2018            // Actor reservation is held. Otherwise an initially unassigned
2019            // Root acquires its default Workspace in the first round after
2020            // Source binding, and correctly fails its own scope fence before
2021            // reaching the provider. This grants no Workspace change to a
2022            // bound Source; later refreshes still validate the frozen scope.
2023            if session.kind == SessionKind::Root {
2024                if let Err(error) = self
2025                    .agent
2026                    .prepare_external_session_for_execution(&mut session)
2027                    .await
2028                {
2029                    tracing::error!(%session_id, %error, "Root resume context preparation rejected before Source binding");
2030                    execution_reservation.abandon().await;
2031                    return;
2032                }
2033            }
2034            match adapter(
2035                self.agent.clone(),
2036                &session,
2037                &execution_reservation,
2038                root_tools,
2039            ) {
2040                Ok(bound) => bound,
2041                Err(error) => {
2042                    tracing::error!(%session_id, %error, "registered Root execution producer rejected resume");
2043                    execution_reservation.abandon().await;
2044                    return;
2045                }
2046            }
2047        } else {
2048            (self.agent.clone(), root_tools)
2049        };
2050
2051        let config_snapshot = self.config.read().await.clone();
2052        let model = session.model.clone();
2053        let session_model_ref = session_effective_model_ref(&session);
2054        let requested_provider = session_model_ref
2055            .as_ref()
2056            .map(|model_ref| model_ref.provider.as_str())
2057            .unwrap_or(config.provider_name.as_str());
2058        let resolved_provider_name = match resolve_provider_routing_key(
2059            &config_snapshot,
2060            requested_provider,
2061            &self.provider_registry,
2062        ) {
2063            Ok(provider) => provider,
2064            Err(error) => {
2065                tracing::error!(
2066                    session_id = %session_id,
2067                    provider = requested_provider,
2068                    %error,
2069                    "child-completion resume provider is unavailable; refusing to fall back"
2070                );
2071                execution_reservation.abandon().await;
2072                return;
2073            }
2074        };
2075        let provider_override = if let Some(mut model_ref) = session_model_ref {
2076            model_ref.provider = resolved_provider_name.clone();
2077            persist_model_ref(&mut session, &model_ref);
2078            match self.provider_router.route(&model_ref) {
2079                Ok(provider) => Some(provider),
2080                Err(error) => {
2081                    tracing::error!(
2082                        session_id = %session_id,
2083                        provider = %model_ref.provider,
2084                        model = %model_ref.model,
2085                        %error,
2086                        "child-completion resume provider routing failed closed"
2087                    );
2088                    execution_reservation.abandon().await;
2089                    return;
2090                }
2091            }
2092        } else {
2093            match self.provider_registry.get(&resolved_provider_name) {
2094                Some(provider) => Some(provider),
2095                None => {
2096                    tracing::error!(
2097                        session_id = %session_id,
2098                        provider = %resolved_provider_name,
2099                        "child-completion resume provider disappeared after resolution; refusing to fall back"
2100                    );
2101                    execution_reservation.abandon().await;
2102                    return;
2103                }
2104            }
2105        };
2106        let resolved_fast_provider = resolve_fast_model(
2107            &config_snapshot,
2108            &resolved_provider_name,
2109            &self.provider_registry,
2110        )
2111        .map(|model| model.provider);
2112        let reasoning_effort = session.reasoning_effort;
2113        let reasoning_effort_source = session
2114            .metadata
2115            .get("reasoning_effort_source")
2116            .cloned()
2117            .unwrap_or_default();
2118        let gold_config = resolve_gold_config(
2119            &config_snapshot,
2120            session
2121                .metadata
2122                .get(GOLD_CONFIG_METADATA_KEY)
2123                .map(String::as_str),
2124        )
2125        .or(config.gold_config.clone());
2126
2127        let (mpsc_tx, _forwarder, history_commit_barrier) =
2128            crate::execution::create_event_forwarder_with_root_actor(
2129                session_id.clone(),
2130                execution_reservation.run_id().to_string(),
2131                event_sender,
2132                self.agent_runners.clone(),
2133                self.account_feed_inbox.clone(),
2134                root_publication,
2135            );
2136
2137        let config_handle = self.config.clone();
2138        let cached_config = Arc::new(StdRwLock::new(config_snapshot.clone()));
2139        let provider_registry = self.provider_registry.clone();
2140        let provider_name_for_aux = resolved_provider_name.clone();
2141        let auxiliary_model_resolver = std::sync::Arc::new(move || {
2142            let config_snapshot = read_config_snapshot(&config_handle, cached_config.as_ref());
2143            // Auxiliary models are global (config-derived), never session-bound.
2144            let areas = resolve_global_area_models(
2145                &config_snapshot,
2146                &provider_name_for_aux,
2147                &provider_registry,
2148            );
2149            crate::AuxiliaryModelConfig {
2150                fast_model_name: areas.fast.as_ref().map(|m| m.model_name.clone()),
2151                fast_model_provider: areas.fast.map(|m| m.provider),
2152                background_model_name: areas.background.as_ref().map(|m| m.model_name.clone()),
2153                planning_model_name: None,
2154                search_model_name: None,
2155                summarization_model_name: areas
2156                    .summarization
2157                    .as_ref()
2158                    .map(|m| m.model_name.clone()),
2159                background_model_provider: areas.background.map(|m| m.provider),
2160                summarization_model_provider: areas.summarization.map(|m| m.provider),
2161                summarization_model_error: areas.summarization_error,
2162            }
2163        });
2164        let model_roster = crate::ModelRoster {
2165            model: Some(model),
2166            provider_name: Some(resolved_provider_name),
2167            provider_type: config.provider_type.clone(),
2168            fast: crate::RoleModel::from_parts(config.fast_model, resolved_fast_provider),
2169            background: crate::RoleModel::from_parts(
2170                config.background_model,
2171                config.background_model_provider,
2172            ),
2173            summarization: crate::RoleModel::from_parts(
2174                config.summarization_model,
2175                config.summarization_model_provider,
2176            ),
2177        };
2178
2179        // Re-inject guardian state on resume so a reject→fix verdict can be
2180        // re-reviewed: config from the session (persisted at first spawn),
2181        // spawner from the coordinator-held handle. Absent guardian config this
2182        // stays `None`, and the approve→complete path is unchanged.
2183        let guardian_config = read_guardian_config(&session);
2184        let guardian_spawner = self.guardian_spawner.read().await.clone();
2185
2186        consume_pending_clarification_resume(&mut session);
2187        spawn_session_execution(SessionExecutionArgs {
2188            agent,
2189            session_id,
2190            session,
2191            execution_reservation,
2192            tools_override: Some(root_tools),
2193            provider_override,
2194            model_roster,
2195            reasoning_effort,
2196            reasoning_effort_source,
2197            auxiliary_model_resolver: Some(auxiliary_model_resolver),
2198            // Resumed child runs keep the spawn-time disabled snapshot (#136 lives
2199            // on the long-running main agent path; children are short-lived).
2200            disabled_filter_resolver: None,
2201            disabled_tools: Some(config.disabled_tools),
2202            disabled_skill_ids: Some(config.disabled_skill_ids),
2203            selected_skill_ids: None,
2204            selected_skill_mode: None,
2205            mpsc_tx,
2206            history_commit_barrier,
2207            image_fallback: config.image_fallback,
2208            gold_config,
2209            guardian_config,
2210            guardian_spawner,
2211            bash_resume_hook: {
2212                let hook: Arc<dyn BashResumeHook> = Arc::new(self.clone());
2213                Some(hook)
2214            },
2215            bash_completion_sink: {
2216                // Resumed runs keep the push wired too, so a background shell
2217                // launched after resume still notifies the loop.
2218                let sink: Arc<dyn BashCompletionSink> = Arc::new(self.clone());
2219                Some(sink)
2220            },
2221            app_data_dir: Some(self.app_data_dir.clone()),
2222            // Resume does not carry a fresh per-request override; the
2223            // config-level default (issue #221) still applies.
2224            run_budget: None,
2225            runners: self.agent_runners.clone(),
2226            sessions_cache: self.sessions.clone(),
2227            on_complete: None,
2228            // A resumed session that is itself a CHILD (nested sub-agents)
2229            // must publish its terminal completion so ITS parent is woken
2230            // in turn (issue #546).
2231            child_completion_handler: Some(Arc::new(self.clone())),
2232        });
2233    }
2234}
2235
2236/// Real SessionInbox activation adapter. It reserves through the exact same
2237/// runner registry as every existing resume path, but deliberately bypasses
2238/// `has_pending_user_message`: the typed envelope is still in the durable inbox
2239/// and will be admitted by the loop's first safe turn boundary.
2240#[async_trait]
2241impl SessionActivationSpawner for ChildCompletionCoordinator {
2242    async fn reserve_activation(
2243        &self,
2244        target_session_id: &str,
2245        _inbox_generation: u64,
2246    ) -> Result<SessionActivationReserveOutcome, bamboo_domain::SessionActivationError> {
2247        let Some(inbox) = self.agent.session_inbox() else {
2248            return Err(bamboo_domain::SessionActivationError::Internal(
2249                "agent runtime has no SessionInbox".to_string(),
2250            ));
2251        };
2252        let backlog = inbox
2253            .inspect(target_session_id)
2254            .await
2255            .map_err(|error| bamboo_domain::SessionActivationError::Internal(error.to_string()))?;
2256        if !backlog.activation_pending() {
2257            return Ok(SessionActivationReserveOutcome::NoWork);
2258        }
2259        // Load and mutate under the persistence lock. A coordinator that armed
2260        // a child/Bash wait immediately before this activation therefore wins.
2261        let prepared = prepare_session_inbox_activation(
2262            &self.persistence,
2263            target_session_id,
2264            backlog.interrupt_pending(),
2265        )
2266        .await
2267        .map_err(|error| {
2268            bamboo_domain::SessionActivationError::Internal(format!(
2269                "persist resumable SessionInbox target: {error}"
2270            ))
2271        })?;
2272        let Some((session, ready)) = prepared else {
2273            return Ok(SessionActivationReserveOutcome::NotFound);
2274        };
2275        if !ready {
2276            tracing::info!(
2277                session_id = target_session_id,
2278                "SessionInbox backlog is activation-eligible but a specific durable wait remains armed"
2279            );
2280            return Ok(SessionActivationReserveOutcome::NoWork);
2281        }
2282
2283        enum LaunchPlan {
2284            Root(Box<ResumeConfigSnapshot>),
2285            Child {
2286                scheduler: Arc<SpawnScheduler>,
2287                parent_session_id: String,
2288                model: String,
2289                disabled_tools: Option<Vec<String>>,
2290            },
2291        }
2292
2293        // Derive every execution/security input from the latest locked session
2294        // returned above, never from a stale pre-lock snapshot.
2295        let launch_plan = match session.kind {
2296            SessionKind::Root => {
2297                if self.root_tools.read().await.is_none() {
2298                    return Err(bamboo_domain::SessionActivationError::Internal(
2299                        "root tool surface is not initialized".to_string(),
2300                    ));
2301                }
2302                let config_snapshot = self.config.read().await.clone();
2303                LaunchPlan::Root(Box::new(
2304                    self.build_resume_config(&session, &config_snapshot),
2305                ))
2306            }
2307            SessionKind::Child => {
2308                let scheduler = self.spawn_scheduler.read().await.upgrade().ok_or_else(|| {
2309                    bamboo_domain::SessionActivationError::Internal(
2310                        "child spawn scheduler is not initialized".to_string(),
2311                    )
2312                })?;
2313                let parent_session_id = session
2314                    .parent_session_id
2315                    .as_deref()
2316                    .map(str::trim)
2317                    .filter(|id| !id.is_empty())
2318                    .map(ToOwned::to_owned)
2319                    .ok_or_else(|| {
2320                        bamboo_domain::SessionActivationError::Internal(format!(
2321                            "child SessionInbox target {target_session_id} has no parent owner"
2322                        ))
2323                    })?;
2324                let parent = self
2325                    .storage
2326                    .load_session(&parent_session_id)
2327                    .await
2328                    .map_err(|error| {
2329                        bamboo_domain::SessionActivationError::Internal(format!(
2330                            "load parent owner {parent_session_id}: {error}"
2331                        ))
2332                    })?
2333                    .ok_or_else(|| {
2334                        bamboo_domain::SessionActivationError::Internal(format!(
2335                            "child SessionInbox parent owner {parent_session_id} disappeared"
2336                        ))
2337                    })?;
2338                let child_root = if session.root_session_id.trim().is_empty() {
2339                    parent_session_id.as_str()
2340                } else {
2341                    session.root_session_id.as_str()
2342                };
2343                let parent_root = if parent.root_session_id.trim().is_empty() {
2344                    parent.id.as_str()
2345                } else {
2346                    parent.root_session_id.as_str()
2347                };
2348                if child_root != parent_root {
2349                    return Err(bamboo_domain::SessionActivationError::Internal(format!(
2350                        "child SessionInbox target {target_session_id} does not share its parent owner's root"
2351                    )));
2352                }
2353                let model = if session.model.trim().is_empty() {
2354                    parent.model.clone()
2355                } else {
2356                    session.model.clone()
2357                };
2358                if model.trim().is_empty() {
2359                    return Err(bamboo_domain::SessionActivationError::Internal(format!(
2360                        "child SessionInbox target {target_session_id} has no executable model"
2361                    )));
2362                }
2363                let disabled_tools = match session.metadata.get("disabled_tools") {
2364                    None => None,
2365                    Some(raw) => {
2366                        let tools = serde_json::from_str::<std::collections::BTreeSet<String>>(raw)
2367                            .map_err(|error| {
2368                                bamboo_domain::SessionActivationError::Internal(format!(
2369                                    "child SessionInbox target {target_session_id} has malformed disabled_tools: {error}"
2370                                ))
2371                            })?;
2372                        (!tools.is_empty()).then(|| tools.into_iter().collect())
2373                    }
2374                };
2375                LaunchPlan::Child {
2376                    scheduler,
2377                    parent_session_id,
2378                    model,
2379                    disabled_tools,
2380                }
2381            }
2382        };
2383        let event_sender =
2384            ResumeExecutionPort::get_or_create_event_sender(self, target_session_id).await;
2385
2386        let reservation = match reserve_runner_core(
2387            &self.agent_runners,
2388            &self.session_event_senders,
2389            target_session_id,
2390            &event_sender,
2391        )
2392        .await
2393        {
2394            ReserveOutcome::Reserved(reservation) => reservation,
2395            ReserveOutcome::AlreadyRunning(run_id) => {
2396                return Ok(SessionActivationReserveOutcome::AlreadyRunning { run_id });
2397            }
2398        };
2399        let run_id = reservation.run_id.clone();
2400        let launch = match launch_plan {
2401            LaunchPlan::Root(config) => {
2402                let mut execution_reservation =
2403                    SessionExecutionReservation::from_activation_placeholder(
2404                        target_session_id,
2405                        reservation,
2406                        self.agent
2407                            .activation_router()
2408                            .expect("SessionInbox activation requires an activation router")
2409                            .clone(),
2410                        self.agent_runners.clone(),
2411                    );
2412                // Busy must reach the router before it commits a launched
2413                // generation. Its existing recovery backoff can then retry the
2414                // durable input when the old Host's actual lease expires.
2415                if self
2416                    .agent
2417                    .persistence()
2418                    .root_actor_execution_required(&session)
2419                    && self.root_account_sink.is_none()
2420                {
2421                    execution_reservation
2422                        .rollback_unpublished_activation()
2423                        .await;
2424                    return Err(bamboo_domain::SessionActivationError::Internal(
2425                        "Root activation has no actual account sink".into(),
2426                    ));
2427                }
2428                if let Err(error) = execution_reservation
2429                    .bind_root_actor(&self.agent, &session)
2430                    .await
2431                {
2432                    execution_reservation
2433                        .rollback_unpublished_activation()
2434                        .await;
2435                    return Err(bamboo_domain::SessionActivationError::Internal(format!(
2436                        "Root Actor reservation rejected: {error}"
2437                    )));
2438                }
2439                // Launch and rollback share one exact RAII reservation. Dropping
2440                // an unlaunched SessionActivationLaunch cannot race a raw slot
2441                // removal against the reservation's router-placeholder cleanup.
2442                let reservation_cell = Arc::new(StdMutex::new(Some(execution_reservation)));
2443                let launch_reservation = reservation_cell.clone();
2444                let rollback_reservation = reservation_cell;
2445                let coordinator = self.clone();
2446                let launch_sessions = self.sessions.clone();
2447                let launch_session_id = session.id.clone();
2448                let launch_session = session.clone();
2449                let request_session_id = target_session_id.to_string();
2450                SessionActivationLaunch::new_with_async_rollback(
2451                    run_id,
2452                    move || {
2453                        let mut execution_reservation = launch_reservation
2454                            .lock()
2455                            .unwrap_or_else(std::sync::PoisonError::into_inner)
2456                            .take()
2457                            .expect("activation reservation launches or rolls back exactly once");
2458                        execution_reservation.mark_activation_published();
2459                        // The router publishes the exact owner before invoking
2460                        // this closure, so only now may the prepared snapshot
2461                        // replace the shared cache entry.
2462                        if execution_reservation.root_actor_writer().is_none() {
2463                            launch_sessions.insert(
2464                                launch_session_id,
2465                                Arc::new(crate::SessionSnapshot::new(launch_session)),
2466                            );
2467                        }
2468                        let request = ResumeSpawnRequest {
2469                            session_id: request_session_id,
2470                            session,
2471                            execution_reservation,
2472                            event_sender,
2473                            config: *config,
2474                        };
2475                        tokio::spawn(async move {
2476                            ResumeExecutionPort::spawn_resume_execution(&coordinator, request)
2477                                .await;
2478                        });
2479                    },
2480                    move || async move {
2481                        let reservation = rollback_reservation
2482                            .lock()
2483                            .unwrap_or_else(std::sync::PoisonError::into_inner)
2484                            .take();
2485                        if let Some(reservation) = reservation {
2486                            reservation.rollback_unpublished_activation().await;
2487                        }
2488                    },
2489                )
2490            }
2491            LaunchPlan::Child {
2492                scheduler,
2493                parent_session_id,
2494                model,
2495                disabled_tools,
2496            } => {
2497                let execution_reservation =
2498                    SessionExecutionReservation::from_activation_placeholder(
2499                        target_session_id,
2500                        reservation,
2501                        self.agent
2502                            .activation_router()
2503                            .expect("SessionInbox activation requires an activation router")
2504                            .clone(),
2505                        self.agent_runners.clone(),
2506                    );
2507                let reservation_cell = Arc::new(StdMutex::new(Some(execution_reservation)));
2508                let launch_reservation = reservation_cell.clone();
2509                let rollback_reservation = reservation_cell;
2510                let job = SpawnJob {
2511                    parent_session_id,
2512                    child_session_id: target_session_id.to_string(),
2513                    model,
2514                    disabled_tools,
2515                };
2516                let launch_sessions = self.sessions.clone();
2517                let launch_session_id = session.id.clone();
2518                let launch_session = session;
2519                SessionActivationLaunch::new_with_async_rollback(
2520                    run_id,
2521                    move || {
2522                        let mut execution_reservation = launch_reservation
2523                            .lock()
2524                            .unwrap_or_else(std::sync::PoisonError::into_inner)
2525                            .take()
2526                            .expect("activation reservation launches or rolls back exactly once");
2527                        execution_reservation.mark_activation_published();
2528                        // As with root activation, publish the prepared cache
2529                        // snapshot only after router ownership commits.
2530                        launch_sessions.insert(
2531                            launch_session_id,
2532                            Arc::new(crate::SessionSnapshot::new(launch_session)),
2533                        );
2534                        // Dropping a JoinHandle detaches the task. The captured
2535                        // combined reservation remains RAII-protected if the
2536                        // task is later aborted or unwinds during setup.
2537                        drop(scheduler.launch_reserved(job, execution_reservation));
2538                    },
2539                    move || async move {
2540                        let reservation = rollback_reservation
2541                            .lock()
2542                            .unwrap_or_else(std::sync::PoisonError::into_inner)
2543                            .take();
2544                        if let Some(reservation) = reservation {
2545                            reservation.rollback_unpublished_activation().await;
2546                        }
2547                    },
2548                )
2549            }
2550        };
2551        Ok(SessionActivationReserveOutcome::Reserved(launch))
2552    }
2553}
2554
2555/// Hidden resume message for a bash-completion self-resume (issue #84 Phase 2b).
2556/// Mirrors [`runtime_resume_message`]'s hidden/compressible shape so the resume
2557/// port's `has_pending_user_message` gate is satisfied.
2558///
2559/// `timed_out` selects the wording: the normal path (all shells finished)
2560/// announces completion; the deadline path (the 6h+10m wait ceiling was hit with
2561/// shells STILL running) must NOT claim the shells completed — it says they may
2562/// still be running so the model verifies with BashOutput instead of assuming
2563/// success on a false premise.
2564fn bash_completion_resume_message(bash_ids: &[String], timed_out: bool) -> Message {
2565    let body = if timed_out {
2566        format!(
2567            "Runtime notification: the background-Bash wait ceiling was reached while one or more \
2568             shell(s) ({}) may still be running. The session is being resumed so it is not \
2569             stranded; verify their actual status with BashOutput before assuming completion.",
2570            bash_ids.join(", ")
2571        )
2572    } else {
2573        format!(
2574            "Runtime notification: all background Bash shell(s) ({}) have completed. \
2575             Review their output with BashOutput and resume the task from where you left off.",
2576            bash_ids.join(", ")
2577        )
2578    };
2579    let mut message = Message::user(body);
2580    message.metadata = Some(serde_json::json!({
2581        RUNTIME_RESUME_MESSAGE_HIDDEN_KEY: true,
2582        RUNTIME_RESUME_MESSAGE_KIND_KEY: BASH_COMPLETION_RESUME_KIND,
2583    }));
2584    message.never_compress = false;
2585    message
2586}
2587
2588/// Decide whether the bash self-resume should retry its clear→append→resume
2589/// sequence after a resume attempt returned `outcome`, given that the persisted
2590/// bash wait is (`true`) / is not (`false`) still set on reload.
2591///
2592/// Retry **only** when the resume did NOT spawn (`Completed` — no pending user
2593/// message, i.e. our resume message was dropped — or `AlreadyRunning`) AND the
2594/// persisted bash wait is still set: the signature of the finalize-clobber, where
2595/// the suspending runner's one-shot final `merge_save_runtime` lands after our
2596/// save and reverts `waiting_for_bash=Some` while dropping our resume message, so
2597/// `has_pending_user_message` fails and nothing spawns. `Started` (resume fired)
2598/// and `NotFound` (session gone) never retry. Pure helper so the clobber
2599/// detection is unit-testable in isolation from async I/O.
2600fn bash_resume_should_retry(outcome: &ResumeOutcome, persisted_waiting_for_bash: bool) -> bool {
2601    match outcome {
2602        ResumeOutcome::Started { .. } | ResumeOutcome::NotFound => false,
2603        ResumeOutcome::Completed | ResumeOutcome::AlreadyRunning { .. } => {
2604            persisted_waiting_for_bash
2605        }
2606    }
2607}
2608
2609/// Whether a background-shell completion push should **resume** the owning loop
2610/// (vs merely enqueue an injection). Resume only when the loop is actually
2611/// suspended on a bash wait AND every shell it was waiting on has now finished —
2612/// resuming while other waited shells are still running would drop them back into
2613/// a foreground turn prematurely. The last shell to finish (or the backstop)
2614/// drives the resume; earlier ones enqueue their notice. Pure so the invariant is
2615/// unit-testable in isolation.
2616fn bash_completion_should_resume(
2617    loop_suspended_on_bash: bool,
2618    all_waited_shells_done: bool,
2619) -> bool {
2620    loop_suspended_on_bash && all_waited_shells_done
2621}
2622
2623#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2624enum BashCompletionDeliveryPlan {
2625    /// Preserve the durable wait and do not reserve a successor. The last
2626    /// sibling completion (or the wait backstop) activates the accumulated
2627    /// inbox in order.
2628    DurableOnly,
2629    /// The loop is live/not waiting; notify its current owner after admission.
2630    Activate,
2631    /// The last waited shell finished: clear the wait durably, then activate.
2632    ClearWaitThenActivate,
2633}
2634
2635fn bash_completion_delivery_plan(
2636    loop_suspended_on_bash: bool,
2637    all_waited_shells_done: bool,
2638) -> BashCompletionDeliveryPlan {
2639    if bash_completion_should_resume(loop_suspended_on_bash, all_waited_shells_done) {
2640        BashCompletionDeliveryPlan::ClearWaitThenActivate
2641    } else if loop_suspended_on_bash {
2642        BashCompletionDeliveryPlan::DurableOnly
2643    } else {
2644        BashCompletionDeliveryPlan::Activate
2645    }
2646}
2647
2648fn background_bash_post_tool_payload(info: &BashCompletionInfo) -> HookPayload {
2649    let success = info.status == "completed" && info.exit_code == Some(0);
2650    let response = serde_json::json!({
2651        "bash_id": info.bash_id,
2652        "command": info.command,
2653        "exit_code": info.exit_code,
2654        "status": info.status,
2655        "output_tail": info.output_tail,
2656    });
2657    HookPayload::ToolResult {
2658        tool_name: "Bash".to_string(),
2659        tool_call_id: info.bash_id.clone(),
2660        outcome: HookToolOutcome {
2661            success,
2662            result: success.then(|| response.to_string()),
2663            error: (!success).then(|| response.to_string()),
2664            needs_human: false,
2665            duration_ms: 0,
2666        },
2667    }
2668}
2669
2670fn append_background_bash_hook_feedback(info: &mut BashCompletionInfo, feedback: Vec<String>) {
2671    let feedback = feedback
2672        .into_iter()
2673        .map(|text| text.trim().to_string())
2674        .filter(|text| !text.is_empty())
2675        .collect::<Vec<_>>();
2676    if feedback.is_empty() {
2677        return;
2678    }
2679    if !info.output_tail.is_empty() {
2680        info.output_tail.push_str("\n\n");
2681    }
2682    info.output_tail.push_str("<post_tool_use_feedback>\n");
2683    info.output_tail.push_str(&feedback.join("\n"));
2684    info.output_tail.push_str("\n</post_tool_use_feedback>");
2685}
2686
2687async fn run_background_bash_post_tool_hooks(
2688    config: &bamboo_config::LifecycleHooksConfig,
2689    fallback_cwd: Option<std::path::PathBuf>,
2690    session: &Session,
2691    info: &mut BashCompletionInfo,
2692) -> bool {
2693    let runner = crate::HookRunner::new().with_lifecycle_config(config, fallback_cwd);
2694    if !runner.has_hooks_for(AgentHookPoint::AfterToolExecution) {
2695        return false;
2696    }
2697
2698    let mut runtime_state = session
2699        .agent_runtime_state
2700        .clone()
2701        .unwrap_or_else(|| AgentRuntimeState::new(&session.id));
2702    let outcome = runner
2703        .run_observer_hooks(
2704            AgentHookPoint::AfterToolExecution,
2705            &background_bash_post_tool_payload(info),
2706            session,
2707            &mut runtime_state,
2708            None,
2709        )
2710        .await;
2711    append_background_bash_hook_feedback(info, outcome.injected_contexts);
2712    true
2713}
2714
2715/// Apply the bash-resume state transition to a loaded session **in place**: clear
2716/// the `waiting_for_bash` wait, mark the runtime Idle, drop the suspension +
2717/// `runtime.suspend_reason`, and append `resume_message`. Returns `false` (a
2718/// no-op) when the session was not actually waiting on bash — the double-resume
2719/// guard shared by the push and the backstop. Pure (no I/O) so both
2720/// [`ChildCompletionCoordinator::perform_bash_resume`] and unit tests exercise the
2721/// exact same transition.
2722fn apply_bash_resume_transition(session: &mut Session, resume_message: &Message) -> bool {
2723    let mut runtime_state = read_runtime_state(session);
2724    if runtime_state.waiting_for_bash.is_none() {
2725        return false;
2726    }
2727    runtime_state.waiting_for_bash = None;
2728    runtime_state.status = AgentStatusState::Idle;
2729    runtime_state.suspension = None;
2730    write_runtime_state(session, &runtime_state);
2731    session.metadata.remove("runtime.suspend_reason");
2732    session.metadata.remove("runtime.background_exec_id");
2733    session.add_message(resume_message.clone());
2734    true
2735}
2736
2737/// Bash self-resume support (issue #84 Phase 2b; push follow-up).
2738impl ChildCompletionCoordinator {
2739    /// **Backstop** for a session suspended on `waiting_for_bash`. The primary,
2740    /// event-driven wake is the loop-facing push
2741    /// ([`BashCompletionSink::on_bash_completed`] → [`Self::deliver_bash_completion`]):
2742    /// the shell's completion task fires it the instant the process exits, and it
2743    /// resumes the loop directly. This task exists ONLY to catch a **lost push** —
2744    /// the completion landing in the window before the suspend was persisted (so
2745    /// the push saw no `waiting_for_bash` and only queued an injection), or a
2746    /// configuration with no sink wired — and to honour the wait ceiling.
2747    ///
2748    /// So it is deliberately NOT a hot spin: a coarse backoff (1 s → 30 s) that
2749    /// **yields to the push**. In the happy path the push has already cleared
2750    /// `waiting_for_bash` before the first check fires, so this returns after one
2751    /// cheap load with no registry polling at all. It only performs a resume when
2752    /// the shell(s) have finished but the loop is somehow still suspended, or the
2753    /// 6 h wait ceiling is reached.
2754    async fn bash_self_resume(&self, session_id: String, bash_ids: Vec<String>) {
2755        let mut delay = Duration::from_secs(1);
2756        let max_delay = Duration::from_secs(30);
2757        // Hard ceiling: the wait lease (6 h) + the registry GC TTL (5 min) +
2758        // margin. After this the shells are gone from the registry regardless,
2759        // so force-resume to avoid stranding the session on a GC edge case.
2760        let max_poll = Duration::from_secs(6 * 3600 + 600);
2761        let deadline = tokio::time::Instant::now() + max_poll;
2762
2763        loop {
2764            tokio::time::sleep(delay).await;
2765
2766            let Some(session) = self.load_session(&session_id).await else {
2767                tracing::info!(%session_id, "bash self-resume backstop: session gone; nothing to do");
2768                return;
2769            };
2770            let Some(wait) = read_runtime_state(&session).waiting_for_bash else {
2771                // The push (or another path) already resumed. This is the common
2772                // case — the backstop yields silently after a single load.
2773                return;
2774            };
2775            if wait.bash_ids != bash_ids {
2776                // A newer Human turn may have installed a different wait.
2777                return;
2778            }
2779
2780            let still_running = bash_ids.iter().any(|id| {
2781                bamboo_tools::tools::bash_runtime::get_shell(id).is_some_and(|shell| {
2782                    shell.session_id.as_deref() == Some(session_id.as_str())
2783                        && shell.status() == "running"
2784                })
2785            });
2786            let timed_out = tokio::time::Instant::now() >= deadline;
2787            if session.metadata.contains_key("runtime.background_exec_id")
2788                && self.runner_is_running(&session_id).await
2789            {
2790                delay = (delay * 2).min(max_delay);
2791                continue;
2792            }
2793            if !still_running || timed_out {
2794                // The shell(s) finished but the loop is still suspended → the push
2795                // was lost (pre-persist window / no sink), or the ceiling hit.
2796                // Resume under the shared per-session lock so we never race the
2797                // push or a concurrent child-completion resume.
2798                let guard = session_resume_lock(&session_id);
2799                let _held = guard.lock().await;
2800                tracing::warn!(
2801                    %session_id,
2802                    shell_count = bash_ids.len(),
2803                    timed_out,
2804                    "bash self-resume backstop engaged (push lost or wait ceiling reached)"
2805                );
2806                self.perform_bash_resume(
2807                    &session_id,
2808                    if session.metadata.contains_key("runtime.background_exec_id") {
2809                        exec_wait_resume_message(&bash_ids, timed_out)
2810                    } else {
2811                        bash_completion_resume_message(&bash_ids, timed_out)
2812                    },
2813                    &bash_ids,
2814                )
2815                .await;
2816                return;
2817            }
2818
2819            delay = (delay * 2).min(max_delay);
2820        }
2821    }
2822
2823    /// Clear a session's `waiting_for_bash` state, append `resume_message`, and
2824    /// drive the parent resume — the shared clear→append→resume used by BOTH the
2825    /// event-driven push ([`Self::deliver_bash_completion`]) and the backstop poll
2826    /// ([`Self::bash_self_resume`]).
2827    ///
2828    /// **The caller MUST hold the [`session_resume_lock`] for `session_id`** so the
2829    /// load-check-clear-resume critical section is serialized against every other
2830    /// resume source (no double resume). No-op when the persisted wait was already
2831    /// cleared (another source handled it first).
2832    ///
2833    /// The clear→append→resume is a **bounded retry loop** that closes the
2834    /// finalize-clobber strand. The suspending runner's `finalize_task_context`
2835    /// runs a full `save_runtime_session` (same `merge_save_runtime`, which
2836    /// overwrites the whole `messages` array) that can land AFTER our save,
2837    /// reverting `waiting_for_bash=Some` and dropping our resume message, so
2838    /// `has_pending_user_message` fails and `resume_parent` returns `Completed`
2839    /// without spawning. We detect that (persisted wait still set after a
2840    /// non-`Started` outcome) and re-clear/re-append/re-resume. It converges
2841    /// because the runner's finalize persist is one-shot: once landed, our retry's
2842    /// save is the last writer, the message sticks, and resume fires.
2843    async fn perform_bash_resume(
2844        &self,
2845        session_id: &str,
2846        resume_message: Message,
2847        expected_ids: &[String],
2848    ) {
2849        let retry_backoff = Duration::from_millis(200);
2850        const MAX_RESUME_ATTEMPTS: u8 = 5;
2851        for attempt in 0..MAX_RESUME_ATTEMPTS {
2852            if attempt > 0 {
2853                tokio::time::sleep(retry_backoff).await;
2854            }
2855
2856            let Some(mut session) = self.load_session(session_id).await else {
2857                tracing::warn!(%session_id, "bash resume: session not found; nothing to resume");
2858                return;
2859            };
2860            if read_runtime_state(&session)
2861                .waiting_for_bash
2862                .as_ref()
2863                .is_none_or(|wait| wait.bash_ids != expected_ids)
2864            {
2865                return;
2866            }
2867
2868            // A Human may already be talking in the same session. Let that
2869            // owner receive the durable result at its normal inbox boundary.
2870            if session.metadata.contains_key("runtime.background_exec_id")
2871                && self.runner_is_running(session_id).await
2872            {
2873                continue;
2874            }
2875
2876            if !apply_bash_resume_transition(&mut session, &resume_message) {
2877                // Double-resume guard: the wait was already cleared by another
2878                // source (the push, the backstop, or a user-driven resume). Do
2879                // not append a duplicate message or request a redundant resume.
2880                tracing::info!(
2881                    %session_id, attempt,
2882                    "bash resume: persisted bash wait already cleared; nothing to resume"
2883                );
2884                return;
2885            }
2886            session.updated_at = Utc::now();
2887            self.save_and_cache(&mut session).await;
2888            tracing::info!(
2889                %session_id, attempt,
2890                "bash resume: cleared bash wait and appended resume message"
2891            );
2892
2893            let outcome = self.resume_parent(session_id.to_string()).await;
2894            match outcome {
2895                ResumeOutcome::Started { .. } => {
2896                    tracing::info!(%session_id, attempt, "bash resume: resume fired");
2897                    return;
2898                }
2899                ResumeOutcome::NotFound => {
2900                    tracing::warn!(%session_id, "bash resume: session vanished during resume");
2901                    return;
2902                }
2903                _ => {
2904                    // Completed (no pending user message ⇒ our resume message was
2905                    // dropped by the runner's finalize persist) or AlreadyRunning.
2906                    // Decide via the persisted bash wait: still set ⇒
2907                    // finalize-clobber ⇒ retry; cleared ⇒ the session is being
2908                    // handled (by us or a concurrent resume) ⇒ stop.
2909                    let clobbered = match self.load_session(session_id).await {
2910                        Some(reloaded) => read_runtime_state(&reloaded).waiting_for_bash.is_some(),
2911                        None => {
2912                            tracing::warn!(%session_id, "bash resume: session vanished after resume");
2913                            return;
2914                        }
2915                    };
2916                    if bash_resume_should_retry(&outcome, clobbered) {
2917                        tracing::warn!(
2918                            %session_id, attempt,
2919                            outcome = outcome.as_str(),
2920                            "bash resume: persisted wait still set after resume (finalize-clobber); retrying"
2921                        );
2922                        continue;
2923                    }
2924                    tracing::info!(
2925                        %session_id, attempt,
2926                        outcome = outcome.as_str(),
2927                        "bash resume: wait cleared and resume handled; stopping"
2928                    );
2929                    return;
2930                }
2931            }
2932        }
2933
2934        tracing::warn!(
2935            %session_id,
2936            attempts = MAX_RESUME_ATTEMPTS,
2937            "bash resume: exhausted clobber-retry budget without confirming resume; giving up"
2938        );
2939    }
2940}
2941
2942impl BashResumeHook for ChildCompletionCoordinator {
2943    fn arrange_bash_self_resume(&self, session_id: String, bash_ids: Vec<String>) {
2944        let coordinator = Arc::new(self.clone());
2945        tokio::spawn(async move {
2946            coordinator.bash_self_resume(session_id, bash_ids).await;
2947        });
2948    }
2949
2950    fn supports_background_exec(&self) -> bool {
2951        self.agent
2952            .session_messenger()
2953            .is_some_and(|messenger| messenger.supports_runtime_target_lifetime())
2954    }
2955
2956    fn arrange_exec_self_resume(
2957        &self,
2958        job: bamboo_tools::tools::exec_command::BackgroundExecJob,
2959        session_created_at: chrono::DateTime<Utc>,
2960    ) {
2961        let session_id = job.session_id().to_owned();
2962        let ids = vec![job.id().to_owned()];
2963        let coordinator = self.clone();
2964        tokio::spawn(async move {
2965            let info = job.completion().await;
2966            drop(job);
2967            coordinator
2968                .deliver_shell_completion_for_lifetime(
2969                    info,
2970                    ShellCompletionKind::Exec,
2971                    Some(session_created_at),
2972                )
2973                .await;
2974        });
2975        self.arrange_bash_self_resume(session_id, ids);
2976    }
2977}
2978
2979#[derive(Clone, Copy, PartialEq, Eq)]
2980enum ShellCompletionKind {
2981    Bash,
2982    Exec,
2983}
2984
2985async fn clear_completed_exec_wait<P>(
2986    persistence: &LockedSessionStore,
2987    session: &Session,
2988    handle: &str,
2989    publish: P,
2990) -> std::io::Result<Option<Session>>
2991where
2992    P: FnOnce(&Session),
2993{
2994    let expected_wait = read_runtime_state(session).waiting_for_bash;
2995    let birth = session.created_at;
2996    let handle = handle.to_owned();
2997    persistence
2998        .update_runtime_config_and_publish(
2999            &session.id,
3000            move |latest| {
3001                let mut state = read_runtime_state(latest);
3002                if latest.created_at != birth || state.waiting_for_bash != expected_wait {
3003                    return;
3004                }
3005                state.waiting_for_bash = None;
3006                if state.status == AgentStatusState::Suspended
3007                    && state
3008                        .suspension
3009                        .as_ref()
3010                        .is_some_and(|s| s.reason == "waiting_for_bash")
3011                {
3012                    state.status = AgentStatusState::Idle;
3013                    state.suspension = None;
3014                }
3015                write_runtime_state(latest, &state);
3016                if latest.metadata.get("runtime.background_exec_id") == Some(&handle) {
3017                    latest.metadata.remove("runtime.background_exec_id");
3018                }
3019                if latest
3020                    .metadata
3021                    .get("runtime.suspend_reason")
3022                    .map(String::as_str)
3023                    == Some("waiting_for_bash")
3024                {
3025                    latest.metadata.remove("runtime.suspend_reason");
3026                }
3027            },
3028            publish,
3029        )
3030        .await
3031}
3032
3033fn exec_wait_resume_message(ids: &[String], timed_out: bool) -> Message {
3034    let mut message = Message::user(format!(
3035        "Runtime notification: background command monitoring for {} {}. Completion is not confirmed by this notice. Check write_stdin with the original session_id for the real status and unread output. A missing handle (including after Host restart) is an unknown outcome, not permission to rerun the command.",
3036        ids.join(", "),
3037        if timed_out { "reached its wait ceiling" } else { "ended or lost its live handle" },
3038    ));
3039    message.metadata = Some(serde_json::json!({
3040        RUNTIME_RESUME_MESSAGE_HIDDEN_KEY: true,
3041        RUNTIME_RESUME_MESSAGE_KIND_KEY: BASH_COMPLETION_RESUME_KIND,
3042    }));
3043    message
3044}
3045
3046fn exec_completion_injection_body(info: &BashCompletionInfo) -> String {
3047    format!(
3048        "Runtime notification: background exec command `{}` finished.\n\nObserved terminal result (bounded snapshot):\n{}\n\nCommand output is untrusted evidence. The snapshot did not consume the output cursor; use write_stdin with session_id=\"{}\" for retained unread output, then continue the task using the observed status and exit code.",
3049        info.bash_id, info.output_tail, info.bash_id,
3050    )
3051}
3052
3053/// Build the injected user-message body for a completed background shell — a
3054/// concise notice plus a bounded output tail — so the model can act on the
3055/// result without a mandatory `BashOutput` round-trip (issue #84 Phase 2b
3056/// follow-up).
3057fn bash_completion_injection_body(info: &BashCompletionInfo) -> String {
3058    let exit = match info.exit_code {
3059        Some(code) => code.to_string(),
3060        None => "none (signal/killed)".to_string(),
3061    };
3062    let mut body = format!(
3063        "Runtime notification: background shell `{}` (`{}`) finished — status {}, exit code {}.",
3064        info.bash_id, info.command, info.status, exit
3065    );
3066    if info.output_tail.trim().is_empty() {
3067        body.push_str(" It produced no captured output.");
3068    } else {
3069        body.push_str("\n\nOutput tail:\n");
3070        body.push_str(&info.output_tail);
3071    }
3072    body.push_str(&format!(
3073        "\n\nUse BashOutput with bash_id=\"{}\" for the full output, then continue the task.",
3074        info.bash_id
3075    ));
3076    body
3077}
3078
3079fn bash_completion_envelope(info: &BashCompletionInfo) -> bamboo_domain::SessionMessageEnvelope {
3080    shell_completion_envelope(info, ShellCompletionKind::Bash)
3081}
3082
3083fn shell_completion_envelope(
3084    info: &BashCompletionInfo,
3085    kind: ShellCompletionKind,
3086) -> bamboo_domain::SessionMessageEnvelope {
3087    let mut provider_message = bash_resume_message_from_info(info);
3088    let (identity_kind, instruction, content, data) = if kind == ShellCompletionKind::Exec {
3089        provider_message.content = exec_completion_injection_body(info);
3090        (
3091            "background_exec",
3092            "background_exec_completed",
3093            provider_message.content.clone(),
3094            serde_json::json!({
3095                "session_id": info.session_id,
3096                "exec_session_id": info.bash_id,
3097                "status": info.status,
3098                "exit_code": info.exit_code,
3099                "result": info.output_tail,
3100            }),
3101        )
3102    } else {
3103        (
3104            "background_bash",
3105            "background_bash_completed",
3106            bash_completion_injection_body(info),
3107            serde_json::json!({
3108            "session_id": info.session_id,
3109            "bash_id": info.bash_id,
3110            "command": info.command,
3111            "exit_code": info.exit_code,
3112            "status": info.status,
3113            "output_tail": info.output_tail,
3114            }),
3115        )
3116    };
3117    // The id covers the immutable completion snapshot. An exact delivery retry
3118    // (whose transport timestamp may differ) is idempotent; a changed status,
3119    // output tail, command, or exit code is a distinct correction rather than
3120    // silently reusing one id with different semantics.
3121    let identity = data.clone();
3122    bamboo_domain::SessionMessageEnvelope {
3123        id: bamboo_domain::SessionMessageId::stable(
3124            &format!("{identity_kind}_completion"),
3125            &identity,
3126        ),
3127        source: bamboo_domain::SessionMessageSource::Runtime {
3128            subsystem: identity_kind.to_string(),
3129        },
3130        target_session_id: info.session_id.clone(),
3131        kind: bamboo_domain::SessionMessageKind::RuntimeInstruction,
3132        body: bamboo_domain::SessionMessageBody::RuntimeInstruction(
3133            bamboo_domain::SessionRuntimeInstruction {
3134                instruction: instruction.to_string(),
3135                content: Some(bamboo_domain::SessionMessageContent::text(content)),
3136                data: Some(data),
3137                provider_message: Some(session_provider_message(&provider_message)),
3138            },
3139        ),
3140        created_at: Utc::now(),
3141        thread_id: None,
3142        in_reply_to: None,
3143        attempt: None,
3144        correlation_id: Some(info.bash_id.clone()),
3145    }
3146}
3147
3148/// Enqueue a completed shell's summary as a pending injected message on the
3149/// owning session for the rolling-upgrade polling backstop. New push delivery
3150/// uses [`SessionMessenger`]; this helper remains only so an older process that
3151/// has not wired the typed delivery plane can still resume persisted sessions.
3152/// Race-safe: `update_runtime_config` loads and saves under the per-session lock.
3153async fn enqueue_bash_completion_injection(
3154    persistence: &LockedSessionStore,
3155    info: &BashCompletionInfo,
3156) -> std::io::Result<Option<Session>> {
3157    let body = bash_completion_injection_body(info);
3158    let queued = serde_json::json!({
3159        "content": body,
3160        "created_at": Utc::now(),
3161    });
3162    persistence
3163        .update_runtime_config(&info.session_id, move |session| {
3164            let mut pending = session.pending_injected_messages().unwrap_or_default();
3165            pending.push(queued);
3166            session.set_pending_injected_messages(pending);
3167        })
3168        .await
3169}
3170
3171/// Build the hidden, compressible resume message for a completed background
3172/// shell — the same rich notice body used for a live-loop injection
3173/// ([`bash_completion_injection_body`]), but tagged as a resume message so it
3174/// satisfies the `has_pending_user_message` gate that lets a suspended session
3175/// spawn. This is what the **push** appends when it wakes a suspended loop, so
3176/// the model gets the shell's status + output tail in one shot without a
3177/// separate `BashOutput` round-trip.
3178fn bash_resume_message_from_info(info: &BashCompletionInfo) -> Message {
3179    let mut message = Message::user(bash_completion_injection_body(info));
3180    message.metadata = Some(serde_json::json!({
3181        RUNTIME_RESUME_MESSAGE_HIDDEN_KEY: true,
3182        RUNTIME_RESUME_MESSAGE_KIND_KEY: BASH_COMPLETION_RESUME_KIND,
3183    }));
3184    message.never_compress = false;
3185    message
3186}
3187
3188impl ChildCompletionCoordinator {
3189    /// Loop-facing delivery of a completed background shell. Two paths, chosen
3190    /// under the per-session resume lock so we never race the backstop poll or a
3191    /// concurrent child-completion resume:
3192    ///
3193    /// - **Suspended loop** (the model ended its turn with the shell running, so
3194    ///   `waiting_for_bash` is set) AND every waited shell has now finished →
3195    ///   **resume the loop directly**, event-driven, appending the rich completion
3196    ///   notice as the resume message. This is the push's whole point: no polling.
3197    /// - Every notice is then delivered through the same typed SessionMessenger
3198    ///   as peer/user steering. The durable inbox and activation router decide
3199    ///   whether to notify the current owner or reserve one successor.
3200    async fn deliver_bash_completion(&self, info: BashCompletionInfo) {
3201        self.deliver_shell_completion(info, ShellCompletionKind::Bash)
3202            .await;
3203    }
3204
3205    async fn deliver_shell_completion(&self, info: BashCompletionInfo, kind: ShellCompletionKind) {
3206        self.deliver_shell_completion_for_lifetime(info, kind, None)
3207            .await;
3208    }
3209
3210    async fn deliver_shell_completion_for_lifetime(
3211        &self,
3212        mut info: BashCompletionInfo,
3213        kind: ShellCompletionKind,
3214        expected_birth: Option<chrono::DateTime<Utc>>,
3215    ) {
3216        // A background shell completes outside the originating tool call, so
3217        // fire its PostToolUse seam here before routing the completion into the
3218        // next round/resume message. Do not hold the resume lock while an
3219        // arbitrary command hook runs: its configured timeout must never block
3220        // the independent liveness backstop.
3221        if let Some(hook_session) = self
3222            .load_session(&info.session_id)
3223            .await
3224            .filter(|_| kind == ShellCompletionKind::Bash)
3225        {
3226            let config_snapshot = self.config.read().await.clone();
3227            let _ = run_background_bash_post_tool_hooks(
3228                &config_snapshot.lifecycle_hooks,
3229                Some(self.app_data_dir.clone()),
3230                &hook_session,
3231                &mut info,
3232            )
3233            .await;
3234        }
3235
3236        let guard = session_resume_lock(&info.session_id);
3237        let _held = guard.lock().await;
3238
3239        let Some(session) = self.load_session(&info.session_id).await else {
3240            tracing::warn!(
3241                session_id = %info.session_id,
3242                bash_id = %info.bash_id,
3243                "background bash completion: owning session not found; nothing to notify"
3244            );
3245            return;
3246        };
3247
3248        if expected_birth.is_some_and(|birth| session.created_at != birth) {
3249            // A reused display id cannot redirect an old command's result into
3250            // a new Session lifetime.
3251            return;
3252        }
3253        let runtime = read_runtime_state(&session);
3254        let waiting = runtime.waiting_for_bash.as_ref().is_some_and(|wait| {
3255            kind == ShellCompletionKind::Bash || wait.bash_ids.contains(&info.bash_id)
3256        });
3257        // The producer flips the shell's `running` flag false BEFORE firing this
3258        // push, so a now-empty per-session registry means every shell the loop was
3259        // waiting on has finished — safe to resume. If OTHER waited shells are
3260        // still running, fall through to the enqueue path and let the last one (or
3261        // the backstop) drive the resume.
3262        let all_shells_done = runtime.waiting_for_bash.as_ref().is_none_or(|wait| {
3263            wait.bash_ids.iter().all(|id| {
3264                id == &info.bash_id
3265                    || bamboo_tools::tools::bash_runtime::get_shell(id).is_none_or(|shell| {
3266                        shell.session_id.as_deref() == Some(info.session_id.as_str())
3267                            && shell.status() != "running"
3268                    })
3269            })
3270        });
3271        let delivery_plan = bash_completion_delivery_plan(waiting, all_shells_done);
3272
3273        // Stage 1: make the completion durable BEFORE clearing the wait. The
3274        // activation is intentionally deferred until the wait-state mutation
3275        // below is durably committed; otherwise a successor can observe the old
3276        // suspension, while clear-before-deliver can lose the only wake on
3277        // crash.
3278        let messenger = self.agent.session_messenger().cloned();
3279        let admission = match messenger.as_ref() {
3280            Some(messenger) => match if kind == ShellCompletionKind::Exec {
3281                messenger
3282                    .admit_runtime_for_target(
3283                        shell_completion_envelope(&info, kind),
3284                        expected_birth.unwrap_or(session.created_at),
3285                    )
3286                    .await
3287            } else {
3288                messenger.admit(bash_completion_envelope(&info)).await
3289            } {
3290                Ok(admission) => Some(admission),
3291                Err(error) => {
3292                    tracing::warn!(
3293                        session_id = %info.session_id,
3294                        bash_id = %info.bash_id,
3295                        %error,
3296                        "background bash completion: durable SessionInbox admission failed; leaving wait armed"
3297                    );
3298                    return;
3299                }
3300            },
3301            None => {
3302                tracing::warn!(
3303                    session_id = %info.session_id,
3304                    bash_id = %info.bash_id,
3305                    "SessionMessenger unavailable; using compatibility injection before clearing wait"
3306                );
3307                match enqueue_bash_completion_injection(&self.persistence, &info).await {
3308                    Ok(Some(_)) => None,
3309                    Ok(None) => {
3310                        tracing::warn!(
3311                            session_id = %info.session_id,
3312                            "background bash compatibility target disappeared"
3313                        );
3314                        return;
3315                    }
3316                    Err(error) => {
3317                        tracing::warn!(
3318                            session_id = %info.session_id,
3319                            %error,
3320                            "background bash compatibility admission failed; leaving wait armed"
3321                        );
3322                        return;
3323                    }
3324                }
3325            }
3326        };
3327
3328        if delivery_plan == BashCompletionDeliveryPlan::DurableOnly {
3329            tracing::info!(
3330                session_id = %info.session_id,
3331                bash_id = %info.bash_id,
3332                "background bash completion is durable; sibling shells still run, so wait remains armed"
3333            );
3334            return;
3335        }
3336
3337        if let (Some(messenger), Some(admission)) = (messenger.as_ref(), admission.as_ref()) {
3338            let prepared = if kind == ShellCompletionKind::Exec {
3339                messenger
3340                    .prepare_message_activation(
3341                        admission,
3342                        SessionActivationPolicy::RespectSpecificWait,
3343                    )
3344                    .await
3345            } else {
3346                messenger.prepare_activation(admission).await
3347            };
3348            if let Err(error) = prepared {
3349                tracing::warn!(
3350                    session_id = %info.session_id,
3351                    bash_id = %info.bash_id,
3352                    %error,
3353                    "background bash completion activation watermark failed; leaving wait armed"
3354                );
3355                return;
3356            }
3357        }
3358
3359        if delivery_plan == BashCompletionDeliveryPlan::ClearWaitThenActivate {
3360            tracing::info!(
3361                session_id = %info.session_id,
3362                bash_id = %info.bash_id,
3363                status = %info.status,
3364                "background bash completion: push-resuming suspended loop (event-driven)"
3365            );
3366            if kind == ShellCompletionKind::Exec {
3367                // The Human may have started a new foreground turn since the
3368                // handoff. Clear only this exact wait under the persistence
3369                // lock; never save the earlier message/status snapshot over
3370                // that active conversation.
3371                match clear_completed_exec_wait(
3372                    &self.persistence,
3373                    &session,
3374                    &info.bash_id,
3375                    |saved| {
3376                        self.sessions.insert(
3377                            saved.id.clone(),
3378                            Arc::new(crate::SessionSnapshot::new(saved.clone())),
3379                        );
3380                    },
3381                )
3382                .await
3383                {
3384                    Ok(Some(_)) => {}
3385                    Ok(None) => return,
3386                    Err(error) => {
3387                        tracing::warn!(session_id = %info.session_id, %error, "background exec completion is durable but wait clear failed");
3388                        return;
3389                    }
3390                }
3391            } else {
3392                let mut resumable = session.clone();
3393                let mut runtime_state = read_runtime_state(&resumable);
3394                runtime_state.waiting_for_bash = None;
3395                runtime_state.status = AgentStatusState::Idle;
3396                runtime_state.suspension = None;
3397                write_runtime_state(&mut resumable, &runtime_state);
3398                resumable.metadata.remove("runtime.suspend_reason");
3399                resumable.metadata.remove("runtime.background_exec_id");
3400                resumable.updated_at = Utc::now();
3401                if let Err(error) = self.persistence.merge_save_runtime(&mut resumable).await {
3402                    tracing::warn!(
3403                        session_id = %info.session_id,
3404                        %error,
3405                        "background bash completion is durable but wait-state clear failed; leaving activation to the wait backstop"
3406                    );
3407                    return;
3408                }
3409                self.sessions.insert(
3410                    resumable.id.clone(),
3411                    Arc::new(crate::SessionSnapshot::new(resumable)),
3412                );
3413            }
3414        }
3415
3416        // Stage 3: activation is allowed only after the durable wait-state
3417        // clear. A crash before here leaves either the wait backstop or startup
3418        // inbox reconciliation able to recover.
3419        let (Some(messenger), Some(admission)) = (messenger, admission) else {
3420            if delivery_plan == BashCompletionDeliveryPlan::ClearWaitThenActivate {
3421                let _ = self.resume_parent(info.session_id.clone()).await;
3422            }
3423            return;
3424        };
3425        match messenger.activate_prepared(&admission).await {
3426            Ok(receipt) => tracing::info!(
3427                session_id = %info.session_id,
3428                bash_id = %info.bash_id,
3429                status = %info.status,
3430                waiting,
3431                generation = receipt.delivery.generation,
3432                activation = ?receipt.activation,
3433                "background bash completion delivered through SessionMessenger"
3434            ),
3435            Err(error) => tracing::warn!(
3436                session_id = %info.session_id,
3437                bash_id = %info.bash_id,
3438                %error,
3439                "background bash completion: SessionMessenger delivery failed"
3440            ),
3441        }
3442    }
3443}
3444
3445impl BashCompletionSink for ChildCompletionCoordinator {
3446    fn on_bash_completed(&self, info: BashCompletionInfo) {
3447        // Best-effort, off the shell's completion-poll task: hand the delivery to
3448        // a detached task so the producer is never blocked (mirrors
3449        // `arrange_bash_self_resume`).
3450        let coordinator = Arc::new(self.clone());
3451        tokio::spawn(async move {
3452            coordinator.deliver_bash_completion(info).await;
3453        });
3454    }
3455}
3456
3457// ---------------------------------------------------------------------------
3458// Child-wait watchdog (issue #546)
3459// ---------------------------------------------------------------------------
3460
3461/// How often the child-wait watchdog sweeps suspended sessions.
3462const CHILD_WAIT_SWEEP_INTERVAL_SECS: u64 = 30;
3463/// Leave a freshly registered wait alone for this long so the event-driven
3464/// completion push always gets the first shot (and just-enqueued spawn jobs
3465/// have time to persist their running marker).
3466const CHILD_WAIT_REGISTRATION_GRACE_SECS: i64 = 60;
3467/// A waited child with NO live runner and a non-terminal index status is
3468/// declared dead once its control-plane has been quiet for this long.
3469const DEAD_CHILD_GRACE_SECS: i64 = 120;
3470/// Slack on top of the per-child liveness policy before a Running-but-frozen
3471/// runner entry (dead task) is force-finalized by the sweeper. The per-child
3472/// watchdog cancels at `max_idle`/`max_total` and the child then publishes its
3473/// own timeout; an entry frozen this far PAST those limits proves that
3474/// machinery is dead.
3475const STALE_RUNNER_SLACK_SECS: i64 = 600;
3476
3477/// Whether a waited child's index status means the sweeper must consider it
3478/// DEAD when nothing is driving it: non-terminal and not legitimately
3479/// suspended. `None` = never ran (created-but-never-started, or a spawn that
3480/// was lost before its running marker persisted).
3481fn is_dead_child_candidate_status(status: Option<&str>) -> bool {
3482    match status {
3483        // "suspended" children wait on a human / their own children / bash —
3484        // their wake has its own driver; never declare them dead here.
3485        Some(status) => !is_terminal_child_status(status) && status != "suspended",
3486        None => true,
3487    }
3488}
3489
3490/// Whether a reported completion's child id is genuinely a child of the parent
3491/// it claims (issue #546 read-side disclosure guard). `SubAgent.wait` ids are
3492/// model-provided, and the watchdog resolves an unowned id by publishing a
3493/// synthetic completion so the parent is unstranded — but the parent must NEVER
3494/// receive a FOREIGN session's content folded into its transcript.
3495/// `child_parent_linkage` is that session's own `parent_session_id` (`None`
3496/// when the session does not exist). Pure so the rule is unit-testable.
3497fn completion_child_is_owned(reported_parent: &str, child_parent_linkage: Option<&str>) -> bool {
3498    child_parent_linkage == Some(reported_parent)
3499}
3500
3501fn child_wait_watchdog_resume_message(body: String) -> Message {
3502    let mut message = Message::user(body);
3503    message.metadata = Some(serde_json::json!({
3504        RUNTIME_RESUME_MESSAGE_HIDDEN_KEY: true,
3505        RUNTIME_RESUME_MESSAGE_KIND_KEY: "child_wait_watchdog_resume",
3506    }));
3507    message.never_compress = false;
3508    message
3509}
3510
3511fn empty_child_wait_message() -> Message {
3512    let inspection = serde_json::json!({"intent": "inspect", "message": "tree"});
3513    child_wait_watchdog_resume_message(format!(
3514        "Runtime notification: this session was suspended waiting for child sessions, but the \
3515         wait tracked no children (internal inconsistency). The session has been resumed; use \
3516         SubAgent with {inspection} to inspect child state and continue the task.",
3517    ))
3518}
3519
3520fn child_wait_lease_expired_message(child_ids: &[String]) -> Message {
3521    let inspection = serde_json::json!({"intent": "inspect", "message": "tree"});
3522    child_wait_watchdog_resume_message(format!(
3523        "Runtime notification: the wait lease for child session(s) [{}] expired before they all \
3524         reported completion. They were NOT cancelled and may still be running or already \
3525         finished — verify their actual status by calling SubAgent with {inspection} before assuming \
3526         anything, then continue the task.",
3527        child_ids.join(", ")
3528    ))
3529}
3530
3531/// Child-wait watchdog (issue #546): the heartbeat backstop for parents
3532/// suspended on `waiting_for_children`.
3533///
3534/// The primary wake is the event-driven completion push (child terminal →
3535/// [`ChildCompletionHandler::on_child_completed`] → resume). This sweeper
3536/// exists because ANY break in that chain — a panicked child task, a dead
3537/// spawn scheduler, a process restart, a clobbered/exhausted resume, a wait
3538/// registered over an already-terminal child — previously stranded the parent
3539/// forever. It mirrors the bash backstop's philosophy: coarse, cheap, yields
3540/// to the push, and only acts when the durable state proves nothing else can.
3541///
3542/// All wake decisions funnel through the SAME machinery the push uses
3543/// (synthetic/replayed completions → `on_child_completed`, per-parent
3544/// serialization via [`session_resume_lock`]), so there is exactly one resume
3545/// implementation.
3546impl ChildCompletionCoordinator {
3547    /// Spawn the watchdog: one boot-time reconciliation pass, then a sweep
3548    /// every [`CHILD_WAIT_SWEEP_INTERVAL_SECS`]. Call once at server startup.
3549    pub fn spawn_child_wait_watchdog(self: &Arc<Self>) {
3550        let coordinator = Arc::clone(self);
3551        tokio::spawn(async move {
3552            use futures::FutureExt;
3553            if std::panic::AssertUnwindSafe(coordinator.reconcile_orphans_at_boot())
3554                .catch_unwind()
3555                .await
3556                .is_err()
3557            {
3558                tracing::error!("child-wait watchdog: boot reconciliation panicked");
3559            }
3560            let mut ticker = tokio::time::interval(std::time::Duration::from_secs(
3561                CHILD_WAIT_SWEEP_INTERVAL_SECS,
3562            ));
3563            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
3564            // Skip the immediate tick (boot reconciliation just ran).
3565            ticker.tick().await;
3566            loop {
3567                ticker.tick().await;
3568                if std::panic::AssertUnwindSafe(coordinator.sweep_child_waits())
3569                    .catch_unwind()
3570                    .await
3571                    .is_err()
3572                {
3573                    tracing::error!("child-wait watchdog: sweep panicked; continuing");
3574                }
3575            }
3576        });
3577    }
3578
3579    /// One-shot startup reconciliation: a process restart kills every in-flight
3580    /// child task AND the in-memory bash backstop polls, but the durable state
3581    /// (child index status `running`, parents suspended on bash) survives —
3582    /// previously stranding those parents forever.
3583    async fn reconcile_orphans_at_boot(&self) {
3584        let cutoff = Utc::now();
3585
3586        // (1) Children left `running` by the previous process: no task in THIS
3587        // process is driving them, so no completion will ever fire. Mark them
3588        // terminal and wake their parents through the canonical path. (Root
3589        // sessions left `running` are user-visible and user-recoverable; only
3590        // children hold a suspended parent hostage.)
3591        let running = self
3592            .storage
3593            .list_sessions_by_run_status("running")
3594            .await
3595            .unwrap_or_default();
3596        for (child_id, parent_id) in running {
3597            let Some(parent_id) = parent_id else { continue };
3598            if self.runner_is_running(&child_id).await {
3599                continue;
3600            }
3601            let Some(control_plane) = self.load_control_plane(&child_id).await else {
3602                continue;
3603            };
3604            // A child that started AFTER boot is alive by definition — the
3605            // cutoff guards the tiny window where a fresh spawn races this scan.
3606            if control_plane.updated_at >= cutoff {
3607                continue;
3608            }
3609            tracing::warn!(
3610                child_session_id = %child_id,
3611                parent_session_id = %parent_id,
3612                "boot reconciliation: child was running when the process died; \
3613                 marking it error and waking the parent"
3614            );
3615            self.synthesize_child_completion(
3616                &parent_id,
3617                &child_id,
3618                "error",
3619                Some(
3620                    "orphaned by server restart: the process died while this child session \
3621                     was running"
3622                        .to_string(),
3623                ),
3624            )
3625            .await;
3626        }
3627
3628        // (2) Sessions suspended on `waiting_for_bash`: their backstop poll was
3629        // an in-memory task that died with the process. Re-arm it — with the
3630        // shell registry empty after a restart it resumes on its first check.
3631        let suspended = self
3632            .storage
3633            .list_sessions_by_run_status("suspended")
3634            .await
3635            .unwrap_or_default();
3636        for (session_id, _) in suspended {
3637            let Some(control_plane) = self.load_control_plane(&session_id).await else {
3638                continue;
3639            };
3640            if control_plane
3641                .metadata
3642                .get("runtime.suspend_reason")
3643                .map(String::as_str)
3644                != Some("waiting_for_bash")
3645            {
3646                continue;
3647            }
3648            if let Some(wait) = read_runtime_state(&control_plane).waiting_for_bash {
3649                tracing::warn!(
3650                    %session_id,
3651                    "boot reconciliation: re-arming bash self-resume backstop lost in restart"
3652                );
3653                let coordinator = self.clone();
3654                tokio::spawn(async move {
3655                    coordinator
3656                        .bash_self_resume(session_id, wait.bash_ids)
3657                        .await;
3658                });
3659            }
3660        }
3661    }
3662
3663    async fn runner_is_running(&self, session_id: &str) -> bool {
3664        let runners = self.agent_runners.read().await;
3665        runners
3666            .get(session_id)
3667            .is_some_and(|runner| matches!(runner.status, AgentStatus::Running))
3668    }
3669
3670    async fn load_control_plane(&self, session_id: &str) -> Option<Session> {
3671        match self.storage.load_runtime_control_plane(session_id).await {
3672            Ok(session) => session,
3673            Err(error) => {
3674                tracing::warn!(
3675                    %session_id,
3676                    %error,
3677                    "child-wait watchdog: failed to load session control plane"
3678                );
3679                None
3680            }
3681        }
3682    }
3683
3684    /// One sweep: inspect every session whose index status is `suspended`.
3685    /// Cheap in the common case — the candidate list is small and each check is
3686    /// a sidecar (control-plane) load.
3687    async fn sweep_child_waits(&self) {
3688        let suspended = match self.storage.list_sessions_by_run_status("suspended").await {
3689            Ok(entries) => entries,
3690            Err(error) => {
3691                tracing::warn!(%error, "child-wait watchdog: failed to list suspended sessions");
3692                return;
3693            }
3694        };
3695        for (session_id, _) in suspended {
3696            self.sweep_one_suspended_session(&session_id).await;
3697        }
3698    }
3699
3700    async fn sweep_one_suspended_session(&self, session_id: &str) {
3701        // A live runner means the session already resumed (the index status
3702        // only advances at its next terminal) — nothing to do.
3703        if self.runner_is_running(session_id).await {
3704            return;
3705        }
3706        let Some(session) = self.load_control_plane(session_id).await else {
3707            return;
3708        };
3709        let runtime_state = read_runtime_state(&session);
3710        let suspend_reason = session
3711            .metadata
3712            .get("runtime.suspend_reason")
3713            .map(String::as_str)
3714            .unwrap_or_default()
3715            .to_string();
3716        match (
3717            suspend_reason.as_str(),
3718            runtime_state.waiting_for_children.clone(),
3719        ) {
3720            // Human-gated or bash-owned waits: not ours to time out.
3721            ("waiting_for_bash", _)
3722            | ("awaiting_clarification", _)
3723            | ("awaiting_parent_approval", _) => {}
3724            (_, Some(wait)) => self.sweep_child_wait(session_id, wait).await,
3725            // Suspended with NO armed wait: either the coordinator cleared the
3726            // wait but its resume never spawned, or state is half-cleared.
3727            ("waiting_for_children", None) | ("", None) => {
3728                self.rescue_stranded_resume(session_id).await;
3729            }
3730            _ => {}
3731        }
3732    }
3733
3734    /// Evaluate one armed child wait against reality and act on what the
3735    /// durable state proves: dead children are synthesized terminal, an
3736    /// already-satisfied wait gets its lost wake replayed, an expired lease or
3737    /// empty wait force-resumes the parent.
3738    async fn sweep_child_wait(&self, parent_session_id: &str, wait: WaitingForChildrenState) {
3739        let now = Utc::now();
3740
3741        if wait.child_session_ids.is_empty() {
3742            tracing::warn!(
3743                %parent_session_id,
3744                "child-wait watchdog: wait armed over an empty child set; force-resuming"
3745            );
3746            self.force_resume_child_wait(parent_session_id, empty_child_wait_message())
3747                .await;
3748            return;
3749        }
3750
3751        // The 6h lease (previously written but never read). Expiry does not
3752        // kill children — child runners own child liveness; the parent is
3753        // resumed with a verify-don't-assume note.
3754        if wait.timeout_at.is_some_and(|deadline| now >= deadline) {
3755            tracing::warn!(
3756                %parent_session_id,
3757                "child-wait watchdog: wait lease expired; force-resuming parent"
3758            );
3759            self.force_resume_child_wait(
3760                parent_session_id,
3761                child_wait_lease_expired_message(&wait.child_session_ids),
3762            )
3763            .await;
3764            return;
3765        }
3766
3767        // Yield to the event-driven push on fresh waits.
3768        if now.signed_duration_since(wait.registered_at).num_seconds()
3769            < CHILD_WAIT_REGISTRATION_GRACE_SECS
3770        {
3771            return;
3772        }
3773
3774        let statuses: HashMap<String, Option<String>> = self
3775            .storage
3776            .list_child_run_statuses(parent_session_id)
3777            .await
3778            .unwrap_or_default()
3779            .into_iter()
3780            .collect();
3781
3782        struct DeadChild {
3783            child_id: String,
3784            status: String,
3785            reason: String,
3786            /// `false` = the id does not name a child of THIS parent (wait ids
3787            /// are model-provided and unvalidated): publish the wake but never
3788            /// persist onto / cancel / finalize the named session.
3789            owned: bool,
3790        }
3791
3792        let mut terminal: Vec<(String, String)> = Vec::new();
3793        let mut dead: Vec<DeadChild> = Vec::new();
3794        for child_id in &wait.child_session_ids {
3795            let status = statuses.get(child_id).and_then(|status| status.as_deref());
3796            if let Some(status) = status {
3797                if is_terminal_child_status(status) {
3798                    terminal.push((child_id.clone(), status.to_string()));
3799                    continue;
3800                }
3801            }
3802            if !is_dead_child_candidate_status(status) {
3803                continue;
3804            }
3805
3806            // Ownership check BEFORE anything destructive: `SubAgent.wait` ids
3807            // are model-provided and unvalidated, so an id absent from the
3808            // parent-scoped status map may name a REAL session in another tree
3809            // (a grandchild, a foreign root). Such a session must never be
3810            // mutated, cancelled, or finalized here — but the parent's bogus
3811            // wait entry still needs a synthetic completion to clear it.
3812            let control_plane = self.load_control_plane(child_id).await;
3813            let owned = control_plane
3814                .as_ref()
3815                .is_some_and(|cp| cp.parent_session_id.as_deref() == Some(parent_session_id));
3816            if !owned {
3817                dead.push(DeadChild {
3818                    child_id: child_id.clone(),
3819                    status: "error".to_string(),
3820                    reason: if control_plane.is_some() {
3821                        "waited-on session id is not a child of this session; clearing it \
3822                         from the wait without touching that session"
3823                            .to_string()
3824                    } else {
3825                        "waited-on child session does not exist".to_string()
3826                    },
3827                    owned: false,
3828                });
3829                continue;
3830            }
3831
3832            let runner = { self.agent_runners.read().await.get(child_id).cloned() };
3833            match runner {
3834                Some(runner) if matches!(runner.status, AgentStatus::Running) => {
3835                    // A live-looking runner: only intervene when it is frozen
3836                    // far PAST the per-child liveness limits — which proves the
3837                    // per-child watchdog machinery itself is dead (task
3838                    // panicked or lost), because it would have cancelled and
3839                    // published a timeout long before.
3840                    let last_activity = runner.last_activity_at().unwrap_or(runner.started_at);
3841                    let idle_secs = now.signed_duration_since(last_activity).num_seconds();
3842                    let total_secs = now.signed_duration_since(runner.started_at).num_seconds();
3843                    let policy = match &control_plane {
3844                        Some(child) => {
3845                            crate::runtime::execution::spawn::watchdog_policy_for_session(child)
3846                        }
3847                        None => Default::default(),
3848                    };
3849                    let idle_limit = policy.max_idle_secs.saturating_add(STALE_RUNNER_SLACK_SECS);
3850                    let total_limit = policy
3851                        .max_total_secs
3852                        .saturating_add(STALE_RUNNER_SLACK_SECS);
3853                    if idle_secs >= idle_limit || total_secs >= total_limit {
3854                        runner.cancel_token.cancel();
3855                        dead.push(DeadChild {
3856                            child_id: child_id.clone(),
3857                            status: "timeout".to_string(),
3858                            reason: format!(
3859                                "child runner stalled: no events for {idle_secs}s \
3860                                 (limit {idle_limit}s including watchdog slack); \
3861                                 force-finalized by the child-wait watchdog"
3862                            ),
3863                            owned: true,
3864                        });
3865                    }
3866                }
3867                _ => {
3868                    // Nothing is driving this child, yet its index status will
3869                    // never advance by itself. The grace covers the enqueue →
3870                    // running-marker window and slow spawn queues.
3871                    let quiet_secs = control_plane
3872                        .as_ref()
3873                        .map(|child| now.signed_duration_since(child.updated_at).num_seconds())
3874                        .unwrap_or(i64::MAX);
3875                    if quiet_secs >= DEAD_CHILD_GRACE_SECS {
3876                        dead.push(DeadChild {
3877                            child_id: child_id.clone(),
3878                            status: "error".to_string(),
3879                            reason: format!(
3880                                "child runner lost (crashed task, dropped spawn job, or \
3881                                 process restart): index status {status:?} with no live \
3882                                 runner driving it"
3883                            ),
3884                            owned: true,
3885                        });
3886                    }
3887                }
3888            }
3889        }
3890
3891        if !dead.is_empty() {
3892            for entry in dead {
3893                tracing::warn!(
3894                    %parent_session_id,
3895                    child_session_id = %entry.child_id,
3896                    status = %entry.status,
3897                    reason = %entry.reason,
3898                    owned = entry.owned,
3899                    "child-wait watchdog: synthesizing terminal completion for dead child"
3900                );
3901                if entry.owned {
3902                    self.synthesize_child_completion(
3903                        parent_session_id,
3904                        &entry.child_id,
3905                        &entry.status,
3906                        Some(entry.reason),
3907                    )
3908                    .await;
3909                } else {
3910                    // Foreign / nonexistent id: wake the parent only.
3911                    self.publish_synthetic_completion(
3912                        parent_session_id,
3913                        &entry.child_id,
3914                        &entry.status,
3915                        Some(entry.reason),
3916                    )
3917                    .await;
3918                }
3919            }
3920            // The publishes above re-evaluate the wait policy themselves.
3921            return;
3922        }
3923
3924        // A lost callback must not hide one ready result behind an unfinished
3925        // All wait. Replay every observed terminal generation through the same
3926        // source/ownership checks and permanent Inbox receipts as the push path.
3927        // Error-like outcomes go first so FirstError releases promptly.
3928        terminal.sort_by_key(|(_, status)| !is_error_like(status));
3929        for (child_id, status) in terminal {
3930            tracing::debug!(
3931                %parent_session_id,
3932                child_session_id = %child_id,
3933                "child-wait watchdog: replaying terminal child outcome"
3934            );
3935            let control_plane = self.load_control_plane(&child_id).await;
3936            let mut completion = ChildCompletion {
3937                parent_session_id: parent_session_id.into(),
3938                child_session_id: child_id.clone(),
3939                status,
3940                error: control_plane.as_ref().and_then(Session::last_run_error),
3941                completed_at: Utc::now(),
3942                source: None,
3943            };
3944            // Lost callbacks replay the committed source, not a synthetic
3945            // latest answer. Check ownership before loading any transcript.
3946            if control_plane
3947                .as_ref()
3948                .is_some_and(|child| child.parent_session_id.as_deref() == Some(parent_session_id))
3949            {
3950                let Ok(Some(child)) = self.storage.load_session(&child_id).await else {
3951                    continue;
3952                };
3953                completion.source = ChildCompletionSource::from_committed_session(&child)
3954                    .filter(|source| source.matches_completion(&completion));
3955                if ChildCompletionSource::has_source_record(&child) && completion.source.is_none() {
3956                    tracing::warn!(%child_id,
3957                            "unavailable committed replay source; leaving wait armed");
3958                    continue;
3959                }
3960                completion.completed_at = child.updated_at;
3961            }
3962            self.publish_completion(completion).await;
3963        }
3964    }
3965
3966    /// Persist a synthesized terminal status on the child (so the index flips
3967    /// and nothing re-detects or re-suspends on it), finalize any lingering
3968    /// runner entry (so a future re-run can reserve), then publish through the
3969    /// canonical completion path — broadcast + `on_child_completed`, exactly
3970    /// like a real child terminal.
3971    async fn synthesize_child_completion(
3972        &self,
3973        parent_session_id: &str,
3974        child_session_id: &str,
3975        status: &str,
3976        error: Option<String>,
3977    ) {
3978        match self.storage.load_session(child_session_id).await {
3979            Ok(Some(mut child)) => {
3980                // Ownership guard (defense in depth — callers check too): only
3981                // a session that IS a child of this parent may be mutated. An
3982                // arbitrary session id named in a wait still wakes the parent
3983                // via the publish below, but its own state stays untouched.
3984                if child.parent_session_id.as_deref() != Some(parent_session_id) {
3985                    tracing::warn!(
3986                        %parent_session_id,
3987                        child_session_id = %child.id,
3988                        "child-wait watchdog: refusing to synthesize status onto a session \
3989                         that is not a child of this parent"
3990                    );
3991                    self.publish_synthetic_completion(
3992                        parent_session_id,
3993                        child_session_id,
3994                        status,
3995                        error,
3996                    )
3997                    .await;
3998                    return;
3999                }
4000                child.set_last_run_status(status);
4001                match &error {
4002                    Some(message) => child.set_last_run_error(message.clone()),
4003                    None => child.clear_last_run_error(),
4004                }
4005                child.updated_at = Utc::now();
4006                if let Err(save_error) = self.persistence.merge_save_runtime(&mut child).await {
4007                    tracing::warn!(
4008                        child_session_id = %child.id,
4009                        %save_error,
4010                        "child-wait watchdog: failed to persist synthesized terminal status"
4011                    );
4012                }
4013                self.sessions.insert(
4014                    child.id.clone(),
4015                    Arc::new(crate::SessionSnapshot::new(child)),
4016                );
4017            }
4018            Ok(None) => {}
4019            Err(load_error) => {
4020                tracing::warn!(
4021                    %child_session_id,
4022                    %load_error,
4023                    "child-wait watchdog: failed to load child for synthesized terminal status"
4024                );
4025            }
4026        }
4027        finalize_runner(
4028            &self.agent_runners,
4029            child_session_id,
4030            &Err(bamboo_agent_core::AgentError::LLM(
4031                error
4032                    .clone()
4033                    .unwrap_or_else(|| format!("synthesized {status}")),
4034            )),
4035        )
4036        .await;
4037        self.publish_synthetic_completion(parent_session_id, child_session_id, status, error)
4038            .await;
4039    }
4040
4041    async fn publish_synthetic_completion(
4042        &self,
4043        parent_session_id: &str,
4044        child_session_id: &str,
4045        status: &str,
4046        error: Option<String>,
4047    ) {
4048        self.publish_completion(ChildCompletion {
4049            parent_session_id: parent_session_id.into(),
4050            child_session_id: child_session_id.into(),
4051            status: status.into(),
4052            error,
4053            completed_at: Utc::now(),
4054            source: None,
4055        })
4056        .await;
4057    }
4058
4059    async fn publish_completion(&self, completion: ChildCompletion) {
4060        let publisher =
4061            crate::runtime::execution::session_events::ReplayableSessionEventPublisher::new(
4062                self.agent_runners.clone(),
4063                self.session_event_senders.clone(),
4064                self.account_feed_inbox.clone(),
4065            );
4066        let handler: Arc<dyn ChildCompletionHandler> = Arc::new(self.clone());
4067        crate::runtime::execution::spawn::publish_child_completion(
4068            &publisher,
4069            Some(handler),
4070            completion,
4071        )
4072        .await;
4073    }
4074
4075    /// A parent whose wait was already cleared (resume message appended) but
4076    /// whose resume never spawned — retry-budget exhaustion, root-tools not
4077    /// yet initialized, or a restart between clear and spawn. Detected by: no
4078    /// live runner, no armed wait, and a pending hidden runtime resume message
4079    /// as the LAST message. Resume is all that's left to do.
4080    async fn rescue_stranded_resume(&self, session_id: &str) {
4081        let Some(session) = self.load_session(session_id).await else {
4082            return;
4083        };
4084        let pending_runtime_resume = session.messages.last().is_some_and(|message| {
4085            matches!(message.role, Role::User)
4086                && message
4087                    .metadata
4088                    .as_ref()
4089                    .is_some_and(|meta| meta.get(RUNTIME_RESUME_MESSAGE_KIND_KEY).is_some())
4090        });
4091        if !pending_runtime_resume {
4092            return;
4093        }
4094        tracing::warn!(
4095            %session_id,
4096            "child-wait watchdog: stranded resume detected (wait cleared, resume never \
4097             spawned); resuming"
4098        );
4099        self.resume_parent(session_id.to_string()).await;
4100    }
4101
4102    /// Clear the parent's child wait, append `resume_message`, and drive the
4103    /// resume — with a bounded clobber-retry mirroring
4104    /// [`Self::perform_bash_resume`]: a suspending runner's one-shot finalize
4105    /// save can land after ours and revert the wait while dropping the
4106    /// message; we detect the re-armed wait and re-clear.
4107    async fn force_resume_child_wait(&self, session_id: &str, resume_message: Message) {
4108        const MAX_ATTEMPTS: u8 = 5;
4109        let lock = session_resume_lock(session_id);
4110        for attempt in 0..MAX_ATTEMPTS {
4111            if attempt > 0 {
4112                tokio::time::sleep(Duration::from_millis(200)).await;
4113            }
4114            {
4115                let _held = lock.lock().await;
4116                let Some(mut session) = self.load_session(session_id).await else {
4117                    return;
4118                };
4119                let mut runtime_state = read_runtime_state(&session);
4120                if runtime_state.waiting_for_children.is_none() {
4121                    // Another source already resumed this parent.
4122                    return;
4123                }
4124                runtime_state.waiting_for_children = None;
4125                runtime_state.status = AgentStatusState::Idle;
4126                runtime_state.suspension = None;
4127                write_runtime_state(&mut session, &runtime_state);
4128                session.metadata.remove("runtime.suspend_reason");
4129                session.add_message(resume_message.clone());
4130                session.updated_at = Utc::now();
4131                self.save_and_cache(&mut session).await;
4132            }
4133            let outcome = self.resume_parent(session_id.to_string()).await;
4134            match outcome {
4135                ResumeOutcome::Started { .. } | ResumeOutcome::NotFound => return,
4136                ResumeOutcome::Completed | ResumeOutcome::AlreadyRunning { .. } => {
4137                    // Only retry when the persisted wait was clobbered back to
4138                    // armed; if it stayed cleared with the message intact, the
4139                    // next sweep's stranded-resume rescue finishes the job.
4140                    let clobbered = self
4141                        .load_session(session_id)
4142                        .await
4143                        .map(|session| read_runtime_state(&session).waiting_for_children.is_some())
4144                        .unwrap_or(false);
4145                    if !clobbered {
4146                        return;
4147                    }
4148                }
4149            }
4150        }
4151        tracing::error!(
4152            %session_id,
4153            "child-wait watchdog: force-resume exhausted its clobber-retry budget"
4154        );
4155    }
4156}
4157
4158#[cfg(test)]
4159mod tests {
4160    use super::*;
4161    include!("child_completion_background_tests.rs");
4162    include!("child_completion_exec_tests.rs");
4163    use bamboo_domain::session::runtime_state::SuspensionState;
4164
4165    #[test]
4166    fn skill_request_child_provider_constructor_keeps_data_absent_and_rejects_injection() {
4167        let provider = Message::user("rendered child outcome");
4168        let presentation = session_provider_message(&provider);
4169        assert_eq!(presentation.content.skill_request, None);
4170        assert_eq!(presentation.content.text, provider.content);
4171        let mut envelope = SessionMessageEnvelope::user_input("parent", "unused");
4172        envelope.source = SessionMessageSource::Runtime {
4173            subsystem: "child_completion_coordinator".into(),
4174        };
4175        envelope.kind = SessionMessageKind::ChildOutcome;
4176        envelope.body = SessionMessageBody::ChildOutcome(SessionChildOutcome {
4177            child_session_id: "child".into(),
4178            status: "completed".into(),
4179            result: Some("typed result".into()),
4180            error: None,
4181            provider_message: Some(presentation),
4182        });
4183        envelope.validate().unwrap();
4184        assert_eq!(
4185            envelope.to_provider_message().unwrap().content,
4186            "rendered child outcome"
4187        );
4188        if let SessionMessageBody::ChildOutcome(outcome) = &mut envelope.body {
4189            outcome
4190                .provider_message
4191                .as_mut()
4192                .unwrap()
4193                .content
4194                .skill_request = Some(bamboo_domain::SessionSkillRequest {
4195                mode: None,
4196                selections: vec![bamboo_domain::SessionSkillSelection {
4197                    id: "review".into(),
4198                    source: "builtin".into(),
4199                    revision: 7,
4200                    args: serde_json::Value::Null,
4201                }],
4202            });
4203        }
4204        assert_eq!(
4205            envelope.validate(),
4206            Err(bamboo_domain::SessionMessageValidationError::KindSourceBodyMismatch)
4207        );
4208    }
4209
4210    #[tokio::test]
4211    async fn cancelled_resume_waiters_do_not_retain_historical_parent_ids() {
4212        for index in 0..512 {
4213            let id = format!("resume-lock-reclaim-{index}");
4214            let owner = session_resume_lock(&id);
4215            let held = owner.lock().await;
4216            let waiter = session_resume_lock(&id);
4217            let mut waiting = Box::pin(waiter.lock());
4218            assert!(futures::poll!(waiting.as_mut()).is_pending());
4219            drop(held);
4220            drop(owner);
4221            drop(waiting);
4222            drop(waiter);
4223            assert!(!parent_locks().lock().recover_poison().contains_key(&id));
4224        }
4225    }
4226
4227    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
4228    async fn resume_lock_reclamation_preserves_exclusive_parent_wake_ownership() {
4229        use std::sync::atomic::{AtomicUsize, Ordering};
4230        let active = Arc::new(AtomicUsize::new(0));
4231        let mut tasks = Vec::new();
4232        for _ in 0..32 {
4233            let active = active.clone();
4234            tasks.push(tokio::spawn(async move {
4235                for _ in 0..16 {
4236                    let lease = session_resume_lock("resume-lock-exclusive");
4237                    let _guard = lease.lock().await;
4238                    assert_eq!(active.fetch_add(1, Ordering::SeqCst), 0);
4239                    tokio::task::yield_now().await;
4240                    assert_eq!(active.fetch_sub(1, Ordering::SeqCst), 1);
4241                }
4242            }));
4243        }
4244        for task in tasks {
4245            task.await.unwrap();
4246        }
4247        assert!(!parent_locks()
4248            .lock()
4249            .recover_poison()
4250            .contains_key("resume-lock-exclusive"));
4251    }
4252    use bamboo_agent_core::Message;
4253    use bamboo_domain::SessionInboxPort;
4254    use futures::stream;
4255    use std::sync::atomic::{AtomicUsize, Ordering};
4256
4257    struct FailingRuntimeSaveStorage {
4258        inner: Arc<bamboo_storage::SessionStoreV2>,
4259    }
4260
4261    #[async_trait]
4262    impl Storage for FailingRuntimeSaveStorage {
4263        async fn save_session(&self, session: &Session) -> std::io::Result<()> {
4264            self.inner.save_session(session).await
4265        }
4266
4267        async fn load_session(&self, id: &str) -> std::io::Result<Option<Session>> {
4268            self.inner.load_session(id).await
4269        }
4270
4271        async fn delete_session(&self, id: &str) -> std::io::Result<bool> {
4272            self.inner.delete_session(id).await
4273        }
4274
4275        async fn load_runtime_control_plane(&self, id: &str) -> std::io::Result<Option<Session>> {
4276            self.inner.load_runtime_control_plane(id).await
4277        }
4278
4279        async fn save_runtime_state(&self, _session: &Session) -> std::io::Result<()> {
4280            Err(std::io::Error::other("injected sidecar save failure"))
4281        }
4282    }
4283
4284    struct EmptyTools;
4285
4286    #[async_trait]
4287    impl bamboo_agent_core::tools::ToolExecutor for EmptyTools {
4288        async fn execute(
4289            &self,
4290            _call: &bamboo_agent_core::tools::ToolCall,
4291        ) -> Result<bamboo_agent_core::tools::ToolResult, bamboo_agent_core::tools::ToolError>
4292        {
4293            Err(bamboo_agent_core::tools::ToolError::NotFound(
4294                "no tools".to_string(),
4295            ))
4296        }
4297
4298        fn list_tools(&self) -> Vec<bamboo_agent_core::tools::ToolSchema> {
4299            Vec::new()
4300        }
4301    }
4302
4303    struct CompletedTestProvider;
4304
4305    #[async_trait]
4306    impl bamboo_llm::LLMProvider for CompletedTestProvider {
4307        async fn chat_stream(
4308            &self,
4309            _messages: &[Message],
4310            _tools: &[bamboo_agent_core::tools::ToolSchema],
4311            _max_output_tokens: Option<u32>,
4312            _model: &str,
4313        ) -> Result<bamboo_llm::LLMStream, bamboo_llm::LLMError> {
4314            let chunks: Vec<bamboo_llm::provider::Result<bamboo_llm::LLMChunk>> = vec![
4315                Ok(bamboo_llm::LLMChunk::Token("done".to_string())),
4316                Ok(bamboo_llm::LLMChunk::Done),
4317            ];
4318            Ok(Box::pin(stream::iter(chunks)))
4319        }
4320    }
4321
4322    struct CountingActivationSpawner {
4323        reservations: Arc<AtomicUsize>,
4324        launches: Arc<AtomicUsize>,
4325    }
4326
4327    #[async_trait]
4328    impl SessionActivationSpawner for CountingActivationSpawner {
4329        async fn reserve_activation(
4330            &self,
4331            target_session_id: &str,
4332            inbox_generation: u64,
4333        ) -> Result<SessionActivationReserveOutcome, bamboo_domain::SessionActivationError>
4334        {
4335            self.reservations.fetch_add(1, Ordering::SeqCst);
4336            let launches = self.launches.clone();
4337            Ok(SessionActivationReserveOutcome::Reserved(
4338                SessionActivationLaunch::new(
4339                    format!("{target_session_id}-{inbox_generation}"),
4340                    move || {
4341                        launches.fetch_add(1, Ordering::SeqCst);
4342                    },
4343                ),
4344            ))
4345        }
4346    }
4347
4348    async fn save_sourced_test_child(store: &bamboo_storage::SessionStoreV2, child: &Session) {
4349        let mut saved = child.clone();
4350        ChildCompletionSource::prepare(
4351            &mut saved,
4352            &format!("test-run-{}", child.id),
4353            &Default::default(),
4354        );
4355        store.save_session(&saved).await.unwrap();
4356    }
4357
4358    async fn test_child_source(
4359        store: &bamboo_storage::SessionStoreV2,
4360        id: &str,
4361    ) -> Option<ChildCompletionSource> {
4362        ChildCompletionSource::from_committed_session(
4363            &store.load_session(id).await.unwrap().unwrap(),
4364        )
4365    }
4366
4367    fn typed_test_report() -> serde_json::Value {
4368        serde_json::json!({"version":1,"outcome":"blocked","summary":"Quoted \"claim\" \\ 用户🪷\n",
4369            "reported_evidence":[{"description":"reported only","reference":"/never/read/reported-ref","sha256":null}],
4370            "reported_verification":[{"check":"fixture","reported_status":"passed","details":"a model claim"}],
4371            "proposals":["Try the next step"],"blockers":["Need approval"],"open_decisions":["Which route?"]})
4372    }
4373
4374    fn typed_test_child(parent: &Session, id: &str, report: &str) -> Session {
4375        let mut child = Session::new_child(id, &parent.id, "model", "Child");
4376        let packet = bamboo_domain::ChildContextPacket {
4377            version: 1,
4378            objective: "bounded task".into(),
4379            constraints: vec![],
4380            acceptance: vec!["report only".into()],
4381            non_goals: vec![],
4382            necessary_user_instructions: vec![],
4383            recorded_decisions: vec![],
4384            source_user_message_ids: vec![parent.messages[0].id.clone()],
4385            background_message_ids: vec![],
4386        };
4387        let resolved = packet.resolve(parent, "bounded task").unwrap();
4388        let binding = bamboo_domain::ChildContextBinding::new(
4389            parent,
4390            id,
4391            resolved.required_brief.clone(),
4392            resolved,
4393        )
4394        .unwrap();
4395        binding.install(&mut child).unwrap();
4396        child.add_message(binding.assignment_message());
4397        child.add_message(Message::assistant(report, None));
4398        child.set_last_run_status("completed");
4399        ChildCompletionSource::prepare(&mut child, "sealed-report-run", &Default::default());
4400        child
4401    }
4402
4403    fn typed_test_completion(child: &Session) -> ChildCompletion {
4404        ChildCompletion {
4405            parent_session_id: child.parent_session_id.clone().unwrap(),
4406            child_session_id: child.id.clone(),
4407            status: child.last_run_status().unwrap(),
4408            error: child.last_run_error(),
4409            completed_at: child.updated_at,
4410            source: ChildCompletionSource::from_committed_session(child),
4411        }
4412    }
4413
4414    fn planner_test_child(parent: &Session, id: &str) -> Session {
4415        let mut report = typed_test_report();
4416        report["plan_markdown"] = serde_json::json!("# Plan\n## Selected\nPLANNER_SECTION_ONLY\n## Appendix\nLARGE_PLANNER_BODY_MUST_STAY_OUT_OF_RESUME");
4417        let mut child = typed_test_child(parent, id, &report.to_string());
4418        child.set_subagent_type("planner");
4419        child.metadata.insert(
4420            super::super::child_session::planner_artifact::CONTRACT_KEY.into(),
4421            "1".into(),
4422        );
4423        child
4424            .agent_runtime_state
4425            .get_or_insert_with(Default::default)
4426            .read_only = true;
4427        ChildCompletionSource::prepare(&mut child, "native-planner-report", &Default::default());
4428        child
4429    }
4430
4431    #[cfg(unix)]
4432    #[tokio::test]
4433    async fn planner_artifact_completion_publishes_before_durable_resume_and_deduplicates() {
4434        let (_temp, store, inbox, coordinator, _, launches) = completion_inbox_fixture().await;
4435        save_waiting_test_parent(
4436            &store,
4437            "plan-parent",
4438            vec!["plan-child".into()],
4439            ChildWaitPolicy::All,
4440        )
4441        .await;
4442        let mut parent = store.load_session("plan-parent").await.unwrap().unwrap();
4443        parent.add_message(Message::user("Original selected source"));
4444        store.save_session(&parent).await.unwrap();
4445        let child = planner_test_child(&parent, "plan-child");
4446        store.save_session(&child).await.unwrap();
4447        let completion = typed_test_completion(&child);
4448        coordinator.on_child_completed(completion.clone()).await;
4449        coordinator.on_child_completed(completion).await;
4450        let claims = inbox.claim(&parent.id, 8).await.unwrap();
4451        assert_eq!(claims.len(), 1);
4452        assert_eq!(launches.load(Ordering::SeqCst), 1);
4453        let message = claims[0].envelope.to_provider_message().unwrap();
4454        assert!(message.content.contains("planner_artifact"));
4455        assert!(!message
4456            .content
4457            .contains("LARGE_PLANNER_BODY_MUST_STAY_OUT_OF_RESUME"));
4458        assert!(fits_child_result_budget(&claims[0].envelope));
4459        let raw = message
4460            .content
4461            .split_once("Child typed result:\n")
4462            .unwrap()
4463            .1
4464            .lines()
4465            .next()
4466            .unwrap();
4467        let projection: serde_json::Value = serde_json::from_str(raw).unwrap();
4468        assert_eq!(projection["execution_approved"], false);
4469        let path = store
4470            .bamboo_home_dir()
4471            .join(projection["file"].as_str().unwrap());
4472        assert!(std::fs::read_to_string(path)
4473            .unwrap()
4474            .contains("PLANNER_SECTION_ONLY"));
4475        let restored = store.load_session(&parent.id).await.unwrap().unwrap();
4476        assert_eq!(restored.kind, parent.kind);
4477        assert_eq!(restored.root_thinking_mode(), parent.root_thinking_mode());
4478        assert_eq!(
4479            restored
4480                .agent_runtime_state
4481                .as_ref()
4482                .unwrap()
4483                .permission_mode,
4484            parent.agent_runtime_state.as_ref().unwrap().permission_mode
4485        );
4486    }
4487
4488    #[cfg(unix)]
4489    #[tokio::test]
4490    async fn planner_artifact_terminal_sibling_recovery_publishes_the_same_bounded_reference() {
4491        let (_temp, store, inbox, coordinator, _, _) = completion_inbox_fixture().await;
4492        save_waiting_test_parent(
4493            &store,
4494            "plan-recovery",
4495            vec!["plan-sibling".into(), "trigger-child".into()],
4496            ChildWaitPolicy::All,
4497        )
4498        .await;
4499        let mut parent = store.load_session("plan-recovery").await.unwrap().unwrap();
4500        parent.add_message(Message::user("Original selected source"));
4501        store.save_session(&parent).await.unwrap();
4502        let child = planner_test_child(&parent, "plan-sibling");
4503        store.save_session(&child).await.unwrap();
4504        let completion =
4505            save_completion_test_child(&store, &parent.id, "trigger-child", "completed").await;
4506        coordinator.on_child_completed(completion).await;
4507        let claims = inbox.claim(&parent.id, 8).await.unwrap();
4508        assert_eq!(claims.len(), 2, "both terminal outcomes remain durable");
4509        let message = claims
4510            .iter()
4511            .map(|c| c.envelope.to_provider_message().unwrap())
4512            .find(|m| m.content.contains("planner_artifact"))
4513            .unwrap();
4514        assert!(!message
4515            .content
4516            .contains("LARGE_PLANNER_BODY_MUST_STAY_OUT_OF_RESUME"));
4517        let raw = message
4518            .content
4519            .split_once("Child typed result:\n")
4520            .unwrap()
4521            .1
4522            .lines()
4523            .next()
4524            .unwrap();
4525        let projection: serde_json::Value = serde_json::from_str(raw).unwrap();
4526        assert!(store
4527            .bamboo_home_dir()
4528            .join(projection["file"].as_str().unwrap())
4529            .is_file());
4530    }
4531
4532    #[tokio::test]
4533    async fn planner_artifact_real_boot_orphan_recovery_admits_unavailable_once_without_unsealed_report(
4534    ) {
4535        let (_temp, store, inbox, coordinator, _, launches) = completion_inbox_fixture().await;
4536        save_waiting_test_parent(
4537            &store,
4538            "lost-plan-parent",
4539            vec!["lost-plan-child".into()],
4540            ChildWaitPolicy::All,
4541        )
4542        .await;
4543        let mut parent = store
4544            .load_session("lost-plan-parent")
4545            .await
4546            .unwrap()
4547            .unwrap();
4548        parent.add_message(Message::user("Original selected source"));
4549        store.save_session(&parent).await.unwrap();
4550        let mut child = planner_test_child(&parent, "lost-plan-child");
4551        child.set_last_run_status("running");
4552        child.messages.last_mut().unwrap().content = "UNSEALED_REPORT_MUST_NOT_BE_IMPORTED".into();
4553        ChildCompletionSource::prepare(&mut child, "lost-worker", &Default::default());
4554        child.updated_at = Utc::now() - chrono::Duration::minutes(5);
4555        store.save_session(&child).await.unwrap();
4556
4557        // Exercise the actual boot watchdog, including canonical error save
4558        // and publish_synthetic_completion(source=None), not a fabricated report.
4559        coordinator.reconcile_orphans_at_boot().await;
4560        coordinator.reconcile_orphans_at_boot().await;
4561        let claims = inbox.claim(&parent.id, 8).await.unwrap();
4562        assert_eq!(
4563            claims.len(),
4564            1,
4565            "the lost worker must not strand the six-hour wait"
4566        );
4567        assert_eq!(launches.load(Ordering::SeqCst), 1);
4568        let message = claims[0].envelope.to_provider_message().unwrap();
4569        assert!(message.content.contains("planner_artifact"));
4570        assert!(message.content.contains("\"available\":false"));
4571        assert!(!serde_json::to_string(&claims[0].envelope)
4572            .unwrap()
4573            .contains("UNSEALED_REPORT"));
4574        let child = store.load_session(&child.id).await.unwrap().unwrap();
4575        assert_eq!(child.last_run_status().as_deref(), Some("error"));
4576        assert!(ChildCompletionSource::from_committed_session(&child).is_none());
4577        assert!(!store.bamboo_home_dir().join("plan/delegated").exists());
4578    }
4579
4580    fn unusable_typed_test_child(
4581        parent: &Session,
4582        id: &str,
4583        case: &str,
4584    ) -> (Session, &'static str) {
4585        let mut child = typed_test_child(parent, id, &typed_test_report().to_string());
4586        let mut prior = Default::default();
4587        let reason = match case {
4588            "prose" => {
4589                child.messages.last_mut().unwrap().content = "UNUSABLE_RAW_PROSE".repeat(64);
4590                "report_malformed"
4591            }
4592            "malformed" => {
4593                child.messages.last_mut().unwrap().content =
4594                    "{\"version\":1,UNUSABLE_RAW_JSON".into();
4595                "report_malformed"
4596            }
4597            "oversized" => {
4598                child
4599                    .messages
4600                    .last_mut()
4601                    .unwrap()
4602                    .content
4603                    .push_str(&" ".repeat(8193));
4604                "result_budget_exceeded"
4605            }
4606            "multipart" => {
4607                child.messages.last_mut().unwrap().content_parts = Some(
4608                    serde_json::from_value(
4609                        serde_json::json!([{"type":"text","text":"UNUSABLE_RAW_PART"}]),
4610                    )
4611                    .unwrap(),
4612                );
4613                "report_not_current_final"
4614            }
4615            "commentary" => {
4616                child.messages.last_mut().unwrap().phase =
4617                    Some(bamboo_domain::MessagePhase::Commentary);
4618                "report_not_current_final"
4619            }
4620            "blank" => {
4621                child.messages.last_mut().unwrap().content = "  ".into();
4622                "report_not_current_final"
4623            }
4624            "old_final" => {
4625                prior = child.messages.iter().map(|m| m.id.clone()).collect();
4626                "report_not_current_final"
4627            }
4628            "error" => {
4629                prior = child.messages.iter().map(|m| m.id.clone()).collect();
4630                child.set_last_run_status("error");
4631                child.set_last_run_error("UNUSABLE_RAW_ERROR\"\\\n".repeat(4096));
4632                "report_not_current_final"
4633            }
4634            "absent" => {
4635                child.messages.pop();
4636                "report_absent"
4637            }
4638            _ => panic!("unknown unusable report case"),
4639        };
4640        ChildCompletionSource::prepare(&mut child, "unusable-report-run", &prior);
4641        (child, reason)
4642    }
4643
4644    #[test]
4645    fn sealed_unusable_reports_keep_current_source_boundary_and_escape_budget() {
4646        let mut parent = Session::new("unavailable-parent", "model");
4647        parent.add_message(Message::user("required source"));
4648        for case in [
4649            "prose",
4650            "malformed",
4651            "oversized",
4652            "multipart",
4653            "commentary",
4654            "blank",
4655            "old_final",
4656            "error",
4657            "absent",
4658        ] {
4659            let (child, reason) = unusable_typed_test_child(&parent, "unavailable-child", case);
4660            let completion = typed_test_completion(&child);
4661            assert!(completion.source.is_some(), "valid seal: {case}");
4662            let mut projection = sealed_child_report(&completion, &child, &parent).unwrap();
4663            assert_eq!(
4664                projection,
4665                unavailable_child_result("typed_result", reason),
4666                "{case}"
4667            );
4668            let mut message = typed_child_resume_message(&completion, 0, &projection);
4669            let raw = completion.source.as_ref().unwrap().result(&child, &parent);
4670            let mut envelope =
4671                child_completion_envelope(&completion, Utc::now(), raw.clone(), &message);
4672            assert!(bound_typed_child_delivery(
4673                &mut envelope,
4674                &mut message,
4675                &mut projection,
4676                &completion,
4677                0
4678            ));
4679            assert!(fits_child_result_budget(&envelope));
4680            assert!(fits_child_result_budget(&message));
4681            assert!(fits_child_result_budget(
4682                &envelope.to_provider_message().unwrap()
4683            ));
4684            assert!(message.content.contains("SubAgent"));
4685            assert_eq!(
4686                message.metadata.as_ref().unwrap()["child_final_response_included"],
4687                false
4688            );
4689            assert!(projection.get("child_report").is_none());
4690            assert!(projection.get("host_observation").is_none());
4691            let SessionMessageBody::ChildOutcome(outcome) = envelope.body else {
4692                panic!("child outcome")
4693            };
4694            assert!(outcome.result.is_none());
4695            assert!(outcome.error.is_none());
4696            if let Some(raw) = raw {
4697                assert!(!message.content.contains(&raw), "no raw report: {case}");
4698            }
4699            // A replaced or invalid source never turns into an unavailable
4700            // completion that could clear a newer wait.
4701            let mut stale = child.clone();
4702            stale
4703                .messages
4704                .last_mut()
4705                .unwrap()
4706                .content
4707                .push_str("changed");
4708            assert!(sealed_child_report(&completion, &stale, &parent).is_none());
4709        }
4710    }
4711
4712    #[test]
4713    fn unavailable_delivery_counts_escaped_wrappers_at_exact_limit() {
4714        let mut parent = Session::new("unavailable-parent", "model");
4715        parent.add_message(Message::user("required source"));
4716        let (child, _) = unusable_typed_test_child(&parent, "unavailable-child", "oversized");
4717        let completion = typed_test_completion(&child);
4718        let mut projection = sealed_child_report(&completion, &child, &parent).unwrap();
4719        let mut message = typed_child_resume_message(&completion, 0, &projection);
4720        let mut base = child_completion_envelope(&completion, Utc::now(), None, &message);
4721        assert!(bound_typed_child_delivery(
4722            &mut base,
4723            &mut message,
4724            &mut projection,
4725            &completion,
4726            0
4727        ));
4728        let bytes = serde_json::to_vec(&base).unwrap().len().max(
4729            serde_json::to_vec(&base.to_provider_message().unwrap())
4730                .unwrap()
4731                .len(),
4732        );
4733        for extra in [8192 - bytes, 8193 - bytes] {
4734            let mut envelope = base.clone();
4735            // Quotes count twice in the actual escaped wrappers.
4736            let padding = format!("{}{}", "\"".repeat(extra / 2), "x".repeat(extra % 2));
4737            envelope.correlation_id.as_mut().unwrap().push_str(&padding);
4738            let mut projection = projection.clone();
4739            let mut message = message.clone();
4740            assert_eq!(
4741                bound_typed_child_delivery(
4742                    &mut envelope,
4743                    &mut message,
4744                    &mut projection,
4745                    &completion,
4746                    0
4747                ),
4748                bytes + extra == 8192
4749            );
4750            assert_eq!(
4751                serde_json::to_vec(&envelope).unwrap().len().max(
4752                    serde_json::to_vec(&envelope.to_provider_message().unwrap())
4753                        .unwrap()
4754                        .len()
4755                ),
4756                bytes + extra
4757            );
4758            assert!(fits_child_result_budget(&message));
4759        }
4760    }
4761
4762    #[test]
4763    fn sealed_typed_projection_keeps_host_source_distinct_from_all_reported_claims() {
4764        let mut parent = Session::new("typed-parent", "model");
4765        parent.add_message(Message::user("required user constraint"));
4766        let report = typed_test_report();
4767        let child = typed_test_child(&parent, "typed-child", &report.to_string());
4768        let completion = typed_test_completion(&child);
4769        let projection = sealed_child_report(&completion, &child, &parent).unwrap();
4770        assert_eq!(projection["child_report"], report);
4771        assert_eq!(
4772            projection["host_observation"]["terminal_source"],
4773            serde_json::to_value(&completion.source).unwrap()
4774        );
4775        assert_eq!(
4776            projection["host_observation"]["kind"],
4777            "committed_terminal_source"
4778        );
4779        assert_eq!(
4780            projection["host_observation"]["last_run_status"],
4781            "completed"
4782        );
4783        assert_eq!(projection["child_report"]["outcome"], "blocked");
4784        assert_eq!(
4785            projection["child_report"]["reported_verification"][0]["reported_status"],
4786            "passed"
4787        );
4788        assert!(projection["host_observation"].get("verified").is_none());
4789        for excluded in ["guardian", "resident", "legacy", "missing_source"] {
4790            let mut excluded_child = child.clone();
4791            match excluded {
4792                "guardian" => {
4793                    excluded_child
4794                        .metadata
4795                        .insert("subagent_type".into(), "guardian".into());
4796                }
4797                "resident" => {
4798                    excluded_child
4799                        .metadata
4800                        .insert("lifecycle".into(), "resident".into());
4801                }
4802                "legacy" => {
4803                    excluded_child =
4804                        Session::new_child("typed-child", &parent.id, "model", "Child");
4805                    excluded_child.add_message(Message::assistant(report.to_string(), None));
4806                    excluded_child.set_last_run_status("completed");
4807                }
4808                _ => {}
4809            }
4810            ChildCompletionSource::prepare(
4811                &mut excluded_child,
4812                "excluded-run",
4813                &Default::default(),
4814            );
4815            let mut excluded_completion = typed_test_completion(&excluded_child);
4816            if excluded == "missing_source" {
4817                excluded_completion.source = None;
4818            }
4819            assert!(
4820                sealed_child_report(&excluded_completion, &excluded_child, &parent).is_none(),
4821                "{excluded}"
4822            );
4823        }
4824    }
4825
4826    #[tokio::test]
4827    async fn typed_delivery_counts_complete_escaped_envelope_and_final_correlation() {
4828        let mut parent = Session::new("typed-parent", "model");
4829        parent.add_message(Message::user("required source"));
4830        let child = typed_test_child(&parent, "typed-child", &typed_test_report().to_string());
4831        let completion = typed_test_completion(&child);
4832        let original = sealed_child_report(&completion, &child, &parent).unwrap();
4833        let presentation = typed_child_resume_message(&completion, 0, &original);
4834        let mut base = child_completion_envelope(
4835            &completion,
4836            Utc::now(),
4837            completion.source.as_ref().unwrap().result(&child, &parent),
4838            &presentation,
4839        );
4840        let mut projection = original.clone();
4841        let mut message = presentation.clone();
4842        assert!(bound_typed_child_delivery(
4843            &mut base,
4844            &mut message,
4845            &mut projection,
4846            &completion,
4847            0
4848        ));
4849        assert_eq!(projection, original);
4850        let temp = tempfile::tempdir().unwrap();
4851        let store = Arc::new(
4852            bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
4853                .await
4854                .unwrap(),
4855        );
4856        store.save_session(&parent).await.unwrap();
4857        let inbox = bamboo_storage::FileSessionInbox::new(store, Default::default());
4858        let first = inbox.deliver(&base).await.unwrap();
4859        let stable_id = base.id.clone();
4860        let base_bytes = serde_json::to_vec(&base.to_provider_message().unwrap())
4861            .unwrap()
4862            .len();
4863        assert!(base_bytes < 8192);
4864        let correlation = base.correlation_id.as_ref().unwrap().clone();
4865        for (extra, available) in [(8192 - base_bytes, true), (8193 - base_bytes, false)] {
4866            let mut envelope = base.clone();
4867            envelope.correlation_id = Some(format!("{correlation}{}", "x".repeat(extra)));
4868            assert_eq!(
4869                serde_json::to_vec(&envelope.to_provider_message().unwrap())
4870                    .unwrap()
4871                    .len(),
4872                base_bytes + extra
4873            );
4874            let mut message = presentation.clone();
4875            let mut projection = original.clone();
4876            assert!(bound_typed_child_delivery(
4877                &mut envelope,
4878                &mut message,
4879                &mut projection,
4880                &completion,
4881                0
4882            ));
4883            assert_eq!(projection["available"], available);
4884            assert!(fits_child_result_budget(&envelope));
4885            assert!(fits_child_result_budget(&message));
4886            assert!(fits_child_result_budget(
4887                &envelope.to_provider_message().unwrap()
4888            ));
4889            assert_eq!(
4890                envelope.idempotency_semantics(),
4891                base.idempotency_semantics()
4892            );
4893            assert_eq!(envelope.id, stable_id);
4894            assert_eq!(
4895                inbox.deliver(&envelope).await.unwrap(),
4896                first,
4897                "presentation overflow cannot change delivery semantics"
4898            );
4899            if !available {
4900                assert_eq!(projection["reason"], "result_budget_exceeded");
4901                assert!(projection.get("child_report").is_none());
4902                assert!(message.content.contains("SubAgent"));
4903                assert!(!message.content.contains("Bounded tail"));
4904            }
4905        }
4906        let mut large = typed_test_report();
4907        large["summary"] = serde_json::json!("\"\\\n".repeat(500));
4908        large["proposals"] = serde_json::json!(["\"\\\n".repeat(250)]);
4909        let child = typed_test_child(&parent, "escaped-child", &large.to_string());
4910        assert!(decode_report(&large.to_string()).is_ok());
4911        let completion = typed_test_completion(&child);
4912        let mut projection = sealed_child_report(&completion, &child, &parent).unwrap();
4913        let mut message = typed_child_resume_message(&completion, 0, &projection);
4914        let mut envelope =
4915            child_completion_envelope(&completion, Utc::now(), Some(large.to_string()), &message);
4916        assert!(serde_json::to_vec(&envelope).unwrap().len() > 8192);
4917        assert!(bound_typed_child_delivery(
4918            &mut envelope,
4919            &mut message,
4920            &mut projection,
4921            &completion,
4922            0
4923        ));
4924        assert_eq!(projection["available"], false);
4925        assert!(fits_child_result_budget(&envelope));
4926        assert!(fits_child_result_budget(&message));
4927        assert!(fits_child_result_budget(
4928            &envelope.to_provider_message().unwrap()
4929        ));
4930    }
4931
4932    #[tokio::test]
4933    async fn typed_source_mutations_reject_before_admission_and_wait_clear() {
4934        for mutation in ["birth", "assignment", "input", "result", "parent_source"] {
4935            let (_temp, store, inbox, coordinator, _, launches) = completion_inbox_fixture().await;
4936            save_waiting_test_parent(
4937                &store,
4938                "typed-parent",
4939                vec!["typed-child".into()],
4940                ChildWaitPolicy::All,
4941            )
4942            .await;
4943            let mut parent = store.load_session("typed-parent").await.unwrap().unwrap();
4944            parent.add_message(Message::user("required source"));
4945            store.save_session(&parent).await.unwrap();
4946            let mut child =
4947                typed_test_child(&parent, "typed-child", &typed_test_report().to_string());
4948            store.save_session(&child).await.unwrap();
4949            let mut completion = typed_test_completion(&child);
4950            match mutation {
4951                "birth" => {
4952                    completion.source.as_mut().unwrap().child_created_at +=
4953                        chrono::Duration::seconds(1)
4954                }
4955                "assignment" => {
4956                    child.messages[0].content.push_str("changed assignment");
4957                }
4958                "input" => child.add_message(Message::user("new user input")),
4959                "result" => child
4960                    .messages
4961                    .last_mut()
4962                    .unwrap()
4963                    .content
4964                    .push_str("changed"),
4965                _ => {
4966                    parent.messages[0].content.push_str("changed");
4967                    store.save_session(&parent).await.unwrap();
4968                }
4969            }
4970            store.save_session(&child).await.unwrap();
4971            coordinator.on_child_completed(completion).await;
4972            assert_eq!(
4973                inbox.inspect(&parent.id).await.unwrap().pending,
4974                0,
4975                "{mutation}"
4976            );
4977            assert_eq!(launches.load(Ordering::SeqCst), 0, "{mutation}");
4978            assert!(
4979                read_runtime_state(&store.load_session(&parent.id).await.unwrap().unwrap())
4980                    .waiting_for_children
4981                    .is_some(),
4982                "{mutation}"
4983            );
4984        }
4985    }
4986
4987    #[tokio::test]
4988    async fn typed_cold_watchdog_and_terminal_siblings_admit_each_source_once() {
4989        for case in [
4990            "valid",
4991            "prose",
4992            "malformed",
4993            "oversized",
4994            "multipart",
4995            "commentary",
4996            "blank",
4997            "old_final",
4998            "error",
4999            "absent",
5000        ] {
5001            for cold_watchdog in [false, true] {
5002                let (_temp, store, inbox, coordinator, reservations, launches) =
5003                    completion_inbox_fixture().await;
5004                let ids = vec!["typed-a".into(), "typed-b".into()];
5005                save_waiting_test_parent(&store, "typed-parent", ids.clone(), ChildWaitPolicy::All)
5006                    .await;
5007                let mut parent = store.load_session("typed-parent").await.unwrap().unwrap();
5008                parent.add_message(Message::user("required source"));
5009                store.save_session(&parent).await.unwrap();
5010                let mut completions = Vec::new();
5011                for id in &ids {
5012                    let child = if case == "valid" {
5013                        typed_test_child(&parent, id, &typed_test_report().to_string())
5014                    } else {
5015                        unusable_typed_test_child(&parent, id, case).0
5016                    };
5017                    store.save_session(&child).await.unwrap();
5018                    completions.push(typed_test_completion(&child));
5019                }
5020                if cold_watchdog {
5021                    // No callbacks or cached Session snapshots precede this disk-only sweep.
5022                    let mut wait = read_runtime_state(&parent).waiting_for_children.unwrap();
5023                    wait.registered_at = Utc::now() - chrono::Duration::seconds(61);
5024                    coordinator.sweep_child_wait(&parent.id, wait.clone()).await;
5025                    coordinator.sweep_child_wait(&parent.id, wait).await;
5026                } else {
5027                    // The terminal index is ahead of the second sibling's callback.
5028                    coordinator.on_child_completed(completions[0].clone()).await;
5029                }
5030                for completion in completions {
5031                    coordinator.on_child_completed(completion).await;
5032                }
5033                let claims = inbox.claim(&parent.id, 8).await.unwrap();
5034                assert_eq!(claims.len(), 2);
5035                assert_eq!(reservations.load(Ordering::SeqCst), 1);
5036                assert_eq!(launches.load(Ordering::SeqCst), 1);
5037                for claim in claims {
5038                    assert!(fits_child_result_budget(&claim.envelope));
5039                    assert!(fits_child_result_budget(
5040                        &claim.envelope.to_provider_message().unwrap()
5041                    ));
5042                    let actual = claim.envelope.to_provider_message().unwrap();
5043                    assert!(!actual.content.contains("UNUSABLE_RAW"));
5044                    let SessionMessageBody::ChildOutcome(outcome) = claim.envelope.body else {
5045                        panic!("child outcome");
5046                    };
5047                    let projection: serde_json::Value = serde_json::from_str(
5048                        outcome
5049                            .provider_message
5050                            .as_ref()
5051                            .unwrap()
5052                            .content
5053                            .text
5054                            .split_once("Child typed result:\n")
5055                            .unwrap()
5056                            .1
5057                            .lines()
5058                            .next()
5059                            .unwrap(),
5060                    )
5061                    .unwrap();
5062                    if case != "valid" {
5063                        let expected =
5064                            unusable_typed_test_child(&parent, &outcome.child_session_id, case).1;
5065                        assert_eq!(
5066                            projection,
5067                            unavailable_child_result("typed_result", expected)
5068                        );
5069                        assert!(outcome.result.is_none());
5070                        assert!(outcome.error.is_none());
5071                        assert!(fits_child_result_budget(&outcome.provider_message));
5072                        continue;
5073                    }
5074                    assert_eq!(projection["child_report"], typed_test_report());
5075                    assert_eq!(
5076                        projection["host_observation"]["terminal_source"]["child_session_id"],
5077                        outcome.child_session_id
5078                    );
5079                    assert_eq!(
5080                        projection["host_observation"]["last_run_status"],
5081                        "completed"
5082                    );
5083                    assert!(fits_child_result_budget(&outcome.provider_message));
5084                }
5085            }
5086        }
5087    }
5088
5089    async fn completion_inbox_fixture() -> (
5090        tempfile::TempDir,
5091        Arc<bamboo_storage::SessionStoreV2>,
5092        Arc<dyn SessionInboxPort>,
5093        Arc<ChildCompletionCoordinator>,
5094        Arc<AtomicUsize>,
5095        Arc<AtomicUsize>,
5096    ) {
5097        completion_inbox_fixture_with_limits(bamboo_domain::SessionInboxLimits::default()).await
5098    }
5099
5100    async fn completion_inbox_fixture_with_limits(
5101        limits: bamboo_domain::SessionInboxLimits,
5102    ) -> (
5103        tempfile::TempDir,
5104        Arc<bamboo_storage::SessionStoreV2>,
5105        Arc<dyn SessionInboxPort>,
5106        Arc<ChildCompletionCoordinator>,
5107        Arc<AtomicUsize>,
5108        Arc<AtomicUsize>,
5109    ) {
5110        let temp = tempfile::tempdir().unwrap();
5111        let store = Arc::new(
5112            bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
5113                .await
5114                .unwrap(),
5115        );
5116        let storage: Arc<dyn Storage> = store.clone();
5117        let locked = Arc::new(LockedSessionStore::new(storage.clone()));
5118        let inbox: Arc<dyn SessionInboxPort> =
5119            Arc::new(bamboo_storage::FileSessionInbox::new(store.clone(), limits));
5120        let router = crate::SessionActivationRouter::new();
5121        let messenger = Arc::new(crate::SessionMessenger::new(
5122            storage.clone(),
5123            inbox.clone(),
5124            router.clone(),
5125        ));
5126        let reservations = Arc::new(AtomicUsize::new(0));
5127        let launches = Arc::new(AtomicUsize::new(0));
5128        router
5129            .set_spawner(Arc::new(CountingActivationSpawner {
5130                reservations: reservations.clone(),
5131                launches: launches.clone(),
5132            }))
5133            .await;
5134        let provider: Arc<dyn bamboo_llm::LLMProvider> = Arc::new(CompletedTestProvider);
5135        let config = Arc::new(RwLock::new(Config::default()));
5136        let metrics = bamboo_metrics::MetricsCollector::spawn(
5137            Arc::new(bamboo_metrics::SqliteMetricsStorage::new(
5138                temp.path().join("metrics.db"),
5139            )),
5140            7,
5141        );
5142        let tools: Arc<dyn ToolExecutor> = Arc::new(EmptyTools);
5143        let agent = Arc::new(
5144            Agent::builder()
5145                .storage(storage.clone())
5146                .persistence(locked.clone())
5147                .session_inbox(inbox.clone())
5148                .activation_router(router)
5149                .session_messenger(messenger)
5150                .attachment_reader(store.clone())
5151                .skill_manager(Arc::new(bamboo_skills::SkillManager::new()))
5152                .metrics_collector(metrics)
5153                .config(config.clone())
5154                .provider(provider.clone())
5155                .default_tools(tools)
5156                .build()
5157                .unwrap(),
5158        );
5159        let mut providers = HashMap::new();
5160        providers.insert("test".to_string(), provider);
5161        let registry = Arc::new(ProviderRegistry::new(providers, "test".to_string()));
5162        let provider_router = Arc::new(ProviderModelRouter::new(registry.clone()));
5163        let coordinator = Arc::new(ChildCompletionCoordinator::new(
5164            storage,
5165            locked,
5166            Arc::default(),
5167            Arc::new(RwLock::new(HashMap::new())),
5168            Arc::new(RwLock::new(HashMap::new())),
5169            agent,
5170            config,
5171            registry,
5172            provider_router,
5173            temp.path().to_path_buf(),
5174            None,
5175        ));
5176        (temp, store, inbox, coordinator, reservations, launches)
5177    }
5178
5179    async fn save_waiting_test_parent(
5180        store: &bamboo_storage::SessionStoreV2,
5181        parent_id: &str,
5182        ids: Vec<String>,
5183        policy: ChildWaitPolicy,
5184    ) {
5185        let mut parent = Session::new(parent_id, "model");
5186        let mut runtime = AgentRuntimeState::new("waiting-run");
5187        runtime.status = AgentStatusState::Suspended;
5188        runtime.waiting_for_children = Some(WaitingForChildrenState::for_children(
5189            ids,
5190            policy,
5191            Utc::now(),
5192        ));
5193        runtime.suspension = Some(SuspensionState {
5194            reason: "waiting_for_children".into(),
5195            suspended_at: Utc::now(),
5196            resumable: true,
5197            hook_point: Some("ChildCompletion".into()),
5198        });
5199        write_runtime_state(&mut parent, &runtime);
5200        parent.metadata.insert(
5201            "runtime.suspend_reason".into(),
5202            "waiting_for_children".into(),
5203        );
5204        store.save_session(&parent).await.unwrap();
5205    }
5206
5207    async fn save_completion_test_child(
5208        store: &bamboo_storage::SessionStoreV2,
5209        parent_id: &str,
5210        child_id: &str,
5211        status: &str,
5212    ) -> ChildCompletion {
5213        let mut child = store
5214            .load_session(child_id)
5215            .await
5216            .unwrap()
5217            .unwrap_or_else(|| Session::new_child(child_id, parent_id, "model", "Child"));
5218        child.add_message(Message::assistant(format!("result-{child_id}"), None));
5219        child.set_last_run_status(status);
5220        save_sourced_test_child(store, &child).await;
5221        ChildCompletion {
5222            parent_session_id: parent_id.into(),
5223            child_session_id: child_id.into(),
5224            status: status.into(),
5225            error: None,
5226            completed_at: child.updated_at,
5227            source: test_child_source(store, child_id).await,
5228        }
5229    }
5230
5231    #[tokio::test]
5232    async fn unrelated_invalid_pending_body_does_not_block_incremental_child_activation() {
5233        for queue in ["new", "cur"] {
5234            let (_temp, store, inbox, coordinator, reservations, launches) =
5235                completion_inbox_fixture().await;
5236            let parent_id = "invalid-pending-parent";
5237            save_waiting_test_parent(
5238                &store,
5239                parent_id,
5240                vec!["invalid-pending-fast".into(), "invalid-pending-slow".into()],
5241                ChildWaitPolicy::All,
5242            )
5243            .await;
5244            let before = read_runtime_state(&store.load_session(parent_id).await.unwrap().unwrap());
5245            let fast =
5246                save_completion_test_child(&store, parent_id, "invalid-pending-fast", "completed")
5247                    .await;
5248            save_completion_test_child(&store, parent_id, "invalid-pending-slow", "running").await;
5249
5250            let mut unrelated = SessionMessageEnvelope::user_input(parent_id, "unrelated bash");
5251            unrelated.source = SessionMessageSource::Runtime {
5252                subsystem: "bash".into(),
5253            };
5254            unrelated.kind = SessionMessageKind::RuntimeInstruction;
5255            unrelated.body =
5256                SessionMessageBody::RuntimeInstruction(bamboo_domain::SessionRuntimeInstruction {
5257                    instruction: "bash_completion".into(),
5258                    content: Some(bamboo_domain::SessionMessageContent::text("unrelated bash")),
5259                    data: None,
5260                    provider_message: None,
5261                });
5262            inbox.deliver(&unrelated).await.unwrap();
5263            let dir = store
5264                .bamboo_home_dir()
5265                .join(store.resolve_rel_path(parent_id).await.unwrap())
5266                .join("inbox");
5267            let staged = tokio::fs::read_dir(dir.join("new"))
5268                .await
5269                .unwrap()
5270                .next_entry()
5271                .await
5272                .unwrap()
5273                .unwrap()
5274                .path();
5275            let mut transport: serde_json::Value =
5276                serde_json::from_slice(&tokio::fs::read(&staged).await.unwrap()).unwrap();
5277            transport["body"] = serde_json::json!({"not": "an envelope"});
5278            let invalid_bytes = serde_json::to_vec(&transport).unwrap();
5279            tokio::fs::write(&staged, &invalid_bytes).await.unwrap();
5280            let invalid_path = if queue == "cur" {
5281                let destination = dir.join("cur").join(staged.file_name().unwrap());
5282                tokio::fs::create_dir_all(dir.join("cur")).await.unwrap();
5283                tokio::fs::rename(&staged, &destination).await.unwrap();
5284                destination
5285            } else {
5286                staged
5287            };
5288
5289            coordinator.on_child_completed(fast.clone()).await;
5290            let outcomes = inbox
5291                .pending_child_outcomes(parent_id, "invalid-pending-fast")
5292                .await
5293                .unwrap();
5294            assert_eq!(outcomes.len(), 1);
5295            assert!(fits_child_result_budget(&outcomes[0]));
5296            let after = read_runtime_state(&store.load_session(parent_id).await.unwrap().unwrap());
5297            assert_eq!(after, before, "All wait and suspension remain armed");
5298            assert_eq!(reservations.load(Ordering::SeqCst), 1);
5299            assert_eq!(launches.load(Ordering::SeqCst), 1);
5300            let backlog = inbox.inspect(parent_id).await.unwrap();
5301            assert!(backlog.interrupt_pending());
5302            assert_eq!(backlog.coordinator_generation, 0);
5303            let ready = inbox
5304                .inspect_wake_readiness(parent_id, Utc::now())
5305                .await
5306                .unwrap()
5307                .ready
5308                .unwrap();
5309            assert_eq!(ready.id, outcomes[0].id);
5310            assert_eq!(
5311                ready.activation_policy,
5312                SessionActivationPolicy::InterruptSpecificWait
5313            );
5314            assert_eq!(tokio::fs::read(&invalid_path).await.unwrap(), invalid_bytes);
5315            assert!(!inbox.was_admitted(parent_id, &unrelated.id).await.unwrap());
5316
5317            let claims = inbox.claim(parent_id, 8).await.unwrap();
5318            assert_eq!(claims.len(), 1);
5319            assert_eq!(claims[0].envelope.id, outcomes[0].id);
5320            inbox.ack(parent_id, &claims[0]).await.unwrap();
5321            coordinator.on_child_completed(fast).await;
5322            assert_eq!(launches.load(Ordering::SeqCst), 1);
5323            assert!(!inbox.was_admitted(parent_id, &unrelated.id).await.unwrap());
5324        }
5325    }
5326
5327    #[tokio::test]
5328    async fn partial_all_outcome_preserves_wait_and_active_parent_control_state() {
5329        let (_temp, store, inbox, coordinator, _, launches) = completion_inbox_fixture().await;
5330        let parent_id = "incremental-parent";
5331        save_waiting_test_parent(
5332            &store,
5333            parent_id,
5334            vec!["ready-child".into(), "slow-child".into()],
5335            ChildWaitPolicy::All,
5336        )
5337        .await;
5338        let mut parent = store.load_session(parent_id).await.unwrap().unwrap();
5339        let mut runtime = read_runtime_state(&parent);
5340        runtime.status = AgentStatusState::Running;
5341        runtime.suspension = None;
5342        parent.metadata.remove("runtime.suspend_reason");
5343        write_runtime_state(&mut parent, &runtime);
5344        store.save_session(&parent).await.unwrap();
5345        let ready = save_completion_test_child(&store, parent_id, "ready-child", "completed").await;
5346        save_completion_test_child(&store, parent_id, "slow-child", "running").await;
5347
5348        coordinator.on_child_completed(ready.clone()).await;
5349        let after = store.load_session(parent_id).await.unwrap().unwrap();
5350        assert_eq!(read_runtime_state(&after), runtime);
5351        assert_eq!(after.updated_at, parent.updated_at);
5352        assert_eq!(launches.load(Ordering::SeqCst), 1);
5353        let backlog = inbox.inspect(parent_id).await.unwrap();
5354        assert!(backlog.interrupt_pending());
5355        assert_eq!(backlog.coordinator_generation, 0, "no blanket prefix grant");
5356        let claims = inbox.claim(parent_id, 8).await.unwrap();
5357        assert_eq!(claims.len(), 1);
5358        inbox.ack(parent_id, &claims[0]).await.unwrap();
5359
5360        coordinator.on_child_completed(ready).await;
5361        assert_eq!(inbox.inspect(parent_id).await.unwrap().pending, 0);
5362        let slow = save_completion_test_child(&store, parent_id, "slow-child", "completed").await;
5363        coordinator.on_child_completed(slow).await;
5364        assert!(
5365            read_runtime_state(&store.load_session(parent_id).await.unwrap().unwrap())
5366                .waiting_for_children
5367                .is_none()
5368        );
5369        let claims = inbox.claim(parent_id, 8).await.unwrap();
5370        assert_eq!(
5371            claims.len(),
5372            1,
5373            "the acknowledged first result is not repeated at wait release"
5374        );
5375        let SessionMessageBody::ChildOutcome(outcome) = &claims[0].envelope.body else {
5376            panic!("child outcome");
5377        };
5378        assert_eq!(outcome.child_session_id, "slow-child");
5379    }
5380
5381    #[tokio::test]
5382    async fn incremental_completions_during_owner_exit_share_one_successor() {
5383        let (_temp, store, inbox, coordinator, reservations, launches) =
5384            completion_inbox_fixture().await;
5385        let parent_id = "handoff-parent";
5386        save_waiting_test_parent(
5387            &store,
5388            parent_id,
5389            vec![
5390                "handoff-a".into(),
5391                "handoff-b".into(),
5392                "handoff-slow".into(),
5393            ],
5394            ChildWaitPolicy::All,
5395        )
5396        .await;
5397        let a = save_completion_test_child(&store, parent_id, "handoff-a", "completed").await;
5398        let b = save_completion_test_child(&store, parent_id, "handoff-b", "completed").await;
5399        save_completion_test_child(&store, parent_id, "handoff-slow", "running").await;
5400        let router = coordinator.agent.activation_router().unwrap();
5401        router.set_inbox(inbox.clone());
5402        let mut owner = router
5403            .register_run(parent_id, "old-parent-run")
5404            .await
5405            .unwrap();
5406
5407        coordinator.on_child_completed(a.clone()).await;
5408        assert!(inbox
5409            .claim_for_turn(parent_id, 8, Some("old-parent-run"))
5410            .await
5411            .unwrap()
5412            .is_empty());
5413        owner.begin_finalization().await;
5414        coordinator.on_child_completed(b.clone()).await;
5415        assert_eq!(reservations.load(Ordering::SeqCst), 0);
5416        assert_eq!(launches.load(Ordering::SeqCst), 0);
5417        owner.finish(0).await.unwrap();
5418        assert_eq!(reservations.load(Ordering::SeqCst), 1);
5419        assert_eq!(launches.load(Ordering::SeqCst), 1);
5420        let successor = router.current_run_id(parent_id).await.unwrap();
5421        let claims = inbox
5422            .claim_for_turn(parent_id, 8, Some(&successor))
5423            .await
5424            .unwrap();
5425        assert_eq!(claims.len(), 2);
5426        for claim in &claims {
5427            inbox.ack(parent_id, claim).await.unwrap();
5428        }
5429        tokio::join!(
5430            coordinator.on_child_completed(a),
5431            coordinator.on_child_completed(b)
5432        );
5433        assert_eq!(inbox.inspect(parent_id).await.unwrap().pending, 0);
5434        assert_eq!(reservations.load(Ordering::SeqCst), 1);
5435        assert!(
5436            read_runtime_state(&store.load_session(parent_id).await.unwrap().unwrap())
5437                .waiting_for_children
5438                .is_some()
5439        );
5440    }
5441
5442    #[tokio::test]
5443    async fn cold_watchdog_replays_each_ready_child_before_all_wait_is_satisfied() {
5444        let (_temp, store, inbox, coordinator, _, launches) = completion_inbox_fixture().await;
5445        let parent_id = "cold-incremental-parent";
5446        save_waiting_test_parent(
5447            &store,
5448            parent_id,
5449            vec!["cold-a".into(), "cold-b".into(), "cold-slow".into()],
5450            ChildWaitPolicy::All,
5451        )
5452        .await;
5453        let mut parent = store.load_session(parent_id).await.unwrap().unwrap();
5454        let mut runtime = read_runtime_state(&parent);
5455        runtime.waiting_for_children.as_mut().unwrap().registered_at -=
5456            chrono::Duration::seconds(61);
5457        write_runtime_state(&mut parent, &runtime);
5458        store.save_session(&parent).await.unwrap();
5459        save_completion_test_child(&store, parent_id, "cold-a", "completed").await;
5460        save_completion_test_child(&store, parent_id, "cold-b", "completed").await;
5461        save_completion_test_child(&store, parent_id, "cold-slow", "running").await;
5462
5463        let wait = runtime.waiting_for_children.unwrap();
5464        coordinator.sweep_child_wait(parent_id, wait.clone()).await;
5465        let reopened = bamboo_storage::FileSessionInbox::new(store.clone(), Default::default());
5466        let backlog = reopened.inspect(parent_id).await.unwrap();
5467        assert_eq!(backlog.pending, 2);
5468        assert!(backlog.interrupt_pending());
5469        assert_eq!(backlog.coordinator_generation, 0);
5470        assert_eq!(launches.load(Ordering::SeqCst), 1);
5471        let claims = reopened.claim(parent_id, 8).await.unwrap();
5472        assert_eq!(claims.len(), 2);
5473        for claim in &claims {
5474            reopened.ack(parent_id, claim).await.unwrap();
5475        }
5476        coordinator.sweep_child_wait(parent_id, wait).await;
5477        assert_eq!(inbox.inspect(parent_id).await.unwrap().pending, 0);
5478        assert!(
5479            read_runtime_state(&store.load_session(parent_id).await.unwrap().unwrap())
5480                .waiting_for_children
5481                .is_some()
5482        );
5483    }
5484
5485    #[tokio::test]
5486    async fn any_wait_delivers_a_late_direct_child_without_repeating_the_first_outcome() {
5487        let (_temp, store, inbox, coordinator, _, _) = completion_inbox_fixture().await;
5488        let parent_id = "any-incremental-parent";
5489        save_waiting_test_parent(
5490            &store,
5491            parent_id,
5492            vec!["any-first".into(), "any-late".into()],
5493            ChildWaitPolicy::Any,
5494        )
5495        .await;
5496        let first = save_completion_test_child(&store, parent_id, "any-first", "completed").await;
5497        save_completion_test_child(&store, parent_id, "any-late", "running").await;
5498        coordinator.on_child_completed(first.clone()).await;
5499        assert!(
5500            read_runtime_state(&store.load_session(parent_id).await.unwrap().unwrap())
5501                .waiting_for_children
5502                .is_none()
5503        );
5504        let first_claims = inbox.claim(parent_id, 8).await.unwrap();
5505        assert_eq!(first_claims.len(), 1);
5506        inbox.ack(parent_id, &first_claims[0]).await.unwrap();
5507
5508        let late = save_completion_test_child(&store, parent_id, "any-late", "completed").await;
5509        coordinator.on_child_completed(late.clone()).await;
5510        coordinator.on_child_completed(first).await;
5511        coordinator.on_child_completed(late).await;
5512        let claims = inbox.claim(parent_id, 8).await.unwrap();
5513        assert_eq!(claims.len(), 1);
5514        let SessionMessageBody::ChildOutcome(outcome) = &claims[0].envelope.body else {
5515            panic!("child outcome");
5516        };
5517        assert_eq!(outcome.child_session_id, "any-late");
5518        assert!(
5519            read_runtime_state(&store.load_session(parent_id).await.unwrap().unwrap())
5520                .waiting_for_children
5521                .is_none()
5522        );
5523    }
5524
5525    #[tokio::test]
5526    async fn terminal_batch_upgrades_lower_pending_outcome_below_an_acknowledged_sibling() {
5527        let (_temp, store, inbox, coordinator, _, _) = completion_inbox_fixture().await;
5528        let parent_id = "terminal-batch-parent";
5529        save_waiting_test_parent(
5530            &store,
5531            parent_id,
5532            vec!["batch-low".into(), "batch-high".into()],
5533            ChildWaitPolicy::All,
5534        )
5535        .await;
5536        let mut parent = store.load_session(parent_id).await.unwrap().unwrap();
5537        let wait = read_runtime_state(&parent).waiting_for_children.unwrap();
5538        let low = save_completion_test_child(&store, parent_id, "batch-low", "completed").await;
5539        let high = save_completion_test_child(&store, parent_id, "batch-high", "completed").await;
5540        let mut receipts = Vec::new();
5541        for completion in [&low, &high] {
5542            let result = format!("result-{}", completion.child_session_id);
5543            let presentation = runtime_resume_message(completion, 0, Some(&result));
5544            let envelope = child_completion_envelope(
5545                completion,
5546                wait.registered_at,
5547                Some(result),
5548                &presentation,
5549            );
5550            receipts.push(inbox.deliver(&envelope).await.unwrap());
5551        }
5552        inbox
5553            .mark_message_activation_eligible(
5554                parent_id,
5555                &receipts[1],
5556                SessionActivationPolicy::InterruptSpecificWait,
5557            )
5558            .await
5559            .unwrap();
5560        let claims = inbox.claim(parent_id, 8).await.unwrap();
5561        assert_eq!(claims.len(), 1);
5562        assert_eq!(claims[0].generation, receipts[1].generation);
5563        parent.add_message(claims[0].envelope.to_provider_message().unwrap());
5564        store.save_session(&parent).await.unwrap();
5565        inbox.ack(parent_id, &claims[0]).await.unwrap();
5566
5567        coordinator.on_child_completed(low).await;
5568        let mut rearmed = store.load_session(parent_id).await.unwrap().unwrap();
5569        let mut runtime = read_runtime_state(&rearmed);
5570        assert!(runtime.waiting_for_children.is_none());
5571        let new_wait = WaitingForChildrenState::for_children(
5572            vec!["next-slow-child".into()],
5573            ChildWaitPolicy::All,
5574            Utc::now(),
5575        );
5576        runtime.waiting_for_children = Some(new_wait.clone());
5577        runtime.status = AgentStatusState::Suspended;
5578        write_runtime_state(&mut rearmed, &runtime);
5579        store.save_session(&rearmed).await.unwrap();
5580        let backlog = inbox.inspect(parent_id).await.unwrap();
5581        assert_eq!(backlog.pending, 1);
5582        assert!(
5583            backlog.interrupt_pending(),
5584            "the low generation has its own grant"
5585        );
5586        let (prepared, ready) = prepare_session_inbox_activation(
5587            &coordinator.persistence,
5588            parent_id,
5589            backlog.interrupt_pending(),
5590        )
5591        .await
5592        .unwrap()
5593        .unwrap();
5594        assert!(ready);
5595        assert_eq!(
5596            read_runtime_state(&prepared).waiting_for_children,
5597            Some(new_wait)
5598        );
5599        let claims = inbox.claim(parent_id, 8).await.unwrap();
5600        assert_eq!(claims.len(), 1);
5601        assert_eq!(claims[0].generation, receipts[0].generation);
5602    }
5603
5604    #[tokio::test]
5605    async fn large_legal_typed_source_falls_back_within_the_delivery_budget() {
5606        let (_temp, store, inbox, coordinator, _, launches) = completion_inbox_fixture().await;
5607        let parent_id = "large-source-parent";
5608        let child_id = "large-source-child";
5609        save_waiting_test_parent(
5610            &store,
5611            parent_id,
5612            vec![child_id.into()],
5613            ChildWaitPolicy::All,
5614        )
5615        .await;
5616        let mut parent = store.load_session(parent_id).await.unwrap().unwrap();
5617        parent.add_message(Message::user("required original source"));
5618        store.save_session(&parent).await.unwrap();
5619        let mut child = typed_test_child(&parent, child_id, &typed_test_report().to_string());
5620        let source = ChildCompletionSource::from_committed_session(&child).unwrap();
5621        let run_length =
5622            3900 - serde_json::to_vec(&source).unwrap().len() + source.activation_run_id.len();
5623        ChildCompletionSource::prepare(&mut child, &"r".repeat(run_length), &Default::default());
5624        let completion = typed_test_completion(&child);
5625        let source_bytes = serde_json::to_vec(completion.source.as_ref().unwrap())
5626            .unwrap()
5627            .len();
5628        assert!((3800..=4096).contains(&source_bytes));
5629        store.save_session(&child).await.unwrap();
5630
5631        coordinator.on_child_completed(completion).await;
5632        let claims = inbox.claim(parent_id, 8).await.unwrap();
5633        assert_eq!(claims.len(), 1);
5634        assert_eq!(launches.load(Ordering::SeqCst), 1);
5635        assert!(fits_child_result_budget(&claims[0].envelope));
5636        let message = claims[0].envelope.to_provider_message().unwrap();
5637        assert!(fits_child_result_budget(&message));
5638        assert!(message.content.contains("result_budget_exceeded"));
5639    }
5640
5641    #[tokio::test]
5642    async fn upgrade_promotes_one_existing_legacy_partial_delivery_without_a_second_id() {
5643        let (_temp, store, inbox, coordinator, _, launches) = completion_inbox_fixture().await;
5644        let parent_id = "legacy-partial-parent";
5645        save_waiting_test_parent(
5646            &store,
5647            parent_id,
5648            vec!["legacy-ready".into(), "legacy-slow".into()],
5649            ChildWaitPolicy::All,
5650        )
5651        .await;
5652        let completion =
5653            save_completion_test_child(&store, parent_id, "legacy-ready", "completed").await;
5654        save_completion_test_child(&store, parent_id, "legacy-slow", "running").await;
5655        let parent = store.load_session(parent_id).await.unwrap().unwrap();
5656        let wait = read_runtime_state(&parent).waiting_for_children.unwrap();
5657        let result = "result-legacy-ready";
5658        let presentation = runtime_resume_message(&completion, 1, Some(result));
5659        let mut legacy = child_completion_envelope(
5660            &completion,
5661            wait.registered_at,
5662            Some(result.into()),
5663            &presentation,
5664        );
5665        legacy.id = child_completion_id(&completion, wait.registered_at, Some(result), true);
5666        let receipt = inbox.deliver(&legacy).await.unwrap();
5667        assert!(!inbox.inspect(parent_id).await.unwrap().activation_pending());
5668
5669        coordinator.on_child_completed(completion.clone()).await;
5670        coordinator.on_child_completed(completion).await;
5671        let claims = inbox.claim(parent_id, 8).await.unwrap();
5672        assert_eq!(claims.len(), 1);
5673        assert_eq!(claims[0].envelope.id, legacy.id);
5674        assert_eq!(claims[0].generation, receipt.generation);
5675        assert_eq!(launches.load(Ordering::SeqCst), 1);
5676        assert_eq!(
5677            read_runtime_state(&store.load_session(parent_id).await.unwrap().unwrap())
5678                .waiting_for_children
5679                .as_ref(),
5680            Some(&wait)
5681        );
5682    }
5683
5684    #[tokio::test]
5685    async fn upgrade_keeps_admitted_legacy_raw_guardian_and_typed_sources_deduplicated_without_a_wait(
5686    ) {
5687        for presentation_kind in ["raw", "guardian", "typed", "typed_unavailable_digest"] {
5688            let (_temp, store, inbox, coordinator, _, launches) = completion_inbox_fixture().await;
5689            let parent_id = "legacy-admitted-parent";
5690            let child_id = "legacy-admitted-child";
5691            let mut parent = Session::new(parent_id, "model");
5692            parent.add_message(Message::user("required original source"));
5693            store.save_session(&parent).await.unwrap();
5694            let completion = if presentation_kind.starts_with("typed") {
5695                let child = typed_test_child(&parent, child_id, &typed_test_report().to_string());
5696                store.save_session(&child).await.unwrap();
5697                typed_test_completion(&child)
5698            } else {
5699                save_completion_test_child(&store, parent_id, child_id, "completed").await
5700            };
5701            let child = store.load_session(child_id).await.unwrap().unwrap();
5702            let result = completion.source.as_ref().unwrap().result(&child, &parent);
5703            let mut projection = sealed_child_report(&completion, &child, &parent);
5704            if presentation_kind == "typed_unavailable_digest" {
5705                projection = Some(unavailable_child_result(
5706                    "typed_result",
5707                    "result_budget_exceeded",
5708                ));
5709            }
5710            let mut presentation = match presentation_kind {
5711                "guardian" => guardian_resume_message(&completion, &GuardianVerdict::approved()),
5712                "typed" | "typed_unavailable_digest" => {
5713                    typed_child_resume_message(&completion, 0, projection.as_ref().unwrap())
5714                }
5715                _ => runtime_resume_message(&completion, 0, result.as_deref()),
5716            };
5717            if presentation_kind == "typed" {
5718                presentation
5719                    .metadata
5720                    .as_mut()
5721                    .unwrap()
5722                    .as_object_mut()
5723                    .unwrap()
5724                    .remove("child_completion_source_sha256");
5725            }
5726            let old_wait = Utc::now() - chrono::Duration::minutes(2);
5727            let mut legacy =
5728                child_completion_envelope(&completion, old_wait, result.clone(), &presentation);
5729            legacy.id = child_completion_id(&completion, old_wait, result.as_deref(), true);
5730            if let Some(projection) = projection.as_mut() {
5731                assert!(bound_typed_child_delivery(
5732                    &mut legacy,
5733                    &mut presentation,
5734                    projection,
5735                    &completion,
5736                    0
5737                ));
5738            }
5739            let receipt = inbox.deliver(&legacy).await.unwrap();
5740            inbox
5741                .mark_message_activation_eligible(
5742                    parent_id,
5743                    &receipt,
5744                    SessionActivationPolicy::InterruptSpecificWait,
5745                )
5746                .await
5747                .unwrap();
5748            let claims = inbox.claim(parent_id, 1).await.unwrap();
5749            parent.add_message(claims[0].envelope.to_provider_message().unwrap());
5750            store.save_session(&parent).await.unwrap();
5751            inbox.ack(parent_id, &claims[0]).await.unwrap();
5752
5753            coordinator.on_child_completed(completion).await;
5754            assert_eq!(
5755                inbox.inspect(parent_id).await.unwrap().generation,
5756                receipt.generation,
5757                "{presentation_kind}"
5758            );
5759            assert_eq!(
5760                inbox.inspect(parent_id).await.unwrap().pending,
5761                0,
5762                "{presentation_kind}"
5763            );
5764            assert_eq!(launches.load(Ordering::SeqCst), 0, "{presentation_kind}");
5765        }
5766    }
5767
5768    #[tokio::test]
5769    async fn upgrade_reuses_a_pending_legacy_source_after_its_wait_was_cleared() {
5770        for (arm, acked) in [
5771            ("none", false),
5772            ("partial", false),
5773            ("terminal", false),
5774            ("terminal", true),
5775        ] {
5776            let (_temp, store, inbox, coordinator, _, launches) = completion_inbox_fixture().await;
5777            let parent_id = "legacy-pending-parent";
5778            let child_id = "legacy-pending-child";
5779            let mut parent = Session::new(parent_id, "model");
5780            store.save_session(&parent).await.unwrap();
5781            let completion =
5782                save_completion_test_child(&store, parent_id, child_id, "completed").await;
5783            let result = "result-legacy-pending-child";
5784            let presentation = runtime_resume_message(&completion, 0, Some(result));
5785            let old_wait = Utc::now() - chrono::Duration::minutes(2);
5786            let mut legacy = child_completion_envelope(
5787                &completion,
5788                old_wait,
5789                Some(result.into()),
5790                &presentation,
5791            );
5792            legacy.id = child_completion_id(&completion, old_wait, Some(result), true);
5793            let receipt = inbox.deliver(&legacy).await.unwrap();
5794            inbox
5795                .mark_message_activation_eligible(
5796                    parent_id,
5797                    &receipt,
5798                    SessionActivationPolicy::RespectSpecificWait,
5799                )
5800                .await
5801                .unwrap();
5802            if acked {
5803                let claims = inbox.claim(parent_id, 1).await.unwrap();
5804                parent.add_message(claims[0].envelope.to_provider_message().unwrap());
5805                store.save_session(&parent).await.unwrap();
5806                inbox.ack(parent_id, &claims[0]).await.unwrap();
5807            }
5808            let new_wait = if arm != "none" {
5809                let mut ids = vec![child_id.into()];
5810                if arm == "partial" {
5811                    ids.push("legacy-pending-slow".into());
5812                    save_completion_test_child(&store, parent_id, "legacy-pending-slow", "running")
5813                        .await;
5814                }
5815                let wait =
5816                    WaitingForChildrenState::for_children(ids, ChildWaitPolicy::All, Utc::now());
5817                let mut runtime = read_runtime_state(&parent);
5818                runtime.waiting_for_children = Some(wait.clone());
5819                runtime.status = AgentStatusState::Suspended;
5820                write_runtime_state(&mut parent, &runtime);
5821                store.save_session(&parent).await.unwrap();
5822                Some(wait)
5823            } else {
5824                None
5825            };
5826
5827            coordinator.on_child_completed(completion.clone()).await;
5828            coordinator.on_child_completed(completion).await;
5829            let backlog = inbox.inspect(parent_id).await.unwrap();
5830            assert_eq!(
5831                backlog.generation, receipt.generation,
5832                "{arm}, acked={acked}"
5833            );
5834            assert_eq!(backlog.pending, usize::from(!acked), "{arm}, acked={acked}");
5835            assert_eq!(backlog.interrupt_pending(), !acked, "{arm}, acked={acked}");
5836            // Clearing a fresh terminal arm still requests activation. This
5837            // counting adapter reserves unconditionally; the production adapter
5838            // rechecks the Inbox and returns NoWork for an ACK-only receipt.
5839            assert_eq!(
5840                launches.load(Ordering::SeqCst),
5841                usize::from(!acked || arm == "terminal"),
5842                "{arm}, acked={acked}"
5843            );
5844            let wait = read_runtime_state(&store.load_session(parent_id).await.unwrap().unwrap())
5845                .waiting_for_children;
5846            assert_eq!(wait, if arm == "partial" { new_wait } else { None });
5847            let claims = inbox.claim(parent_id, 8).await.unwrap();
5848            assert_eq!(claims.len(), usize::from(!acked));
5849            if !acked {
5850                assert_eq!(claims[0].envelope.id, legacy.id);
5851                assert_eq!(claims[0].generation, receipt.generation);
5852            }
5853        }
5854    }
5855
5856    #[tokio::test]
5857    async fn admitted_legacy_guardian_source_still_commits_a_pending_verdict() {
5858        let (_temp, store, inbox, coordinator, _, launches) = completion_inbox_fixture().await;
5859        let parent_id = "legacy-pending-guardian-parent";
5860        let child_id = "legacy-pending-guardian-child";
5861        let mut parent = Session::new(parent_id, "model");
5862        let mut guardian = crate::runtime::guardian_state::ensure_guardian_state(&parent);
5863        guardian.record_spawn(child_id);
5864        write_guardian_state(&mut parent, guardian);
5865        store.save_session(&parent).await.unwrap();
5866        let verdict = GuardianVerdict::approved();
5867        let mut child = Session::new_child(child_id, parent_id, "model", "Guardian");
5868        child.set_subagent_type("guardian");
5869        child.add_message(Message::assistant(
5870            serde_json::to_string(&verdict).unwrap(),
5871            None,
5872        ));
5873        child.set_last_run_status("completed");
5874        save_sourced_test_child(&store, &child).await;
5875        let completion = ChildCompletion {
5876            parent_session_id: parent_id.into(),
5877            child_session_id: child_id.into(),
5878            status: "completed".into(),
5879            error: None,
5880            completed_at: child.updated_at,
5881            source: test_child_source(&store, child_id).await,
5882        };
5883        let result = serde_json::to_string(&verdict).unwrap();
5884        let old_wait = Utc::now() - chrono::Duration::minutes(2);
5885        let presentation = guardian_resume_message(&completion, &verdict);
5886        let mut legacy =
5887            child_completion_envelope(&completion, old_wait, Some(result.clone()), &presentation);
5888        legacy.id = child_completion_id(&completion, old_wait, Some(&result), true);
5889        let receipt = inbox.deliver(&legacy).await.unwrap();
5890        inbox
5891            .mark_message_activation_eligible(
5892                parent_id,
5893                &receipt,
5894                SessionActivationPolicy::InterruptSpecificWait,
5895            )
5896            .await
5897            .unwrap();
5898        let claims = inbox.claim(parent_id, 1).await.unwrap();
5899        parent.add_message(claims[0].envelope.to_provider_message().unwrap());
5900        store.save_session(&parent).await.unwrap();
5901        inbox.ack(parent_id, &claims[0]).await.unwrap();
5902
5903        coordinator.on_child_completed(completion.clone()).await;
5904        let saved = store.load_session(parent_id).await.unwrap().unwrap();
5905        let guardian = read_guardian_state(&saved).unwrap();
5906        assert!(guardian.phase.is_reviewed());
5907        assert!(guardian.last_approved());
5908        assert_eq!(guardian.review_count, 1);
5909        let saved_guardian = saved.metadata["guardian.state"].clone();
5910        coordinator.on_child_completed(completion).await;
5911        assert_eq!(
5912            store
5913                .load_session(parent_id)
5914                .await
5915                .unwrap()
5916                .unwrap()
5917                .metadata["guardian.state"],
5918            saved_guardian
5919        );
5920        let backlog = inbox.inspect(parent_id).await.unwrap();
5921        assert_eq!(backlog.generation, receipt.generation);
5922        assert_eq!(backlog.pending, 0);
5923        assert_eq!(launches.load(Ordering::SeqCst), 0);
5924    }
5925
5926    #[tokio::test]
5927    async fn terminal_index_ahead_of_callbacks_preserves_every_waited_child_outcome() {
5928        for partial_first in [false, true] {
5929            let (_temp, store, inbox, coordinator, reservations, launches) =
5930                completion_inbox_fixture().await;
5931            let parent_id = "parallel-parent";
5932            let ids: Vec<_> = (0..4)
5933                .map(|slot| format!("parallel-child-{slot}"))
5934                .collect();
5935            save_waiting_test_parent(&store, parent_id, ids.clone(), ChildWaitPolicy::All).await;
5936            for (slot, id) in ids.iter().enumerate() {
5937                let mut child = Session::new_child(id, parent_id, "model", "Child");
5938                child.add_message(Message::assistant(format!("result-{id}"), None));
5939                child.set_last_run_status(if partial_first && slot > 0 {
5940                    "running"
5941                } else {
5942                    "completed"
5943                });
5944                save_sourced_test_child(&store, &child).await;
5945            }
5946            let mut sources = HashMap::new();
5947            for id in &ids {
5948                sources.insert(id.clone(), test_child_source(&store, id).await);
5949            }
5950            let completion = |id: &str| ChildCompletion {
5951                parent_session_id: parent_id.into(),
5952                child_session_id: id.into(),
5953                status: "completed".into(),
5954                error: None,
5955                completed_at: Utc::now(),
5956                source: sources.get(id).cloned().flatten(),
5957            };
5958            if partial_first {
5959                ChildCompletionHandler::on_child_completed(
5960                    coordinator.as_ref(),
5961                    completion(&ids[0]),
5962                )
5963                .await;
5964                assert!(
5965                    read_runtime_state(&store.load_session(parent_id).await.unwrap().unwrap())
5966                        .waiting_for_children
5967                        .is_some()
5968                );
5969                assert_eq!(inbox.inspect(parent_id).await.unwrap().pending, 1);
5970                assert_eq!(launches.load(Ordering::SeqCst), 1);
5971                for id in &ids[1..] {
5972                    let mut child = store.load_session(id).await.unwrap().unwrap();
5973                    child.set_last_run_status("completed");
5974                    save_sourced_test_child(&store, &child).await;
5975                }
5976            }
5977            // The index knows all four outcomes, but only one completion
5978            // callback has reached the coordinator at this wake boundary.
5979            for id in &ids {
5980                sources.insert(id.clone(), test_child_source(&store, id).await);
5981            }
5982            let completion = |id: &str| ChildCompletion {
5983                parent_session_id: parent_id.into(),
5984                child_session_id: id.into(),
5985                status: "completed".into(),
5986                error: None,
5987                completed_at: Utc::now(),
5988                source: sources.get(id).cloned().flatten(),
5989            };
5990            ChildCompletionHandler::on_child_completed(coordinator.as_ref(), completion(&ids[1]))
5991                .await;
5992            assert!(
5993                read_runtime_state(&store.load_session(parent_id).await.unwrap().unwrap())
5994                    .waiting_for_children
5995                    .is_none()
5996            );
5997            assert_eq!(inbox.inspect(parent_id).await.unwrap().pending, 4);
5998            assert_eq!(reservations.load(Ordering::SeqCst), 1);
5999            assert_eq!(launches.load(Ordering::SeqCst), 1);
6000            for id in &ids {
6001                ChildCompletionHandler::on_child_completed(coordinator.as_ref(), completion(id))
6002                    .await;
6003            }
6004            let claims = inbox.claim(parent_id, 8).await.unwrap();
6005            assert_eq!(claims.len(), 4, "late callbacks cannot duplicate outcomes");
6006            assert_eq!(reservations.load(Ordering::SeqCst), 1);
6007            for id in &ids {
6008                let matching: Vec<_> = claims
6009                    .iter()
6010                    .filter_map(|claim| match &claim.envelope.body {
6011                        SessionMessageBody::ChildOutcome(outcome)
6012                            if &outcome.child_session_id == id =>
6013                        {
6014                            Some(outcome)
6015                        }
6016                        _ => None,
6017                    })
6018                    .collect();
6019                assert_eq!(matching.len(), 1);
6020                assert_eq!(
6021                    matching[0].result.as_deref(),
6022                    Some(format!("result-{id}").as_str())
6023                );
6024            }
6025        }
6026    }
6027
6028    #[tokio::test]
6029    async fn saved_a_callback_barrier_never_attributes_completed_b_to_a() {
6030        for guardian in [false, true] {
6031            let (_temp, store, inbox, coordinator, _, launches) = completion_inbox_fixture().await;
6032            let parent_id = "source-barrier-parent";
6033            let child_id = "source-barrier-child";
6034            save_waiting_test_parent(
6035                &store,
6036                parent_id,
6037                vec![child_id.into()],
6038                ChildWaitPolicy::All,
6039            )
6040            .await;
6041            if guardian {
6042                let mut parent = store.load_session(parent_id).await.unwrap().unwrap();
6043                let mut state = crate::runtime::guardian_state::ensure_guardian_state(&parent);
6044                state.record_spawn(child_id);
6045                write_guardian_state(&mut parent, state);
6046                store.save_session(&parent).await.unwrap();
6047            }
6048            let mut child = Session::new_child(child_id, parent_id, "model", "Child");
6049            if guardian {
6050                child
6051                    .metadata
6052                    .insert("subagent_type".into(), "guardian".into());
6053            }
6054            child.add_message(Message::user("assignment A"));
6055            store.save_session(&child).await.unwrap();
6056            let prior = child.messages.iter().map(|m| m.id.clone()).collect();
6057            child.add_message(Message::assistant("answer A", None));
6058            child.set_last_run_status("completed");
6059            ChildCompletionSource::prepare(&mut child, "actual-run-A", &prior);
6060            coordinator
6061                .agent
6062                .persistence()
6063                .save_runtime_session(&mut child)
6064                .await
6065                .unwrap();
6066            let completion_a = ChildCompletion {
6067                parent_session_id: parent_id.into(),
6068                child_session_id: child_id.into(),
6069                status: "completed".into(),
6070                error: None,
6071                completed_at: Utc::now(),
6072                source: ChildCompletionSource::after_final_save(&child, true),
6073            };
6074            assert_eq!(
6075                completion_a.source.as_ref().unwrap().activation_run_id,
6076                "actual-run-A"
6077            );
6078            // Pause the real callback boundary after A's canonical save. B can
6079            // now finish on the same Child before A enters the coordinator.
6080            let (release, barrier) = tokio::sync::oneshot::channel();
6081            let target = coordinator.clone();
6082            let callback = tokio::spawn(async move {
6083                barrier.await.unwrap();
6084                target.on_child_completed(completion_a).await;
6085            });
6086            let prior = child.messages.iter().map(|m| m.id.clone()).collect();
6087            child.add_message(Message::user("assignment B"));
6088            let answer_b = if guardian {
6089                r#"{"approve":true,"summary":"B approved","findings":[]}"#
6090            } else {
6091                "answer B"
6092            };
6093            child.add_message(Message::assistant(answer_b, None));
6094            child.set_last_run_status("completed");
6095            ChildCompletionSource::prepare(&mut child, "actual-run-B", &prior);
6096            coordinator
6097                .agent
6098                .persistence()
6099                .save_runtime_session(&mut child)
6100                .await
6101                .unwrap();
6102            release.send(()).unwrap();
6103            callback.await.unwrap();
6104            assert_eq!(inbox.inspect(parent_id).await.unwrap().pending, 0);
6105            let parent = store.load_session(parent_id).await.unwrap().unwrap();
6106            assert!(read_runtime_state(&parent).waiting_for_children.is_some());
6107            assert_eq!(launches.load(Ordering::SeqCst), 0);
6108            if guardian {
6109                assert!(read_guardian_state(&parent).unwrap().phase.is_pending());
6110            }
6111
6112            let completion_b = ChildCompletion {
6113                parent_session_id: parent_id.into(),
6114                child_session_id: child_id.into(),
6115                status: "completed".into(),
6116                error: None,
6117                completed_at: Utc::now(),
6118                source: ChildCompletionSource::after_final_save(&child, true),
6119            };
6120            coordinator.on_child_completed(completion_b.clone()).await;
6121            coordinator.on_child_completed(completion_b).await;
6122            let claims = inbox.claim(parent_id, 8).await.unwrap();
6123            assert_eq!(
6124                claims.len(),
6125                1,
6126                "B is admitted once with B's own provenance"
6127            );
6128            let SessionMessageBody::ChildOutcome(outcome) = &claims[0].envelope.body else {
6129                panic!("expected ChildOutcome");
6130            };
6131            assert_eq!(outcome.result.as_deref(), Some(answer_b));
6132            assert_eq!(launches.load(Ordering::SeqCst), 1);
6133        }
6134    }
6135
6136    #[tokio::test]
6137    async fn missing_source_cannot_import_latest_guardian_approval() {
6138        let (_temp, store, inbox, coordinator, _, _) = completion_inbox_fixture().await;
6139        let parent_id = "unavailable-guardian-parent";
6140        let child_id = "unavailable-guardian-child";
6141        save_waiting_test_parent(
6142            &store,
6143            parent_id,
6144            vec![child_id.into()],
6145            ChildWaitPolicy::All,
6146        )
6147        .await;
6148        let mut parent = store.load_session(parent_id).await.unwrap().unwrap();
6149        let mut state = crate::runtime::guardian_state::ensure_guardian_state(&parent);
6150        state.record_spawn(child_id);
6151        write_guardian_state(&mut parent, state);
6152        store.save_session(&parent).await.unwrap();
6153        let mut child = Session::new_child(child_id, parent_id, "model", "Child");
6154        child
6155            .metadata
6156            .insert("subagent_type".into(), "guardian".into());
6157        child.add_message(Message::assistant(
6158            r#"{"approve":true,"summary":"unbound latest","findings":[]}"#,
6159            None,
6160        ));
6161        child.set_last_run_status("completed");
6162        store.save_session(&child).await.unwrap();
6163        coordinator
6164            .on_child_completed(ChildCompletion {
6165                parent_session_id: parent_id.into(),
6166                child_session_id: child_id.into(),
6167                status: "completed".into(),
6168                error: None,
6169                completed_at: Utc::now(),
6170                source: None,
6171            })
6172            .await;
6173        let parent = store.load_session(parent_id).await.unwrap().unwrap();
6174        let state = read_guardian_state(&parent).unwrap();
6175        assert!(state.phase.is_reviewed());
6176        assert!(
6177            !state.last_approved(),
6178            "source-less latest approval is never authoritative"
6179        );
6180        let claims = inbox.claim(parent_id, 8).await.unwrap();
6181        let SessionMessageBody::ChildOutcome(outcome) = &claims[0].envelope.body else {
6182            panic!("expected ChildOutcome");
6183        };
6184        assert!(outcome.result.is_none());
6185    }
6186
6187    #[tokio::test]
6188    async fn lost_callback_replay_uses_the_committed_seal() {
6189        for valid in [true, false] {
6190            let (_temp, store, inbox, coordinator, _, _) = completion_inbox_fixture().await;
6191            save_waiting_test_parent(
6192                &store,
6193                "replay-parent",
6194                vec!["replay-child".into()],
6195                ChildWaitPolicy::All,
6196            )
6197            .await;
6198            let mut child = Session::new_child("replay-child", "replay-parent", "model", "Child");
6199            child.add_message(Message::assistant("sealed replay result", None));
6200            child.set_last_run_status("completed");
6201            save_sourced_test_child(&store, &child).await;
6202            if !valid {
6203                let mut child = store.load_session("replay-child").await.unwrap().unwrap();
6204                child.messages[0].content = "unsealed successor result".into();
6205                store.save_session(&child).await.unwrap();
6206            }
6207            let parent = store.load_session("replay-parent").await.unwrap().unwrap();
6208            let mut wait = read_runtime_state(&parent).waiting_for_children.unwrap();
6209            wait.registered_at = Utc::now() - chrono::Duration::seconds(61);
6210            coordinator.sweep_child_wait("replay-parent", wait).await;
6211            let claims = inbox.claim("replay-parent", 1).await.unwrap();
6212            if !valid {
6213                assert!(claims.is_empty());
6214                let parent = store.load_session("replay-parent").await.unwrap().unwrap();
6215                assert!(read_runtime_state(&parent).waiting_for_children.is_some());
6216                continue;
6217            }
6218            let SessionMessageBody::ChildOutcome(outcome) = &claims[0].envelope.body else {
6219                panic!("expected ChildOutcome");
6220            };
6221            assert_eq!(outcome.result.as_deref(), Some("sealed replay result"));
6222        }
6223    }
6224
6225    #[tokio::test]
6226    async fn partial_admission_then_source_mutation_ignores_stale_retry() {
6227        for parent_changed in [false, true] {
6228            let (_temp, store, inbox, coordinator, _, launches) = completion_inbox_fixture().await;
6229            let parent_id = "partial-source-parent";
6230            let child_id = "partial-source-child";
6231            save_waiting_test_parent(
6232                &store,
6233                parent_id,
6234                vec![child_id.into(), "still-running-child".into()],
6235                ChildWaitPolicy::All,
6236            )
6237            .await;
6238            let mut child = Session::new_child(child_id, parent_id, "model", "Child");
6239            if parent_changed {
6240                let mut parent = store.load_session(parent_id).await.unwrap().unwrap();
6241                let mut source = Message::user("required parent constraint");
6242                source.id = "parent-source".into();
6243                parent.add_message(source);
6244                store.save_session(&parent).await.unwrap();
6245                let packet = bamboo_domain::ChildContextPacket {
6246                    version: 1,
6247                    objective: "task".into(),
6248                    constraints: vec![],
6249                    acceptance: vec!["source remains attributable".into()],
6250                    non_goals: vec![],
6251                    necessary_user_instructions: vec![],
6252                    recorded_decisions: vec![],
6253                    source_user_message_ids: vec!["parent-source".into()],
6254                    background_message_ids: vec![],
6255                };
6256                let resolved = packet.resolve(&parent, "task").unwrap();
6257                let binding = bamboo_domain::ChildContextBinding::new(
6258                    &parent,
6259                    child_id,
6260                    resolved.required_brief.clone(),
6261                    resolved,
6262                )
6263                .unwrap();
6264                binding.install(&mut child).unwrap();
6265                child.add_message(binding.assignment_message());
6266            }
6267            child.add_message(Message::assistant("original source", None));
6268            child.set_last_run_status("completed");
6269            save_sourced_test_child(&store, &child).await;
6270            let completion = ChildCompletion {
6271                parent_session_id: parent_id.into(),
6272                child_session_id: child_id.into(),
6273                status: "completed".into(),
6274                error: None,
6275                completed_at: Utc::now(),
6276                source: test_child_source(&store, child_id).await,
6277            };
6278            coordinator.on_child_completed(completion.clone()).await;
6279            assert_eq!(inbox.inspect(parent_id).await.unwrap().pending, 1);
6280            if parent_changed {
6281                let mut parent = store.load_session(parent_id).await.unwrap().unwrap();
6282                parent.messages[0].content = "modified parent source".into();
6283                store.save_session(&parent).await.unwrap();
6284            } else {
6285                child = store.load_session(child_id).await.unwrap().unwrap();
6286                child.messages[0].content = "modified after admission".into();
6287                store.save_session(&child).await.unwrap();
6288            }
6289            coordinator.on_child_completed(completion).await;
6290            assert_eq!(inbox.inspect(parent_id).await.unwrap().pending, 1);
6291            assert_eq!(launches.load(Ordering::SeqCst), 1);
6292            assert!(
6293                read_runtime_state(&store.load_session(parent_id).await.unwrap().unwrap())
6294                    .waiting_for_children
6295                    .is_some()
6296            );
6297            let mut sibling =
6298                Session::new_child("still-running-child", parent_id, "model", "Child");
6299            sibling.add_message(Message::assistant("sibling result", None));
6300            sibling.set_last_run_status("completed");
6301            save_sourced_test_child(&store, &sibling).await;
6302            coordinator
6303                .on_child_completed(ChildCompletion {
6304                    parent_session_id: parent_id.into(),
6305                    child_session_id: sibling.id.clone(),
6306                    status: "completed".into(),
6307                    error: None,
6308                    completed_at: Utc::now(),
6309                    source: test_child_source(&store, &sibling.id).await,
6310                })
6311                .await;
6312            assert_eq!(
6313                inbox.inspect(parent_id).await.unwrap().pending,
6314                2,
6315                "sibling completion cannot restage mutated A as a neutral outcome"
6316            );
6317            assert_eq!(launches.load(Ordering::SeqCst), 1);
6318            assert!(
6319                read_runtime_state(&store.load_session(parent_id).await.unwrap().unwrap())
6320                    .waiting_for_children
6321                    .is_some()
6322            );
6323            assert_eq!(
6324                inbox.inspect(parent_id).await.unwrap().claimed,
6325                0,
6326                "stale replay does not create another claim or reasoning owner"
6327            );
6328        }
6329    }
6330
6331    #[tokio::test]
6332    async fn short_circuit_wait_stages_terminal_siblings_without_waiting_or_foreign_content() {
6333        for policy in [ChildWaitPolicy::Any, ChildWaitPolicy::FirstError] {
6334            let (_temp, store, inbox, coordinator, _, launches) = completion_inbox_fixture().await;
6335            let parent_id = "short-circuit-parent";
6336            let ids = vec![
6337                "failed-child".into(),
6338                "done-child".into(),
6339                "running-child".into(),
6340                "foreign-child".into(),
6341            ];
6342            save_waiting_test_parent(&store, parent_id, ids, policy).await;
6343            for (id, owner, status, result) in [
6344                ("failed-child", parent_id, "error", "failed-owned-result"),
6345                ("done-child", parent_id, "completed", "done-owned-result"),
6346                ("running-child", parent_id, "running", "not-terminal"),
6347                (
6348                    "foreign-child",
6349                    "another-parent",
6350                    "completed",
6351                    "foreign-secret",
6352                ),
6353            ] {
6354                let mut child = Session::new_child(id, owner, "model", "Child");
6355                child.add_message(Message::assistant(result, None));
6356                child.set_last_run_status(status);
6357                save_sourced_test_child(&store, &child).await;
6358            }
6359            ChildCompletionHandler::on_child_completed(
6360                coordinator.as_ref(),
6361                ChildCompletion {
6362                    parent_session_id: parent_id.into(),
6363                    child_session_id: "failed-child".into(),
6364                    status: "error".into(),
6365                    error: None,
6366                    completed_at: Utc::now(),
6367                    source: test_child_source(&store, "failed-child").await,
6368                },
6369            )
6370            .await;
6371            assert!(
6372                read_runtime_state(&store.load_session(parent_id).await.unwrap().unwrap())
6373                    .waiting_for_children
6374                    .is_none()
6375            );
6376            let claims = inbox.claim(parent_id, 8).await.unwrap();
6377            assert_eq!(claims.len(), 2);
6378            let content = serde_json::to_string(
6379                &claims
6380                    .iter()
6381                    .map(|claim| &claim.envelope)
6382                    .collect::<Vec<_>>(),
6383            )
6384            .unwrap();
6385            assert!(content.contains("done-owned-result"));
6386            assert!(!content.contains("foreign-secret"));
6387            assert!(!content.contains("not-terminal"));
6388            assert_eq!(
6389                store
6390                    .load_session("running-child")
6391                    .await
6392                    .unwrap()
6393                    .unwrap()
6394                    .last_run_status()
6395                    .as_deref(),
6396                Some("running")
6397            );
6398            assert_eq!(launches.load(Ordering::SeqCst), 1);
6399        }
6400    }
6401
6402    #[tokio::test]
6403    async fn sibling_admission_failure_keeps_wait_armed_without_activation() {
6404        let limits = bamboo_domain::SessionInboxLimits {
6405            max_backlog: 1,
6406            ..Default::default()
6407        };
6408        let (_temp, store, inbox, coordinator, reservations, launches) =
6409            completion_inbox_fixture_with_limits(limits).await;
6410        let parent_id = "partial-stage-parent";
6411        let ids = vec!["first-child".into(), "second-child".into()];
6412        save_waiting_test_parent(&store, parent_id, ids, ChildWaitPolicy::All).await;
6413        for id in ["first-child", "second-child"] {
6414            let mut child = Session::new_child(id, parent_id, "model", "Child");
6415            child.add_message(Message::assistant(format!("result-{id}"), None));
6416            child.set_last_run_status("completed");
6417            save_sourced_test_child(&store, &child).await;
6418        }
6419        ChildCompletionHandler::on_child_completed(
6420            coordinator.as_ref(),
6421            ChildCompletion {
6422                parent_session_id: parent_id.into(),
6423                child_session_id: "first-child".into(),
6424                status: "completed".into(),
6425                error: None,
6426                completed_at: Utc::now(),
6427                source: test_child_source(&store, "first-child").await,
6428            },
6429        )
6430        .await;
6431        let durable = store.load_session(parent_id).await.unwrap().unwrap();
6432        assert!(read_runtime_state(&durable).waiting_for_children.is_some());
6433        assert_eq!(
6434            read_runtime_state(&durable).status,
6435            AgentStatusState::Suspended
6436        );
6437        assert_eq!(inbox.inspect(parent_id).await.unwrap().pending, 1);
6438        let backlog = inbox.inspect(parent_id).await.unwrap();
6439        assert!(backlog.activation_pending());
6440        assert!(
6441            !backlog.interrupt_pending(),
6442            "failed wait transition is still respected"
6443        );
6444        assert_eq!(reservations.load(Ordering::SeqCst), 0);
6445        assert_eq!(launches.load(Ordering::SeqCst), 0);
6446    }
6447
6448    #[tokio::test]
6449    async fn supervisor_resume_rejects_old_incarnation_without_replacing_cache() {
6450        let (_temp, store, _inbox, coordinator, _reservations, _launches) =
6451            completion_inbox_fixture().await;
6452        let original = store
6453            .get_or_create_default_supervisor("model")
6454            .await
6455            .unwrap();
6456        let mut stale = store
6457            .load_session(&original.session_id)
6458            .await
6459            .unwrap()
6460            .unwrap();
6461        store.delete_session(&original.session_id).await.unwrap();
6462        let recreated = store
6463            .get_or_create_default_supervisor("replacement")
6464            .await
6465            .unwrap();
6466        assert_ne!(original.incarnation_id, recreated.incarnation_id);
6467        let mut current = store
6468            .load_session(&recreated.session_id)
6469            .await
6470            .unwrap()
6471            .unwrap();
6472        coordinator.save_and_cache(&mut current).await;
6473        let cached = coordinator
6474            .sessions
6475            .get(&current.id)
6476            .unwrap()
6477            .value()
6478            .clone();
6479        coordinator.save_and_cache(&mut stale).await;
6480        assert!(Arc::ptr_eq(
6481            &cached,
6482            coordinator.sessions.get(&current.id).unwrap().value()
6483        ));
6484        assert_eq!(
6485            store
6486                .load_session(&current.id)
6487                .await
6488                .unwrap()
6489                .unwrap()
6490                .authority_identity,
6491            current.authority_identity
6492        );
6493    }
6494
6495    // ── child-wait watchdog pure helpers (issue #546) ────────────────────
6496
6497    #[test]
6498    fn dead_child_candidate_status_matrix() {
6499        // Never ran / lost before the running marker: dead candidate.
6500        assert!(is_dead_child_candidate_status(None));
6501        // Actively-reported non-terminal statuses: dead candidates when nothing
6502        // is driving them.
6503        assert!(is_dead_child_candidate_status(Some("running")));
6504        assert!(is_dead_child_candidate_status(Some("pending")));
6505        // Legitimately quiescent: waiting on a human / own children / bash.
6506        assert!(!is_dead_child_candidate_status(Some("suspended")));
6507        // Terminal statuses can never be "dead" — they are already done.
6508        for status in ["completed", "error", "timeout", "cancelled", "skipped"] {
6509            assert!(!is_dead_child_candidate_status(Some(status)), "{status}");
6510        }
6511    }
6512
6513    #[test]
6514    fn completion_child_ownership_gates_content_fold() {
6515        // Owned: the child's own parent linkage matches the reporting parent.
6516        assert!(completion_child_is_owned("parent-1", Some("parent-1")));
6517        // Foreign: a real session that belongs to a DIFFERENT parent — its
6518        // content must never be folded into parent-1's transcript.
6519        assert!(!completion_child_is_owned("parent-1", Some("parent-2")));
6520        // Root/unparented session, or a nonexistent id (linkage None).
6521        assert!(!completion_child_is_owned("parent-1", None));
6522    }
6523
6524    #[test]
6525    fn watchdog_resume_messages_are_hidden_runtime_messages() {
6526        for message in [
6527            empty_child_wait_message(),
6528            child_wait_lease_expired_message(&["c-1".to_string(), "c-2".to_string()]),
6529        ] {
6530            assert!(matches!(message.role, Role::User));
6531            let meta = message.metadata.expect("hidden runtime metadata");
6532            assert_eq!(meta[RUNTIME_RESUME_MESSAGE_HIDDEN_KEY], true);
6533            assert_eq!(
6534                meta[RUNTIME_RESUME_MESSAGE_KIND_KEY],
6535                "child_wait_watchdog_resume"
6536            );
6537        }
6538        let lease = child_wait_lease_expired_message(&["c-1".to_string()]);
6539        // The lease message must never claim the children finished.
6540        assert!(lease.content.contains("NOT cancelled"));
6541        assert!(lease.content.contains("c-1"));
6542    }
6543
6544    #[test]
6545    fn runtime_child_guidance_uses_compact_inspection_calls() {
6546        let completion = make_completion("completed");
6547        let child_inspection = serde_json::json!({
6548            "intent": "inspect", "target": "child-1", "message": "messages",
6549        })
6550        .to_string();
6551        let tree_inspection =
6552            serde_json::json!({"intent": "inspect", "message": "tree"}).to_string();
6553        for (message, expected_call) in [
6554            (
6555                runtime_resume_message(&completion, 0, Some("evidence")),
6556                &child_inspection,
6557            ),
6558            (
6559                guardian_resume_message(&completion, &GuardianVerdict::approved()),
6560                &child_inspection,
6561            ),
6562            (empty_child_wait_message(), &tree_inspection),
6563            (
6564                child_wait_lease_expired_message(&["child-1".to_string()]),
6565                &tree_inspection,
6566            ),
6567        ] {
6568            assert!(message.content.contains(expected_call));
6569            for legacy in [
6570                "SubAgent.get",
6571                "SubAgent.list",
6572                "SubAgent.wait",
6573                "child_session_id=",
6574            ] {
6575                assert!(!message.content.contains(legacy), "stale hint: {legacy}");
6576            }
6577        }
6578    }
6579
6580    // ── on_child_completed terminality guard (issue #546) ────────────────
6581
6582    #[test]
6583    fn non_terminal_statuses_never_satisfy_wait_policies() {
6584        // The guard keys on `is_terminal_child_status`; "suspended" (and any
6585        // unknown non-terminal string) must not count toward any policy.
6586        assert!(!is_terminal_child_status("suspended"));
6587        assert!(!is_terminal_child_status("running"));
6588        assert!(!is_terminal_child_status("pending"));
6589    }
6590
6591    fn make_completion(status: &str) -> ChildCompletion {
6592        ChildCompletion {
6593            parent_session_id: "parent-1".to_string(),
6594            child_session_id: "child-1".to_string(),
6595            status: status.to_string(),
6596            error: None,
6597            completed_at: Utc::now(),
6598            source: None,
6599        }
6600    }
6601
6602    #[test]
6603    fn oversized_child_outcome_is_bounded_and_keeps_full_content_identity() {
6604        let mut completion = make_completion("completed");
6605        let wait_registered_at = Utc::now();
6606        let huge = format!("prefix-A-{}", "x".repeat(300 * 1024));
6607        let presentation = runtime_resume_message(&completion, 0, Some(&huge));
6608        let first = child_completion_envelope(
6609            &completion,
6610            wait_registered_at,
6611            Some(huge.clone()),
6612            &presentation,
6613        );
6614        assert!(
6615            serde_json::to_vec(&first).unwrap().len()
6616                < bamboo_domain::SessionInboxLimits::default().max_payload_bytes
6617        );
6618        let SessionMessageBody::ChildOutcome(outcome) = &first.body else {
6619            panic!("typed child outcome");
6620        };
6621        let stored = outcome.result.as_deref().unwrap();
6622        assert!(stored.contains("sha256="));
6623        assert!(stored.contains("\"intent\":\"inspect\""));
6624        assert!(stored.contains("\"target\":\"child-1\""));
6625        assert!(stored.contains("\"message\":\"result\""));
6626        assert!(!stored.contains("SubAgent.get"));
6627        assert!(stored.len() < CHILD_COMPLETION_INLINE_FIELD_BYTES);
6628
6629        // Retry-only completion timestamps and provider presentation do not
6630        // change the logical id; full oversized content does.
6631        completion.completed_at += chrono::Duration::seconds(1);
6632        let exact_retry = child_completion_envelope(
6633            &completion,
6634            wait_registered_at,
6635            Some(huge.clone()),
6636            &runtime_resume_message(&completion, 9, Some(&huge)),
6637        );
6638        assert_eq!(exact_retry.id, first.id);
6639        let changed = format!("prefix-B-{}", "x".repeat(300 * 1024));
6640        let corrected = child_completion_envelope(
6641            &completion,
6642            wait_registered_at,
6643            Some(changed.clone()),
6644            &runtime_resume_message(&completion, 0, Some(&changed)),
6645        );
6646        assert_ne!(corrected.id, first.id);
6647    }
6648
6649    #[test]
6650    fn oversized_child_error_points_to_bounded_error_view() {
6651        let mut completion = make_completion("error");
6652        completion.error = Some("失败🙂".repeat(30_000));
6653        let presentation = runtime_resume_message(&completion, 0, None);
6654        let envelope = child_completion_envelope(&completion, Utc::now(), None, &presentation);
6655        assert!(
6656            serde_json::to_vec(&envelope).unwrap().len()
6657                < bamboo_domain::SessionInboxLimits::default().max_payload_bytes
6658        );
6659        let SessionMessageBody::ChildOutcome(outcome) = &envelope.body else {
6660            panic!("typed child outcome");
6661        };
6662        let stored = outcome.error.as_deref().expect("bounded error");
6663        assert!(stored.contains(r#""message":"diagnostics""#));
6664        assert!(stored.contains("authenticated inspector"));
6665        assert!(!stored.contains("bounded slices"));
6666        assert!(!stored.contains("cursors"));
6667        assert!(stored.len() < CHILD_COMPLETION_INLINE_FIELD_BYTES);
6668        let metadata_error = outcome
6669            .provider_message
6670            .as_ref()
6671            .and_then(|provider| provider.metadata.get("child_error"))
6672            .and_then(serde_json::Value::as_str)
6673            .expect("bounded metadata error");
6674        assert_eq!(metadata_error, stored);
6675        assert!(!metadata_error.contains(&"失败🙂".repeat(30_000)));
6676    }
6677
6678    #[test]
6679    fn escaped_and_mixed_child_fields_fit_the_serialized_inbox_budget() {
6680        let mut completion = make_completion("error");
6681        completion.error = Some("\u{0001}".repeat(CHILD_COMPLETION_INLINE_FIELD_BYTES));
6682        let result = "\n".repeat(CHILD_COMPLETION_INLINE_FIELD_BYTES);
6683        let wait_registered_at = Utc::now();
6684        let presentation = runtime_resume_message(&completion, 0, Some(&result));
6685        let envelope = child_completion_envelope(
6686            &completion,
6687            wait_registered_at,
6688            Some(result.clone()),
6689            &presentation,
6690        );
6691        assert!(
6692            serde_json::to_vec(&envelope).unwrap().len()
6693                < bamboo_domain::SessionInboxLimits::default().max_payload_bytes
6694        );
6695        let SessionMessageBody::ChildOutcome(outcome) = &envelope.body else {
6696            panic!("typed child outcome");
6697        };
6698        let error = outcome.error.as_deref().unwrap();
6699        let stored_result = outcome.result.as_deref().unwrap();
6700        assert!(error.contains(r#""message":"diagnostics""#));
6701        assert!(stored_result.contains(r#""message":"result""#));
6702        assert!(!error.contains("Bounded tail"));
6703        assert!(!stored_result.contains("Bounded tail"));
6704        assert_eq!(
6705            outcome
6706                .provider_message
6707                .as_ref()
6708                .and_then(|provider| provider.metadata.get("child_error"))
6709                .and_then(serde_json::Value::as_str),
6710            Some(error)
6711        );
6712        let retry = child_completion_envelope(
6713            &completion,
6714            wait_registered_at,
6715            Some(result),
6716            &runtime_resume_message(&completion, 8, None),
6717        );
6718        assert_eq!(envelope.id, retry.id);
6719    }
6720
6721    #[test]
6722    fn compact_guardian_outcome_keeps_rejected_verdict_and_result_retrieval() {
6723        let completion = make_completion("completed");
6724        let result = format!(
6725            "{}{{\"approve\":false,\"findings\":[\"needs fix\"]}}",
6726            "\u{0001}".repeat(CHILD_COMPLETION_INLINE_FIELD_BYTES - 80)
6727        );
6728        assert!(result.len() <= CHILD_COMPLETION_INLINE_FIELD_BYTES);
6729        let verdict = parse_guardian_verdict(&result).expect("trailing guardian verdict");
6730        let presentation = guardian_resume_message(&completion, &verdict);
6731        assert!(
6732            serde_json::to_vec(&presentation).unwrap().len() <= CHILD_COMPLETION_INLINE_FIELD_BYTES,
6733            "the original guardian presentation must fit the inline cap"
6734        );
6735        let wait_registered_at = Utc::now();
6736        let expanded = build_child_completion_envelope(
6737            &completion,
6738            wait_registered_at,
6739            Some(&result),
6740            &presentation,
6741            false,
6742        );
6743        assert!(
6744            serde_json::to_vec(&expanded).unwrap().len()
6745                > bamboo_domain::SessionInboxLimits::default().max_payload_bytes,
6746            "JSON escaping must trigger the compact fallback"
6747        );
6748
6749        let envelope =
6750            child_completion_envelope(&completion, wait_registered_at, Some(result), &presentation);
6751        assert!(
6752            serde_json::to_vec(&envelope).unwrap().len()
6753                <= bamboo_domain::SessionInboxLimits::default().max_payload_bytes
6754        );
6755        let SessionMessageBody::ChildOutcome(outcome) = &envelope.body else {
6756            panic!("typed child outcome");
6757        };
6758        let provider = outcome.provider_message.as_ref().unwrap();
6759        assert!(provider.content.text.contains("Guardian review REJECTED"));
6760        assert!(provider.content.text.contains(r#""message":"result""#));
6761        assert_eq!(
6762            provider.metadata.get("guardian_approved"),
6763            Some(&serde_json::json!(false))
6764        );
6765        assert!(envelope
6766            .to_provider_message()
6767            .unwrap()
6768            .content
6769            .contains("Guardian review REJECTED"));
6770    }
6771
6772    #[tokio::test]
6773    async fn oversized_multibyte_child_error_is_durable_and_replayable() {
6774        let home = tempfile::tempdir().unwrap();
6775        let store = Arc::new(
6776            bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
6777                .await
6778                .unwrap(),
6779        );
6780        let parent_id = "large-error-parent";
6781        store
6782            .save_session(&Session::new(parent_id, "model"))
6783            .await
6784            .unwrap();
6785        let inbox = bamboo_storage::FileSessionInbox::new(
6786            store.clone(),
6787            bamboo_domain::SessionInboxLimits::default(),
6788        );
6789        let mut completion = make_completion("error");
6790        completion.parent_session_id = parent_id.to_string();
6791        completion.error = Some("失败🙂".repeat(30_000));
6792        let presentation = runtime_resume_message(&completion, 0, None);
6793        let envelope = child_completion_envelope(&completion, Utc::now(), None, &presentation);
6794        inbox.deliver(&envelope).await.unwrap();
6795        completion.child_session_id = "escaped-error-child".to_string();
6796        completion.error = Some("\u{0001}".repeat(CHILD_COMPLETION_INLINE_FIELD_BYTES));
6797        let result = "\n".repeat(CHILD_COMPLETION_INLINE_FIELD_BYTES);
6798        let mixed = child_completion_envelope(
6799            &completion,
6800            Utc::now(),
6801            Some(result.clone()),
6802            &runtime_resume_message(&completion, 0, Some(&result)),
6803        );
6804        let receipt = inbox.deliver(&mixed).await.unwrap();
6805        inbox
6806            .mark_activation_eligible(
6807                parent_id,
6808                receipt.generation,
6809                bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
6810            )
6811            .await
6812            .unwrap();
6813        drop(inbox);
6814        drop(store);
6815
6816        let reopened_store = Arc::new(
6817            bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
6818                .await
6819                .unwrap(),
6820        );
6821        let reopened = bamboo_storage::FileSessionInbox::new(
6822            reopened_store,
6823            bamboo_domain::SessionInboxLimits::default(),
6824        );
6825        let claims = reopened.claim(parent_id, 2).await.unwrap();
6826        assert_eq!(claims.len(), 2);
6827        assert_eq!(claims[0].envelope.id, envelope.id);
6828        assert_eq!(claims[1].envelope.id, mixed.id);
6829        for claim in claims {
6830            assert!(
6831                serde_json::to_vec(&claim.envelope).unwrap().len()
6832                    < bamboo_domain::SessionInboxLimits::default().max_payload_bytes
6833            );
6834        }
6835    }
6836
6837    #[tokio::test]
6838    async fn failed_child_wait_sidecar_save_keeps_cache_wait_and_transcript() {
6839        let (_temp, store, inbox, mut coordinator, reservations, launches) =
6840            completion_inbox_fixture().await;
6841        let parent_id = "failed-sidecar-parent";
6842        let child_id = "failed-sidecar-child";
6843        let now = Utc::now();
6844        let mut parent = Session::new(parent_id, "model");
6845        parent.add_message(Message::user("durable root turn"));
6846        let mut runtime = AgentRuntimeState::new("waiting-run");
6847        runtime.status = AgentStatusState::Suspended;
6848        runtime.waiting_for_children = Some(WaitingForChildrenState::for_children(
6849            vec![child_id.to_string()],
6850            ChildWaitPolicy::All,
6851            now,
6852        ));
6853        write_runtime_state(&mut parent, &runtime);
6854        parent.metadata.insert(
6855            "runtime.suspend_reason".to_string(),
6856            "waiting_for_children".to_string(),
6857        );
6858        store.save_session(&parent).await.unwrap();
6859        let mut cached = parent.clone();
6860        cached.add_message(Message::assistant("newer cached root turn", None));
6861        coordinator.sessions.insert(
6862            parent_id.to_string(),
6863            Arc::new(crate::SessionSnapshot::new(cached)),
6864        );
6865
6866        let mut child = Session::new_child(child_id, parent_id, "model", "Child");
6867        child.set_last_run_status("completed");
6868        save_sourced_test_child(&store, &child).await;
6869        Arc::get_mut(&mut coordinator).unwrap().persistence = Arc::new(LockedSessionStore::new(
6870            Arc::new(FailingRuntimeSaveStorage {
6871                inner: store.clone(),
6872            }),
6873        ));
6874
6875        ChildCompletionHandler::on_child_completed(
6876            coordinator.as_ref(),
6877            ChildCompletion {
6878                parent_session_id: parent_id.to_string(),
6879                child_session_id: child_id.to_string(),
6880                status: "completed".to_string(),
6881                error: None,
6882                completed_at: Utc::now(),
6883                source: None,
6884            },
6885        )
6886        .await;
6887
6888        let durable = store.load_session(parent_id).await.unwrap().unwrap();
6889        assert!(read_runtime_state(&durable).waiting_for_children.is_some());
6890        assert_eq!(durable.messages.len(), 1);
6891        assert_eq!(durable.messages[0].content, "durable root turn");
6892        let cached = coordinator.sessions.get(parent_id).unwrap();
6893        let cached = cached.value().read();
6894        assert!(read_runtime_state(&cached).waiting_for_children.is_some());
6895        assert!(cached
6896            .messages
6897            .iter()
6898            .any(|message| message.content == "newer cached root turn"));
6899        let backlog = inbox.inspect(parent_id).await.unwrap();
6900        assert_eq!(backlog.pending + backlog.claimed, 1);
6901        assert_eq!(reservations.load(Ordering::SeqCst), 0);
6902        assert_eq!(launches.load(Ordering::SeqCst), 0);
6903    }
6904
6905    #[tokio::test]
6906    async fn oversized_child_completion_clears_wait_and_activates_exactly_once() {
6907        let (_temp, store, inbox, coordinator, reservations, launches) =
6908            completion_inbox_fixture().await;
6909        let parent_id = "oversized-parent";
6910        let child_id = "oversized-child";
6911        let now = Utc::now();
6912        let mut parent = Session::new(parent_id, "model");
6913        let mut parent_runtime = AgentRuntimeState::new("waiting-run");
6914        parent_runtime.status = AgentStatusState::Suspended;
6915        parent_runtime.waiting_for_children = Some(WaitingForChildrenState::for_children(
6916            vec![child_id.to_string()],
6917            ChildWaitPolicy::All,
6918            now,
6919        ));
6920        parent_runtime.suspension = Some(SuspensionState {
6921            reason: "waiting_for_children".to_string(),
6922            suspended_at: now,
6923            resumable: true,
6924            hook_point: Some("ChildCompletion".to_string()),
6925        });
6926        write_runtime_state(&mut parent, &parent_runtime);
6927        parent.metadata.insert(
6928            "runtime.suspend_reason".to_string(),
6929            "waiting_for_children".to_string(),
6930        );
6931        store.save_session(&parent).await.unwrap();
6932        // The running Root can have a newer in-memory transcript while this
6933        // coordinator reads the durable wait. Publishing the control-plane
6934        // transition must not replace that transcript with the stale load.
6935        let mut cached_parent = parent.clone();
6936        cached_parent.add_message(Message::assistant("concurrent root turn", None));
6937        coordinator.sessions.insert(
6938            parent_id.to_string(),
6939            Arc::new(crate::SessionSnapshot::new(cached_parent)),
6940        );
6941
6942        let mut child = Session::new_child(child_id, parent_id, "model", "Child");
6943        child.add_message(Message::assistant("z".repeat(300 * 1024), None));
6944        child.set_last_run_status("completed");
6945        save_sourced_test_child(&store, &child).await;
6946        let completion = ChildCompletion {
6947            parent_session_id: parent_id.to_string(),
6948            child_session_id: child_id.to_string(),
6949            status: "completed".to_string(),
6950            error: None,
6951            completed_at: Utc::now(),
6952            source: test_child_source(&store, child_id).await,
6953        };
6954
6955        ChildCompletionHandler::on_child_completed(coordinator.as_ref(), completion.clone()).await;
6956        let durable_parent = store.load_session(parent_id).await.unwrap().unwrap();
6957        let durable_runtime = read_runtime_state(&durable_parent);
6958        assert!(durable_runtime.waiting_for_children.is_none());
6959        assert_eq!(durable_runtime.status, AgentStatusState::Idle);
6960        assert!(!durable_parent
6961            .metadata
6962            .contains_key("runtime.suspend_reason"));
6963        let cached_parent = coordinator.sessions.get(parent_id).unwrap();
6964        let cached_parent = cached_parent.value().read();
6965        assert!(cached_parent
6966            .messages
6967            .iter()
6968            .any(|message| message.content == "concurrent root turn"));
6969        assert!(read_runtime_state(&cached_parent)
6970            .waiting_for_children
6971            .is_none());
6972        let backlog = inbox.inspect(parent_id).await.unwrap();
6973        assert_eq!(backlog.pending + backlog.claimed, 1);
6974        assert!(backlog.activation_pending());
6975        assert_eq!(reservations.load(Ordering::SeqCst), 1);
6976        assert_eq!(launches.load(Ordering::SeqCst), 1);
6977
6978        let claim = inbox.claim(parent_id, 1).await.unwrap().remove(0);
6979        assert!(
6980            serde_json::to_vec(&claim.envelope).unwrap().len()
6981                < bamboo_domain::SessionInboxLimits::default().max_payload_bytes
6982        );
6983        // Duplicate terminal notification after the wait was already cleared
6984        // cannot enqueue or activate a second outcome.
6985        ChildCompletionHandler::on_child_completed(coordinator.as_ref(), completion).await;
6986        assert_eq!(reservations.load(Ordering::SeqCst), 1);
6987        assert_eq!(launches.load(Ordering::SeqCst), 1);
6988        assert_eq!(inbox.inspect(parent_id).await.unwrap().claimed, 1);
6989    }
6990
6991    #[tokio::test]
6992    async fn completion_before_parent_wait_does_not_checkpoint_stale_root() {
6993        let (_temp, store, inbox, coordinator, reservations, launches) =
6994            completion_inbox_fixture().await;
6995        let parent_id = "parent-without-wait";
6996        let child_id = "already-finished-child";
6997        let mut parent = Session::new(parent_id, "model");
6998        parent.add_message(Message::assistant("root is still reasoning", None));
6999        store.save_session(&parent).await.unwrap();
7000        let before = store.load_session(parent_id).await.unwrap().unwrap();
7001
7002        let mut child = Session::new_child(child_id, parent_id, "model", "Child");
7003        child.set_last_run_status("completed");
7004        save_sourced_test_child(&store, &child).await;
7005        ChildCompletionHandler::on_child_completed(
7006            coordinator.as_ref(),
7007            ChildCompletion {
7008                parent_session_id: parent_id.to_string(),
7009                child_session_id: child_id.to_string(),
7010                status: "completed".to_string(),
7011                error: None,
7012                completed_at: Utc::now(),
7013                source: None,
7014            },
7015        )
7016        .await;
7017
7018        let after = store.load_session(parent_id).await.unwrap().unwrap();
7019        assert_eq!(after.updated_at, before.updated_at);
7020        assert_eq!(
7021            serde_json::to_value(&after.messages).unwrap(),
7022            serde_json::to_value(&before.messages).unwrap()
7023        );
7024        assert!(read_runtime_state(&after).waiting_for_children.is_none());
7025        assert_eq!(inbox.inspect(parent_id).await.unwrap().pending, 0);
7026        assert_eq!(reservations.load(Ordering::SeqCst), 0);
7027        assert_eq!(launches.load(Ordering::SeqCst), 0);
7028    }
7029
7030    #[tokio::test]
7031    async fn latest_locked_activation_mutation_preserves_wait_armed_after_stale_load() {
7032        let temp = tempfile::tempdir().unwrap();
7033        let store = Arc::new(
7034            bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
7035                .await
7036                .unwrap(),
7037        );
7038        let storage: Arc<dyn Storage> = store.clone();
7039        let locked = LockedSessionStore::new(storage);
7040        let mut session = Session::new("activation-stale-wait", "model");
7041        session.agent_runtime_state = Some(AgentRuntimeState::new("old-run"));
7042        store.save_session(&session).await.unwrap();
7043
7044        // The activation path has already loaded this stale, wait-free snapshot.
7045        let stale = store.load_session(&session.id).await.unwrap().unwrap();
7046        assert!(read_runtime_state(&stale).waiting_for_children.is_none());
7047
7048        locked
7049            .update_runtime_config(&session.id, |latest| {
7050                let mut state = read_runtime_state(latest);
7051                state.status = AgentStatusState::Suspended;
7052                state.waiting_for_children = Some(WaitingForChildrenState::for_children(
7053                    vec!["child-new".to_string()],
7054                    ChildWaitPolicy::All,
7055                    Utc::now(),
7056                ));
7057                state.suspension = Some(SuspensionState {
7058                    reason: "waiting_for_children".to_string(),
7059                    suspended_at: Utc::now(),
7060                    resumable: true,
7061                    hook_point: None,
7062                });
7063                write_runtime_state(latest, &state);
7064                latest.metadata.insert(
7065                    "runtime.suspend_reason".to_string(),
7066                    "waiting_for_children".to_string(),
7067                );
7068            })
7069            .await
7070            .unwrap();
7071
7072        let (prepared, ready) = prepare_session_inbox_activation(&locked, &session.id, false)
7073            .await
7074            .unwrap()
7075            .unwrap();
7076        assert!(!ready, "latest durable specific wait must block activation");
7077        let state = read_runtime_state(&prepared);
7078        assert!(state.waiting_for_children.is_some());
7079        assert_eq!(state.status, AgentStatusState::Suspended);
7080        assert_eq!(
7081            prepared
7082                .metadata
7083                .get("runtime.suspend_reason")
7084                .map(String::as_str),
7085            Some("waiting_for_children")
7086        );
7087    }
7088
7089    #[tokio::test]
7090    async fn partial_child_and_bash_backlogs_remain_inert_after_inbox_reopen() {
7091        let temp = tempfile::tempdir().unwrap();
7092        let store = Arc::new(
7093            bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
7094                .await
7095                .unwrap(),
7096        );
7097        store
7098            .save_session(&Session::new("restart-child-parent", "model"))
7099            .await
7100            .unwrap();
7101        store
7102            .save_session(&Session::new("restart-bash-parent", "model"))
7103            .await
7104            .unwrap();
7105        let inbox = bamboo_storage::FileSessionInbox::new(
7106            store.clone(),
7107            bamboo_domain::SessionInboxLimits::default(),
7108        );
7109
7110        let completion = ChildCompletion {
7111            parent_session_id: "restart-child-parent".to_string(),
7112            child_session_id: "child-a".to_string(),
7113            status: "completed".to_string(),
7114            error: None,
7115            completed_at: Utc::now(),
7116            source: None,
7117        };
7118        let child_resume = runtime_resume_message(&completion, 1, Some("first child"));
7119        inbox
7120            .deliver(&child_completion_envelope(
7121                &completion,
7122                Utc::now(),
7123                Some("first child".to_string()),
7124                &child_resume,
7125            ))
7126            .await
7127            .unwrap();
7128        let bash = BashCompletionInfo {
7129            session_id: "restart-bash-parent".to_string(),
7130            bash_id: "bash-a".to_string(),
7131            command: "true".to_string(),
7132            exit_code: Some(0),
7133            status: "completed".to_string(),
7134            output_tail: String::new(),
7135        };
7136        inbox
7137            .deliver(&bash_completion_envelope(&bash))
7138            .await
7139            .unwrap();
7140
7141        let reopened = bamboo_storage::FileSessionInbox::new(
7142            store,
7143            bamboo_domain::SessionInboxLimits::default(),
7144        );
7145        for session_id in ["restart-child-parent", "restart-bash-parent"] {
7146            let backlog = reopened.inspect(session_id).await.unwrap();
7147            assert_eq!(backlog.pending, 1);
7148            assert_eq!(backlog.activation_generation, 0);
7149            assert!(
7150                !backlog.activation_pending(),
7151                "startup must not run a partial wait backlog for {session_id}"
7152            );
7153        }
7154    }
7155
7156    // ── ② derive completed children from the index ──────────────────────
7157
7158    struct StubChildIndex {
7159        children: Vec<(String, Option<String>)>,
7160    }
7161
7162    #[async_trait]
7163    impl Storage for StubChildIndex {
7164        async fn save_session(&self, _session: &Session) -> std::io::Result<()> {
7165            Ok(())
7166        }
7167        async fn load_session(&self, _id: &str) -> std::io::Result<Option<Session>> {
7168            Ok(None)
7169        }
7170        async fn delete_session(&self, _id: &str) -> std::io::Result<bool> {
7171            Ok(false)
7172        }
7173        async fn list_child_run_statuses(
7174            &self,
7175            _parent_session_id: &str,
7176        ) -> std::io::Result<Vec<(String, Option<String>)>> {
7177            Ok(self.children.clone())
7178        }
7179    }
7180
7181    #[tokio::test]
7182    async fn derive_completed_only_includes_terminal_children() {
7183        let storage: Arc<dyn Storage> = Arc::new(StubChildIndex {
7184            children: vec![
7185                ("a".into(), Some("completed".into())),
7186                ("b".into(), Some("running".into())),
7187                ("c".into(), Some("error".into())),
7188                ("d".into(), None),
7189            ],
7190        });
7191        let completed = derive_completed_child_ids(&storage, "parent-1", "b").await;
7192        // Terminal from index: a, c. Plus the just-completed child b folded in.
7193        assert_eq!(
7194            completed,
7195            vec!["a".to_string(), "b".to_string(), "c".to_string()]
7196        );
7197    }
7198
7199    #[tokio::test]
7200    async fn derive_completed_folds_in_just_completed_when_index_lags() {
7201        // Index hasn't caught up — reports the child as still running.
7202        let storage: Arc<dyn Storage> = Arc::new(StubChildIndex {
7203            children: vec![("only".into(), Some("running".into()))],
7204        });
7205        let completed = derive_completed_child_ids(&storage, "parent-1", "only").await;
7206        assert_eq!(completed, vec!["only".to_string()]);
7207    }
7208
7209    #[test]
7210    fn wait_policy_all_uses_derived_completed_set() {
7211        let waited = vec!["a".to_string(), "b".to_string()];
7212        assert!(!wait_policy_satisfied(
7213            ChildWaitPolicy::All,
7214            &waited,
7215            &["a".to_string()],
7216            "a",
7217            "completed"
7218        ));
7219        assert!(wait_policy_satisfied(
7220            ChildWaitPolicy::All,
7221            &waited,
7222            &["a".to_string(), "b".to_string()],
7223            "b",
7224            "completed"
7225        ));
7226    }
7227
7228    #[test]
7229    fn wait_policy_first_error_requires_tracked_membership() {
7230        let waited = vec!["a".to_string(), "b".to_string()];
7231        // An error from a TRACKED child resumes immediately.
7232        assert!(wait_policy_satisfied(
7233            ChildWaitPolicy::FirstError,
7234            &waited,
7235            &["a".to_string()],
7236            "a",
7237            "error"
7238        ));
7239        // An error-like completion from an UNTRACKED child (e.g. a zombie
7240        // task from an earlier run waking up late) must not resume the wait.
7241        assert!(!wait_policy_satisfied(
7242            ChildWaitPolicy::FirstError,
7243            &waited,
7244            &["a".to_string()],
7245            "stray-child",
7246            "timeout"
7247        ));
7248        // The all-complete fallback still applies regardless of the reporter.
7249        assert!(wait_policy_satisfied(
7250            ChildWaitPolicy::FirstError,
7251            &waited,
7252            &["a".to_string(), "b".to_string()],
7253            "stray-child",
7254            "completed"
7255        ));
7256    }
7257
7258    #[test]
7259    fn child_final_assistant_text_returns_last_assistant() {
7260        let mut session = Session::new("child-1", "gpt-4");
7261        session.messages.push(Message::user("hi"));
7262        session
7263            .messages
7264            .push(Message::assistant("first answer", None));
7265        session.messages.push(Message::user("again"));
7266        session
7267            .messages
7268            .push(Message::assistant("final answer", None));
7269
7270        assert_eq!(
7271            child_final_assistant_text(&session).as_deref(),
7272            Some("final answer")
7273        );
7274    }
7275
7276    #[test]
7277    fn child_final_assistant_text_returns_none_when_blank() {
7278        let mut session = Session::new("child-1", "gpt-4");
7279        session.messages.push(Message::assistant("   ", None));
7280        assert!(child_final_assistant_text(&session).is_none());
7281    }
7282
7283    #[test]
7284    fn child_final_assistant_text_returns_none_when_no_assistant() {
7285        let mut session = Session::new("child-1", "gpt-4");
7286        session.messages.push(Message::user("hi"));
7287        assert!(child_final_assistant_text(&session).is_none());
7288    }
7289
7290    #[test]
7291    fn runtime_resume_message_folds_full_response_without_truncation() {
7292        // A very long child final response is folded in verbatim (no 4000-char
7293        // cap, no truncation marker).
7294        let completion = make_completion("completed");
7295        let long: String = "a".repeat(10_000);
7296        let message = runtime_resume_message(&completion, 0, Some(&long));
7297        assert!(message.content.contains(&long));
7298        assert!(!message.content.contains("truncated"));
7299    }
7300
7301    #[test]
7302    fn runtime_resume_message_includes_child_response_when_provided() {
7303        let completion = make_completion("completed");
7304        let message = runtime_resume_message(&completion, 0, Some("the answer is 42"));
7305
7306        assert!(matches!(message.role, Role::User));
7307        // Folded child results are now compressible so the parent context can
7308        // reclaim them under compaction.
7309        assert!(!message.never_compress);
7310        assert!(message.content.contains("Child final response:"));
7311        assert!(message.content.contains("the answer is 42"));
7312
7313        let metadata = message.metadata.expect("metadata present");
7314        assert_eq!(
7315            metadata.get("hidden_from_ui").and_then(|v| v.as_bool()),
7316            Some(true)
7317        );
7318        assert_eq!(
7319            metadata.get("runtime_kind").and_then(|v| v.as_str()),
7320            Some("child_completion_resume")
7321        );
7322        assert_eq!(
7323            metadata
7324                .get("child_final_response_included")
7325                .and_then(|v| v.as_bool()),
7326            Some(true)
7327        );
7328    }
7329
7330    #[test]
7331    fn runtime_resume_message_falls_back_to_error_when_no_response() {
7332        let mut completion = make_completion("error");
7333        completion.error = Some("boom".to_string());
7334
7335        let message = runtime_resume_message(&completion, 1, None);
7336        assert!(message.content.contains("Child error:"));
7337        assert!(message.content.contains("boom"));
7338        let metadata = message.metadata.expect("metadata present");
7339        assert_eq!(
7340            metadata
7341                .get("child_final_response_included")
7342                .and_then(|v| v.as_bool()),
7343            Some(false)
7344        );
7345    }
7346
7347    #[test]
7348    fn runtime_resume_message_minimal_when_no_response_and_no_error() {
7349        let completion = make_completion("completed");
7350        let message = runtime_resume_message(&completion, 2, None);
7351        assert!(!message.content.contains("Child final response:"));
7352        assert!(!message.content.contains("Child error:"));
7353        assert!(message.content.contains("Resume the parent task"));
7354    }
7355
7356    #[test]
7357    fn read_config_snapshot_refreshes_cached_snapshot_from_live_config() {
7358        let runtime = tokio::runtime::Runtime::new().expect("runtime");
7359
7360        runtime.block_on(async {
7361            let config = Arc::new(RwLock::new(Config::default()));
7362            config.write().await.provider = "copilot".to_string();
7363            let cached_config = StdRwLock::new(Config::default());
7364
7365            let snapshot = read_config_snapshot(&config, &cached_config);
7366
7367            assert_eq!(snapshot.provider, "copilot");
7368            assert_eq!(
7369                cached_config.read().expect("cached snapshot lock").provider,
7370                "copilot"
7371            );
7372        });
7373    }
7374
7375    #[test]
7376    fn read_config_snapshot_uses_cached_snapshot_when_live_lock_is_busy() {
7377        let runtime = tokio::runtime::Runtime::new().expect("runtime");
7378
7379        runtime.block_on(async {
7380            let mut cached_snapshot = Config::default();
7381            cached_snapshot.provider = "cached-provider".to_string();
7382
7383            let config = Arc::new(RwLock::new(Config::default()));
7384            let cached_config = StdRwLock::new(cached_snapshot);
7385            let _write_guard = config.write().await;
7386
7387            let snapshot = read_config_snapshot(&config, &cached_config);
7388
7389            assert_eq!(snapshot.provider, "cached-provider");
7390        });
7391    }
7392
7393    // ── Bash self-resume (issue #84 Phase 2b): deadline message + clobber-retry ──
7394
7395    #[test]
7396    fn bash_completion_resume_message_normal_announces_completion() {
7397        let ids = vec!["bg-1".to_string(), "bg-2".to_string()];
7398        let message = bash_completion_resume_message(&ids, false);
7399        // Normal path: the shells genuinely finished.
7400        assert!(
7401            message.content.contains("have completed"),
7402            "normal resume message must announce completion: {}",
7403            message.content
7404        );
7405        // Hidden + compressible so the resume gate sees it but the UI hides it.
7406        let metadata = message.metadata.expect("metadata present");
7407        assert_eq!(
7408            metadata
7409                .get(RUNTIME_RESUME_MESSAGE_HIDDEN_KEY)
7410                .and_then(|v| v.as_bool()),
7411            Some(true),
7412            "resume message must be hidden from the UI"
7413        );
7414        assert_eq!(
7415            metadata
7416                .get(RUNTIME_RESUME_MESSAGE_KIND_KEY)
7417                .and_then(|v| v.as_str()),
7418            Some(BASH_COMPLETION_RESUME_KIND),
7419            "resume message must carry the bash-completion kind discriminant"
7420        );
7421    }
7422
7423    #[test]
7424    fn bash_completion_resume_message_deadline_does_not_claim_completion() {
7425        // The 6h+10m deadline force-breaks with shells STILL running. The message
7426        // must NOT say "have completed" — that would let the model assume success
7427        // on a false premise. It must direct the model to verify with BashOutput.
7428        let ids = vec!["bg-long".to_string()];
7429        let message = bash_completion_resume_message(&ids, true);
7430        assert!(
7431            !message.content.contains("have completed"),
7432            "deadline resume message must NOT claim the shells completed: {}",
7433            message.content
7434        );
7435        assert!(
7436            message.content.contains("may still be running"),
7437            "deadline resume message must warn shells may still be running: {}",
7438            message.content
7439        );
7440        assert!(
7441            message.content.contains("BashOutput"),
7442            "deadline resume message must direct verification via BashOutput: {}",
7443            message.content
7444        );
7445        // Same hidden/kind shape so the resume gate is satisfied identically.
7446        let metadata = message.metadata.expect("metadata present");
7447        assert_eq!(
7448            metadata
7449                .get(RUNTIME_RESUME_MESSAGE_KIND_KEY)
7450                .and_then(|v| v.as_str()),
7451            Some(BASH_COMPLETION_RESUME_KIND)
7452        );
7453    }
7454
7455    #[test]
7456    fn bash_resume_should_retry_matrix() {
7457        // The finalize-clobber retry predicate (issue #84 Phase 2b). Retry only
7458        // when the resume did NOT spawn (Completed / AlreadyRunning) AND the
7459        // persisted bash wait is still set on reload — the clobber signature.
7460
7461        // Started: the resume fired — never retry, regardless of persisted state.
7462        assert!(!bash_resume_should_retry(
7463            &ResumeOutcome::Started { run_id: "r".into() },
7464            true
7465        ));
7466        assert!(!bash_resume_should_retry(
7467            &ResumeOutcome::Started { run_id: "r".into() },
7468            false
7469        ));
7470
7471        // NotFound: session gone — never retry.
7472        assert!(!bash_resume_should_retry(&ResumeOutcome::NotFound, true));
7473        assert!(!bash_resume_should_retry(&ResumeOutcome::NotFound, false));
7474
7475        // Completed + persisted wait still set ⇒ finalize-clobber ⇒ retry.
7476        assert!(bash_resume_should_retry(&ResumeOutcome::Completed, true));
7477        // Completed + persisted wait cleared ⇒ handled (our message stuck, or a
7478        // concurrent resume finished) ⇒ stop.
7479        assert!(!bash_resume_should_retry(&ResumeOutcome::Completed, false));
7480
7481        // AlreadyRunning + persisted wait still set ⇒ clobbered while a runner is
7482        // (stale-)active ⇒ retry to re-establish the resume message.
7483        assert!(bash_resume_should_retry(
7484            &ResumeOutcome::AlreadyRunning { run_id: "r".into() },
7485            true
7486        ));
7487        // AlreadyRunning + wait cleared ⇒ a runner owns the session ⇒ stop.
7488        assert!(!bash_resume_should_retry(
7489            &ResumeOutcome::AlreadyRunning { run_id: "r".into() },
7490            false
7491        ));
7492    }
7493
7494    // ── bash completion injection body (Phase 2b follow-up) ──────────────
7495
7496    #[test]
7497    fn injection_body_includes_status_exit_command_and_tail() {
7498        let info = BashCompletionInfo {
7499            session_id: "s".into(),
7500            bash_id: "abc123".into(),
7501            command: "make build".into(),
7502            exit_code: Some(0),
7503            status: "completed".into(),
7504            output_tail: "BUILD OK".into(),
7505        };
7506        let body = bash_completion_injection_body(&info);
7507        assert!(body.contains("abc123"), "body: {body}");
7508        assert!(body.contains("make build"), "body: {body}");
7509        assert!(body.contains("completed"), "body: {body}");
7510        assert!(body.contains("exit code 0"), "body: {body}");
7511        assert!(body.contains("BUILD OK"), "body: {body}");
7512        // The model is pointed at BashOutput for the full log.
7513        assert!(body.contains("BashOutput"), "body: {body}");
7514        assert!(body.contains("bash_id=\"abc123\""), "body: {body}");
7515    }
7516
7517    #[test]
7518    fn injection_body_handles_no_output_and_signal_kill() {
7519        let info = BashCompletionInfo {
7520            session_id: "s".into(),
7521            bash_id: "xyz".into(),
7522            command: "sleep 99".into(),
7523            exit_code: None,
7524            status: "killed".into(),
7525            output_tail: String::new(),
7526        };
7527        let body = bash_completion_injection_body(&info);
7528        assert!(body.contains("killed"), "body: {body}");
7529        assert!(body.contains("none (signal/killed)"), "body: {body}");
7530        assert!(body.contains("no captured output"), "body: {body}");
7531        // No output tail section when there is nothing to show.
7532        assert!(!body.contains("Output tail:"), "body: {body}");
7533    }
7534
7535    #[test]
7536    fn background_completion_builds_post_tool_use_payload_and_feedback() {
7537        let mut info = BashCompletionInfo {
7538            session_id: "s".into(),
7539            bash_id: "bg-7".into(),
7540            command: "cargo test".into(),
7541            exit_code: Some(0),
7542            status: "completed".into(),
7543            output_tail: "test result: ok".into(),
7544        };
7545
7546        let payload = background_bash_post_tool_payload(&info);
7547        match payload {
7548            HookPayload::ToolResult {
7549                tool_name,
7550                tool_call_id,
7551                outcome,
7552            } => {
7553                assert_eq!(tool_name, "Bash");
7554                assert_eq!(tool_call_id, "bg-7");
7555                assert!(outcome.success);
7556                let response: serde_json::Value =
7557                    serde_json::from_str(outcome.result.as_deref().unwrap()).unwrap();
7558                assert_eq!(response["command"], "cargo test");
7559                assert_eq!(response["exit_code"], 0);
7560                assert_eq!(response["status"], "completed");
7561                assert_eq!(response["output_tail"], "test result: ok");
7562            }
7563            other => panic!("expected PostToolUse payload, got {other:?}"),
7564        }
7565
7566        append_background_bash_hook_feedback(
7567            &mut info,
7568            vec!["Run the formatter before continuing".to_string()],
7569        );
7570        assert!(info.output_tail.contains("<post_tool_use_feedback>"));
7571        assert!(info
7572            .output_tail
7573            .contains("Run the formatter before continuing"));
7574    }
7575
7576    #[tokio::test]
7577    async fn bash_completion_payload_identity_matches_file_inbox_idempotency() {
7578        let baseline = BashCompletionInfo {
7579            session_id: "session".into(),
7580            bash_id: "bg-7".into(),
7581            command: "cargo test".into(),
7582            exit_code: Some(0),
7583            status: "completed".into(),
7584            output_tail: "first tail".into(),
7585        };
7586        let mut retried = baseline.clone();
7587        retried.output_tail = "first tail\nlater bytes\n<hook feedback>".into();
7588        retried.status = "completed-after-hook".into();
7589        let baseline_envelope = bash_completion_envelope(&baseline);
7590        let exact_retry = bash_completion_envelope(&baseline);
7591        let corrected_envelope = bash_completion_envelope(&retried);
7592        assert_eq!(baseline_envelope.id, exact_retry.id);
7593        assert_ne!(
7594            baseline_envelope.id, corrected_envelope.id,
7595            "changed payload semantics must receive a distinct id"
7596        );
7597
7598        let mut other_shell = baseline.clone();
7599        other_shell.bash_id = "bg-8".into();
7600        assert_ne!(
7601            bash_completion_envelope(&baseline).id,
7602            bash_completion_envelope(&other_shell).id
7603        );
7604
7605        let temp = tempfile::tempdir().unwrap();
7606        let store = Arc::new(
7607            bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
7608                .await
7609                .unwrap(),
7610        );
7611        store
7612            .save_session(&Session::new("session", "model"))
7613            .await
7614            .unwrap();
7615        let inbox = bamboo_storage::FileSessionInbox::new(
7616            store,
7617            bamboo_domain::SessionInboxLimits::default(),
7618        );
7619        let first = inbox.deliver(&baseline_envelope).await.unwrap();
7620        let duplicate = inbox.deliver(&exact_retry).await.unwrap();
7621        let corrected = inbox.deliver(&corrected_envelope).await.unwrap();
7622        assert_eq!(duplicate, first, "exact payload retry is idempotent");
7623        assert_ne!(corrected.id, first.id);
7624        assert_eq!(corrected.generation, first.generation + 1);
7625        assert_eq!(inbox.inspect("session").await.unwrap().pending, 2);
7626    }
7627
7628    #[cfg(unix)]
7629    #[tokio::test]
7630    async fn background_completion_fires_configured_post_tool_use_command() {
7631        use bamboo_config::{
7632            LifecycleHookGroup, LifecycleHookHandler, LifecycleHooksConfig,
7633            DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS,
7634        };
7635
7636        let dir = tempfile::tempdir().unwrap();
7637        let output = dir.path().join("background-post-tool.json");
7638        let command = format!(
7639            "cat > '{}'; printf '%s' '{{\"additional_context\":\"inspect the completed build log\"}}'",
7640            output.display()
7641        );
7642        let config = LifecycleHooksConfig {
7643            enabled: true,
7644            post_tool_use: vec![LifecycleHookGroup {
7645                enabled: true,
7646                matcher: Some("^Bash$".to_string()),
7647                hooks: vec![LifecycleHookHandler::command(
7648                    command,
7649                    DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS,
7650                )],
7651            }],
7652            ..Default::default()
7653        };
7654        let mut session = Session::new("session-bg-hook", "test-model");
7655        session.workspace = Some(dir.path().to_string_lossy().into_owned());
7656        let mut info = BashCompletionInfo {
7657            session_id: session.id.clone(),
7658            bash_id: "bg-9".into(),
7659            command: "cargo test".into(),
7660            exit_code: Some(0),
7661            status: "completed".into(),
7662            output_tail: "test result: ok".into(),
7663        };
7664
7665        assert!(run_background_bash_post_tool_hooks(&config, None, &session, &mut info).await);
7666        let envelope: serde_json::Value =
7667            serde_json::from_str(&std::fs::read_to_string(output).unwrap()).unwrap();
7668        assert_eq!(envelope["hook_event_name"], "PostToolUse");
7669        assert_eq!(envelope["tool_name"], "Bash");
7670        assert_eq!(envelope["payload"]["tool_call_id"], "bg-9");
7671        let response = envelope["tool_response"]["result"]
7672            .as_str()
7673            .map(serde_json::from_str::<serde_json::Value>)
7674            .transpose()
7675            .unwrap()
7676            .unwrap();
7677        assert_eq!(response["command"], "cargo test");
7678        assert_eq!(response["status"], "completed");
7679        assert!(info.output_tail.contains("inspect the completed build log"));
7680    }
7681
7682    async fn temp_store() -> (tempfile::TempDir, Arc<dyn Storage>, LockedSessionStore) {
7683        let temp = tempfile::tempdir().unwrap();
7684        let storage: Arc<dyn Storage> = Arc::new(
7685            bamboo_storage::v2::SessionStoreV2::new(temp.path().to_path_buf())
7686                .await
7687                .expect("storage init"),
7688        );
7689        let persistence = LockedSessionStore::new(storage.clone());
7690        (temp, storage, persistence)
7691    }
7692
7693    #[tokio::test]
7694    async fn enqueue_writes_pending_injection_and_preserves_messages() {
7695        let (_temp, storage, persistence) = temp_store().await;
7696
7697        let mut session = Session::new("sess-enq", "test-model");
7698        session.add_message(Message::user("do the build"));
7699        storage.save_session(&session).await.unwrap();
7700
7701        let info = BashCompletionInfo {
7702            session_id: "sess-enq".into(),
7703            bash_id: "sh-1".into(),
7704            command: "make".into(),
7705            exit_code: Some(0),
7706            status: "completed".into(),
7707            output_tail: "done".into(),
7708        };
7709        let saved = enqueue_bash_completion_injection(&persistence, &info)
7710            .await
7711            .expect("enqueue io ok")
7712            .expect("session exists");
7713
7714        let pending = saved
7715            .pending_injected_messages()
7716            .expect("pending injection present");
7717        assert_eq!(pending.len(), 1);
7718        let content = pending[0].get("content").and_then(|v| v.as_str()).unwrap();
7719        assert!(content.contains("sh-1"), "content: {content}");
7720        assert!(content.contains("make"), "content: {content}");
7721        assert!(content.contains("done"), "content: {content}");
7722        // The pre-existing conversation is untouched (no clobber).
7723        assert_eq!(saved.messages.len(), 1);
7724    }
7725
7726    #[tokio::test]
7727    async fn enqueue_returns_none_for_missing_session() {
7728        let (_temp, _storage, persistence) = temp_store().await;
7729        let info = BashCompletionInfo {
7730            session_id: "does-not-exist".into(),
7731            bash_id: "x".into(),
7732            command: "true".into(),
7733            exit_code: Some(0),
7734            status: "completed".into(),
7735            output_tail: String::new(),
7736        };
7737        let result = enqueue_bash_completion_injection(&persistence, &info)
7738            .await
7739            .expect("io ok");
7740        assert!(result.is_none(), "no session → nothing enqueued");
7741    }
7742
7743    // ── push-driven resume: the state transition + decision the push applies ──
7744
7745    /// A session suspended on `waiting_for_bash`, given the rich completion
7746    /// message, is transitioned to a resumable state: the wait is cleared, the
7747    /// runtime is Idle, the suspend-reason marker is gone, and the resume message
7748    /// is appended. This is exactly what the PUSH does to wake the loop
7749    /// event-driven (vs the old backstop poll).
7750    #[test]
7751    fn apply_bash_resume_transition_clears_wait_and_appends_message() {
7752        use bamboo_domain::session::runtime_state::WaitingForBashState;
7753
7754        let mut session = Session::new("sess-resume", "test-model");
7755        session.add_message(Message::user("kick off the build"));
7756        let mut rt = read_runtime_state(&session);
7757        rt.status = AgentStatusState::Running;
7758        rt.waiting_for_bash = Some(WaitingForBashState::for_bash(
7759            vec!["sh-1".into()],
7760            Utc::now(),
7761        ));
7762        write_runtime_state(&mut session, &rt);
7763        session.metadata.insert(
7764            "runtime.suspend_reason".to_string(),
7765            "waiting_for_bash".to_string(),
7766        );
7767
7768        let resume = bash_completion_resume_message(&["sh-1".to_string()], false);
7769        let did = apply_bash_resume_transition(&mut session, &resume);
7770
7771        assert!(did, "a suspended session must transition");
7772        let after = read_runtime_state(&session);
7773        assert!(
7774            after.waiting_for_bash.is_none(),
7775            "bash wait must be cleared"
7776        );
7777        assert_eq!(after.status, AgentStatusState::Idle, "runtime must be Idle");
7778        assert!(
7779            !session.metadata.contains_key("runtime.suspend_reason"),
7780            "suspend-reason marker must be removed"
7781        );
7782        assert_eq!(session.messages.len(), 2, "resume message must be appended");
7783        assert!(matches!(
7784            session.messages.last().map(|m| &m.role),
7785            Some(Role::User)
7786        ));
7787    }
7788
7789    /// The double-resume guard: a session NOT waiting on bash is a no-op — no
7790    /// message appended, nothing mutated. This is what makes the backstop poll
7791    /// harmlessly yield once the push has already resumed (and vice versa).
7792    #[test]
7793    fn apply_bash_resume_transition_noops_when_not_waiting() {
7794        let mut session = Session::new("sess-live", "test-model");
7795        session.add_message(Message::user("hi"));
7796
7797        let resume = bash_completion_resume_message(&["sh-1".to_string()], false);
7798        let did = apply_bash_resume_transition(&mut session, &resume);
7799
7800        assert!(!did, "a non-waiting session must not transition");
7801        assert_eq!(session.messages.len(), 1, "no resume message appended");
7802    }
7803
7804    /// The resume invariant: push-resume fires ONLY when the loop is suspended on
7805    /// bash AND every waited shell has finished. A still-running sibling shell
7806    /// keeps it on the enqueue path.
7807    #[test]
7808    fn bash_completion_should_resume_only_when_suspended_and_all_done() {
7809        assert!(bash_completion_should_resume(true, true));
7810        assert!(!bash_completion_should_resume(true, false)); // other shells still running
7811        assert!(!bash_completion_should_resume(false, true)); // live loop, not suspended
7812        assert!(!bash_completion_should_resume(false, false));
7813    }
7814
7815    #[test]
7816    fn two_shell_delivery_stages_backlog_before_one_final_activation() {
7817        let mut waiting = true;
7818        let mut durable_backlog = 0;
7819        let mut reservations = 0;
7820
7821        // First shell: its completion is durable, but a sibling still runs.
7822        durable_backlog += 1;
7823        let first = bash_completion_delivery_plan(waiting, false);
7824        assert_eq!(first, BashCompletionDeliveryPlan::DurableOnly);
7825        assert!(waiting);
7826        assert_eq!(durable_backlog, 1);
7827        assert_eq!(reservations, 0);
7828
7829        // Last shell: its own completion joins the same ordered backlog, then
7830        // the durable wait is cleared and exactly one activation is requested.
7831        durable_backlog += 1;
7832        let last = bash_completion_delivery_plan(waiting, true);
7833        assert_eq!(last, BashCompletionDeliveryPlan::ClearWaitThenActivate);
7834        waiting = false;
7835        reservations += 1;
7836        assert!(!waiting);
7837        assert_eq!(durable_backlog, 2);
7838        assert_eq!(reservations, 1);
7839    }
7840
7841    /// The push's resume message carries the shell's identity + status + output
7842    /// tail (so the model needs no `BashOutput` round-trip) and is tagged as a
7843    /// bash-completion resume so it satisfies the `has_pending_user_message` gate.
7844    #[test]
7845    fn bash_resume_message_from_info_carries_bashid_tail_and_kind() {
7846        let info = BashCompletionInfo {
7847            session_id: "s".into(),
7848            bash_id: "sh-42".into(),
7849            command: "cargo test".into(),
7850            exit_code: Some(0),
7851            status: "completed".into(),
7852            output_tail: "test result: ok".into(),
7853        };
7854        let msg = bash_resume_message_from_info(&info);
7855
7856        assert!(matches!(msg.role, Role::User));
7857        assert!(msg.content.contains("sh-42"), "content: {}", msg.content);
7858        assert!(
7859            msg.content.contains("cargo test"),
7860            "content: {}",
7861            msg.content
7862        );
7863        assert!(
7864            msg.content.contains("test result: ok"),
7865            "content: {}",
7866            msg.content
7867        );
7868        assert!(
7869            msg.content.contains("BashOutput"),
7870            "content: {}",
7871            msg.content
7872        );
7873        let meta = serde_json::to_string(&msg.metadata).unwrap();
7874        assert!(
7875            meta.contains(BASH_COMPLETION_RESUME_KIND),
7876            "resume message must be tagged as a bash-completion resume: {meta}"
7877        );
7878    }
7879}