Skip to main content

basis_acp/
session.rs

1//! The sessions an ACP connection is holding open.
2//!
3//! # Two locks, deliberately
4//!
5//! A session holds its [`PreparedRun`] behind an **async** mutex, held for the
6//! whole turn — one conversation runs one turn at a time, which is what ACP
7//! assumes, so a second `session/prompt` waits rather than interleaving.
8//!
9//! The cancellation token sits outside that lock, behind its own **sync**
10//! mutex that is never held across an await. It has to: `session/cancel`
11//! arrives *while* a turn is running and therefore while the turn lock is
12//! held. Putting the token inside would mean cancel waits for the turn it is
13//! trying to cancel — a deadlock that only shows up when someone presses stop.
14//!
15//! The session's mode lives outside the turn lock for the same reason, and is
16//! its own type — see [`mode`](crate::mode).
17//!
18//! # Which id is the session id
19//!
20//! basis uses mentra's **agent id**, not its session id. mentra persists agents;
21//! a `Session` is one process's view of one. Keying on the agent id is what
22//! makes `session/load` free — it is exactly the handle
23//! [`Workspace::resume`](basis::Workspace::resume) takes, so a client can
24//! reconnect to a conversation this process never saw.
25
26use std::{
27    collections::HashMap,
28    sync::{Arc, Mutex},
29};
30
31use agent_client_protocol::schema::v1::SessionId;
32
33use crate::mode::{ApprovalMode, SessionModes};
34use basis::{PreparedRun, run::TurnOptions};
35use mentra::runtime::CancellationToken;
36
37/// One open conversation.
38#[derive(Clone)]
39pub struct AcpSession {
40    /// Held for the duration of a turn.
41    run: Arc<tokio::sync::Mutex<PreparedRun>>,
42    /// Set while a turn is in flight. Reachable without the turn lock, which
43    /// is the entire point — see the module docs.
44    cancel: Arc<Mutex<Option<CancellationToken>>>,
45    /// Also reachable without the turn lock: ACP says `session/set_mode` may
46    /// arrive while the agent is generating.
47    modes: SessionModes,
48    id: SessionId,
49}
50
51impl AcpSession {
52    /// Opens a session at `initial_mode`, which is where the client's mode
53    /// picker starts.
54    pub fn new(run: PreparedRun, initial_mode: ApprovalMode) -> Self {
55        Self {
56            id: SessionId::new(run.agent_id().to_string()),
57            run: Arc::new(tokio::sync::Mutex::new(run)),
58            cancel: Arc::new(Mutex::new(None)),
59            modes: SessionModes::new(initial_mode),
60        }
61    }
62
63    /// The ACP session id for this conversation: mentra's persisted agent id.
64    pub fn id(&self) -> SessionId {
65        self.id.clone()
66    }
67
68    /// This conversation's mode, shared with whatever turn is running.
69    pub fn modes(&self) -> &SessionModes {
70        &self.modes
71    }
72
73    /// Takes the turn lock. Held until the returned guard drops.
74    pub async fn lock_turn(&self) -> tokio::sync::MutexGuard<'_, PreparedRun> {
75        self.run.lock().await
76    }
77
78    /// Arms a fresh cancellation token for a turn about to start.
79    pub fn begin_turn(&self) -> TurnOptions {
80        let (options, token) = TurnOptions::cancellable();
81        *self.cancel_slot() = Some(token);
82        options
83    }
84
85    /// Disarms after a turn ends, so a late `session/cancel` cannot cancel the
86    /// *next* turn.
87    pub fn end_turn(&self) {
88        *self.cancel_slot() = None;
89    }
90
91    /// Trips the in-flight turn's token. `false` when no turn is running.
92    pub fn cancel(&self) -> bool {
93        match self.cancel_slot().take() {
94            Some(token) => {
95                token.cancel();
96                true
97            }
98            None => false,
99        }
100    }
101
102    fn cancel_slot(&self) -> std::sync::MutexGuard<'_, Option<CancellationToken>> {
103        self.cancel
104            .lock()
105            .unwrap_or_else(|poisoned| poisoned.into_inner())
106    }
107}
108
109/// Every conversation this connection is holding.
110///
111/// Cloneable: each clone shares one map, which is what lets a spawned prompt
112/// task and the dispatch loop reach the same session.
113#[derive(Clone, Default)]
114pub struct SessionRegistry {
115    sessions: Arc<Mutex<HashMap<SessionId, AcpSession>>>,
116}
117
118impl SessionRegistry {
119    pub fn new() -> Self {
120        Self::default()
121    }
122
123    /// Files a session under its own id and hands that id back.
124    pub fn insert(&self, session: AcpSession) -> SessionId {
125        let id = session.id();
126        self.lock().insert(id.clone(), session);
127        id
128    }
129
130    pub fn get(&self, id: &SessionId) -> Option<AcpSession> {
131        self.lock().get(id).cloned()
132    }
133
134    pub fn remove(&self, id: &SessionId) -> Option<AcpSession> {
135        self.lock().remove(id)
136    }
137
138    pub fn len(&self) -> usize {
139        self.lock().len()
140    }
141
142    pub fn is_empty(&self) -> bool {
143        self.len() == 0
144    }
145
146    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<SessionId, AcpSession>> {
147        // A poisoned registry means some other task panicked mid-update. The
148        // map itself is still structurally sound, and refusing to serve every
149        // later request over it would turn one panic into a dead connection.
150        self.sessions
151            .lock()
152            .unwrap_or_else(|poisoned| poisoned.into_inner())
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn an_unknown_session_is_not_found() {
162        let registry = SessionRegistry::new();
163
164        assert!(registry.is_empty());
165        assert!(registry.get(&SessionId::new("nobody")).is_none());
166        assert!(registry.remove(&SessionId::new("nobody")).is_none());
167    }
168
169    #[test]
170    fn clones_share_one_map() {
171        let registry = SessionRegistry::new();
172        let clone = registry.clone();
173
174        assert_eq!(registry.len(), clone.len());
175        assert!(clone.is_empty());
176    }
177}