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}