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}