Skip to main content

soothe_client/appkit/
pool.rs

1//! Connection pool mapping application sessions to daemon loops (Go `ConnectionPool` parity).
2
3use std::collections::HashMap;
4use std::future::Future;
5use std::pin::Pin;
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::Arc;
8use std::time::{Duration, Instant};
9
10use serde_json::{json, Map, Value};
11use tokio::sync::Mutex;
12use tokio::task::JoinHandle;
13
14use crate::client::Client;
15use crate::errors::{Error, Result};
16use crate::protocol::new_notification;
17use crate::session::{bootstrap_loop_session, BootstrapOptions};
18
19use super::session_store::{SessionRecord, SessionStore};
20
21/// Returned when no idle pool slot is immediately available.
22#[derive(Debug, Clone, thiserror::Error)]
23#[error("appkit: connection pool exhausted")]
24pub struct ErrPoolExhausted;
25
26/// Pool configuration.
27#[derive(Debug, Clone)]
28pub struct PoolConfig {
29    /// Max concurrent pooled connections (pre-seeded idle slots).
30    pub pool_size: usize,
31    /// Max idle time before recycle on `acquire`.
32    pub max_idle_time: Duration,
33}
34
35impl Default for PoolConfig {
36    fn default() -> Self {
37        Self {
38            pool_size: 1000,
39            max_idle_time: Duration::from_secs(10 * 60),
40        }
41    }
42}
43
44/// Pool stats snapshot.
45#[derive(Debug, Clone, Default)]
46pub struct PoolStats {
47    /// Active checked-out connections keyed by session id.
48    pub active: usize,
49    /// Idle slots waiting in the pool channel.
50    pub idle: usize,
51}
52
53/// One connection slot in the pool.
54pub struct PooledConn {
55    /// Internal slot id.
56    pub slot_id: u32,
57    /// Application session id (`app_key` in Go).
58    pub session_id: String,
59    /// Bound StrangeLoop id.
60    pub loop_id: Mutex<String>,
61    /// Workspace id used at bootstrap.
62    pub workspace_id: String,
63    /// Underlying protocol client.
64    pub client: Client,
65    /// Forwarded event stream (`ReceiveMessages` → mpsc); `None` until `start_reader`.
66    pub event_rx: Mutex<Option<tokio::sync::mpsc::Receiver<Value>>>,
67    /// Whether the forwarder task is still running.
68    pub reader_live: AtomicBool,
69    /// Last time this slot was acquired.
70    pub last_used: Mutex<Instant>,
71    reader_task: Mutex<Option<JoinHandle<()>>>,
72}
73
74impl PooledConn {
75    /// Loop id (Go `getLoopID`).
76    pub async fn get_loop_id(&self) -> String {
77        self.loop_id.lock().await.clone()
78    }
79
80    /// Whether the underlying client is connected and has not signalled disconnect.
81    pub async fn is_connected(&self) -> bool {
82        self.client.is_connected() && !Self::client_disconnect_notified(&self.client).await
83    }
84
85    /// Whether `ReceiveMessages` is still feeding `event_rx`.
86    pub async fn event_stream_live(&self) -> bool {
87        let has_rx = self.event_rx.lock().await.is_some();
88        has_rx && self.reader_live.load(Ordering::SeqCst)
89    }
90
91    async fn client_disconnect_notified(client: &Client) -> bool {
92        client.disconnect_cause().await.is_some() || !client.is_connection_alive()
93    }
94}
95
96/// Handle to an acquired pooled connection (shared across async tasks).
97pub type PooledConnHandle = Arc<PooledConn>;
98
99type ClientFactory = Arc<dyn Fn(&str) -> Client + Send + Sync>;
100type BootstrapFn = Arc<
101    dyn Fn(Client, String, String, String) -> Pin<Box<dyn Future<Output = Result<String>> + Send>>
102        + Send
103        + Sync,
104>;
105
106struct IdleSlot {
107    client: Client,
108}
109
110struct PoolState {
111    active_slots: HashMap<String, PooledConnHandle>,
112    next_slot_id: u32,
113    idle_rx: tokio::sync::mpsc::Receiver<IdleSlot>,
114    idle_tx: tokio::sync::mpsc::Sender<IdleSlot>,
115    idle_count: usize,
116}
117
118/// Manages daemon connections, one active slot per session id.
119pub struct ConnectionPool<S: SessionStore> {
120    daemon_url: String,
121    store: Arc<S>,
122    cfg: PoolConfig,
123    factory: ClientFactory,
124    bootstrap: BootstrapFn,
125    state: Mutex<PoolState>,
126}
127
128impl<S: SessionStore + 'static> ConnectionPool<S> {
129    /// Construct a pool for `daemon_url`. `cfg` defaults via [`PoolConfig::default`].
130    pub fn new(daemon_url: impl Into<String>, store: Arc<S>, cfg: Option<PoolConfig>) -> Self {
131        let cfg = cfg.unwrap_or_default();
132        let pool_size = if cfg.pool_size == 0 {
133            PoolConfig::default().pool_size
134        } else {
135            cfg.pool_size
136        };
137        let daemon_url = daemon_url.into();
138        let factory: ClientFactory = Arc::new(|url| Client::new(url));
139        let bootstrap = default_bootstrap_fn();
140        let (idle_tx, idle_rx) = tokio::sync::mpsc::channel(pool_size);
141        for _ in 0..pool_size {
142            let _ = idle_tx.try_send(IdleSlot {
143                client: factory(&daemon_url),
144            });
145        }
146        Self {
147            daemon_url,
148            store,
149            cfg: PoolConfig { pool_size, ..cfg },
150            factory,
151            bootstrap,
152            state: Mutex::new(PoolState {
153                active_slots: HashMap::new(),
154                next_slot_id: 1,
155                idle_rx,
156                idle_tx,
157                idle_count: pool_size,
158            }),
159        }
160    }
161
162    /// Override the client factory (logging/metrics wrappers, test fakes).
163    pub fn with_client_factory(mut self, f: Box<dyn Fn(&str) -> Client + Send + Sync>) -> Self {
164        self.factory = Arc::from(f);
165        self
166    }
167
168    /// Override loop bootstrap (`loop_new` + subscribe).
169    pub fn with_bootstrap(
170        mut self,
171        f: impl Fn(
172                Client,
173                String,
174                String,
175                String,
176            ) -> Pin<Box<dyn Future<Output = Result<String>> + Send>>
177            + Send
178            + Sync
179            + 'static,
180    ) -> Self {
181        self.bootstrap = Arc::new(f);
182        self
183    }
184
185    /// Snapshot active and idle slot counts.
186    pub async fn stats(&self) -> PoolStats {
187        let state = self.state.lock().await;
188        PoolStats {
189            active: state.active_slots.len(),
190            idle: state.idle_count,
191        }
192    }
193
194    /// Acquire a live connection for `session_id`, bootstrapping or reattaching as needed.
195    ///
196    /// The caller must call [`Self::release`] when the session connection should be torn down
197    /// (TurnRunner keeps the slot active across turns on the same session).
198    pub async fn acquire(
199        &self,
200        session_id: &str,
201        workspace_id: &str,
202        user_id: &str,
203    ) -> Result<PooledConnHandle> {
204        // 1. Reuse active connection when still live.
205        if let Some(existing) = {
206            let state = self.state.lock().await;
207            state.active_slots.get(session_id).cloned()
208        } {
209            let disconnect = PooledConn::client_disconnect_notified(&existing.client).await
210                || !existing.is_connected().await
211                || !existing.event_stream_live().await;
212            if disconnect {
213                tracing::warn!(
214                    session_id,
215                    "previous connection dropped or event stream dead, releasing for fresh bootstrap"
216                );
217                self.release(session_id).await;
218            } else {
219                let idle_too_long = self.cfg.max_idle_time > Duration::ZERO
220                    && existing.last_used.lock().await.elapsed() > self.cfg.max_idle_time;
221                if idle_too_long {
222                    tracing::warn!(
223                        session_id,
224                        max_idle = ?self.cfg.max_idle_time,
225                        "session idle beyond max, releasing"
226                    );
227                    self.release(session_id).await;
228                } else {
229                    *existing.last_used.lock().await = Instant::now();
230                    self.store.update_last_used(session_id).await;
231                    return Ok(existing);
232                }
233            }
234        }
235
236        // 2. Pull a slot from the pool (non-blocking — Go `default` branch).
237        let idle = {
238            let mut state = self.state.lock().await;
239            match state.idle_rx.try_recv() {
240                Ok(slot) => {
241                    state.idle_count = state.idle_count.saturating_sub(1);
242                    slot
243                }
244                Err(_) => return Err(Error::from(ErrPoolExhausted)),
245            }
246        };
247
248        let slot_id = {
249            let mut state = self.state.lock().await;
250            let id = state.next_slot_id;
251            state.next_slot_id += 1;
252            id
253        };
254
255        let conn = Arc::new(PooledConn {
256            slot_id,
257            session_id: session_id.to_string(),
258            loop_id: Mutex::new(String::new()),
259            workspace_id: workspace_id.to_string(),
260            client: idle.client,
261            event_rx: Mutex::new(None),
262            reader_live: AtomicBool::new(false),
263            last_used: Mutex::new(Instant::now()),
264            reader_task: Mutex::new(None),
265        });
266
267        let (stored_loop_id, has_loop) = self.loop_id_for(session_id).await;
268        let loop_id = if !has_loop || stored_loop_id.is_empty() {
269            conn.client
270                .connect()
271                .await
272                .map_err(|e| Error::msg(format!("connect: {e}")))?;
273            let loop_id = match self
274                .bootstrap_new(Arc::clone(&conn), session_id, workspace_id, user_id)
275                .await
276            {
277                Ok(id) => id,
278                Err(e) => {
279                    self.release(session_id).await;
280                    return Err(Error::msg(format!("bootstrap new loop: {e}")));
281                }
282            };
283            if let Err(e) = self
284                .persist_new_session(session_id, workspace_id, user_id, &loop_id)
285                .await
286            {
287                tracing::warn!(session_id, error = %e, "create session failed");
288            }
289            loop_id
290        } else if self
291            .resume_and_reattach(Arc::clone(&conn), &stored_loop_id)
292            .await
293            .is_err()
294        {
295            tracing::warn!(session_id, "reattach failed, bootstrapping fresh");
296            if let Err(e) = conn.client.connect().await {
297                self.release(session_id).await;
298                return Err(Error::msg(format!("connect after reattach fail: {e}")));
299            }
300            let loop_id = match self
301                .bootstrap_new(Arc::clone(&conn), session_id, workspace_id, user_id)
302                .await
303            {
304                Ok(id) => id,
305                Err(e) => {
306                    self.release(session_id).await;
307                    return Err(Error::msg(format!("bootstrap after reattach fail: {e}")));
308                }
309            };
310            if let Err(e) = self
311                .persist_new_session(session_id, workspace_id, user_id, &loop_id)
312                .await
313            {
314                tracing::warn!(session_id, error = %e, "create session after bootstrap failed");
315            }
316            loop_id
317        } else {
318            stored_loop_id
319        };
320
321        *conn.loop_id.lock().await = loop_id.clone();
322        *conn.last_used.lock().await = Instant::now();
323        self.store.update_last_used(session_id).await;
324
325        {
326            let mut state = self.state.lock().await;
327            state
328                .active_slots
329                .insert(session_id.to_string(), Arc::clone(&conn));
330        }
331
332        tracing::info!(slot_id, session_id, loop_id, "acquired pool slot");
333        Ok(conn)
334    }
335
336    /// Tear down the connection for `session_id` and return a fresh slot to the pool.
337    pub async fn release(&self, session_id: &str) {
338        let conn = {
339            let mut state = self.state.lock().await;
340            state.active_slots.remove(session_id)
341        };
342        let Some(conn) = conn else {
343            return;
344        };
345
346        if let Some(handle) = conn.reader_task.lock().await.take() {
347            handle.abort();
348        }
349        conn.reader_live.store(false, Ordering::SeqCst);
350        *conn.event_rx.lock().await = None;
351        let _ = conn.client.close().await;
352
353        tracing::info!(slot_id = conn.slot_id, session_id, "released pool slot");
354
355        let new_slot = IdleSlot {
356            client: (self.factory)(&self.daemon_url),
357        };
358        let mut state = self.state.lock().await;
359        if state.idle_tx.try_send(new_slot).is_ok() {
360            state.idle_count += 1;
361        } else {
362            tracing::warn!(session_id, "pool full when returning slot");
363        }
364    }
365
366    /// Tear down the connection so the next `acquire` bootstraps fresh.
367    pub async fn reset_session(&self, session_id: &str) {
368        self.release(session_id).await;
369        tracing::info!(
370            session_id,
371            "reset session — next message will create new loop"
372        );
373    }
374
375    /// Gracefully shut down all active and idle connections.
376    pub async fn stop(&self) {
377        let active: Vec<PooledConnHandle> = {
378            let mut state = self.state.lock().await;
379            state.active_slots.drain().map(|(_, c)| c).collect()
380        };
381        for conn in active {
382            if let Some(handle) = conn.reader_task.lock().await.take() {
383                handle.abort();
384            }
385            let _ = conn.client.close().await;
386        }
387        loop {
388            let slot = {
389                let mut state = self.state.lock().await;
390                match state.idle_rx.try_recv() {
391                    Ok(s) => {
392                        state.idle_count = state.idle_count.saturating_sub(1);
393                        Some(s)
394                    }
395                    Err(_) => None,
396                }
397            };
398            match slot {
399                Some(s) => {
400                    let _ = s.client.close().await;
401                }
402                None => break,
403            }
404        }
405    }
406
407    async fn loop_id_for(&self, session_id: &str) -> (String, bool) {
408        let Some(loop_id) = self.store.get_loop_id_for_session(session_id).await else {
409            return (String::new(), false);
410        };
411        let loop_id = loop_id.trim().to_string();
412        if loop_id.is_empty() || loop_id.starts_with("pending-") {
413            return (String::new(), false);
414        }
415        (loop_id, true)
416    }
417
418    async fn persist_new_session(
419        &self,
420        session_id: &str,
421        workspace_id: &str,
422        user_id: &str,
423        loop_id: &str,
424    ) -> Result<()> {
425        if self.store.get_session(session_id).await.is_none() {
426            self.store
427                .create_session(SessionRecord {
428                    session_id: session_id.to_string(),
429                    workspace_id: workspace_id.to_string(),
430                    user_id: user_id.to_string(),
431                    loop_id: Some(loop_id.to_string()),
432                    ..Default::default()
433                })
434                .await;
435        } else {
436            self.store.set_loop_id(session_id, loop_id).await;
437        }
438        Ok(())
439    }
440
441    async fn bootstrap_new(
442        &self,
443        conn: PooledConnHandle,
444        session_id: &str,
445        workspace_id: &str,
446        user_id: &str,
447    ) -> Result<String> {
448        let loop_id = (self.bootstrap)(
449            conn.client.clone(),
450            session_id.to_string(),
451            workspace_id.to_string(),
452            user_id.to_string(),
453        )
454        .await?;
455        self.start_reader(&conn).await;
456        Ok(loop_id)
457    }
458
459    async fn resume_and_reattach(&self, conn: PooledConnHandle, loop_id: &str) -> Result<()> {
460        conn.client.connect().await?;
461        conn.client.reattach_and_probe(loop_id).await?;
462        self.start_reader(&conn).await;
463        Ok(())
464    }
465
466    /// Launch `ReceiveMessages(256)` forwarder; detached from caller cancellation.
467    async fn start_reader(&self, conn: &PooledConnHandle) {
468        if let Some(handle) = conn.reader_task.lock().await.take() {
469            handle.abort();
470        }
471        conn.reader_live.store(false, Ordering::SeqCst);
472
473        let raw_rx = conn.client.receive_messages(256);
474        let (tx, event_rx) = tokio::sync::mpsc::channel(256);
475        *conn.event_rx.lock().await = Some(event_rx);
476        conn.reader_live.store(true, Ordering::SeqCst);
477
478        let reader_live_flag = Arc::clone(conn);
479        let session_id = conn.session_id.clone();
480
481        let handle = tokio::spawn(async move {
482            let mut raw = raw_rx;
483            while let Some(msg) = raw.recv().await {
484                if tx.send(msg).await.is_err() {
485                    break;
486                }
487            }
488            reader_live_flag.reader_live.store(false, Ordering::SeqCst);
489            tracing::debug!(session_id, "pool event reader stopped");
490        });
491
492        *conn.reader_task.lock().await = Some(handle);
493    }
494}
495
496fn default_bootstrap_fn() -> BootstrapFn {
497    Arc::new(|client, _session_id, workspace_id, user_id| {
498        Box::pin(async move {
499            let mut boot = BootstrapOptions::new();
500            boot.workspace = Some(workspace_id);
501            boot.user_id = Some(user_id);
502            let ready = bootstrap_loop_session(&client, boot, None).await?;
503            let loop_id = ready
504                .get("loop_id")
505                .and_then(|v| v.as_str())
506                .unwrap_or("")
507                .trim()
508                .to_string();
509            if loop_id.is_empty() {
510                return Err(Error::msg("bootstrap missing loop_id"));
511            }
512            Ok(loop_id)
513        })
514    })
515}
516
517impl From<ErrPoolExhausted> for Error {
518    fn from(e: ErrPoolExhausted) -> Self {
519        Error::msg(e.to_string())
520    }
521}
522
523/// Build a protocol-1 `loop_input` notification envelope (Go `InputMessageForLoop`).
524pub fn input_message_for_loop(
525    text: &str,
526    loop_id: &str,
527    attachments: Option<Value>,
528    opts: Option<&super::turn_runner::InputOpts>,
529) -> Map<String, Value> {
530    let mut params = Map::new();
531    params.insert("content".into(), json!(text));
532    params.insert("autonomous".into(), json!(false));
533    if !loop_id.trim().is_empty() {
534        params.insert("loop_id".into(), json!(loop_id));
535    }
536    if let Some(atts) = attachments {
537        params.insert("attachments".into(), atts);
538    }
539    if let Some(o) = opts {
540        if let Some(h) = o
541            .intent_hint
542            .as_ref()
543            .map(|s| s.trim())
544            .filter(|s| !s.is_empty())
545        {
546            params.insert("intent_hint".into(), json!(h));
547        }
548        if let Some(s) = o
549            .preferred_subagent
550            .as_ref()
551            .map(|s| s.trim())
552            .filter(|s| !s.is_empty())
553        {
554            params.insert("preferred_subagent".into(), json!(s));
555        }
556        if let Some(schema) = &o.response_schema {
557            params.insert("response_schema".into(), schema.clone());
558        }
559        if let Some(n) = o
560            .response_schema_name
561            .as_ref()
562            .map(|s| s.trim())
563            .filter(|s| !s.is_empty())
564        {
565            params.insert("response_schema_name".into(), json!(n));
566        }
567        if let Some(strict) = o.response_schema_strict {
568            params.insert("response_schema_strict".into(), json!(strict));
569        }
570    }
571    let env = new_notification("loop_input", params);
572    match serde_json::to_value(env) {
573        Ok(Value::Object(m)) => m,
574        _ => {
575            let mut fallback = Map::new();
576            fallback.insert("proto".into(), json!(crate::protocol::PROTO_VERSION));
577            fallback.insert("type".into(), json!("notification"));
578            fallback.insert("method".into(), json!("loop_input"));
579            fallback.insert(
580                "params".into(),
581                Value::Object({
582                    let mut p = Map::new();
583                    p.insert("content".into(), json!(text));
584                    p.insert("autonomous".into(), json!(false));
585                    if !loop_id.trim().is_empty() {
586                        p.insert("loop_id".into(), json!(loop_id));
587                    }
588                    p
589                }),
590            );
591            fallback
592        }
593    }
594}