Skip to main content

bamboo_engine/external_agents/
live.rs

1//! Live actor registry: in-band delivery to currently-running actor children.
2//!
3//! While `ActorChildRunner` drives a child over WebSocket, it registers a frame
4//! sender here keyed by `child_session_id`. `send_message` (running, no
5//! interrupt) consults this map: when the child is live, the message rides the
6//! existing WS as a `ParentFrame::Message` and is admitted by the worker's
7//! agent loop at its next round boundary — the same mechanism in-process
8//! children use, extended across the process boundary. When the child is not
9//! live, callers fall back to the durable `pending_injected_messages` queue.
10
11use std::collections::HashMap;
12use std::sync::{Mutex, OnceLock};
13
14use bamboo_agent_core::AgentEvent;
15use bamboo_domain::poison::PoisonRecover;
16use bamboo_subagent::proto::ParentFrame;
17use tokio::sync::mpsc;
18
19use super::approval_registry::{
20    ApprovalRegistry, ApprovalState, DurableApproval, SharedApprovalRegistry,
21};
22
23type ScopeId = usize;
24type LiveKey = (ScopeId, String, u32);
25type PendingKey = (ScopeId, String, String, u32, String);
26
27fn scope_id(registry: Option<&SharedApprovalRegistry>) -> ScopeId {
28    registry.map_or(0, |registry| registry.lock().recover_poison().scope_id())
29}
30
31fn map() -> &'static Mutex<HashMap<LiveKey, mpsc::UnboundedSender<ParentFrame>>> {
32    static MAP: OnceLock<Mutex<HashMap<LiveKey, mpsc::UnboundedSender<ParentFrame>>>> =
33        OnceLock::new();
34    MAP.get_or_init(|| Mutex::new(HashMap::new()))
35}
36
37/// Process-global registry of pending human-loop approval requests, keyed by
38/// `child_id` → set of `request_id`s currently awaiting a decision. Only the
39/// human-in-the-loop path (top orchestrator) registers here; trusted internal
40/// paths (model-review, escalation-bridge) do NOT — so the external handler's
41/// [`deliver_approval_checked`] correctly rejects any stray external POST aimed
42/// at a request that isn't a genuinely-pending human-loop one.
43#[derive(Clone)]
44struct PendingApproval {
45    parent_session_id: String,
46    tool_name: String,
47    permission: String,
48    resource: String,
49    created_at: String,
50    version: u64,
51    child_attempt: u32,
52    event_tx: mpsc::Sender<AgentEvent>,
53}
54
55fn pending() -> &'static Mutex<HashMap<PendingKey, PendingApproval>> {
56    static PENDING: OnceLock<Mutex<HashMap<PendingKey, PendingApproval>>> = OnceLock::new();
57    PENDING.get_or_init(|| Mutex::new(HashMap::new()))
58}
59
60/// Configure durable approval storage and fail-close records whose live
61/// transport was lost across restart.
62pub fn initialize_durable_approvals(
63    path: std::path::PathBuf,
64) -> std::io::Result<(SharedApprovalRegistry, Vec<AgentEvent>)> {
65    let mut registry = ApprovalRegistry::open(path)?;
66    let reconciled = registry.reconcile_restart()?;
67    let events = reconciled.into_iter().map(record_event).collect();
68    Ok((std::sync::Arc::new(Mutex::new(registry)), events))
69}
70
71/// Record a `(child_id, request_id)` as a pending human-loop approval. Called
72/// just before surfacing `ChildApprovalRequested` so an external POST can be
73/// correlated against a genuinely-pending request.
74pub fn register_pending_approval_observed(
75    registry: Option<&SharedApprovalRegistry>,
76    parent_session_id: &str,
77    child_id: &str,
78    child_attempt: u32,
79    request_id: &str,
80    tool_name: &str,
81    permission: &str,
82    resource: &str,
83    event_tx: mpsc::Sender<AgentEvent>,
84) -> (u64, String) {
85    let now = chrono::Utc::now();
86    let version = now.timestamp_micros().max(0) as u64;
87    let created_at = now.to_rfc3339();
88    let durable_record = DurableApproval {
89        parent_session_id: parent_session_id.to_string(),
90        child_session_id: child_id.to_string(),
91        child_attempt,
92        request_id: request_id.to_string(),
93        tool_name: tool_name.to_string(),
94        permission: permission.to_string(),
95        resource: resource.to_string(),
96        created_at: created_at.clone(),
97        updated_at: created_at.clone(),
98        version,
99        state: ApprovalState::Pending,
100        approved: None,
101        reason: None,
102    };
103    if let Some(registry) = registry {
104        if let Err(error) = registry.lock().recover_poison().register(durable_record) {
105            tracing::error!("failed to persist pending child approval: {error}");
106            return (0, created_at);
107        }
108    }
109    pending().lock().recover_poison().insert(
110        (
111            scope_id(registry),
112            parent_session_id.to_string(),
113            child_id.to_string(),
114            child_attempt,
115            request_id.to_string(),
116        ),
117        PendingApproval {
118            parent_session_id: parent_session_id.to_string(),
119            tool_name: tool_name.to_string(),
120            permission: permission.to_string(),
121            resource: resource.to_string(),
122            created_at: created_at.clone(),
123            version,
124            child_attempt,
125            event_tx,
126        },
127    );
128    (version, created_at)
129}
130
131#[cfg(test)]
132fn register_pending_approval(child_id: &str, request_id: &str) {
133    let (event_tx, _rx) = mpsc::channel(1);
134    let _ = register_pending_approval_observed(
135        None,
136        "test-parent",
137        child_id,
138        0,
139        request_id,
140        "test-tool",
141        "test-permission",
142        "test-resource",
143        event_tx,
144    );
145}
146
147/// One-shot consume of a `(child_id, request_id)` pending pair: remove it and
148/// return whether it WAS present. A second call for the same pair returns
149/// `false`, so a request can't be answered (or replayed) twice.
150pub fn take_pending_approval(child_id: &str, request_id: &str) -> bool {
151    remove_unique_pending(None, child_id, request_id).is_some()
152}
153
154/// Drop all pending approvals for a child (e.g. when its live connection ends).
155pub fn clear_pending_approvals_for(
156    registry: Option<&SharedApprovalRegistry>,
157    child_id: &str,
158    child_attempt: u32,
159) {
160    let records: Vec<_> = {
161        let mut guard = pending().lock().recover_poison();
162        let keys: Vec<_> = guard
163            .keys()
164            .filter(|(scope, _, child, attempt, _)| {
165                *scope == scope_id(registry) && child == child_id && *attempt == child_attempt
166            })
167            .cloned()
168            .collect();
169        keys.into_iter()
170            .filter_map(|key| guard.remove(&key).map(|record| (key.4, record)))
171            .collect()
172    };
173    for (request_id, record) in records {
174        let durable = finish_durable(
175            registry,
176            &record.parent_session_id,
177            child_id,
178            record.child_attempt,
179            &request_id,
180            false,
181            Some("child_disconnected"),
182        );
183        if registry.is_none() || durable.is_some() {
184            emit_resolution(
185                child_id,
186                &request_id,
187                record,
188                "delivery_failed",
189                Some("child_disconnected"),
190                durable.as_ref(),
191            );
192        }
193    }
194}
195
196pub fn expire_pending_approval(
197    registry: Option<&SharedApprovalRegistry>,
198    child_id: &str,
199    request_id: &str,
200) -> bool {
201    let record = remove_unique_pending(registry, child_id, request_id);
202    let Some(record) = record else {
203        return false;
204    };
205    let durable = finish_durable(
206        registry,
207        &record.parent_session_id,
208        child_id,
209        record.child_attempt,
210        request_id,
211        false,
212        Some("approval_timeout"),
213    );
214    if registry.is_none() || durable.is_some() {
215        emit_resolution(
216            child_id,
217            request_id,
218            record,
219            "expired",
220            Some("approval_timeout"),
221            durable.as_ref(),
222        );
223    }
224    true
225}
226
227fn emit_resolution(
228    child_id: &str,
229    request_id: &str,
230    record: PendingApproval,
231    status: &str,
232    reason: Option<&str>,
233    durable: Option<&DurableApproval>,
234) {
235    let now = chrono::Utc::now();
236    let event = AgentEvent::ChildApprovalChanged {
237        parent_session_id: record.parent_session_id,
238        child_session_id: child_id.to_string(),
239        child_attempt: record.child_attempt,
240        request_id: request_id.to_string(),
241        version: durable.map_or_else(
242            || (now.timestamp_micros().max(0) as u64).max(record.version.saturating_add(1)),
243            |record| record.version,
244        ),
245        status: status.to_string(),
246        reason: reason.map(str::to_string),
247        tool_name: record.tool_name,
248        permission: record.permission,
249        resource: record.resource,
250        created_at: record.created_at,
251        resolved_at: Some(
252            durable
253                .map(|record| record.updated_at.clone())
254                .unwrap_or_else(|| now.to_rfc3339()),
255        ),
256    };
257    match record.event_tx.try_send(event) {
258        Ok(()) | Err(mpsc::error::TrySendError::Closed(_)) => {}
259        Err(mpsc::error::TrySendError::Full(event)) => {
260            let tx = record.event_tx;
261            tokio::spawn(async move {
262                let _ = tx.send(event).await;
263            });
264        }
265    }
266}
267
268/// Validated external entry point: deliver an approval decision ONLY if the
269/// `(child_id, request_id)` pair is currently pending. Consumes the pending
270/// entry (one-shot) before delivering, so the same request can't be replayed,
271/// and rejects (returns `false`) any `request_id` that isn't currently pending
272/// — unknown, already-answered/timed-out, or a non-human-loop path
273/// (model-review / escalation) that never registered. This is the entry the
274/// external HTTP handler must use.
275pub fn deliver_approval_checked(
276    registry: Option<&SharedApprovalRegistry>,
277    child_id: &str,
278    request_id: &str,
279    approved: bool,
280) -> bool {
281    let Some((key, record)) = find_unique_pending(registry, child_id, request_id) else {
282        return false;
283    };
284    // Persist DecisionRecorded before touching the live transport. Duplicate or
285    // concurrent decisions fail this transition and cannot deliver twice.
286    if let Some(registry) = registry {
287        match registry.lock().recover_poison().record_decision(
288            &record.parent_session_id,
289            child_id,
290            record.child_attempt,
291            request_id,
292            approved,
293        ) {
294            Ok(Some(_)) => {}
295            Ok(None) => return false,
296            Err(error) => {
297                tracing::error!("failed to persist child approval decision: {error}");
298                return false;
299            }
300        }
301    }
302    let Some(record) = pending().lock().recover_poison().remove(&key) else {
303        let _ = finish_durable(
304            registry,
305            &record.parent_session_id,
306            child_id,
307            record.child_attempt,
308            request_id,
309            false,
310            Some("pending_state_lost"),
311        );
312        return false;
313    };
314    let delivered = deliver_approval_scoped(
315        registry,
316        child_id,
317        record.child_attempt,
318        request_id,
319        approved,
320    );
321    let status = if delivered {
322        if approved {
323            "approved"
324        } else {
325            "denied"
326        }
327    } else {
328        "delivery_failed"
329    };
330    let durable = finish_durable(
331        registry,
332        &record.parent_session_id,
333        child_id,
334        record.child_attempt,
335        request_id,
336        delivered,
337        (!delivered).then_some("child_not_live"),
338    );
339    if registry.is_none() || durable.is_some() {
340        emit_resolution(
341            child_id,
342            request_id,
343            record,
344            status,
345            (!delivered).then_some("child_not_live"),
346            durable.as_ref(),
347        );
348    }
349    delivered
350}
351
352fn finish_durable(
353    registry: Option<&SharedApprovalRegistry>,
354    parent_id: &str,
355    child_id: &str,
356    child_attempt: u32,
357    request_id: &str,
358    delivered: bool,
359    reason: Option<&str>,
360) -> Option<DurableApproval> {
361    if let Some(registry) = registry {
362        match registry.lock().recover_poison().finish(
363            parent_id,
364            child_id,
365            child_attempt,
366            request_id,
367            delivered,
368            reason,
369        ) {
370            Ok(record) => return record,
371            Err(error) => {
372                tracing::error!("failed to persist child approval resolution: {error}");
373            }
374        }
375    }
376    None
377}
378
379fn record_event(record: DurableApproval) -> AgentEvent {
380    AgentEvent::ChildApprovalChanged {
381        parent_session_id: record.parent_session_id,
382        child_session_id: record.child_session_id,
383        child_attempt: record.child_attempt,
384        request_id: record.request_id,
385        version: record.version,
386        status: match record.state {
387            ApprovalState::Pending => "pending",
388            ApprovalState::DecisionRecorded => "decision_recorded",
389            ApprovalState::Delivered if record.approved == Some(true) => "approved",
390            ApprovalState::Delivered => "denied",
391            ApprovalState::DeliveryFailed => "delivery_failed",
392            ApprovalState::Expired => "expired",
393        }
394        .to_string(),
395        reason: record.reason,
396        tool_name: record.tool_name,
397        permission: record.permission,
398        resource: record.resource,
399        created_at: record.created_at,
400        resolved_at: Some(record.updated_at),
401    }
402}
403
404/// Unregisters the child on drop, so a panicking/returning runner can't leak
405/// a stale sender.
406pub struct LiveActorGuard {
407    scope_id: ScopeId,
408    child_id: String,
409    child_attempt: u32,
410    approval_registry: Option<SharedApprovalRegistry>,
411}
412
413impl Drop for LiveActorGuard {
414    fn drop(&mut self) {
415        map().lock().recover_poison().remove(&(
416            self.scope_id,
417            self.child_id.clone(),
418            self.child_attempt,
419        ));
420        // A disconnecting child can't answer any still-pending approval — drop
421        // them so a late external POST finds nothing pending and is rejected.
422        clear_pending_approvals_for(
423            self.approval_registry.as_ref(),
424            &self.child_id,
425            self.child_attempt,
426        );
427    }
428}
429
430/// Register a live child's frame sender for the duration of its run.
431pub fn register(
432    child_id: &str,
433    tx: mpsc::UnboundedSender<ParentFrame>,
434    child_attempt: u32,
435    approval_registry: Option<SharedApprovalRegistry>,
436) -> LiveActorGuard {
437    let scope_id = scope_id(approval_registry.as_ref());
438    map()
439        .lock()
440        .recover_poison()
441        .insert((scope_id, child_id.to_string(), child_attempt), tx);
442    LiveActorGuard {
443        scope_id,
444        child_id: child_id.to_string(),
445        child_attempt,
446        approval_registry,
447    }
448}
449
450fn find_unique_pending(
451    registry: Option<&SharedApprovalRegistry>,
452    child_id: &str,
453    request_id: &str,
454) -> Option<(PendingKey, PendingApproval)> {
455    let guard = pending().lock().recover_poison();
456    let scope = scope_id(registry);
457    let mut matches = guard
458        .iter()
459        .filter(|(key, _)| key.0 == scope && key.2 == child_id && key.4 == request_id);
460    let (key, record) = matches.next()?;
461    if matches.next().is_some() {
462        return None;
463    }
464    Some((key.clone(), record.clone()))
465}
466
467fn remove_unique_pending(
468    registry: Option<&SharedApprovalRegistry>,
469    child_id: &str,
470    request_id: &str,
471) -> Option<PendingApproval> {
472    let mut guard = pending().lock().recover_poison();
473    let scope = scope_id(registry);
474    let mut keys = guard
475        .keys()
476        .filter(|(key_scope, _, child, _, request)| {
477            *key_scope == scope && child == child_id && request == request_id
478        })
479        .cloned();
480    let key = keys.next()?;
481    if keys.next().is_some() {
482        return None;
483    }
484    guard.remove(&key)
485}
486
487/// Deliver an in-band steering message to a live child. Returns `false` when
488/// the child is not live (caller should use the durable queue instead).
489pub fn deliver_message(child_id: &str, text: &str) -> bool {
490    let guard = map().lock().recover_poison();
491    let mut senders = guard
492        .iter()
493        .filter(|((_, child, _), _)| child == child_id)
494        .map(|(_, sender)| sender);
495    let Some(tx) = senders.next() else {
496        return false;
497    };
498    if senders.next().is_some() {
499        return false;
500    }
501    tx.send(ParentFrame::Message {
502        text: text.to_string(),
503    })
504    .is_ok()
505}
506
507/// Deliver a host/human approval decision to a live child's pending gated-tool
508/// request (Phase 2: child → parent approval delegation). Sends
509/// `ParentFrame::ApprovalReply{id, approved}` over the child's live WS
510/// connection; `drive()` forwards it to the worker, whose pending map resolves
511/// the `host.approval_call` the child's gated tool is blocked on (approve ⇒ the
512/// tool proceeds, deny ⇒ it fails closed). This is the decision-DOWN half of the
513/// human-in-the-loop route: a parent-side responder (e.g. a `/respond`-style
514/// handler) calls this with the `request_id` it surfaced to the human. Returns
515/// `false` when the child is not live (no connection to answer on — the caller
516/// should treat that as a denied/expired request).
517pub fn deliver_approval(child_id: &str, request_id: &str, approved: bool) -> bool {
518    deliver_approval_scoped(None, child_id, 0, request_id, approved)
519}
520
521pub fn deliver_approval_scoped(
522    registry: Option<&SharedApprovalRegistry>,
523    child_id: &str,
524    child_attempt: u32,
525    request_id: &str,
526    approved: bool,
527) -> bool {
528    let guard = map().lock().recover_poison();
529    match guard.get(&(scope_id(registry), child_id.to_string(), child_attempt)) {
530        Some(tx) => tx
531            .send(ParentFrame::ApprovalReply {
532                id: request_id.to_string(),
533                approved,
534            })
535            .is_ok(),
536        None => false,
537    }
538}
539
540/// Whether a child currently has a live actor connection.
541pub fn is_live(child_id: &str) -> bool {
542    map()
543        .lock()
544        .recover_poison()
545        .keys()
546        .any(|(_, child, _)| child == child_id)
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    fn registry() -> SharedApprovalRegistry {
554        std::sync::Arc::new(Mutex::new(
555            ApprovalRegistry::open(tempfile::tempdir().unwrap().keep().join("registry.json"))
556                .unwrap(),
557        ))
558    }
559
560    #[test]
561    fn register_deliver_unregister() {
562        let (tx, mut rx) = mpsc::unbounded_channel();
563        let guard = register("c-live", tx, 0, None);
564        assert!(is_live("c-live"));
565        assert!(deliver_message("c-live", "hi"));
566        match rx.try_recv() {
567            Ok(ParentFrame::Message { text }) => assert_eq!(text, "hi"),
568            other => panic!("expected message frame, got {other:?}"),
569        }
570
571        drop(guard);
572        assert!(!is_live("c-live"));
573        assert!(!deliver_message("c-live", "gone"));
574    }
575
576    #[test]
577    fn deliver_fails_when_receiver_dropped() {
578        let (tx, rx) = mpsc::unbounded_channel();
579        let _guard = register("c-dead", tx, 0, None);
580        drop(rx);
581        assert!(!deliver_message("c-dead", "hi"));
582    }
583
584    #[test]
585    fn deliver_approval_routes_reply_frame() {
586        let (tx, mut rx) = mpsc::unbounded_channel();
587        let guard = register("c-appr", tx, 0, None);
588        assert!(deliver_approval("c-appr", "req-7", true));
589        match rx.try_recv() {
590            Ok(ParentFrame::ApprovalReply { id, approved }) => {
591                assert_eq!(id, "req-7");
592                assert!(approved);
593            }
594            other => panic!("expected approval reply, got {other:?}"),
595        }
596        drop(guard);
597        // Not-live child ⇒ false (no connection to answer on).
598        assert!(!deliver_approval("c-appr", "req-8", false));
599    }
600
601    #[test]
602    fn app_scopes_and_attempt_guards_do_not_cross_talk() {
603        let first_registry = registry();
604        let second_registry = registry();
605        let (first_tx, mut first_rx) = mpsc::unbounded_channel();
606        let (retry_tx, mut retry_rx) = mpsc::unbounded_channel();
607        let (second_tx, mut second_rx) = mpsc::unbounded_channel();
608        let old_guard = register("shared-child", first_tx, 1, Some(first_registry.clone()));
609        let retry_guard = register("shared-child", retry_tx, 2, Some(first_registry.clone()));
610        let second_guard = register("shared-child", second_tx, 1, Some(second_registry.clone()));
611
612        drop(old_guard);
613        assert!(deliver_approval_scoped(
614            Some(&first_registry),
615            "shared-child",
616            2,
617            "retry-request",
618            true,
619        ));
620        assert!(matches!(
621            retry_rx.try_recv(),
622            Ok(ParentFrame::ApprovalReply { id, .. }) if id == "retry-request"
623        ));
624        assert!(deliver_approval_scoped(
625            Some(&second_registry),
626            "shared-child",
627            1,
628            "other-app-request",
629            false,
630        ));
631        assert!(matches!(
632            second_rx.try_recv(),
633            Ok(ParentFrame::ApprovalReply { id, .. }) if id == "other-app-request"
634        ));
635        assert!(first_rx.try_recv().is_err());
636        drop(retry_guard);
637        drop(second_guard);
638    }
639
640    #[test]
641    fn pending_approval_is_one_shot() {
642        register_pending_approval("c-pend", "req-1");
643        // First take consumes it; the second finds nothing.
644        assert!(take_pending_approval("c-pend", "req-1"));
645        assert!(!take_pending_approval("c-pend", "req-1"));
646    }
647
648    #[test]
649    fn take_of_unregistered_pair_is_false() {
650        // Unknown child entirely.
651        assert!(!take_pending_approval("c-unknown", "req-x"));
652        // Known child, but an unregistered request_id.
653        register_pending_approval("c-known", "req-real");
654        assert!(!take_pending_approval("c-known", "req-bogus"));
655        // The real one is still pending (a bogus take didn't disturb it).
656        assert!(take_pending_approval("c-known", "req-real"));
657    }
658
659    #[test]
660    fn deliver_approval_checked_only_delivers_for_registered_pair() {
661        let (tx, mut rx) = mpsc::unbounded_channel();
662        let _guard = register("c-checked", tx, 0, None);
663
664        // Not registered ⇒ rejected, nothing on the wire.
665        assert!(!deliver_approval_checked(
666            None,
667            "c-checked",
668            "req-stray",
669            true
670        ));
671        assert!(rx.try_recv().is_err());
672
673        // Registered ⇒ delivered, frame rides the wire, and consumed.
674        register_pending_approval("c-checked", "req-ok");
675        assert!(deliver_approval_checked(None, "c-checked", "req-ok", true));
676        match rx.try_recv() {
677            Ok(ParentFrame::ApprovalReply { id, approved }) => {
678                assert_eq!(id, "req-ok");
679                assert!(approved);
680            }
681            other => panic!("expected approval reply, got {other:?}"),
682        }
683        // One-shot: a replay is rejected (and nothing further on the wire).
684        assert!(!deliver_approval_checked(None, "c-checked", "req-ok", true));
685        assert!(rx.try_recv().is_err());
686    }
687
688    #[test]
689    fn clear_pending_approvals_for_drops_them() {
690        register_pending_approval("c-clear", "req-a");
691        register_pending_approval("c-clear", "req-b");
692        clear_pending_approvals_for(None, "c-clear", 0);
693        assert!(!take_pending_approval("c-clear", "req-a"));
694        assert!(!take_pending_approval("c-clear", "req-b"));
695    }
696
697    #[tokio::test]
698    async fn observed_approval_emits_exactly_one_terminal_outcome() {
699        let (wire_tx, _wire_rx) = mpsc::unbounded_channel();
700        let _guard = register("c-audit", wire_tx, 0, None);
701        let (event_tx, mut event_rx) = mpsc::channel(8);
702        register_pending_approval_observed(
703            None,
704            "parent-audit",
705            "c-audit",
706            0,
707            "req-audit",
708            "Bash",
709            "execute",
710            "/tmp/x",
711            event_tx,
712        );
713
714        assert!(deliver_approval_checked(
715            None,
716            "c-audit",
717            "req-audit",
718            false
719        ));
720        assert!(!deliver_approval_checked(
721            None,
722            "c-audit",
723            "req-audit",
724            true
725        ));
726        assert!(matches!(
727            event_rx.recv().await,
728            Some(AgentEvent::ChildApprovalChanged { status, .. }) if status == "denied"
729        ));
730        assert!(event_rx.try_recv().is_err());
731    }
732
733    #[tokio::test]
734    async fn durable_resolution_uses_registry_version_and_attempt() {
735        let registry = registry();
736        let (wire_tx, _wire_rx) = mpsc::unbounded_channel();
737        let _guard = register("c-versioned", wire_tx, 7, Some(registry.clone()));
738        let (event_tx, mut event_rx) = mpsc::channel(4);
739        let (pending_version, _) = register_pending_approval_observed(
740            Some(&registry),
741            "parent-versioned",
742            "c-versioned",
743            7,
744            "req-versioned",
745            "Bash",
746            "execute",
747            "/tmp/versioned",
748            event_tx,
749        );
750
751        assert!(deliver_approval_checked(
752            Some(&registry),
753            "c-versioned",
754            "req-versioned",
755            true,
756        ));
757        assert!(matches!(
758            event_rx.recv().await,
759            Some(AgentEvent::ChildApprovalChanged {
760                child_attempt: 7,
761                version,
762                status,
763                ..
764            }) if version == pending_version + 2 && status == "approved"
765        ));
766    }
767
768    #[tokio::test]
769    async fn timeout_and_disconnect_emit_terminal_outcomes() {
770        let (event_tx, mut event_rx) = mpsc::channel(8);
771        register_pending_approval_observed(
772            None,
773            "parent-audit",
774            "c-expire",
775            0,
776            "req-expire",
777            "Bash",
778            "execute",
779            "/tmp/x",
780            event_tx.clone(),
781        );
782        assert!(expire_pending_approval(None, "c-expire", "req-expire"));
783        assert!(!expire_pending_approval(None, "c-expire", "req-expire"));
784        assert!(matches!(
785            event_rx.recv().await,
786            Some(AgentEvent::ChildApprovalChanged { status, .. }) if status == "expired"
787        ));
788
789        register_pending_approval_observed(
790            None,
791            "parent-audit",
792            "c-disconnect",
793            0,
794            "req-disconnect",
795            "Write",
796            "write",
797            "/tmp/y",
798            event_tx,
799        );
800        clear_pending_approvals_for(None, "c-disconnect", 0);
801        assert!(matches!(
802            event_rx.recv().await,
803            Some(AgentEvent::ChildApprovalChanged { status, .. }) if status == "delivery_failed"
804        ));
805    }
806}