agentd/subagent/
replies.rs1use serde_json::Value;
9use std::collections::HashMap;
10use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
11use std::sync::{Condvar, Mutex};
12use std::time::{Duration, Instant};
13
14#[derive(Debug, Clone, PartialEq)]
16pub enum Reply {
17 Tool {
18 result: Value,
19 is_error: bool,
20 },
21 Budget {
22 ok: bool,
23 wait_ms: u64,
24 model: Option<String>,
25 reason: Option<String>,
26 },
27}
28
29#[derive(Default)]
30pub struct Replies {
31 slots: Mutex<HashMap<u64, Reply>>,
32 cv: Condvar,
33 closed: AtomicBool,
34 next_id: AtomicU64,
35}
36
37impl Replies {
38 pub fn new() -> Replies {
39 Replies {
40 next_id: AtomicU64::new(1),
41 ..Default::default()
42 }
43 }
44
45 pub fn next_id(&self) -> u64 {
47 self.next_id.fetch_add(1, Ordering::Relaxed)
48 }
49
50 pub fn deliver(&self, id: u64, reply: Reply) {
52 self.slots
53 .lock()
54 .unwrap_or_else(|e| e.into_inner())
55 .insert(id, reply);
56 self.cv.notify_all();
57 }
58
59 pub fn close(&self) {
61 self.closed.store(true, Ordering::Relaxed);
62 self.cv.notify_all();
63 }
64
65 pub fn is_closed(&self) -> bool {
66 self.closed.load(Ordering::Relaxed)
67 }
68
69 pub fn wait(&self, id: u64, deadline: Instant, cancel: &AtomicBool) -> Option<Reply> {
72 let mut slots = self.slots.lock().unwrap_or_else(|e| e.into_inner());
73 loop {
74 if let Some(r) = slots.remove(&id) {
75 return Some(r);
76 }
77 if self.is_closed() || cancel.load(Ordering::Relaxed) {
78 return None;
79 }
80 let now = Instant::now();
81 if now >= deadline {
82 return None;
83 }
84 let wait = (deadline - now).min(Duration::from_millis(100));
85 let (guard, _) = self
86 .cv
87 .wait_timeout(slots, wait)
88 .unwrap_or_else(|e| e.into_inner());
89 slots = guard;
90 }
91 }
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97 use serde_json::json;
98 use std::sync::Arc;
99
100 #[test]
101 fn replies_are_delivered_by_id_and_waiters_wake_on_close_or_cancel() {
102 let r = Arc::new(Replies::new());
103 let id = r.next_id();
104 let r2 = r.clone();
105 let t = std::thread::spawn(move || {
106 std::thread::sleep(Duration::from_millis(30));
107 r2.deliver(
108 99,
109 Reply::Tool {
110 result: json!("other"),
111 is_error: false,
112 },
113 );
114 r2.deliver(
115 id,
116 Reply::Tool {
117 result: json!({"ok": true}),
118 is_error: false,
119 },
120 );
121 });
122 let cancel = AtomicBool::new(false);
123 let got = r
124 .wait(id, Instant::now() + Duration::from_secs(2), &cancel)
125 .unwrap();
126 assert_eq!(
127 got,
128 Reply::Tool {
129 result: json!({"ok": true}),
130 is_error: false
131 }
132 );
133 t.join().unwrap();
134 assert!(
136 r.wait(12345, Instant::now() + Duration::from_millis(20), &cancel)
137 .is_none()
138 );
139 cancel.store(true, Ordering::Relaxed);
141 assert!(
142 r.wait(12345, Instant::now() + Duration::from_secs(5), &cancel)
143 .is_none()
144 );
145 let cancel2 = AtomicBool::new(false);
147 let r3 = r.clone();
148 let t = std::thread::spawn(move || {
149 std::thread::sleep(Duration::from_millis(20));
150 r3.close();
151 });
152 assert!(
153 r.wait(777, Instant::now() + Duration::from_secs(5), &cancel2)
154 .is_none()
155 );
156 t.join().unwrap();
157 r.deliver(
159 5,
160 Reply::Budget {
161 ok: true,
162 wait_ms: 0,
163 model: None,
164 reason: None,
165 },
166 );
167 assert!(matches!(
168 r.wait(5, Instant::now(), &cancel2),
169 Some(Reply::Budget { ok: true, .. })
170 ));
171 }
172}