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    ApprovalDecisionCasResult, ApprovalRegistry, ApprovalState, DurableApproval,
21    SharedApprovalRegistry,
22};
23
24type ScopeId = usize;
25type LiveKey = (ScopeId, String, u32);
26type PendingKey = (ScopeId, String, String, u32, String);
27
28fn scope_id(registry: Option<&SharedApprovalRegistry>) -> ScopeId {
29    registry.map_or(0, |registry| registry.lock().recover_poison().scope_id())
30}
31
32fn map() -> &'static Mutex<HashMap<LiveKey, mpsc::UnboundedSender<ParentFrame>>> {
33    static MAP: OnceLock<Mutex<HashMap<LiveKey, mpsc::UnboundedSender<ParentFrame>>>> =
34        OnceLock::new();
35    MAP.get_or_init(|| Mutex::new(HashMap::new()))
36}
37
38/// Process-global registry of pending human-loop approval requests, keyed by
39/// `child_id` → set of `request_id`s currently awaiting a decision. Only the
40/// human-in-the-loop path (top orchestrator) registers here; trusted internal
41/// paths (model-review, escalation-bridge) do NOT — so the external handler's
42/// [`deliver_approval_checked`] correctly rejects any stray external POST aimed
43/// at a request that isn't a genuinely-pending human-loop one.
44#[derive(Clone)]
45struct PendingApproval {
46    parent_session_id: String,
47    tool_name: String,
48    permission: String,
49    resource: String,
50    created_at: String,
51    version: u64,
52    child_attempt: u32,
53    event_tx: mpsc::Sender<AgentEvent>,
54}
55
56fn pending() -> &'static Mutex<HashMap<PendingKey, PendingApproval>> {
57    static PENDING: OnceLock<Mutex<HashMap<PendingKey, PendingApproval>>> = OnceLock::new();
58    PENDING.get_or_init(|| Mutex::new(HashMap::new()))
59}
60
61/// Configure durable approval storage and fail-close records whose live
62/// transport was lost across restart.
63pub fn initialize_durable_approvals(
64    path: std::path::PathBuf,
65) -> std::io::Result<(SharedApprovalRegistry, Vec<AgentEvent>)> {
66    let mut registry = ApprovalRegistry::open(path)?;
67    let reconciled = registry.reconcile_restart()?;
68    let events = reconciled.into_iter().map(record_event).collect();
69    Ok((std::sync::Arc::new(Mutex::new(registry)), events))
70}
71
72/// Named identity, audit, and delivery inputs for one observed approval.
73pub struct PendingApprovalObservation<'a> {
74    pub registry: Option<&'a SharedApprovalRegistry>,
75    pub parent_session_id: &'a str,
76    pub child_id: &'a str,
77    pub child_attempt: u32,
78    pub request_id: &'a str,
79    pub tool_name: &'a str,
80    pub permission: &'a str,
81    pub resource: &'a str,
82    pub event_tx: mpsc::Sender<AgentEvent>,
83}
84
85/// Record a `(child_id, request_id)` as a pending human-loop approval. Called
86/// just before surfacing `ChildApprovalRequested` so an external POST can be
87/// correlated against a genuinely-pending request.
88pub fn observe_pending_approval(observation: PendingApprovalObservation<'_>) -> (u64, String) {
89    let PendingApprovalObservation {
90        registry,
91        parent_session_id,
92        child_id,
93        child_attempt,
94        request_id,
95        tool_name,
96        permission,
97        resource,
98        event_tx,
99    } = observation;
100    let now = chrono::Utc::now();
101    let version = now.timestamp_micros().max(0) as u64;
102    let created_at = now.to_rfc3339();
103    let durable_record = DurableApproval {
104        parent_session_id: parent_session_id.to_string(),
105        child_session_id: child_id.to_string(),
106        child_attempt,
107        request_id: request_id.to_string(),
108        tool_name: tool_name.to_string(),
109        permission: permission.to_string(),
110        resource: resource.to_string(),
111        created_at: created_at.clone(),
112        updated_at: created_at.clone(),
113        version,
114        state: ApprovalState::Pending,
115        approved: None,
116        reason: None,
117    };
118    if let Some(registry) = registry {
119        if let Err(error) = registry.lock().recover_poison().register(durable_record) {
120            tracing::error!("failed to persist pending child approval: {error}");
121            return (0, created_at);
122        }
123    }
124    pending().lock().recover_poison().insert(
125        (
126            scope_id(registry),
127            parent_session_id.to_string(),
128            child_id.to_string(),
129            child_attempt,
130            request_id.to_string(),
131        ),
132        PendingApproval {
133            parent_session_id: parent_session_id.to_string(),
134            tool_name: tool_name.to_string(),
135            permission: permission.to_string(),
136            resource: resource.to_string(),
137            created_at: created_at.clone(),
138            version,
139            child_attempt,
140            event_tx,
141        },
142    );
143    (version, created_at)
144}
145
146/// Backward-compatible positional wrapper for existing engine consumers.
147///
148/// New call sites should use [`observe_pending_approval`] so each approval
149/// identity and audit field is named at the call site.
150#[allow(
151    clippy::too_many_arguments,
152    reason = "public compatibility wrapper; the typed observation is the canonical API"
153)]
154pub fn register_pending_approval_observed(
155    registry: Option<&SharedApprovalRegistry>,
156    parent_session_id: &str,
157    child_id: &str,
158    child_attempt: u32,
159    request_id: &str,
160    tool_name: &str,
161    permission: &str,
162    resource: &str,
163    event_tx: mpsc::Sender<AgentEvent>,
164) -> (u64, String) {
165    observe_pending_approval(PendingApprovalObservation {
166        registry,
167        parent_session_id,
168        child_id,
169        child_attempt,
170        request_id,
171        tool_name,
172        permission,
173        resource,
174        event_tx,
175    })
176}
177
178#[cfg(test)]
179fn register_pending_approval(child_id: &str, request_id: &str) {
180    let (event_tx, _rx) = mpsc::channel(1);
181    let _ = observe_pending_approval(PendingApprovalObservation {
182        registry: None,
183        parent_session_id: "test-parent",
184        child_id,
185        child_attempt: 0,
186        request_id,
187        tool_name: "test-tool",
188        permission: "test-permission",
189        resource: "test-resource",
190        event_tx,
191    });
192}
193
194/// One-shot consume of a `(child_id, request_id)` pending pair: remove it and
195/// return whether it WAS present. A second call for the same pair returns
196/// `false`, so a request can't be answered (or replayed) twice.
197pub fn take_pending_approval(child_id: &str, request_id: &str) -> bool {
198    remove_unique_pending(None, child_id, request_id).is_some()
199}
200
201/// Drop all pending approvals for a child (e.g. when its live connection ends).
202pub fn clear_pending_approvals_for(
203    registry: Option<&SharedApprovalRegistry>,
204    child_id: &str,
205    child_attempt: u32,
206) {
207    let records: Vec<_> = {
208        let mut guard = pending().lock().recover_poison();
209        let keys: Vec<_> = guard
210            .keys()
211            .filter(|(scope, _, child, attempt, _)| {
212                *scope == scope_id(registry) && child == child_id && *attempt == child_attempt
213            })
214            .cloned()
215            .collect();
216        keys.into_iter()
217            .filter_map(|key| guard.remove(&key).map(|record| (key.4, record)))
218            .collect()
219    };
220    for (request_id, record) in records {
221        let durable = finish_durable(
222            registry,
223            &record.parent_session_id,
224            child_id,
225            record.child_attempt,
226            &request_id,
227            false,
228            Some("child_disconnected"),
229        );
230        if registry.is_none() || durable.is_some() {
231            emit_resolution(
232                child_id,
233                &request_id,
234                record,
235                "delivery_failed",
236                Some("child_disconnected"),
237                durable.as_ref(),
238            );
239        }
240    }
241}
242
243pub fn expire_pending_approval(
244    registry: Option<&SharedApprovalRegistry>,
245    child_id: &str,
246    request_id: &str,
247) -> bool {
248    let record = remove_unique_pending(registry, child_id, request_id);
249    let Some(record) = record else {
250        return false;
251    };
252    let durable = finish_durable(
253        registry,
254        &record.parent_session_id,
255        child_id,
256        record.child_attempt,
257        request_id,
258        false,
259        Some("approval_timeout"),
260    );
261    if registry.is_none() || durable.is_some() {
262        emit_resolution(
263            child_id,
264            request_id,
265            record,
266            "expired",
267            Some("approval_timeout"),
268            durable.as_ref(),
269        );
270    }
271    true
272}
273
274fn emit_resolution(
275    child_id: &str,
276    request_id: &str,
277    record: PendingApproval,
278    status: &str,
279    reason: Option<&str>,
280    durable: Option<&DurableApproval>,
281) {
282    let now = chrono::Utc::now();
283    let event = AgentEvent::ChildApprovalChanged {
284        parent_session_id: record.parent_session_id,
285        child_session_id: child_id.to_string(),
286        child_attempt: record.child_attempt,
287        request_id: request_id.to_string(),
288        version: durable.map_or_else(
289            || (now.timestamp_micros().max(0) as u64).max(record.version.saturating_add(1)),
290            |record| record.version,
291        ),
292        status: status.to_string(),
293        reason: reason.map(str::to_string),
294        tool_name: record.tool_name,
295        permission: record.permission,
296        resource: record.resource,
297        created_at: record.created_at,
298        resolved_at: Some(
299            durable
300                .map(|record| record.updated_at.clone())
301                .unwrap_or_else(|| now.to_rfc3339()),
302        ),
303    };
304    match record.event_tx.try_send(event) {
305        Ok(()) | Err(mpsc::error::TrySendError::Closed(_)) => {}
306        Err(mpsc::error::TrySendError::Full(event)) => {
307            let tx = record.event_tx;
308            tokio::spawn(async move {
309                let _ = tx.send(event).await;
310            });
311        }
312    }
313}
314
315/// Validated external entry point: deliver an approval decision ONLY if the
316/// `(child_id, request_id)` pair is currently pending. Consumes the pending
317/// entry (one-shot) before delivering, so the same request can't be replayed,
318/// and rejects (returns `false`) any `request_id` that isn't currently pending
319/// — unknown, already-answered/timed-out, or a non-human-loop path
320/// (model-review / escalation) that never registered. This is the entry the
321/// external HTTP handler must use.
322pub fn deliver_approval_checked(
323    registry: Option<&SharedApprovalRegistry>,
324    child_id: &str,
325    request_id: &str,
326    approved: bool,
327) -> bool {
328    let Some((_, record)) = find_unique_pending(registry, child_id, request_id) else {
329        return false;
330    };
331    deliver_approval_checked_cas(
332        registry,
333        &record.parent_session_id,
334        child_id,
335        record.child_attempt,
336        request_id,
337        record.version,
338        approved,
339    ) == ApprovalDeliveryResult::Delivered
340}
341
342#[derive(Debug, Clone, Copy, PartialEq, Eq)]
343pub enum ApprovalDeliveryResult {
344    Delivered,
345    NotFound,
346    Conflict,
347    DeliveryFailed,
348}
349
350/// Versioned external entry point used by typed HTTP clients. The complete
351/// durable identity is checked against both live pending state and the durable
352/// registry before either is mutated. Identity/version mismatches are reported
353/// as conflicts and never fall back to another attempt with the same request id.
354#[allow(
355    clippy::too_many_arguments,
356    reason = "the complete durable approval CAS identity must remain explicit"
357)]
358pub fn deliver_approval_checked_cas(
359    registry: Option<&SharedApprovalRegistry>,
360    parent_session_id: &str,
361    child_id: &str,
362    child_attempt: u32,
363    request_id: &str,
364    expected_version: u64,
365    approved: bool,
366) -> ApprovalDeliveryResult {
367    let (key, record) = match find_pending_cas(
368        registry,
369        parent_session_id,
370        child_id,
371        child_attempt,
372        request_id,
373        expected_version,
374    ) {
375        Ok(found) => found,
376        Err(result) => return result,
377    };
378    // Persist DecisionRecorded before touching the live transport. Duplicate or
379    // concurrent decisions fail this transition and cannot deliver twice.
380    if let Some(registry) = registry {
381        match registry.lock().recover_poison().record_decision_cas(
382            parent_session_id,
383            child_id,
384            child_attempt,
385            request_id,
386            expected_version,
387            approved,
388        ) {
389            Ok(ApprovalDecisionCasResult::Recorded(_)) => {}
390            Ok(ApprovalDecisionCasResult::Conflict) => return ApprovalDeliveryResult::Conflict,
391            Ok(ApprovalDecisionCasResult::NotFound) => return ApprovalDeliveryResult::NotFound,
392            Err(error) => {
393                tracing::error!("failed to persist child approval decision: {error}");
394                return ApprovalDeliveryResult::DeliveryFailed;
395            }
396        }
397    }
398    let Some(record) = pending().lock().recover_poison().remove(&key) else {
399        let _ = finish_durable(
400            registry,
401            &record.parent_session_id,
402            child_id,
403            record.child_attempt,
404            request_id,
405            false,
406            Some("pending_state_lost"),
407        );
408        return ApprovalDeliveryResult::DeliveryFailed;
409    };
410    let delivered = deliver_approval_scoped(
411        registry,
412        child_id,
413        record.child_attempt,
414        request_id,
415        approved,
416    );
417    let status = if delivered {
418        if approved {
419            "approved"
420        } else {
421            "denied"
422        }
423    } else {
424        "delivery_failed"
425    };
426    let durable = finish_durable(
427        registry,
428        &record.parent_session_id,
429        child_id,
430        record.child_attempt,
431        request_id,
432        delivered,
433        (!delivered).then_some("child_not_live"),
434    );
435    if registry.is_none() || durable.is_some() {
436        emit_resolution(
437            child_id,
438            request_id,
439            record,
440            status,
441            (!delivered).then_some("child_not_live"),
442            durable.as_ref(),
443        );
444    }
445    if delivered {
446        ApprovalDeliveryResult::Delivered
447    } else {
448        ApprovalDeliveryResult::DeliveryFailed
449    }
450}
451
452fn finish_durable(
453    registry: Option<&SharedApprovalRegistry>,
454    parent_id: &str,
455    child_id: &str,
456    child_attempt: u32,
457    request_id: &str,
458    delivered: bool,
459    reason: Option<&str>,
460) -> Option<DurableApproval> {
461    if let Some(registry) = registry {
462        match registry.lock().recover_poison().finish(
463            parent_id,
464            child_id,
465            child_attempt,
466            request_id,
467            delivered,
468            reason,
469        ) {
470            Ok(record) => return record,
471            Err(error) => {
472                tracing::error!("failed to persist child approval resolution: {error}");
473            }
474        }
475    }
476    None
477}
478
479fn record_event(record: DurableApproval) -> AgentEvent {
480    AgentEvent::ChildApprovalChanged {
481        parent_session_id: record.parent_session_id,
482        child_session_id: record.child_session_id,
483        child_attempt: record.child_attempt,
484        request_id: record.request_id,
485        version: record.version,
486        status: match record.state {
487            ApprovalState::Pending => "pending",
488            ApprovalState::DecisionRecorded => "decision_recorded",
489            ApprovalState::Delivered if record.approved == Some(true) => "approved",
490            ApprovalState::Delivered => "denied",
491            ApprovalState::DeliveryFailed => "delivery_failed",
492            ApprovalState::Expired => "expired",
493        }
494        .to_string(),
495        reason: record.reason,
496        tool_name: record.tool_name,
497        permission: record.permission,
498        resource: record.resource,
499        created_at: record.created_at,
500        resolved_at: Some(record.updated_at),
501    }
502}
503
504/// Unregisters the child on drop, so a panicking/returning runner can't leak
505/// a stale sender.
506pub struct LiveActorGuard {
507    scope_id: ScopeId,
508    child_id: String,
509    child_attempt: u32,
510    approval_registry: Option<SharedApprovalRegistry>,
511}
512
513impl Drop for LiveActorGuard {
514    fn drop(&mut self) {
515        map().lock().recover_poison().remove(&(
516            self.scope_id,
517            self.child_id.clone(),
518            self.child_attempt,
519        ));
520        // A disconnecting child can't answer any still-pending approval — drop
521        // them so a late external POST finds nothing pending and is rejected.
522        clear_pending_approvals_for(
523            self.approval_registry.as_ref(),
524            &self.child_id,
525            self.child_attempt,
526        );
527    }
528}
529
530/// Register a live child's frame sender for the duration of its run.
531pub fn register(
532    child_id: &str,
533    tx: mpsc::UnboundedSender<ParentFrame>,
534    child_attempt: u32,
535    approval_registry: Option<SharedApprovalRegistry>,
536) -> LiveActorGuard {
537    let scope_id = scope_id(approval_registry.as_ref());
538    map()
539        .lock()
540        .recover_poison()
541        .insert((scope_id, child_id.to_string(), child_attempt), tx);
542    LiveActorGuard {
543        scope_id,
544        child_id: child_id.to_string(),
545        child_attempt,
546        approval_registry,
547    }
548}
549
550fn find_unique_pending(
551    registry: Option<&SharedApprovalRegistry>,
552    child_id: &str,
553    request_id: &str,
554) -> Option<(PendingKey, PendingApproval)> {
555    let guard = pending().lock().recover_poison();
556    let scope = scope_id(registry);
557    let mut matches = guard
558        .iter()
559        .filter(|(key, _)| key.0 == scope && key.2 == child_id && key.4 == request_id);
560    let (key, record) = matches.next()?;
561    if matches.next().is_some() {
562        return None;
563    }
564    Some((key.clone(), record.clone()))
565}
566
567fn find_pending_cas(
568    registry: Option<&SharedApprovalRegistry>,
569    parent_session_id: &str,
570    child_id: &str,
571    child_attempt: u32,
572    request_id: &str,
573    expected_version: u64,
574) -> Result<(PendingKey, PendingApproval), ApprovalDeliveryResult> {
575    let scope = scope_id(registry);
576    let guard = pending().lock().recover_poison();
577    let key = (
578        scope,
579        parent_session_id.to_string(),
580        child_id.to_string(),
581        child_attempt,
582        request_id.to_string(),
583    );
584    if let Some(record) = guard.get(&key) {
585        if record.parent_session_id != parent_session_id
586            || record.child_attempt != child_attempt
587            || record.version != expected_version
588        {
589            return Err(ApprovalDeliveryResult::Conflict);
590        }
591        return Ok((key, record.clone()));
592    }
593    if guard.keys().any(|candidate| {
594        candidate.0 == scope && candidate.2 == child_id && candidate.4 == request_id
595    }) {
596        Err(ApprovalDeliveryResult::Conflict)
597    } else {
598        Err(ApprovalDeliveryResult::NotFound)
599    }
600}
601
602fn remove_unique_pending(
603    registry: Option<&SharedApprovalRegistry>,
604    child_id: &str,
605    request_id: &str,
606) -> Option<PendingApproval> {
607    let mut guard = pending().lock().recover_poison();
608    let scope = scope_id(registry);
609    let mut keys = guard
610        .keys()
611        .filter(|(key_scope, _, child, _, request)| {
612            *key_scope == scope && child == child_id && request == request_id
613        })
614        .cloned();
615    let key = keys.next()?;
616    if keys.next().is_some() {
617        return None;
618    }
619    guard.remove(&key)
620}
621
622/// Deliver an in-band steering message to a live child. Returns `false` when
623/// the child is not live (caller should use the durable queue instead).
624pub fn deliver_message(child_id: &str, text: &str) -> bool {
625    let guard = map().lock().recover_poison();
626    let mut senders = guard
627        .iter()
628        .filter(|((_, child, _), _)| child == child_id)
629        .map(|(_, sender)| sender);
630    let Some(tx) = senders.next() else {
631        return false;
632    };
633    if senders.next().is_some() {
634        return false;
635    }
636    tx.send(ParentFrame::Message {
637        text: text.to_string(),
638    })
639    .is_ok()
640}
641
642/// Deliver a host/human approval decision to a live child's pending gated-tool
643/// request (Phase 2: child → parent approval delegation). Sends
644/// `ParentFrame::ApprovalReply{id, approved}` over the child's live WS
645/// connection; `drive()` forwards it to the worker, whose pending map resolves
646/// the `host.approval_call` the child's gated tool is blocked on (approve ⇒ the
647/// tool proceeds, deny ⇒ it fails closed). This is the decision-DOWN half of the
648/// human-in-the-loop route: a parent-side responder (e.g. a `/respond`-style
649/// handler) calls this with the `request_id` it surfaced to the human. Returns
650/// `false` when the child is not live (no connection to answer on — the caller
651/// should treat that as a denied/expired request).
652pub fn deliver_approval(child_id: &str, request_id: &str, approved: bool) -> bool {
653    deliver_approval_scoped(None, child_id, 0, request_id, approved)
654}
655
656pub fn deliver_approval_scoped(
657    registry: Option<&SharedApprovalRegistry>,
658    child_id: &str,
659    child_attempt: u32,
660    request_id: &str,
661    approved: bool,
662) -> bool {
663    let guard = map().lock().recover_poison();
664    match guard.get(&(scope_id(registry), child_id.to_string(), child_attempt)) {
665        Some(tx) => tx
666            .send(ParentFrame::ApprovalReply {
667                id: request_id.to_string(),
668                approved,
669            })
670            .is_ok(),
671        None => false,
672    }
673}
674
675/// Whether a child currently has a live actor connection.
676pub fn is_live(child_id: &str) -> bool {
677    map()
678        .lock()
679        .recover_poison()
680        .keys()
681        .any(|(_, child, _)| child == child_id)
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687
688    fn registry() -> SharedApprovalRegistry {
689        std::sync::Arc::new(Mutex::new(
690            ApprovalRegistry::open(tempfile::tempdir().unwrap().keep().join("registry.json"))
691                .unwrap(),
692        ))
693    }
694
695    #[test]
696    fn register_deliver_unregister() {
697        let (tx, mut rx) = mpsc::unbounded_channel();
698        let guard = register("c-live", tx, 0, None);
699        assert!(is_live("c-live"));
700        assert!(deliver_message("c-live", "hi"));
701        match rx.try_recv() {
702            Ok(ParentFrame::Message { text }) => assert_eq!(text, "hi"),
703            other => panic!("expected message frame, got {other:?}"),
704        }
705
706        drop(guard);
707        assert!(!is_live("c-live"));
708        assert!(!deliver_message("c-live", "gone"));
709    }
710
711    #[test]
712    fn deliver_fails_when_receiver_dropped() {
713        let (tx, rx) = mpsc::unbounded_channel();
714        let _guard = register("c-dead", tx, 0, None);
715        drop(rx);
716        assert!(!deliver_message("c-dead", "hi"));
717    }
718
719    #[test]
720    fn deliver_approval_routes_reply_frame() {
721        let (tx, mut rx) = mpsc::unbounded_channel();
722        let guard = register("c-appr", tx, 0, None);
723        assert!(deliver_approval("c-appr", "req-7", true));
724        match rx.try_recv() {
725            Ok(ParentFrame::ApprovalReply { id, approved }) => {
726                assert_eq!(id, "req-7");
727                assert!(approved);
728            }
729            other => panic!("expected approval reply, got {other:?}"),
730        }
731        drop(guard);
732        // Not-live child ⇒ false (no connection to answer on).
733        assert!(!deliver_approval("c-appr", "req-8", false));
734    }
735
736    #[test]
737    fn app_scopes_and_attempt_guards_do_not_cross_talk() {
738        let first_registry = registry();
739        let second_registry = registry();
740        let (first_tx, mut first_rx) = mpsc::unbounded_channel();
741        let (retry_tx, mut retry_rx) = mpsc::unbounded_channel();
742        let (second_tx, mut second_rx) = mpsc::unbounded_channel();
743        let old_guard = register("shared-child", first_tx, 1, Some(first_registry.clone()));
744        let retry_guard = register("shared-child", retry_tx, 2, Some(first_registry.clone()));
745        let second_guard = register("shared-child", second_tx, 1, Some(second_registry.clone()));
746
747        drop(old_guard);
748        assert!(deliver_approval_scoped(
749            Some(&first_registry),
750            "shared-child",
751            2,
752            "retry-request",
753            true,
754        ));
755        assert!(matches!(
756            retry_rx.try_recv(),
757            Ok(ParentFrame::ApprovalReply { id, .. }) if id == "retry-request"
758        ));
759        assert!(deliver_approval_scoped(
760            Some(&second_registry),
761            "shared-child",
762            1,
763            "other-app-request",
764            false,
765        ));
766        assert!(matches!(
767            second_rx.try_recv(),
768            Ok(ParentFrame::ApprovalReply { id, .. }) if id == "other-app-request"
769        ));
770        assert!(first_rx.try_recv().is_err());
771        drop(retry_guard);
772        drop(second_guard);
773    }
774
775    #[test]
776    fn pending_approval_is_one_shot() {
777        register_pending_approval("c-pend", "req-1");
778        // First take consumes it; the second finds nothing.
779        assert!(take_pending_approval("c-pend", "req-1"));
780        assert!(!take_pending_approval("c-pend", "req-1"));
781    }
782
783    #[test]
784    fn take_of_unregistered_pair_is_false() {
785        // Unknown child entirely.
786        assert!(!take_pending_approval("c-unknown", "req-x"));
787        // Known child, but an unregistered request_id.
788        register_pending_approval("c-known", "req-real");
789        assert!(!take_pending_approval("c-known", "req-bogus"));
790        // The real one is still pending (a bogus take didn't disturb it).
791        assert!(take_pending_approval("c-known", "req-real"));
792    }
793
794    #[test]
795    fn deliver_approval_checked_only_delivers_for_registered_pair() {
796        let (tx, mut rx) = mpsc::unbounded_channel();
797        let _guard = register("c-checked", tx, 0, None);
798
799        // Not registered ⇒ rejected, nothing on the wire.
800        assert!(!deliver_approval_checked(
801            None,
802            "c-checked",
803            "req-stray",
804            true
805        ));
806        assert!(rx.try_recv().is_err());
807
808        // Registered ⇒ delivered, frame rides the wire, and consumed.
809        register_pending_approval("c-checked", "req-ok");
810        assert!(deliver_approval_checked(None, "c-checked", "req-ok", true));
811        match rx.try_recv() {
812            Ok(ParentFrame::ApprovalReply { id, approved }) => {
813                assert_eq!(id, "req-ok");
814                assert!(approved);
815            }
816            other => panic!("expected approval reply, got {other:?}"),
817        }
818        // One-shot: a replay is rejected (and nothing further on the wire).
819        assert!(!deliver_approval_checked(None, "c-checked", "req-ok", true));
820        assert!(rx.try_recv().is_err());
821    }
822
823    #[test]
824    fn clear_pending_approvals_for_drops_them() {
825        register_pending_approval("c-clear", "req-a");
826        register_pending_approval("c-clear", "req-b");
827        clear_pending_approvals_for(None, "c-clear", 0);
828        assert!(!take_pending_approval("c-clear", "req-a"));
829        assert!(!take_pending_approval("c-clear", "req-b"));
830    }
831
832    #[tokio::test]
833    async fn observed_approval_emits_exactly_one_terminal_outcome() {
834        let (wire_tx, _wire_rx) = mpsc::unbounded_channel();
835        let _guard = register("c-audit", wire_tx, 0, None);
836        let (event_tx, mut event_rx) = mpsc::channel(8);
837        observe_pending_approval(PendingApprovalObservation {
838            registry: None,
839            parent_session_id: "parent-audit",
840            child_id: "c-audit",
841            child_attempt: 0,
842            request_id: "req-audit",
843            tool_name: "Bash",
844            permission: "execute",
845            resource: "/tmp/x",
846            event_tx,
847        });
848
849        assert!(deliver_approval_checked(
850            None,
851            "c-audit",
852            "req-audit",
853            false
854        ));
855        assert!(!deliver_approval_checked(
856            None,
857            "c-audit",
858            "req-audit",
859            true
860        ));
861        assert!(matches!(
862            event_rx.recv().await,
863            Some(AgentEvent::ChildApprovalChanged { status, .. }) if status == "denied"
864        ));
865        assert!(event_rx.try_recv().is_err());
866    }
867
868    #[tokio::test]
869    async fn durable_resolution_uses_registry_version_and_attempt() {
870        let registry = registry();
871        let (wire_tx, _wire_rx) = mpsc::unbounded_channel();
872        let _guard = register("c-versioned", wire_tx, 7, Some(registry.clone()));
873        let (event_tx, mut event_rx) = mpsc::channel(4);
874        let (pending_version, _) = observe_pending_approval(PendingApprovalObservation {
875            registry: Some(&registry),
876            parent_session_id: "parent-versioned",
877            child_id: "c-versioned",
878            child_attempt: 7,
879            request_id: "req-versioned",
880            tool_name: "Bash",
881            permission: "execute",
882            resource: "/tmp/versioned",
883            event_tx,
884        });
885
886        assert!(deliver_approval_checked(
887            Some(&registry),
888            "c-versioned",
889            "req-versioned",
890            true,
891        ));
892        assert!(matches!(
893            event_rx.recv().await,
894            Some(AgentEvent::ChildApprovalChanged {
895                child_attempt: 7,
896                version,
897                status,
898                ..
899            }) if version == pending_version + 2 && status == "approved"
900        ));
901    }
902
903    #[tokio::test]
904    async fn delayed_attempt_one_decision_cannot_approve_attempt_two() {
905        let registry = registry();
906        let child_id = "c-delayed-attempt";
907        let request_id = "req-reused";
908        let (attempt_one_tx, mut attempt_one_rx) = mpsc::unbounded_channel();
909        let attempt_one_guard = register(child_id, attempt_one_tx, 1, Some(registry.clone()));
910        let (event_tx, _event_rx) = mpsc::channel(8);
911        let (attempt_one_version, _) = observe_pending_approval(PendingApprovalObservation {
912            registry: Some(&registry),
913            parent_session_id: "parent-delayed",
914            child_id,
915            child_attempt: 1,
916            request_id,
917            tool_name: "Bash",
918            permission: "execute",
919            resource: "cargo test",
920            event_tx: event_tx.clone(),
921        });
922        clear_pending_approvals_for(Some(&registry), child_id, 1);
923
924        let (attempt_two_tx, mut attempt_two_rx) = mpsc::unbounded_channel();
925        let _attempt_two_guard = register(child_id, attempt_two_tx, 2, Some(registry.clone()));
926        let (attempt_two_version, _) = observe_pending_approval(PendingApprovalObservation {
927            registry: Some(&registry),
928            parent_session_id: "parent-delayed",
929            child_id,
930            child_attempt: 2,
931            request_id,
932            tool_name: "Bash",
933            permission: "execute",
934            resource: "cargo test",
935            event_tx,
936        });
937
938        assert_eq!(
939            deliver_approval_checked_cas(
940                Some(&registry),
941                "parent-delayed",
942                child_id,
943                1,
944                request_id,
945                attempt_one_version,
946                true,
947            ),
948            ApprovalDeliveryResult::Conflict
949        );
950        assert!(attempt_one_rx.try_recv().is_err());
951        assert!(attempt_two_rx.try_recv().is_err());
952
953        assert_eq!(
954            deliver_approval_checked_cas(
955                Some(&registry),
956                "parent-delayed",
957                child_id,
958                2,
959                request_id,
960                attempt_two_version,
961                true,
962            ),
963            ApprovalDeliveryResult::Delivered
964        );
965        assert!(matches!(
966            attempt_two_rx.try_recv(),
967            Ok(ParentFrame::ApprovalReply { id, approved }) if id == request_id && approved
968        ));
969        drop(attempt_one_guard);
970    }
971
972    #[tokio::test]
973    async fn parent_and_version_mismatches_do_not_consume_current_approval() {
974        let registry = registry();
975        let child_id = "c-identity-cas";
976        let request_id = "req-identity-cas";
977        let (wire_tx, mut wire_rx) = mpsc::unbounded_channel();
978        let _guard = register(child_id, wire_tx, 3, Some(registry.clone()));
979        let (event_tx, _event_rx) = mpsc::channel(4);
980        let (version, _) = observe_pending_approval(PendingApprovalObservation {
981            registry: Some(&registry),
982            parent_session_id: "parent-current",
983            child_id,
984            child_attempt: 3,
985            request_id,
986            tool_name: "Write",
987            permission: "write",
988            resource: "/tmp/current",
989            event_tx,
990        });
991
992        assert_eq!(
993            deliver_approval_checked_cas(
994                Some(&registry),
995                "parent-stale",
996                child_id,
997                3,
998                request_id,
999                version,
1000                true,
1001            ),
1002            ApprovalDeliveryResult::Conflict
1003        );
1004        assert_eq!(
1005            deliver_approval_checked_cas(
1006                Some(&registry),
1007                "parent-current",
1008                child_id,
1009                3,
1010                request_id,
1011                version.saturating_add(1),
1012                true,
1013            ),
1014            ApprovalDeliveryResult::Conflict
1015        );
1016        assert!(wire_rx.try_recv().is_err());
1017
1018        assert_eq!(
1019            deliver_approval_checked_cas(
1020                Some(&registry),
1021                "parent-current",
1022                child_id,
1023                3,
1024                request_id,
1025                version,
1026                false,
1027            ),
1028            ApprovalDeliveryResult::Delivered
1029        );
1030        assert!(matches!(
1031            wire_rx.try_recv(),
1032            Ok(ParentFrame::ApprovalReply { id, approved }) if id == request_id && !approved
1033        ));
1034    }
1035
1036    #[tokio::test]
1037    async fn timeout_and_disconnect_emit_terminal_outcomes() {
1038        let (event_tx, mut event_rx) = mpsc::channel(8);
1039        observe_pending_approval(PendingApprovalObservation {
1040            registry: None,
1041            parent_session_id: "parent-audit",
1042            child_id: "c-expire",
1043            child_attempt: 0,
1044            request_id: "req-expire",
1045            tool_name: "Bash",
1046            permission: "execute",
1047            resource: "/tmp/x",
1048            event_tx: event_tx.clone(),
1049        });
1050        assert!(expire_pending_approval(None, "c-expire", "req-expire"));
1051        assert!(!expire_pending_approval(None, "c-expire", "req-expire"));
1052        assert!(matches!(
1053            event_rx.recv().await,
1054            Some(AgentEvent::ChildApprovalChanged { status, .. }) if status == "expired"
1055        ));
1056
1057        observe_pending_approval(PendingApprovalObservation {
1058            registry: None,
1059            parent_session_id: "parent-audit",
1060            child_id: "c-disconnect",
1061            child_attempt: 0,
1062            request_id: "req-disconnect",
1063            tool_name: "Write",
1064            permission: "write",
1065            resource: "/tmp/y",
1066            event_tx,
1067        });
1068        clear_pending_approvals_for(None, "c-disconnect", 0);
1069        assert!(matches!(
1070            event_rx.recv().await,
1071            Some(AgentEvent::ChildApprovalChanged { status, .. }) if status == "delivery_failed"
1072        ));
1073    }
1074}