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, HashSet};
12use std::sync::{Mutex, OnceLock};
13
14use bamboo_domain::poison::PoisonRecover;
15use bamboo_subagent::proto::ParentFrame;
16use tokio::sync::mpsc;
17
18fn map() -> &'static Mutex<HashMap<String, mpsc::UnboundedSender<ParentFrame>>> {
19    static MAP: OnceLock<Mutex<HashMap<String, mpsc::UnboundedSender<ParentFrame>>>> =
20        OnceLock::new();
21    MAP.get_or_init(|| Mutex::new(HashMap::new()))
22}
23
24/// Process-global registry of pending human-loop approval requests, keyed by
25/// `child_id` → set of `request_id`s currently awaiting a decision. Only the
26/// human-in-the-loop path (top orchestrator) registers here; trusted internal
27/// paths (model-review, escalation-bridge) do NOT — so the external handler's
28/// [`deliver_approval_checked`] correctly rejects any stray external POST aimed
29/// at a request that isn't a genuinely-pending human-loop one.
30fn pending() -> &'static Mutex<HashMap<String, HashSet<String>>> {
31    static PENDING: OnceLock<Mutex<HashMap<String, HashSet<String>>>> = OnceLock::new();
32    PENDING.get_or_init(|| Mutex::new(HashMap::new()))
33}
34
35/// Record a `(child_id, request_id)` as a pending human-loop approval. Called
36/// just before surfacing `ChildApprovalRequested` so an external POST can be
37/// correlated against a genuinely-pending request.
38pub fn register_pending_approval(child_id: &str, request_id: &str) {
39    pending()
40        .lock()
41        .recover_poison()
42        .entry(child_id.to_string())
43        .or_default()
44        .insert(request_id.to_string());
45}
46
47/// One-shot consume of a `(child_id, request_id)` pending pair: remove it and
48/// return whether it WAS present. A second call for the same pair returns
49/// `false`, so a request can't be answered (or replayed) twice.
50pub fn take_pending_approval(child_id: &str, request_id: &str) -> bool {
51    let mut guard = pending().lock().recover_poison();
52    let Some(set) = guard.get_mut(child_id) else {
53        return false;
54    };
55    let took = set.remove(request_id);
56    if set.is_empty() {
57        guard.remove(child_id);
58    }
59    took
60}
61
62/// Drop all pending approvals for a child (e.g. when its live connection ends).
63pub fn clear_pending_approvals_for(child_id: &str) {
64    pending().lock().recover_poison().remove(child_id);
65}
66
67/// Validated external entry point: deliver an approval decision ONLY if the
68/// `(child_id, request_id)` pair is currently pending. Consumes the pending
69/// entry (one-shot) before delivering, so the same request can't be replayed,
70/// and rejects (returns `false`) any `request_id` that isn't currently pending
71/// — unknown, already-answered/timed-out, or a non-human-loop path
72/// (model-review / escalation) that never registered. This is the entry the
73/// external HTTP handler must use.
74pub fn deliver_approval_checked(child_id: &str, request_id: &str, approved: bool) -> bool {
75    if take_pending_approval(child_id, request_id) {
76        deliver_approval(child_id, request_id, approved)
77    } else {
78        false
79    }
80}
81
82/// Unregisters the child on drop, so a panicking/returning runner can't leak
83/// a stale sender.
84pub struct LiveActorGuard {
85    child_id: String,
86}
87
88impl Drop for LiveActorGuard {
89    fn drop(&mut self) {
90        map().lock().recover_poison().remove(&self.child_id);
91        // A disconnecting child can't answer any still-pending approval — drop
92        // them so a late external POST finds nothing pending and is rejected.
93        clear_pending_approvals_for(&self.child_id);
94    }
95}
96
97/// Register a live child's frame sender for the duration of its run.
98pub fn register(child_id: &str, tx: mpsc::UnboundedSender<ParentFrame>) -> LiveActorGuard {
99    map()
100        .lock()
101        .recover_poison()
102        .insert(child_id.to_string(), tx);
103    LiveActorGuard {
104        child_id: child_id.to_string(),
105    }
106}
107
108/// Deliver an in-band steering message to a live child. Returns `false` when
109/// the child is not live (caller should use the durable queue instead).
110pub fn deliver_message(child_id: &str, text: &str) -> bool {
111    let guard = map().lock().recover_poison();
112    match guard.get(child_id) {
113        Some(tx) => tx
114            .send(ParentFrame::Message {
115                text: text.to_string(),
116            })
117            .is_ok(),
118        None => false,
119    }
120}
121
122/// Deliver a host/human approval decision to a live child's pending gated-tool
123/// request (Phase 2: child → parent approval delegation). Sends
124/// `ParentFrame::ApprovalReply{id, approved}` over the child's live WS
125/// connection; `drive()` forwards it to the worker, whose pending map resolves
126/// the `host.approval_call` the child's gated tool is blocked on (approve ⇒ the
127/// tool proceeds, deny ⇒ it fails closed). This is the decision-DOWN half of the
128/// human-in-the-loop route: a parent-side responder (e.g. a `/respond`-style
129/// handler) calls this with the `request_id` it surfaced to the human. Returns
130/// `false` when the child is not live (no connection to answer on — the caller
131/// should treat that as a denied/expired request).
132pub fn deliver_approval(child_id: &str, request_id: &str, approved: bool) -> bool {
133    let guard = map().lock().recover_poison();
134    match guard.get(child_id) {
135        Some(tx) => tx
136            .send(ParentFrame::ApprovalReply {
137                id: request_id.to_string(),
138                approved,
139            })
140            .is_ok(),
141        None => false,
142    }
143}
144
145/// Whether a child currently has a live actor connection.
146pub fn is_live(child_id: &str) -> bool {
147    map().lock().recover_poison().contains_key(child_id)
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn register_deliver_unregister() {
156        let (tx, mut rx) = mpsc::unbounded_channel();
157        let guard = register("c-live", tx);
158        assert!(is_live("c-live"));
159        assert!(deliver_message("c-live", "hi"));
160        match rx.try_recv() {
161            Ok(ParentFrame::Message { text }) => assert_eq!(text, "hi"),
162            other => panic!("expected message frame, got {other:?}"),
163        }
164
165        drop(guard);
166        assert!(!is_live("c-live"));
167        assert!(!deliver_message("c-live", "gone"));
168    }
169
170    #[test]
171    fn deliver_fails_when_receiver_dropped() {
172        let (tx, rx) = mpsc::unbounded_channel();
173        let _guard = register("c-dead", tx);
174        drop(rx);
175        assert!(!deliver_message("c-dead", "hi"));
176    }
177
178    #[test]
179    fn deliver_approval_routes_reply_frame() {
180        let (tx, mut rx) = mpsc::unbounded_channel();
181        let guard = register("c-appr", tx);
182        assert!(deliver_approval("c-appr", "req-7", true));
183        match rx.try_recv() {
184            Ok(ParentFrame::ApprovalReply { id, approved }) => {
185                assert_eq!(id, "req-7");
186                assert!(approved);
187            }
188            other => panic!("expected approval reply, got {other:?}"),
189        }
190        drop(guard);
191        // Not-live child ⇒ false (no connection to answer on).
192        assert!(!deliver_approval("c-appr", "req-8", false));
193    }
194
195    #[test]
196    fn pending_approval_is_one_shot() {
197        register_pending_approval("c-pend", "req-1");
198        // First take consumes it; the second finds nothing.
199        assert!(take_pending_approval("c-pend", "req-1"));
200        assert!(!take_pending_approval("c-pend", "req-1"));
201    }
202
203    #[test]
204    fn take_of_unregistered_pair_is_false() {
205        // Unknown child entirely.
206        assert!(!take_pending_approval("c-unknown", "req-x"));
207        // Known child, but an unregistered request_id.
208        register_pending_approval("c-known", "req-real");
209        assert!(!take_pending_approval("c-known", "req-bogus"));
210        // The real one is still pending (a bogus take didn't disturb it).
211        assert!(take_pending_approval("c-known", "req-real"));
212    }
213
214    #[test]
215    fn deliver_approval_checked_only_delivers_for_registered_pair() {
216        let (tx, mut rx) = mpsc::unbounded_channel();
217        let _guard = register("c-checked", tx);
218
219        // Not registered ⇒ rejected, nothing on the wire.
220        assert!(!deliver_approval_checked("c-checked", "req-stray", true));
221        assert!(rx.try_recv().is_err());
222
223        // Registered ⇒ delivered, frame rides the wire, and consumed.
224        register_pending_approval("c-checked", "req-ok");
225        assert!(deliver_approval_checked("c-checked", "req-ok", true));
226        match rx.try_recv() {
227            Ok(ParentFrame::ApprovalReply { id, approved }) => {
228                assert_eq!(id, "req-ok");
229                assert!(approved);
230            }
231            other => panic!("expected approval reply, got {other:?}"),
232        }
233        // One-shot: a replay is rejected (and nothing further on the wire).
234        assert!(!deliver_approval_checked("c-checked", "req-ok", true));
235        assert!(rx.try_recv().is_err());
236    }
237
238    #[test]
239    fn clear_pending_approvals_for_drops_them() {
240        register_pending_approval("c-clear", "req-a");
241        register_pending_approval("c-clear", "req-b");
242        clear_pending_approvals_for("c-clear");
243        assert!(!take_pending_approval("c-clear", "req-a"));
244        assert!(!take_pending_approval("c-clear", "req-b"));
245    }
246}