Skip to main content

heartbit_core/agent/
interrupt.rs

1//! A re-armable, per-turn interrupt handle for an [`AgentRunner`](super::AgentRunner).
2//!
3//! A TUI (or any caller) can ask the agent to abandon the **current turn** —
4//! aborting an in-flight LLM generation — and return to awaiting the next input,
5//! WITHOUT tearing down the session (conversation history is preserved). Because
6//! a [`CancellationToken`] is one-shot, this handle swaps in a fresh token after
7//! each interrupt so the next turn starts armed again.
8//!
9//! Semantics (interactive path): on interrupt the runner abandons whatever the
10//! turn is doing and ends it cleanly, then waits for the next message via
11//! `on_input`. An interrupt during an LLM generation aborts the stream; an
12//! interrupt during a tool batch abandons it — synthesizing a result for every
13//! in-flight `tool_use` (so no call is left unanswered) and killing any
14//! subprocess via `kill_on_drop` — then the next LLM-call race ends the turn.
15
16use std::sync::{Arc, Mutex};
17
18use tokio_util::sync::CancellationToken;
19
20/// A cloneable handle to interrupt the in-flight turn of an agent run.
21#[derive(Clone)]
22pub struct InterruptHandle {
23    inner: Arc<Mutex<CancellationToken>>,
24}
25
26impl Default for InterruptHandle {
27    fn default() -> Self {
28        Self {
29            inner: Arc::new(Mutex::new(CancellationToken::new())),
30        }
31    }
32}
33
34impl InterruptHandle {
35    /// Create a fresh, un-triggered handle.
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    /// Request that the agent abandon its current turn. Idempotent until the
41    /// runner [`rearm`](Self::rearm)s for the next turn.
42    pub fn interrupt(&self) {
43        self.inner.lock().expect("interrupt lock poisoned").cancel();
44    }
45
46    /// Whether the current turn's token has been triggered.
47    pub fn is_interrupted(&self) -> bool {
48        self.inner
49            .lock()
50            .expect("interrupt lock poisoned")
51            .is_cancelled()
52    }
53
54    /// A clone of the current turn's token, to race an LLM call against.
55    pub(crate) fn token(&self) -> CancellationToken {
56        self.inner.lock().expect("interrupt lock poisoned").clone()
57    }
58
59    /// Swap in a fresh token, arming the handle for the next turn. Called by the
60    /// runner right after it has handled an interrupt.
61    pub(crate) fn rearm(&self) {
62        *self.inner.lock().expect("interrupt lock poisoned") = CancellationToken::new();
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    #[test]
71    fn starts_un_triggered() {
72        assert!(!InterruptHandle::new().is_interrupted());
73    }
74
75    // Regression test for the live mid-tool-interrupt topology, minimized: a
76    // `select!` racing the per-turn token against a real SUBPROCESS, spawned on a
77    // JoinSet, on a SEPARATE thread with its OWN current_thread runtime (the
78    // hardest flavor — one scheduler thread), cancelled CROSS-THREAD, with a
79    // blocking sync-approval gate before the race. Proves the cancel arm wins
80    // (returns "cancelled") — if it ever returns "completed", cross-runtime
81    // interrupt over a subprocess is broken. Pairs with the end-to-end
82    // `runner::tests::interrupt_during_tool_batch_abandons_it_and_ends_turn`.
83    #[tokio::test(flavor = "multi_thread")]
84    async fn cross_runtime_interrupt_aborts_subprocess_tool_race() {
85        use std::time::Duration;
86        // Force the MAIN (this) runtime to register tokio::process / SIGCHLD first,
87        // exactly like a process that already has a primary runtime (the TUI).
88        let _ = tokio::process::Command::new("true").status().await;
89
90        let handle = InterruptHandle::new();
91        let h2 = handle.clone();
92        let (tx, rx) = std::sync::mpsc::channel();
93        let (appr_tx, appr_rx) = std::sync::mpsc::channel::<()>();
94        // Agent on its OWN current_thread runtime, on a separate thread (like the TUI).
95        std::thread::spawn(move || {
96            let rt = tokio::runtime::Builder::new_current_thread()
97                .enable_all()
98                .build()
99                .unwrap();
100            let outcome = rt.block_on(async move {
101                appr_rx.recv().unwrap(); // mimic the synchronous on_approval block
102                let token = h2.token();
103                tokio::select! {
104                    biased;
105                    _ = token.cancelled() => "cancelled",
106                    _ = async {
107                        let mut js = tokio::task::JoinSet::new();
108                        js.spawn(async {
109                            // Mirror BashTool EXACTLY: piped stdout/stderr +
110                            // wait_with_output wrapped in tokio::time::timeout.
111                            let child = tokio::process::Command::new("sh")
112                                .arg("-c").arg("sleep 5")
113                                .stdin(std::process::Stdio::null())
114                                .stdout(std::process::Stdio::piped())
115                                .stderr(std::process::Stdio::piped())
116                                .spawn();
117                            if let Ok(child) = child {
118                                let _ = tokio::time::timeout(
119                                    Duration::from_secs(30),
120                                    child.wait_with_output(),
121                                )
122                                .await;
123                            }
124                        });
125                        js.join_next().await;
126                    } => "completed",
127                }
128            });
129            let _ = tx.send(outcome);
130        });
131        // Drive timing + the interrupt from a TASK on the MAIN runtime (like the UI).
132        tokio::time::sleep(Duration::from_millis(150)).await;
133        appr_tx.send(()).unwrap();
134        tokio::time::sleep(Duration::from_millis(250)).await;
135        handle.interrupt();
136        let outcome = tokio::task::spawn_blocking(move || rx.recv_timeout(Duration::from_secs(8)))
137            .await
138            .unwrap()
139            .expect("thread finished");
140        assert_eq!(
141            outcome, "cancelled",
142            "cross-thread interrupt must abort the subprocess race"
143        );
144    }
145
146    #[test]
147    fn interrupt_triggers_then_rearm_clears() {
148        let h = InterruptHandle::new();
149        let before = h.token();
150        h.interrupt();
151        assert!(h.is_interrupted());
152        assert!(
153            before.is_cancelled(),
154            "the live token reflects the interrupt"
155        );
156        h.rearm();
157        assert!(!h.is_interrupted(), "rearm arms a fresh token");
158        // The previously-handed-out token stays cancelled (it's the old turn's).
159        assert!(before.is_cancelled());
160    }
161
162    #[test]
163    fn clones_share_state() {
164        let h = InterruptHandle::new();
165        let clone = h.clone();
166        clone.interrupt();
167        assert!(h.is_interrupted(), "a clone interrupts the same run");
168    }
169}