basis_host/session.rs
1//! The sessions a long-lived host 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, so a second prompt
7//! 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: cancellation arrives
11//! *while* a turn is running and therefore while the turn lock is held.
12//! Putting the token inside would mean cancel waits for the turn it is trying
13//! to cancel — a deadlock that only shows up when someone presses stop.
14//!
15//! The session's approval policy lives outside the turn lock for the same
16//! reason, and is its own type — see [`SessionApproval`].
17//!
18//! # Why cancelling is two signals
19//!
20//! mentra's token is a flag the runner reads at round boundaries, and a turn
21//! waiting on `session/request_permission` is not at one: it is parked inside
22//! a tool call until the approver answers, and the approver is waiting on the
23//! client. A stop pressed then would trip a flag nothing reads until an answer
24//! arrives that the person who pressed stop is no longer going to give. So
25//! the token travels with an [`Interrupt`] — an awaitable the approver selects
26//! against — and [`cancel`](HostSession::cancel) trips both. The flag is what
27//! ends the turn; the interrupt is what gets the turn to the boundary where
28//! the flag is read.
29//!
30//! # Which id identifies a session
31//!
32//! basis uses mentra's **agent id**, not mentra's ephemeral session id.
33//! mentra persists agents; a `Session` is one process's view of one. Keying
34//! on the agent id is what makes resume free — it is exactly the handle
35//! [`Workspace::resume`](basis::Workspace::resume) takes, so a host can
36//! reconnect to a conversation this process never saw.
37
38use std::sync::{Arc, Mutex};
39
40use tokio::sync::watch;
41
42use crate::SessionApproval;
43use basis::{CancellationToken, PreparedRun, run::TurnOptions};
44
45/// The half of a cancellation an approver can wait on.
46///
47/// Cloneable, so a turn's approver and anything else parked on the client can
48/// each hold one; [`wait`](Self::wait) resolves once for all of them when the
49/// turn is cancelled, and resolves at once if it already was — a stop pressed
50/// before the question was asked is still a stop.
51#[derive(Clone, Debug)]
52pub struct Interrupt {
53 cancelled: watch::Receiver<bool>,
54}
55
56impl Interrupt {
57 /// Resolves when the turn this belongs to is cancelled.
58 ///
59 /// Also resolves if the turn has already ended and disarmed its token —
60 /// there is nothing left for a waiter to wait for, and an approver still
61 /// asking after the turn is gone has no turn to answer.
62 pub async fn wait(&mut self) {
63 let _ = self.cancelled.wait_for(|cancelled| *cancelled).await;
64 }
65}
66
67/// What [`HostSession::begin_turn`] arms: mentra's flag and the interrupt that
68/// wakes an approver to go and read it.
69struct Armed {
70 token: CancellationToken,
71 interrupt: watch::Sender<bool>,
72}
73
74impl Armed {
75 fn cancel(&self) {
76 self.token.cancel();
77 self.interrupt.send_replace(true);
78 }
79}
80
81/// One open conversation on a long-lived host.
82#[derive(Clone)]
83pub struct HostSession {
84 /// Held for the duration of a turn.
85 run: Arc<tokio::sync::Mutex<PreparedRun>>,
86 /// Set while a turn is in flight. Reachable without the turn lock, which
87 /// is the entire point — see the module docs.
88 cancel: Arc<Mutex<Option<Armed>>>,
89 /// Also reachable without the turn lock: a host may change policy while
90 /// the agent is generating.
91 approval: SessionApproval,
92 agent_id: String,
93}
94
95impl HostSession {
96 /// Opens a session with the approval state its host presents.
97 pub fn new(run: PreparedRun, approval: SessionApproval) -> Self {
98 Self {
99 agent_id: run.agent_id().to_string(),
100 run: Arc::new(tokio::sync::Mutex::new(run)),
101 cancel: Arc::new(Mutex::new(None)),
102 approval,
103 }
104 }
105
106 /// The persisted conversation id for this session: mentra's agent id.
107 pub fn agent_id(&self) -> &str {
108 &self.agent_id
109 }
110
111 /// This conversation's approval state, shared with its active turn.
112 pub fn approval(&self) -> &SessionApproval {
113 &self.approval
114 }
115
116 /// Takes the turn lock. Held until the returned guard drops.
117 pub async fn lock_turn(&self) -> tokio::sync::MutexGuard<'_, PreparedRun> {
118 self.run.lock().await
119 }
120
121 /// Arms a fresh cancellation token for a turn about to start.
122 pub fn begin_turn(&self) -> TurnOptions {
123 let (options, token) = TurnOptions::cancellable();
124 let (interrupt, _) = watch::channel(false);
125 *self.cancel_slot() = Some(Armed { token, interrupt });
126 options
127 }
128
129 /// The interrupt armed for the turn in flight, for whatever is about to
130 /// wait on the client on that turn's behalf. `None` between turns.
131 pub fn interrupt(&self) -> Option<Interrupt> {
132 self.cancel_slot().as_ref().map(|armed| Interrupt {
133 cancelled: armed.interrupt.subscribe(),
134 })
135 }
136
137 /// Disarms after a turn ends, so a late `session/cancel` cannot cancel the
138 /// *next* turn.
139 pub fn end_turn(&self) {
140 *self.cancel_slot() = None;
141 }
142
143 /// Trips the in-flight turn's token and wakes anything waiting on the
144 /// client for it. `false` when no turn is running.
145 pub fn cancel(&self) -> bool {
146 match self.cancel_slot().take() {
147 Some(armed) => {
148 armed.cancel();
149 true
150 }
151 None => false,
152 }
153 }
154
155 fn cancel_slot(&self) -> std::sync::MutexGuard<'_, Option<Armed>> {
156 self.cancel
157 .lock()
158 .unwrap_or_else(|poisoned| poisoned.into_inner())
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 /// A session over a scripted runtime, for tests that arm turns without
167 /// running one.
168 async fn session() -> HostSession {
169 let mock = mentra::test::MockRuntime::builder()
170 .model("mock-model", "openai")
171 .with_policy(mentra::RuntimePolicy::permissive())
172 .text("unused")
173 .build()
174 .expect("mock runtime builds");
175 let workspace = tempfile::tempdir().expect("tempdir");
176 let mentra_session = mock
177 .runtime()
178 .create_session("test", mock.model())
179 .expect("session");
180 let run = basis::run::prepare_with_session(
181 mentra_session,
182 workspace.path(),
183 "",
184 &basis::ContextConfig {
185 file_name: "AGENTS.md".to_string(),
186 global_dir: None,
187 walk_parents: false,
188 },
189 "openai",
190 "mock-model",
191 )
192 .expect("prepared");
193 HostSession::new(run, SessionApproval::new(crate::ApprovalPolicy::Prompt))
194 }
195
196 #[tokio::test]
197 async fn there_is_nothing_to_interrupt_between_turns() {
198 let session = session().await;
199
200 assert!(session.interrupt().is_none());
201 assert!(!session.cancel(), "and nothing to cancel");
202 }
203
204 #[tokio::test]
205 async fn cancelling_wakes_whoever_is_waiting_on_the_turn() {
206 let session = session().await;
207 let options = session.begin_turn();
208 let mut interrupt = session.interrupt().expect("a turn is armed");
209
210 assert!(session.cancel());
211
212 // Both halves tripped: the flag mentra reads, and the wake-up that
213 // gets the turn to where it reads it.
214 assert!(options.cancel.expect("armed").is_cancelled());
215 tokio::time::timeout(std::time::Duration::from_secs(1), interrupt.wait())
216 .await
217 .expect("the interrupt must fire");
218 }
219
220 #[tokio::test]
221 async fn a_turn_that_ended_leaves_no_one_waiting() {
222 let session = session().await;
223 let _options = session.begin_turn();
224 let mut interrupt = session.interrupt().expect("a turn is armed");
225
226 session.end_turn();
227
228 tokio::time::timeout(std::time::Duration::from_secs(1), interrupt.wait())
229 .await
230 .expect("a waiter on a finished turn must not wait forever");
231 assert!(session.interrupt().is_none());
232 }
233}