Skip to main content

ag_agent/app_server/
registry.rs

1//! Shared app-server runtime registry helpers.
2
3use std::collections::HashMap;
4use std::sync::{Arc, Mutex};
5
6use tokio_util::sync::CancellationToken;
7
8use crate::app_server::AppServerError;
9
10/// Shared runtime registry used by app-server providers.
11///
12/// Each session id maps to one idle runtime process. Workers temporarily remove
13/// a runtime while executing a turn and register a cancellation token so
14/// `shutdown_session()` can still interrupt in-flight runtimes.
15pub struct AppServerSessionRegistry<Runtime> {
16    active_turn_cancellations: Arc<Mutex<HashMap<String, CancellationToken>>>,
17    provider_name: &'static str,
18    sessions: Arc<Mutex<HashMap<String, Runtime>>>,
19}
20
21impl<Runtime> AppServerSessionRegistry<Runtime> {
22    /// Creates an empty session runtime registry for one provider.
23    pub fn new(provider_name: &'static str) -> Self {
24        Self {
25            active_turn_cancellations: Arc::new(Mutex::new(HashMap::new())),
26            provider_name,
27            sessions: Arc::new(Mutex::new(HashMap::new())),
28        }
29    }
30
31    /// Removes and returns the runtime stored for `session_id`.
32    ///
33    /// # Errors
34    /// Returns an error when the session map lock is poisoned.
35    pub fn take_session(&self, session_id: &str) -> Result<Option<Runtime>, AppServerError> {
36        let mut sessions = self
37            .sessions
38            .lock()
39            .map_err(|_| AppServerError::LockPoisoned {
40                provider: self.provider_name,
41            })?;
42
43        Ok(sessions.remove(session_id))
44    }
45
46    /// Stores or replaces the runtime for `session_id`.
47    ///
48    /// # Errors
49    /// Returns an error when the session map lock is poisoned.
50    pub fn store_session(
51        &self,
52        session_id: String,
53        session: Runtime,
54    ) -> Result<(), AppServerError> {
55        let mut sessions = self
56            .sessions
57            .lock()
58            .map_err(|_| AppServerError::LockPoisoned {
59                provider: self.provider_name,
60            })?;
61        sessions.insert(session_id, session);
62
63        Ok(())
64    }
65
66    /// Stores or replaces the runtime for `session_id`, returning ownership
67    /// back to the caller when lock acquisition fails.
68    ///
69    /// This allows callers to shut down process-backed runtimes before
70    /// returning an error, preventing orphaned child processes on early exits.
71    ///
72    /// # Errors
73    /// Returns `(error, session)` when the session map lock is poisoned.
74    pub fn store_session_or_recover(
75        &self,
76        session_id: String,
77        session: Runtime,
78    ) -> Result<(), (AppServerError, Runtime)> {
79        let Ok(mut sessions) = self.sessions.lock() else {
80            return Err((
81                AppServerError::LockPoisoned {
82                    provider: self.provider_name,
83                },
84                session,
85            ));
86        };
87        sessions.insert(session_id, session);
88
89        Ok(())
90    }
91
92    /// Returns the provider label used in user-facing retry errors.
93    pub fn provider_name(&self) -> &'static str {
94        self.provider_name
95    }
96
97    /// Registers one in-flight app-server turn and returns its cancellation
98    /// guard.
99    ///
100    /// The returned guard unregisters itself on drop. This keeps
101    /// `shutdown_session()` able to signal a runtime even while the runtime is
102    /// temporarily owned by the running turn rather than stored in
103    /// [`AppServerSessionRegistry::sessions`].
104    ///
105    /// # Errors
106    /// Returns an error when the active-turn map lock is poisoned.
107    pub fn register_active_turn(
108        &self,
109        session_id: &str,
110    ) -> Result<ActiveAppServerTurn, AppServerError> {
111        let token = CancellationToken::new();
112        let mut active_turn_cancellations =
113            self.active_turn_cancellations
114                .lock()
115                .map_err(|_| AppServerError::LockPoisoned {
116                    provider: self.provider_name,
117                })?;
118        active_turn_cancellations.insert(session_id.to_string(), token.clone());
119
120        Ok(ActiveAppServerTurn {
121            active_turn_cancellations: Arc::clone(&self.active_turn_cancellations),
122            session_id: session_id.to_string(),
123            token,
124        })
125    }
126
127    /// Signals an active app-server turn for `session_id`, if one is currently
128    /// registered.
129    ///
130    /// # Errors
131    /// Returns an error when the active-turn map lock is poisoned.
132    pub fn cancel_active_turn(&self, session_id: &str) -> Result<bool, AppServerError> {
133        let active_turn_cancellations =
134            self.active_turn_cancellations
135                .lock()
136                .map_err(|_| AppServerError::LockPoisoned {
137                    provider: self.provider_name,
138                })?;
139
140        let Some(token) = active_turn_cancellations.get(session_id) else {
141            return Ok(false);
142        };
143
144        token.cancel();
145
146        Ok(true)
147    }
148}
149
150/// Clones the registry handle by sharing the same underlying session map.
151impl<Runtime> Clone for AppServerSessionRegistry<Runtime> {
152    fn clone(&self) -> Self {
153        Self {
154            active_turn_cancellations: Arc::clone(&self.active_turn_cancellations),
155            provider_name: self.provider_name,
156            sessions: Arc::clone(&self.sessions),
157        }
158    }
159}
160
161/// RAII guard for one app-server turn cancellation registration.
162pub struct ActiveAppServerTurn {
163    active_turn_cancellations: Arc<Mutex<HashMap<String, CancellationToken>>>,
164    session_id: String,
165    token: CancellationToken,
166}
167
168impl ActiveAppServerTurn {
169    /// Returns the cancellation token observed by the running turn.
170    pub fn token(&self) -> CancellationToken {
171        self.token.clone()
172    }
173}
174
175impl Drop for ActiveAppServerTurn {
176    fn drop(&mut self) {
177        if let Ok(mut active_turn_cancellations) = self.active_turn_cancellations.lock() {
178            active_turn_cancellations.remove(&self.session_id);
179        }
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn new_returns_registry_with_provider_name() {
189        // Arrange / Act
190        let registry = AppServerSessionRegistry::<String>::new("test-provider");
191
192        // Assert
193        assert_eq!(registry.provider_name(), "test-provider");
194    }
195
196    #[test]
197    fn store_and_take_session_round_trips_runtime() {
198        // Arrange
199        let registry = AppServerSessionRegistry::<String>::new("test");
200        registry
201            .store_session("session-1".to_string(), "runtime-a".to_string())
202            .expect("store should succeed");
203
204        // Act
205        let taken = registry
206            .take_session("session-1")
207            .expect("take should succeed");
208
209        // Assert
210        assert_eq!(taken, Some("runtime-a".to_string()));
211    }
212
213    #[test]
214    fn take_session_returns_none_for_missing_id() {
215        // Arrange
216        let registry = AppServerSessionRegistry::<String>::new("test");
217
218        // Act
219        let taken = registry
220            .take_session("missing")
221            .expect("take should succeed");
222
223        // Assert
224        assert_eq!(taken, None);
225    }
226
227    #[test]
228    fn take_session_removes_entry_from_registry() {
229        // Arrange
230        let registry = AppServerSessionRegistry::<String>::new("test");
231        registry
232            .store_session("session-1".to_string(), "runtime-a".to_string())
233            .expect("store should succeed");
234        let _ = registry.take_session("session-1");
235
236        // Act
237        let second_take = registry
238            .take_session("session-1")
239            .expect("take should succeed");
240
241        // Assert
242        assert_eq!(second_take, None);
243    }
244
245    #[test]
246    fn store_session_or_recover_stores_runtime_on_healthy_lock() {
247        // Arrange
248        let registry = AppServerSessionRegistry::<String>::new("test");
249
250        // Act
251        let result =
252            registry.store_session_or_recover("session-1".to_string(), "runtime-a".to_string());
253
254        // Assert
255        assert!(result.is_ok());
256        let taken = registry
257            .take_session("session-1")
258            .expect("take should succeed");
259        assert_eq!(taken, Some("runtime-a".to_string()));
260    }
261
262    #[test]
263    fn clone_shares_underlying_session_map() {
264        // Arrange
265        let registry = AppServerSessionRegistry::<String>::new("test");
266        let cloned = registry.clone();
267        registry
268            .store_session("session-1".to_string(), "runtime-a".to_string())
269            .expect("store should succeed");
270
271        // Act
272        let taken = cloned
273            .take_session("session-1")
274            .expect("take on clone should succeed");
275
276        // Assert
277        assert_eq!(taken, Some("runtime-a".to_string()));
278    }
279
280    #[test]
281    fn cancel_active_turn_signals_registered_token() {
282        // Arrange
283        let registry = AppServerSessionRegistry::<String>::new("test");
284        let active_turn = registry
285            .register_active_turn("session-1")
286            .expect("register should succeed");
287        let token = active_turn.token();
288
289        // Act
290        let did_cancel = registry
291            .cancel_active_turn("session-1")
292            .expect("cancel should succeed");
293
294        // Assert
295        assert!(did_cancel);
296        assert!(token.is_cancelled());
297    }
298
299    #[test]
300    fn active_turn_guard_unregisters_on_drop() {
301        // Arrange
302        let registry = AppServerSessionRegistry::<String>::new("test");
303        let active_turn = registry
304            .register_active_turn("session-1")
305            .expect("register should succeed");
306
307        // Act
308        drop(active_turn);
309        let did_cancel = registry
310            .cancel_active_turn("session-1")
311            .expect("cancel should succeed");
312
313        // Assert
314        assert!(!did_cancel);
315    }
316}