Skip to main content

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}