agentmux_srv/ambient/
mod.rs1pub mod call;
25pub mod cli;
26pub mod digest;
27pub mod limits;
28pub mod outcome;
29pub mod prompt;
30pub mod purpose;
31pub mod reply;
32pub mod sanitize;
33pub mod spend;
34pub mod tasks;
35pub mod title;
36pub mod validate;
37
38use std::collections::HashMap;
39use std::sync::{Mutex, OnceLock};
40
41use tokio_util::sync::CancellationToken;
42
43#[derive(Debug, Clone, PartialEq, Eq, Hash)]
47pub struct AmbientCallKey {
48 pub entity_id: String,
49 pub purpose: &'static str,
50}
51
52impl AmbientCallKey {
53 pub fn new(entity_id: impl Into<String>, purpose: &'static str) -> Self {
54 Self { entity_id: entity_id.into(), purpose }
55 }
56}
57
58struct InFlight {
59 generation: u64,
60 cancel: CancellationToken,
61}
62
63#[derive(Default)]
64struct GatewayState {
65 inflight: HashMap<AmbientCallKey, InFlight>,
66}
67
68pub struct AmbientGateway {
69 state: Mutex<GatewayState>,
70}
71
72static GATEWAY: OnceLock<AmbientGateway> = OnceLock::new();
73
74pub fn gateway() -> &'static AmbientGateway {
76 GATEWAY.get_or_init(|| AmbientGateway { state: Mutex::new(GatewayState::default()) })
77}
78
79pub enum Admission<'a> {
81 Proceed(AmbientCallGuard<'a>),
86 StaleOnArrival,
89}
90
91impl AmbientGateway {
92 pub fn admit(&self, key: AmbientCallKey, generation: u64) -> Admission<'_> {
99 let mut state = self.state.lock().unwrap();
100 if let Some(existing) = state.inflight.get(&key) {
101 if generation <= existing.generation {
102 return Admission::StaleOnArrival;
103 }
104 existing.cancel.cancel();
105 }
106 let cancel = CancellationToken::new();
107 state.inflight.insert(
108 key.clone(),
109 InFlight { generation, cancel: cancel.clone() },
110 );
111 Admission::Proceed(AmbientCallGuard {
112 gateway: self,
113 key,
114 generation,
115 cancel,
116 })
117 }
118}
119
120pub struct AmbientCallGuard<'a> {
122 gateway: &'a AmbientGateway,
123 key: AmbientCallKey,
124 generation: u64,
125 cancel: CancellationToken,
126}
127
128impl AmbientCallGuard<'_> {
129 pub fn cancellation(&self) -> CancellationToken {
133 self.cancel.clone()
134 }
135}
136
137impl Drop for AmbientCallGuard<'_> {
138 fn drop(&mut self) {
139 let mut state = self.gateway.state.lock().unwrap();
140 if let Some(entry) = state.inflight.get(&self.key) {
143 if entry.generation == self.generation {
144 state.inflight.remove(&self.key);
145 }
146 }
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 fn key(id: &str) -> AmbientCallKey {
155 AmbientCallKey::new(id, "activity_summary")
156 }
157
158 #[test]
159 fn first_request_is_admitted() {
160 let gw = AmbientGateway { state: Mutex::new(GatewayState::default()) };
161 match gw.admit(key("block-1"), 1) {
162 Admission::Proceed(_) => {}
163 Admission::StaleOnArrival => panic!("first request should be admitted"),
164 };
165 }
166
167 #[test]
168 fn older_or_equal_generation_is_stale_on_arrival() {
169 let gw = AmbientGateway { state: Mutex::new(GatewayState::default()) };
170 let _guard = match gw.admit(key("block-1"), 5) {
171 Admission::Proceed(g) => g,
172 Admission::StaleOnArrival => panic!("gen 5 should be admitted"),
173 };
174 match gw.admit(key("block-1"), 5) {
175 Admission::StaleOnArrival => {}
176 Admission::Proceed(_) => panic!("equal generation must be rejected"),
177 }
178 match gw.admit(key("block-1"), 3) {
179 Admission::StaleOnArrival => {}
180 Admission::Proceed(_) => panic!("older generation must be rejected"),
181 };
182 }
183
184 #[test]
185 fn newer_generation_cancels_the_older_inflight_request() {
186 let gw = AmbientGateway { state: Mutex::new(GatewayState::default()) };
187 let guard1 = match gw.admit(key("block-1"), 1) {
188 Admission::Proceed(g) => g,
189 Admission::StaleOnArrival => panic!("gen 1 should be admitted"),
190 };
191 let cancel1 = guard1.cancellation();
192 assert!(!cancel1.is_cancelled());
193
194 let _guard2 = match gw.admit(key("block-1"), 2) {
195 Admission::Proceed(g) => g,
196 Admission::StaleOnArrival => panic!("gen 2 should be admitted"),
197 };
198 assert!(cancel1.is_cancelled(), "admitting gen 2 must cancel gen 1's token");
199 }
200
201 #[test]
202 fn different_keys_do_not_interfere() {
203 let gw = AmbientGateway { state: Mutex::new(GatewayState::default()) };
204 let guard1 = match gw.admit(key("block-1"), 1) {
205 Admission::Proceed(g) => g,
206 Admission::StaleOnArrival => panic!("block-1 gen 1 should be admitted"),
207 };
208 match gw.admit(key("block-2"), 1) {
209 Admission::Proceed(_) => {}
210 Admission::StaleOnArrival => panic!("block-2 gen 1 should be admitted independently"),
211 }
212 assert!(!guard1.cancellation().is_cancelled(), "unrelated key must not cancel block-1");
213 }
214
215 #[test]
216 fn dropping_the_guard_clears_the_slot_for_a_later_retry_at_the_same_generation() {
217 let gw = AmbientGateway { state: Mutex::new(GatewayState::default()) };
218 {
219 let _guard = match gw.admit(key("block-1"), 1) {
220 Admission::Proceed(g) => g,
221 Admission::StaleOnArrival => panic!("gen 1 should be admitted"),
222 };
223 } match gw.admit(key("block-1"), 1) {
225 Admission::Proceed(_) => {}
226 Admission::StaleOnArrival => {
227 panic!("gen 1 should be admittable again once the prior call finished")
228 }
229 };
230 }
231
232 #[test]
233 fn stale_guard_dropping_does_not_clear_a_newer_inflight_entry() {
234 let gw = AmbientGateway { state: Mutex::new(GatewayState::default()) };
235 let guard1 = match gw.admit(key("block-1"), 1) {
236 Admission::Proceed(g) => g,
237 Admission::StaleOnArrival => panic!("gen 1 should be admitted"),
238 };
239 let _guard2 = match gw.admit(key("block-1"), 2) {
240 Admission::Proceed(g) => g,
241 Admission::StaleOnArrival => panic!("gen 2 should be admitted"),
242 };
243 drop(guard1); match gw.admit(key("block-1"), 2) {
245 Admission::StaleOnArrival => {}
246 Admission::Proceed(_) => {
247 panic!("gen 2 is still in flight; dropping the stale gen-1 guard must not clear it")
248 }
249 };
250 }
251}