Skip to main content

harn_vm/
tool_call_cancellations.rs

1//! Per-tool-call cancellation registry.
2//!
3//! When the host or a Harn script wants to abort a *specific* in-flight tool
4//! call (e.g. user clicks "stop" on a runaway `git push --force`), they reach
5//! through here. The registry keys cancellation handles by
6//! `(session_id, call_id)` so a caller can target one call without
7//! tearing down the whole session.
8//!
9//! ## Lifecycle
10//!
11//! 1. `host_agent_dispatch_tool_call` calls [`register`] right before it
12//!    runs the underlying tool, holding the returned [`Handle`].
13//! 2. The dispatch wraps the tool execution in a `select!` against
14//!    [`Handle::cancelled`], so a triggered cancellation drops the
15//!    pending tool future immediately.
16//! 3. When the dispatch returns (either normally or via cancellation),
17//!    the [`Guard`] returned by [`register`] auto-unregisters on drop.
18//!
19//! ## Triggering cancellation
20//!
21//! - **Harn:** the public `cancel_in_flight_tool_call` builtin registered
22//!   in [`crate::stdlib::agent_sessions`].
23//! - **Bridge stdin:** the `session/cancel_tool_call` notification
24//!   handled in [`crate::bridge`].
25//! - **ACP:** the `session/cancel_tool_call` method exposed by
26//!   `crates/harn-serve/src/adapters/acp`.
27//!
28//! All three paths funnel into [`cancel`].
29//!
30//! Storage is thread-local because the VM runs on a current-thread tokio
31//! worker — bridge stdin and ACP adapter share that worker via
32//! `tokio::task::LocalSet`, so they can read the same registry without
33//! cross-thread sync.
34
35use std::cell::RefCell;
36use std::collections::HashMap;
37use std::sync::atomic::{AtomicBool, Ordering};
38use std::sync::{Arc, Mutex};
39
40use tokio::sync::Notify;
41
42/// Reason + flags captured when a tool call is cancelled.
43#[derive(Clone, Debug)]
44pub struct CancellationDetails {
45    pub reason: String,
46    pub inject_reminder: bool,
47}
48
49#[derive(Debug)]
50struct Inner {
51    cancelled: AtomicBool,
52    completed: AtomicBool,
53    details: Mutex<Option<CancellationDetails>>,
54    notify: Notify,
55    completion_notify: Notify,
56}
57
58/// Per-tool-call cancellation handle. Cheap to clone (`Arc` inside).
59#[derive(Clone, Debug)]
60pub struct Handle {
61    pub session_id: String,
62    pub call_id: String,
63    pub tool_name: String,
64    inner: Arc<Inner>,
65}
66
67impl Handle {
68    fn new(session_id: String, call_id: String, tool_name: String) -> Self {
69        Self {
70            session_id,
71            call_id,
72            tool_name,
73            inner: Arc::new(Inner {
74                cancelled: AtomicBool::new(false),
75                completed: AtomicBool::new(false),
76                details: Mutex::new(None),
77                notify: Notify::new(),
78                completion_notify: Notify::new(),
79            }),
80        }
81    }
82
83    pub fn is_cancelled(&self) -> bool {
84        self.inner.cancelled.load(Ordering::SeqCst)
85    }
86
87    pub fn is_completed(&self) -> bool {
88        self.inner.completed.load(Ordering::SeqCst)
89    }
90
91    /// Mark dispatch finished — called by the [`Guard`] on drop. Wakes any
92    /// caller awaiting [`Handle::completed`].
93    fn mark_completed(&self) {
94        if !self.inner.completed.swap(true, Ordering::SeqCst) {
95            self.inner.completion_notify.notify_waiters();
96        }
97    }
98
99    /// Mark this call cancelled. Returns `true` if this call was the first
100    /// to trigger cancellation (the caller can then push a reminder, etc.).
101    pub fn cancel(&self, reason: impl Into<String>, inject_reminder: bool) -> bool {
102        if self.inner.cancelled.swap(true, Ordering::SeqCst) {
103            return false;
104        }
105        let reason = reason.into();
106        let mut details = self
107            .inner
108            .details
109            .lock()
110            .unwrap_or_else(|err| err.into_inner());
111        *details = Some(CancellationDetails {
112            reason,
113            inject_reminder,
114        });
115        drop(details);
116        self.inner.notify.notify_waiters();
117        true
118    }
119
120    pub fn details(&self) -> Option<CancellationDetails> {
121        self.inner
122            .details
123            .lock()
124            .unwrap_or_else(|err| err.into_inner())
125            .clone()
126    }
127
128    pub fn reason(&self) -> Option<String> {
129        self.details().map(|d| d.reason)
130    }
131
132    /// Future that resolves when this call is cancelled. Safe to await
133    /// even if cancellation has already fired (returns immediately).
134    pub async fn cancelled(&self) {
135        if self.is_cancelled() {
136            return;
137        }
138        let notified = self.inner.notify.notified();
139        if self.is_cancelled() {
140            return;
141        }
142        notified.await;
143    }
144
145    /// Future that resolves when dispatch finishes and the [`Guard`]
146    /// drops. Lets a caller observe whether a cancellation actually
147    /// landed (used by `cancel_in_flight_tool_call`'s `timeout_ms`).
148    pub async fn completed(&self) {
149        if self.is_completed() {
150            return;
151        }
152        let notified = self.inner.completion_notify.notified();
153        if self.is_completed() {
154            return;
155        }
156        notified.await;
157    }
158}
159
160thread_local! {
161    static REGISTRY: RefCell<HashMap<(String, String), Handle>> =
162        RefCell::new(HashMap::new());
163}
164
165/// RAII guard that unregisters the (session_id, call_id) entry on drop
166/// and signals the matching handle's `completed` future.
167pub struct Guard {
168    session_id: String,
169    call_id: String,
170    handle: Handle,
171}
172
173impl Drop for Guard {
174    fn drop(&mut self) {
175        if self.call_id.is_empty() {
176            return;
177        }
178        self.handle.mark_completed();
179        REGISTRY.with(|registry| {
180            registry
181                .borrow_mut()
182                .remove(&(self.session_id.clone(), self.call_id.clone()));
183        });
184    }
185}
186
187/// Register a fresh handle for an in-flight tool call.
188///
189/// Returns `None` when the call has no id (e.g. some legacy parse paths
190/// omit it). The caller cannot be targeted by `cancel_in_flight_tool_call`
191/// in that case, so cancellation is a no-op; that matches today's behavior
192/// for the few code paths that produce id-less calls.
193pub fn register(
194    session_id: impl Into<String>,
195    call_id: impl Into<String>,
196    tool_name: impl Into<String>,
197) -> Option<(Handle, Guard)> {
198    let session_id = session_id.into();
199    let call_id = call_id.into();
200    let tool_name = tool_name.into();
201    if call_id.is_empty() {
202        return None;
203    }
204    let handle = Handle::new(session_id.clone(), call_id.clone(), tool_name);
205    let guard = Guard {
206        session_id: session_id.clone(),
207        call_id: call_id.clone(),
208        handle: handle.clone(),
209    };
210    REGISTRY.with(|registry| {
211        registry
212            .borrow_mut()
213            .insert((session_id, call_id), handle.clone());
214    });
215    Some((handle, guard))
216}
217
218pub fn lookup(session_id: &str, call_id: &str) -> Option<Handle> {
219    REGISTRY.with(|registry| {
220        registry
221            .borrow()
222            .get(&(session_id.to_string(), call_id.to_string()))
223            .cloned()
224    })
225}
226
227/// List all in-flight handles for a session — used by introspection and
228/// tests; not by the hot path.
229pub fn list_for_session(session_id: &str) -> Vec<Handle> {
230    REGISTRY.with(|registry| {
231        registry
232            .borrow()
233            .values()
234            .filter(|handle| handle.session_id == session_id)
235            .cloned()
236            .collect()
237    })
238}
239
240/// Outcome returned to callers of [`cancel`].
241#[derive(Clone, Copy, Debug, PartialEq, Eq)]
242pub enum CancelStatus {
243    /// Cancellation flag was set on this call. The in-flight future will
244    /// observe it and unwind.
245    Cancelled,
246    /// The call was already cancelled by an earlier request.
247    AlreadyCancelled,
248    /// No in-flight call matches (session_id, call_id) right now —
249    /// either it never started, or it already completed.
250    NotFound,
251}
252
253impl CancelStatus {
254    pub fn as_str(self) -> &'static str {
255        match self {
256            Self::Cancelled => "cancelled",
257            Self::AlreadyCancelled => "already_cancelled",
258            Self::NotFound => "not_found",
259        }
260    }
261}
262
263/// Result of a cancellation request: the status plus, when a handle was
264/// found, the tool's name and the live handle (so callers can wait for
265/// the dispatch to actually unwind via [`Handle::completed`]).
266#[derive(Clone, Debug)]
267pub struct CancelOutcome {
268    pub status: CancelStatus,
269    pub tool_name: Option<String>,
270    pub handle: Option<Handle>,
271}
272
273/// Trigger cancellation for one in-flight tool call.
274pub fn cancel(
275    session_id: &str,
276    call_id: &str,
277    reason: impl Into<String>,
278    inject_reminder: bool,
279) -> CancelOutcome {
280    let Some(handle) = lookup(session_id, call_id) else {
281        return CancelOutcome {
282            status: CancelStatus::NotFound,
283            tool_name: None,
284            handle: None,
285        };
286    };
287    let tool_name = Some(handle.tool_name.clone());
288    let status = if handle.cancel(reason, inject_reminder) {
289        CancelStatus::Cancelled
290    } else {
291        CancelStatus::AlreadyCancelled
292    };
293    CancelOutcome {
294        status,
295        tool_name,
296        handle: Some(handle),
297    }
298}
299
300/// Drop every in-flight cancellation handle on this thread. A handle is
301/// normally unregistered by its [`Guard`] on drop, but an abandoned
302/// dispatch (test timeout, panic during teardown) can leave a
303/// `(session_id, call_id)` entry behind. The per-test reset path calls
304/// this so a reused worker thread does not accumulate stale handles
305/// across the suite.
306pub fn reset_registry() {
307    REGISTRY.with(|registry| registry.borrow_mut().clear());
308}
309
310/// Number of in-flight cancellation handles on this thread. Test-only.
311#[cfg(test)]
312pub fn registry_len() -> usize {
313    REGISTRY.with(|registry| registry.borrow().len())
314}
315
316#[cfg(test)]
317pub fn clear_registry_for_test() {
318    reset_registry();
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[tokio::test]
326    async fn handle_resolves_cancelled_future() {
327        clear_registry_for_test();
328        let (handle, _guard) = register("sess_1", "call_1", "bash").expect("registered");
329        assert!(!handle.is_cancelled());
330        let cancelled = handle.cancel("user requested stop", false);
331        assert!(cancelled);
332        // Awaiting after the fact should resolve immediately.
333        handle.cancelled().await;
334        assert!(handle.is_cancelled());
335        assert_eq!(handle.reason().as_deref(), Some("user requested stop"));
336    }
337
338    #[tokio::test]
339    async fn cancel_returns_not_found_when_missing() {
340        clear_registry_for_test();
341        let outcome = cancel("sess_unknown", "call_unknown", "irrelevant", false);
342        assert_eq!(outcome.status, CancelStatus::NotFound);
343        assert_eq!(outcome.tool_name, None);
344    }
345
346    #[tokio::test]
347    async fn cancel_is_idempotent() {
348        clear_registry_for_test();
349        let (_handle, _guard) = register("sess", "call_2", "shell").expect("registered");
350        let first = cancel("sess", "call_2", "first", false);
351        let second = cancel("sess", "call_2", "second", false);
352        assert_eq!(first.status, CancelStatus::Cancelled);
353        assert_eq!(second.status, CancelStatus::AlreadyCancelled);
354        assert_eq!(first.tool_name.as_deref(), Some("shell"));
355        assert_eq!(second.tool_name.as_deref(), Some("shell"));
356    }
357
358    #[tokio::test]
359    async fn guard_unregisters_on_drop() {
360        clear_registry_for_test();
361        {
362            let _registration = register("sess", "call_g", "tool").expect("registered");
363            assert!(lookup("sess", "call_g").is_some());
364        }
365        assert!(lookup("sess", "call_g").is_none());
366    }
367
368    #[test]
369    fn cancelled_wakes_pending_waiter() {
370        let rt = tokio::runtime::Builder::new_current_thread()
371            .enable_all()
372            .build()
373            .expect("rt");
374        let local = tokio::task::LocalSet::new();
375        local.block_on(&rt, async {
376            clear_registry_for_test();
377            let (handle, _guard) = register("sess", "call_w", "tool").expect("registered");
378            let waiter_handle = handle.clone();
379            let task = tokio::task::spawn_local(async move { waiter_handle.cancelled().await });
380            tokio::task::yield_now().await;
381            handle.cancel("stopping", false);
382            tokio::time::timeout(std::time::Duration::from_secs(1), task)
383                .await
384                .expect("task should resolve quickly")
385                .expect("task did not panic");
386        });
387    }
388
389    #[tokio::test]
390    async fn list_for_session_filters_by_session() {
391        clear_registry_for_test();
392        let _r1 = register("sess_a", "call_x", "tool").expect("registered");
393        let _r2 = register("sess_a", "call_y", "tool").expect("registered");
394        let _r3 = register("sess_b", "call_x", "tool").expect("registered");
395        let mut ids: Vec<String> = list_for_session("sess_a")
396            .into_iter()
397            .map(|h| h.call_id)
398            .collect();
399        ids.sort();
400        assert_eq!(ids, vec!["call_x".to_string(), "call_y".to_string()]);
401    }
402}