1use 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#[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
61pub 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
72pub 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
85pub 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#[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
194pub fn take_pending_approval(child_id: &str, request_id: &str) -> bool {
198 remove_unique_pending(None, child_id, request_id).is_some()
199}
200
201pub 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
315pub 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#[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 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
504pub 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 clear_pending_approvals_for(
523 self.approval_registry.as_ref(),
524 &self.child_id,
525 self.child_attempt,
526 );
527 }
528}
529
530pub 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
622pub 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
642pub 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
675pub 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 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 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 assert!(!take_pending_approval("c-unknown", "req-x"));
787 register_pending_approval("c-known", "req-real");
789 assert!(!take_pending_approval("c-known", "req-bogus"));
790 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 assert!(!deliver_approval_checked(
801 None,
802 "c-checked",
803 "req-stray",
804 true
805 ));
806 assert!(rx.try_recv().is_err());
807
808 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 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(®istry),
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(®istry),
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(®istry),
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(®istry), 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(®istry),
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(®istry),
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(®istry),
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(®istry),
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(®istry),
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(®istry),
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(®istry),
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}