1use anyhow::{Context, Result};
2use std::collections::HashMap;
3use std::path::PathBuf;
4use std::process::Stdio;
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
8use tokio::process::{Child, Command};
9use tokio::sync::{broadcast, mpsc, RwLock};
10use tracing::{debug, error, info, warn};
11
12use crate::push::PushRelayClient;
13
14#[derive(Debug, Clone)]
16pub struct PoolConfig {
17 pub idle_timeout: Duration,
19 pub max_agents: usize,
21 pub buffer_messages: bool,
23 pub max_buffer_size: usize,
25}
26
27impl Default for PoolConfig {
28 fn default() -> Self {
29 Self {
30 idle_timeout: Duration::from_secs(1800),
31 max_agents: 10,
32 buffer_messages: true,
33 max_buffer_size: 10_000,
34 }
35 }
36}
37
38pub struct PooledAgent {
40 process: Child,
42 pub ws_to_agent_tx: mpsc::Sender<String>,
44 pub agent_to_ws_tx: broadcast::Sender<String>,
47 pub connected: bool,
49 pub disconnected_at: Option<Instant>,
51 pub message_buffer: Vec<String>,
53 pub cached_init_response: Option<String>,
57 pub cached_session_response: Option<String>,
62 #[allow(dead_code)]
64 pub agent_command: String,
65}
66
67impl PooledAgent {
68 pub fn is_alive(&mut self) -> bool {
70 match self.process.try_wait() {
71 Ok(Some(_)) => false,
72 Ok(None) => true,
73 Err(_) => false,
74 }
75 }
76
77 pub async fn kill(&mut self) {
79 info!("Killing pooled agent process");
80 if let Err(e) = self.process.kill().await {
81 warn!("Failed to kill agent process: {}", e);
82 }
83 }
84
85 pub fn subscribe(&self) -> broadcast::Receiver<String> {
87 self.agent_to_ws_tx.subscribe()
88 }
89}
90
91pub struct AgentPool {
93 pub(crate) agents: HashMap<String, PooledAgent>,
94 config: PoolConfig,
95 push_relay: Option<Arc<PushRelayClient>>,
96 working_dir: PathBuf,
97}
98
99impl AgentPool {
100 pub fn new(config: PoolConfig) -> Self {
101 Self {
102 agents: HashMap::new(),
103 config,
104 push_relay: None,
105 working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
106 }
107 }
108
109 pub fn with_working_dir(mut self, dir: PathBuf) -> Self {
111 self.working_dir = dir;
112 self
113 }
114
115 pub fn with_push_relay(mut self, push_relay: Arc<PushRelayClient>) -> Self {
117 self.push_relay = Some(push_relay);
118 self
119 }
120
121 pub async fn get_or_spawn(
124 &mut self,
125 token: &str,
126 agent_command: &str,
127 ) -> Result<(mpsc::Sender<String>, broadcast::Receiver<String>, Vec<String>, bool, Option<String>, Option<String>, broadcast::Sender<String>)> {
128 if let Some(agent) = self.agents.get_mut(token) {
130 if agent.is_alive() {
131 info!("Reusing existing agent for token (keep-alive)");
132 agent.connected = true;
133 agent.disconnected_at = None;
134
135 let buffered = std::mem::take(&mut agent.message_buffer);
136 if !buffered.is_empty() {
137 info!("Replaying {} buffered messages", buffered.len());
138 }
139
140 let tx = agent.ws_to_agent_tx.clone();
141 let rx = agent.subscribe();
142 let cached_init = agent.cached_init_response.clone();
143 let cached_session = agent.cached_session_response.clone();
144 let broadcast_tx = agent.agent_to_ws_tx.clone();
145
146 return Ok((tx, rx, buffered, true, cached_init, cached_session, broadcast_tx));
147 } else {
148 info!("Agent process died, removing from pool");
149 self.agents.remove(token);
150 }
151 }
152
153 if self.agents.len() >= self.config.max_agents {
155 let oldest_idle = self
156 .agents
157 .iter()
158 .filter(|(_, a)| !a.connected)
159 .min_by_key(|(_, a)| a.disconnected_at)
160 .map(|(k, _)| k.clone());
161
162 if let Some(key) = oldest_idle {
163 info!("Evicting oldest idle agent to make room");
164 if let Some(mut agent) = self.agents.remove(&key) {
165 agent.kill().await;
166 }
167 } else {
168 anyhow::bail!(
169 "Agent pool is full ({} agents, all connected). Cannot spawn new agent.",
170 self.config.max_agents
171 );
172 }
173 }
174
175 info!("Spawning new pooled agent");
177 self.spawn_agent(token, agent_command).await
178 }
179
180 async fn spawn_agent(
182 &mut self,
183 token: &str,
184 agent_command: &str,
185 ) -> Result<(mpsc::Sender<String>, broadcast::Receiver<String>, Vec<String>, bool, Option<String>, Option<String>, broadcast::Sender<String>)> {
186 let parts: Vec<&str> = agent_command.split_whitespace().collect();
187 if parts.is_empty() {
188 anyhow::bail!("Empty agent command");
189 }
190
191 let command = parts[0];
192 let args = &parts[1..];
193
194 info!("🚀 Spawning pooled agent: {} {:?} (cwd: {})", command, args, self.working_dir.display());
195
196 let mut child = Command::new(command)
197 .args(args)
198 .current_dir(&self.working_dir)
199 .stdin(Stdio::piped())
200 .stdout(Stdio::piped())
201 .stderr(Stdio::piped())
202 .kill_on_drop(false)
203 .spawn()
204 .context(format!("Failed to spawn agent command: {}", agent_command))?;
205
206 let stdin = child.stdin.take().context("Failed to open agent stdin")?;
207 let stdout = child.stdout.take().context("Failed to open agent stdout")?;
208 let stderr = child.stderr.take().context("Failed to open agent stderr")?;
209
210 let (ws_to_agent_tx, mut ws_to_agent_rx) = mpsc::channel::<String>(100);
212
213 let (agent_to_ws_tx, agent_to_ws_rx) = broadcast::channel::<String>(256);
215
216 let mut stdin_writer = stdin;
218 tokio::spawn(async move {
219 while let Some(msg) = ws_to_agent_rx.recv().await {
220 if let Err(e) = stdin_writer.write_all(msg.as_bytes()).await {
221 error!("Failed to write to pooled agent stdin: {}", e);
222 break;
223 }
224 if let Err(e) = stdin_writer.write_all(b"\n").await {
225 error!("Failed to write newline to pooled agent stdin: {}", e);
226 break;
227 }
228 if let Err(e) = stdin_writer.flush().await {
229 error!("Failed to flush pooled agent stdin: {}", e);
230 break;
231 }
232 }
233 debug!("Pooled agent stdin writer task ended");
234 });
235
236 let stdout_tx = agent_to_ws_tx.clone();
238 let stdout_reader = BufReader::new(stdout);
239 let push_relay_for_stdout: Option<Arc<PushRelayClient>> = self.push_relay.clone();
240 let agent_token_for_push = token.to_string();
241 tokio::spawn(async move {
242 let mut lines = stdout_reader.lines();
243 while let Ok(Some(line)) = lines.next_line().await {
244 debug!(
245 "Pooled agent stdout ({} bytes): {}",
246 line.len(),
247 line.chars().take(200).collect::<String>()
248 );
249
250 match stdout_tx.send(line) {
252 Ok(_) => {
253 debug!("Agent output sent to connected client");
255 }
256 Err(_) => {
257 if let Some(ref push_relay) = push_relay_for_stdout {
259 debug!("Agent output with no connected client, triggering push notification");
260 if let Err(e) = push_relay.notify(&agent_token_for_push).await {
261 warn!("Failed to send push notification: {}", e);
262 }
263 }
264 }
265 }
266 }
267 debug!("Pooled agent stdout reader task ended");
268 });
269
270 let stderr_reader = BufReader::new(stderr);
272 tokio::spawn(async move {
273 let mut lines = stderr_reader.lines();
274 while let Ok(Some(line)) = lines.next_line().await {
275 warn!("Pooled agent stderr: {}", line);
276 }
277 debug!("Pooled agent stderr reader task ended");
278 });
279
280 let pooled = PooledAgent {
281 process: child,
282 ws_to_agent_tx: ws_to_agent_tx.clone(),
283 agent_to_ws_tx,
284 connected: true,
285 disconnected_at: None,
286 message_buffer: Vec::new(),
287 cached_init_response: None,
288 cached_session_response: None,
289 agent_command: agent_command.to_string(),
290 };
291
292 self.agents.insert(token.to_string(), pooled);
293
294 let broadcast_tx = self.agents.get(token).unwrap().agent_to_ws_tx.clone();
295
296 Ok((ws_to_agent_tx, agent_to_ws_rx, Vec::new(), false, None, None, broadcast_tx))
297 }
298
299 pub fn mark_disconnected(&mut self, token: &str) {
301 if let Some(agent) = self.agents.get_mut(token) {
302 info!("Client disconnected, agent entering idle state (keep-alive)");
303 agent.connected = false;
304 agent.disconnected_at = Some(Instant::now());
305 }
306 }
307
308 pub fn cache_init_response(&mut self, token: &str, response: String) {
310 if let Some(agent) = self.agents.get_mut(token) {
311 info!("Cached initialize response for agent (keep-alive)");
312 agent.cached_init_response = Some(response);
313 }
314 }
315
316 pub fn cache_session_response(&mut self, token: &str, response: String) {
318 if let Some(agent) = self.agents.get_mut(token) {
319 info!("Cached createSession response for agent (keep-alive)");
320 agent.cached_session_response = Some(response);
321 }
322 }
323
324 pub fn clear_session_response(&mut self, token: &str) {
326 if let Some(agent) = self.agents.get_mut(token) {
327 if agent.cached_session_response.is_some() {
328 info!("Cleared cached session response for agent (session invalidated)");
329 agent.cached_session_response = None;
330 }
331 }
332 }
333
334 #[allow(dead_code)]
336 pub async fn remove_agent(&mut self, token: &str) {
337 if let Some(mut agent) = self.agents.remove(token) {
338 agent.kill().await;
339 }
340 }
341
342 pub async fn reap_idle_agents(&mut self) {
344 let timeout = self.config.idle_timeout;
345 let mut to_remove = Vec::new();
346
347 for (token, agent) in self.agents.iter_mut() {
348 if !agent.is_alive() {
349 info!("Agent for token {}... died, removing", &token[..8.min(token.len())]);
350 to_remove.push(token.clone());
351 continue;
352 }
353
354 if !agent.connected {
355 if let Some(disconnected_at) = agent.disconnected_at {
356 if disconnected_at.elapsed() > timeout {
357 info!(
358 "Agent for token {}... idle for {:?}, terminating",
359 &token[..8.min(token.len())],
360 disconnected_at.elapsed()
361 );
362 to_remove.push(token.clone());
363 }
364 }
365 }
366 }
367
368 for token in to_remove {
369 if let Some(mut agent) = self.agents.remove(&token) {
370 agent.kill().await;
371 }
372 }
373 }
374
375 pub fn stats(&self) -> PoolStats {
377 let total = self.agents.len();
378 let connected = self.agents.values().filter(|a| a.connected).count();
379 let idle = total - connected;
380 PoolStats {
381 total,
382 connected,
383 idle,
384 max: self.config.max_agents,
385 }
386 }
387
388 #[allow(dead_code)]
390 pub fn contains(&self, token: &str) -> bool {
391 self.agents.contains_key(token)
392 }
393
394 #[allow(dead_code)]
397 pub async fn kill_agent(&mut self, token: &str) -> bool {
398 if let Some(agent) = self.agents.get_mut(token) {
399 agent.kill().await;
400 true
401 } else {
402 false
403 }
404 }
405
406 pub fn buffer_message(&mut self, token: &str, message: String) {
408 if !self.config.buffer_messages {
409 return;
410 }
411 if let Some(agent) = self.agents.get_mut(token) {
412 if agent.message_buffer.len() < self.config.max_buffer_size {
413 agent.message_buffer.push(message);
414 } else {
415 warn!("Message buffer full for agent, dropping message");
416 }
417 }
418 }
419
420 #[allow(dead_code)]
422 pub async fn shutdown_all(&mut self) {
423 info!("Shutting down all pooled agents ({} total)", self.agents.len());
424 let tokens: Vec<String> = self.agents.keys().cloned().collect();
425 for token in tokens {
426 if let Some(mut agent) = self.agents.remove(&token) {
427 agent.kill().await;
428 }
429 }
430 }
431}
432
433#[derive(Debug)]
435pub struct PoolStats {
436 pub total: usize,
437 pub connected: usize,
438 pub idle: usize,
439 pub max: usize,
440}
441
442impl std::fmt::Display for PoolStats {
443 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
444 write!(
445 f,
446 "AgentPool: {}/{} agents ({} connected, {} idle)",
447 self.total, self.max, self.connected, self.idle
448 )
449 }
450}
451
452pub fn start_reaper(pool: Arc<RwLock<AgentPool>>, check_interval: Duration) -> tokio::task::JoinHandle<()> {
454 tokio::spawn(async move {
455 let mut interval = tokio::time::interval(check_interval);
456 loop {
457 interval.tick().await;
458 let mut pool = pool.write().await;
459 pool.reap_idle_agents().await;
460 let stats = pool.stats();
461 if stats.total > 0 {
462 debug!("AgentPool stats: {}", stats);
463 }
464 }
465 })
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471
472 fn test_config() -> PoolConfig {
473 PoolConfig {
474 idle_timeout: Duration::from_secs(2),
475 max_agents: 3,
476 buffer_messages: true,
477 max_buffer_size: 5,
478 }
479 }
480
481 #[test]
484 fn pool_config_default_values() {
485 let cfg = PoolConfig::default();
486 assert_eq!(cfg.idle_timeout, Duration::from_secs(1800));
487 assert_eq!(cfg.max_agents, 10);
488 assert!(cfg.buffer_messages);
489 assert_eq!(cfg.max_buffer_size, 10_000);
490 }
491
492 #[test]
495 fn new_pool_is_empty() {
496 let pool = AgentPool::new(test_config());
497 let stats = pool.stats();
498 assert_eq!(stats.total, 0);
499 assert_eq!(stats.connected, 0);
500 assert_eq!(stats.idle, 0);
501 assert_eq!(stats.max, 3);
502 }
503
504 #[tokio::test]
507 async fn spawn_new_agent_with_cat() {
508 let mut pool = AgentPool::new(test_config());
509 let result = pool.get_or_spawn("token_a", "cat").await;
510 assert!(result.is_ok());
511
512 let (_tx, _rx, buffered, was_reused, cached_init, _cached_session, _) = result.unwrap();
513 assert!(!was_reused, "first spawn should not be reused");
514 assert!(buffered.is_empty(), "first spawn should have no buffered msgs");
515 assert!(cached_init.is_none(), "first spawn should have no cached init");
516
517 let stats = pool.stats();
518 assert_eq!(stats.total, 1);
519 assert_eq!(stats.connected, 1);
520
521 pool.shutdown_all().await;
522 }
523
524 #[tokio::test]
525 async fn reuse_existing_agent() {
526 let mut pool = AgentPool::new(test_config());
527
528 let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
530 pool.mark_disconnected("token_a");
531
532 let (_tx, _rx, _buf, was_reused, _cached, _, _) = pool.get_or_spawn("token_a", "cat").await.unwrap();
534 assert!(was_reused, "second call should reuse the agent");
535 assert_eq!(pool.stats().total, 1);
536
537 pool.shutdown_all().await;
538 }
539
540 #[tokio::test]
541 async fn spawn_different_tokens() {
542 let mut pool = AgentPool::new(test_config());
543 let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
544 let _ = pool.get_or_spawn("token_b", "cat").await.unwrap();
545
546 assert_eq!(pool.stats().total, 2);
547 assert_eq!(pool.stats().connected, 2);
548
549 pool.shutdown_all().await;
550 }
551
552 #[tokio::test]
553 async fn spawn_with_invalid_command_fails() {
554 let mut pool = AgentPool::new(test_config());
555 let result = pool.get_or_spawn("token_a", "nonexistent_binary_xyz_42").await;
556 assert!(result.is_err());
557 }
558
559 #[tokio::test]
560 async fn spawn_with_empty_command_fails() {
561 let mut pool = AgentPool::new(test_config());
562 let result = pool.get_or_spawn("token_a", "").await;
563 assert!(result.is_err());
564 }
565
566 #[tokio::test]
569 async fn mark_disconnected_updates_state() {
570 let mut pool = AgentPool::new(test_config());
571 let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
572
573 assert!(pool.agents.get("token_a").unwrap().connected);
574
575 pool.mark_disconnected("token_a");
576
577 let agent = pool.agents.get("token_a").unwrap();
578 assert!(!agent.connected);
579 assert!(agent.disconnected_at.is_some());
580
581 let stats = pool.stats();
582 assert_eq!(stats.connected, 0);
583 assert_eq!(stats.idle, 1);
584
585 pool.shutdown_all().await;
586 }
587
588 #[tokio::test]
589 async fn reconnect_clears_disconnected_state() {
590 let mut pool = AgentPool::new(test_config());
591 let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
592 pool.mark_disconnected("token_a");
593
594 let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
596 let agent = pool.agents.get("token_a").unwrap();
597 assert!(agent.connected);
598 assert!(agent.disconnected_at.is_none());
599
600 pool.shutdown_all().await;
601 }
602
603 #[tokio::test]
606 async fn max_agents_evicts_idle() {
607 let mut pool = AgentPool::new(test_config()); let _ = pool.get_or_spawn("t1", "cat").await.unwrap();
610 let _ = pool.get_or_spawn("t2", "cat").await.unwrap();
611 let _ = pool.get_or_spawn("t3", "cat").await.unwrap();
612 assert_eq!(pool.stats().total, 3);
613
614 pool.mark_disconnected("t1");
616
617 let _ = pool.get_or_spawn("t4", "cat").await.unwrap();
619 assert_eq!(pool.stats().total, 3);
620 assert!(!pool.agents.contains_key("t1"), "idle agent t1 should be evicted");
621 }
622
623 #[tokio::test]
624 async fn max_agents_all_connected_fails() {
625 let mut pool = AgentPool::new(test_config()); let _ = pool.get_or_spawn("t1", "cat").await.unwrap();
628 let _ = pool.get_or_spawn("t2", "cat").await.unwrap();
629 let _ = pool.get_or_spawn("t3", "cat").await.unwrap();
630
631 let result = pool.get_or_spawn("t4", "cat").await;
633 assert!(result.is_err());
634 assert!(result.unwrap_err().to_string().contains("Agent pool is full"));
635
636 pool.shutdown_all().await;
637 }
638
639 #[tokio::test]
642 async fn reap_removes_timed_out_agents() {
643 let cfg = PoolConfig {
644 idle_timeout: Duration::from_millis(50),
645 max_agents: 10,
646 buffer_messages: false,
647 max_buffer_size: 100,
648 };
649 let mut pool = AgentPool::new(cfg);
650
651 let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
652 pool.mark_disconnected("token_a");
653
654 tokio::time::sleep(Duration::from_millis(100)).await;
656
657 pool.reap_idle_agents().await;
658 assert_eq!(pool.stats().total, 0, "timed-out agent should be reaped");
659 }
660
661 #[tokio::test]
662 async fn reap_keeps_connected_agents() {
663 let cfg = PoolConfig {
664 idle_timeout: Duration::from_millis(50),
665 max_agents: 10,
666 buffer_messages: false,
667 max_buffer_size: 100,
668 };
669 let mut pool = AgentPool::new(cfg);
670
671 let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
672 tokio::time::sleep(Duration::from_millis(100)).await;
675
676 pool.reap_idle_agents().await;
677 assert_eq!(pool.stats().total, 1, "connected agent should survive reaping");
678
679 pool.shutdown_all().await;
680 }
681
682 #[tokio::test]
683 async fn reap_keeps_recently_disconnected() {
684 let cfg = PoolConfig {
685 idle_timeout: Duration::from_secs(60),
686 max_agents: 10,
687 buffer_messages: false,
688 max_buffer_size: 100,
689 };
690 let mut pool = AgentPool::new(cfg);
691
692 let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
693 pool.mark_disconnected("token_a");
694
695 pool.reap_idle_agents().await;
697 assert_eq!(pool.stats().total, 1, "recently-disconnected agent should survive");
698
699 pool.shutdown_all().await;
700 }
701
702 #[tokio::test]
705 async fn buffer_message_stores_messages() {
706 let mut pool = AgentPool::new(test_config()); let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
708 pool.mark_disconnected("token_a");
709
710 pool.buffer_message("token_a", "msg1".into());
711 pool.buffer_message("token_a", "msg2".into());
712
713 let agent = pool.agents.get("token_a").unwrap();
714 assert_eq!(agent.message_buffer.len(), 2);
715 assert_eq!(agent.message_buffer[0], "msg1");
716 assert_eq!(agent.message_buffer[1], "msg2");
717
718 pool.shutdown_all().await;
719 }
720
721 #[tokio::test]
722 async fn buffer_message_respects_max_size() {
723 let mut pool = AgentPool::new(test_config()); let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
725
726 for i in 0..10 {
727 pool.buffer_message("token_a", format!("msg{}", i));
728 }
729
730 let agent = pool.agents.get("token_a").unwrap();
731 assert_eq!(agent.message_buffer.len(), 5, "should cap at max_buffer_size");
732
733 pool.shutdown_all().await;
734 }
735
736 #[tokio::test]
737 async fn buffer_disabled_drops_messages() {
738 let cfg = PoolConfig {
739 buffer_messages: false,
740 ..test_config()
741 };
742 let mut pool = AgentPool::new(cfg);
743 let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
744
745 pool.buffer_message("token_a", "msg1".into());
746
747 let agent = pool.agents.get("token_a").unwrap();
748 assert!(agent.message_buffer.is_empty(), "buffering disabled, should drop");
749
750 pool.shutdown_all().await;
751 }
752
753 #[tokio::test]
754 async fn reconnect_drains_buffer() {
755 let mut pool = AgentPool::new(test_config());
756 let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
757 pool.mark_disconnected("token_a");
758
759 pool.buffer_message("token_a", "buffered1".into());
760 pool.buffer_message("token_a", "buffered2".into());
761
762 let (_tx, _rx, buffered, was_reused, _cached, _, _) = pool.get_or_spawn("token_a", "cat").await.unwrap();
764 assert!(was_reused);
765 assert_eq!(buffered.len(), 2);
766 assert_eq!(buffered[0], "buffered1");
767 assert_eq!(buffered[1], "buffered2");
768
769 let agent = pool.agents.get("token_a").unwrap();
771 assert!(agent.message_buffer.is_empty());
772
773 pool.shutdown_all().await;
774 }
775
776 #[tokio::test]
779 async fn remove_agent_kills_and_removes() {
780 let mut pool = AgentPool::new(test_config());
781 let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
782 assert_eq!(pool.stats().total, 1);
783
784 pool.remove_agent("token_a").await;
785 assert_eq!(pool.stats().total, 0);
786 }
787
788 #[tokio::test]
789 async fn shutdown_all_clears_pool() {
790 let mut pool = AgentPool::new(test_config());
791 let _ = pool.get_or_spawn("t1", "cat").await.unwrap();
792 let _ = pool.get_or_spawn("t2", "cat").await.unwrap();
793 let _ = pool.get_or_spawn("t3", "cat").await.unwrap();
794 assert_eq!(pool.stats().total, 3);
795
796 pool.shutdown_all().await;
797 assert_eq!(pool.stats().total, 0);
798 }
799
800 #[tokio::test]
803 async fn stats_reflect_pool_state() {
804 let mut pool = AgentPool::new(test_config());
805 let _ = pool.get_or_spawn("t1", "cat").await.unwrap();
806 let _ = pool.get_or_spawn("t2", "cat").await.unwrap();
807 pool.mark_disconnected("t2");
808
809 let s = pool.stats();
810 assert_eq!(s.total, 2);
811 assert_eq!(s.connected, 1);
812 assert_eq!(s.idle, 1);
813 assert_eq!(s.max, 3);
814 assert!(format!("{}", s).contains("2/3 agents"));
815
816 pool.shutdown_all().await;
817 }
818
819 #[tokio::test]
822 async fn dead_agent_is_replaced_on_reconnect() {
823 let mut pool = AgentPool::new(test_config());
824 let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
825
826 pool.agents.get_mut("token_a").unwrap().kill().await;
828 tokio::time::sleep(Duration::from_millis(50)).await;
830
831 let (_tx, _rx, _buf, was_reused, _cached, _, _) = pool.get_or_spawn("token_a", "cat").await.unwrap();
833 assert!(!was_reused, "dead agent should be replaced, not reused");
834
835 pool.shutdown_all().await;
836 }
837
838 #[tokio::test]
841 async fn reaper_task_cleans_up() {
842 let cfg = PoolConfig {
843 idle_timeout: Duration::from_millis(50),
844 max_agents: 10,
845 buffer_messages: false,
846 max_buffer_size: 100,
847 };
848 let pool = Arc::new(RwLock::new(AgentPool::new(cfg)));
849
850 {
852 let mut p = pool.write().await;
853 let _ = p.get_or_spawn("token_a", "cat").await.unwrap();
854 p.mark_disconnected("token_a");
855 }
856
857 let handle = start_reaper(Arc::clone(&pool), Duration::from_millis(30));
859
860 tokio::time::sleep(Duration::from_millis(200)).await;
862
863 let stats = pool.read().await.stats();
864 assert_eq!(stats.total, 0, "reaper should have cleaned up the idle agent");
865
866 handle.abort();
867 }
868
869 #[tokio::test]
872 async fn cache_init_response_stores_and_returns() {
873 let mut pool = AgentPool::new(test_config());
874 let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
875
876 let agent = pool.agents.get("token_a").unwrap();
878 assert!(agent.cached_init_response.is_none());
879
880 let fake_init = r#"{"jsonrpc":"2.0","id":1,"result":{"capabilities":{}}}"#.to_string();
882 pool.cache_init_response("token_a", fake_init.clone());
883
884 let agent = pool.agents.get("token_a").unwrap();
885 assert_eq!(agent.cached_init_response.as_deref(), Some(fake_init.as_str()));
886
887 pool.mark_disconnected("token_a");
889 let (_tx, _rx, _buf, was_reused, cached, _, _) = pool.get_or_spawn("token_a", "cat").await.unwrap();
890 assert!(was_reused);
891 assert_eq!(cached.as_deref(), Some(fake_init.as_str()));
892
893 pool.shutdown_all().await;
894 }
895
896 #[tokio::test]
897 async fn no_cached_init_for_fresh_spawn() {
898 let mut pool = AgentPool::new(test_config());
899 let (_tx, _rx, _buf, was_reused, cached, _, _) = pool.get_or_spawn("token_a", "cat").await.unwrap();
900 assert!(!was_reused);
901 assert!(cached.is_none(), "fresh spawn should have no cached init");
902
903 pool.shutdown_all().await;
904 }
905
906 #[tokio::test]
907 async fn dead_agent_loses_cached_init() {
908 let mut pool = AgentPool::new(test_config());
909 let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
910
911 pool.cache_init_response(
912 "token_a",
913 r#"{"jsonrpc":"2.0","id":1,"result":{"capabilities":{}}}"#.to_string(),
914 );
915
916 pool.agents.get_mut("token_a").unwrap().kill().await;
918 tokio::time::sleep(Duration::from_millis(50)).await;
919
920 let (_tx, _rx, _buf, was_reused, cached, _, _) = pool.get_or_spawn("token_a", "cat").await.unwrap();
922 assert!(!was_reused, "dead agent should be replaced");
923 assert!(cached.is_none(), "dead agent's cached init should not carry over");
924
925 pool.shutdown_all().await;
926 }
927
928 #[tokio::test]
931 async fn cache_session_response_stores_and_returns() {
932 let mut pool = AgentPool::new(test_config());
933 let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
934
935 let agent = pool.agents.get("token_a").unwrap();
937 assert!(agent.cached_session_response.is_none());
938
939 let fake_session = r#"{"jsonrpc":"2.0","id":2,"result":{"sessionId":"ses-abc-123"}}"#.to_string();
941 pool.cache_session_response("token_a", fake_session.clone());
942
943 let agent = pool.agents.get("token_a").unwrap();
944 assert_eq!(agent.cached_session_response.as_deref(), Some(fake_session.as_str()));
945
946 pool.mark_disconnected("token_a");
948 let (_tx, _rx, _buf, was_reused, _cached_init, cached_session, _) = pool.get_or_spawn("token_a", "cat").await.unwrap();
949 assert!(was_reused);
950 assert_eq!(cached_session.as_deref(), Some(fake_session.as_str()));
951
952 pool.shutdown_all().await;
953 }
954
955 #[tokio::test]
956 async fn no_cached_session_for_fresh_spawn() {
957 let mut pool = AgentPool::new(test_config());
958 let (_tx, _rx, _buf, was_reused, _cached_init, cached_session, _) = pool.get_or_spawn("token_a", "cat").await.unwrap();
959 assert!(!was_reused);
960 assert!(cached_session.is_none(), "fresh spawn should have no cached session");
961
962 pool.shutdown_all().await;
963 }
964
965 #[tokio::test]
966 async fn dead_agent_loses_cached_session() {
967 let mut pool = AgentPool::new(test_config());
968 let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
969
970 pool.cache_session_response(
971 "token_a",
972 r#"{"jsonrpc":"2.0","id":2,"result":{"sessionId":"ses-abc-123"}}"#.to_string(),
973 );
974
975 pool.agents.get_mut("token_a").unwrap().kill().await;
977 tokio::time::sleep(Duration::from_millis(50)).await;
978
979 let (_tx, _rx, _buf, was_reused, _cached_init, cached_session, _) = pool.get_or_spawn("token_a", "cat").await.unwrap();
981 assert!(!was_reused, "dead agent should be replaced");
982 assert!(cached_session.is_none(), "dead agent's cached session should not carry over");
983
984 pool.shutdown_all().await;
985 }
986}