Kill a running command when the turn is cancelled
A foreground run_command was only observed after it exited, so pressing stop left it running for up to the two minute timeout. A cancel or session close now raises a per-session abort signal. The local poll loop kills the process tree on it, and a command in the client's terminal gets terminal/kill, then the output is read and the terminal released as on a timeout. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Co-Authored-By: siGit Code v1.6.6-acp <noreply@sigit.si>
Seto Elkahfi committed
Oct 10, 2026 at 17:05 UTC
f19fe5c40b5bf95b83ffe4de4b5b30888f6b4238
6 files changed
+277
-8
.agents/AGENTS.md
+6
-1
@@ -335,7 +335,12 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
335
the top of `handle_prompt`, before it waits on either lock, and a `PromptRegistration`
336
guard clears it on every return, so a `session/cancel` that arrives while the turn
337
is still queued, switching threads, loading a model or running a slash command ends
338
- it as `cancelled`. The checks sit after each of those waits. Tool
338
+ it as `cancelled`. The checks sit after each of those waits. A cancel (and
339
+ `begin_close`) also raises the session's `tools::AbortSignal`, which the local
340
+ `run_command` poll loop and `client_terminal::Route::run` watch, so a running
341
+ foreground command is killed instead of finishing first. The signal is reset at the
342
+ top of each turn, not when a tool starts, so a cancel that lands during a permission
343
+ prompt still stops the command that follows. Tool
344
execution, subagents included, still runs one session at a time.
345
- **`src/instructions.rs`** — project instruction files, the always-on counterpart to skills.
346
Reads `AGENTS.md` (the cross-tool [agents.md](https://agents.md) standard) and `CLAUDE.md`,
CHANGELOG.md
+5
@@ -9,6 +9,11 @@
9
inference. A cancel that lands while the turn is queued on the workspace,
10
switching threads, loading a model, or running a slash command ends it with
11
`cancelled` instead of `end_turn`.
12
+- **Cancelling a turn stops the command it is running.** A foreground
13
+ `run_command` used to run to its own end (up to two minutes) before the turn
14
+ noticed the cancel. A cancel or `session/close` now kills the process tree, or
15
+ sends `terminal/kill` when the command runs in the editor's terminal, and the
16
+ turn ends `cancelled` within a moment.
17
- **A prompt that is only audio is answered.** A clip with no text or image used
18
to end the turn at once with nothing sent to the model, even though the agent
19
advertises audio.
src/client_terminal.rs
+68
-4
@@ -141,6 +141,8 @@ pub enum Run {
141
},
142
/// The command was still running at the timeout and was killed.
143
TimedOut { terminal_id: String, output: Output },
144
+ /// The user cancelled the turn while the command ran, and it was killed.
145
+ Cancelled { terminal_id: String, output: Output },
146
/// The terminal started but the client failed a later request. The
147
/// command may have run, so it must not be run again.
148
Failed { terminal_id: String, error: String },
@@ -178,9 +180,27 @@ impl Route {
180
self.terminal.embed(session, tool_call_id, id).await;
181
}
182
181
- let waited = tokio::time::timeout(timeout, self.terminal.wait_for_exit(session, id)).await;
183
+ let abort = crate::tools::abort_signal(session);
184
+ let waited = tokio::select! {
185
+ waited = tokio::time::timeout(timeout, self.terminal.wait_for_exit(session, id)) => {
186
+ Some(waited)
187
+ }
188
+ () = abort.aborted() => None,
189
+ };
190
let run = match waited {
183
- Ok(Ok(status)) => match self.read_output(id).await {
191
+ None => {
192
+ if let Err(error) = self
193
+ .ask("terminal/kill", self.terminal.kill(session, id))
194
+ .await
195
+ {
196
+ log::warn!("could not kill terminal {id}: {error}");
197
+ }
198
+ Run::Cancelled {
199
+ terminal_id: terminal_id.clone(),
200
+ output: self.read_output(id).await.unwrap_or_default(),
201
+ }
202
+ }
203
+ Some(Ok(Ok(status))) => match self.read_output(id).await {
204
Ok(output) => Run::Exited {
205
terminal_id: terminal_id.clone(),
206
status,
@@ -191,11 +211,11 @@ impl Route {
211
error,
212
},
213
},
194
- Ok(Err(error)) => Run::Failed {
214
+ Some(Ok(Err(error))) => Run::Failed {
215
terminal_id: terminal_id.clone(),
216
error: format!("terminal/wait_for_exit failed: {error}"),
217
},
198
- Err(_) => {
218
+ Some(Err(_)) => {
219
// Kill before reading, so the output is what the command
220
// printed up to the timeout and nothing more.
221
if let Err(error) = self
@@ -404,6 +424,50 @@ mod tests {
424
assert_eq!(limit, 1000);
425
}
426
427
+ #[tokio::test]
428
+ async fn a_cancel_kills_the_terminal_then_reads_what_it_printed() {
429
+ let terminal = Arc::new(FakeTerminal {
430
+ exit: None,
431
+ output: "partial\n",
432
+ ..FakeTerminal::default()
433
+ });
434
+ let route = Route {
435
+ terminal: Arc::clone(&terminal) as Arc<dyn ClientTerminal>,
436
+ session_id: "session-cancel".to_string(),
437
+ };
438
+ crate::tools::reset_abort("session-cancel");
439
+ let cwd = std::env::temp_dir();
440
+
441
+ let (run, ()) = tokio::join!(
442
+ route.run("sleep 999", &cwd, None, Duration::from_secs(30), 1000,),
443
+ async {
444
+ tokio::time::sleep(Duration::from_millis(50)).await;
445
+ crate::tools::abort_session_tools("session-cancel");
446
+ }
447
+ );
448
+
449
+ assert_eq!(
450
+ run,
451
+ Run::Cancelled {
452
+ terminal_id: "term-1".to_string(),
453
+ output: Output {
454
+ output: "partial\n".to_string(),
455
+ truncated: false
456
+ },
457
+ }
458
+ );
459
+ assert_eq!(
460
+ terminal.calls(),
461
+ [
462
+ "create session-cancel",
463
+ "wait term-1",
464
+ "kill term-1",
465
+ "output term-1",
466
+ "release term-1",
467
+ ]
468
+ );
469
+ }
470
+
471
#[tokio::test]
472
async fn a_command_past_the_timeout_is_killed_then_read() {
473
let terminal = Arc::new(FakeTerminal {
src/main.rs
+7
-3
@@ -3048,6 +3048,7 @@ impl SiGitAgent {
3048
permissions::reset_session(&key);
3049
self.announced_titles.lock().unwrap().remove(&key);
3050
let killed = tools::kill_session_tasks(&key);
3051
+ tools::forget_abort(&key);
3052
self.closing_sessions.lock().unwrap().remove(&key);
3053
3054
log::info!(
@@ -3090,6 +3091,7 @@ impl SiGitAgent {
3091
cancellation.cancelled.store(true, Ordering::Release);
3092
cancellation.notify.notify_one();
3093
}
3094
+ tools::abort_session_tools(&key);
3095
}
3096
3097
async fn handle_fork_session(
@@ -3231,6 +3233,7 @@ impl SiGitAgent {
3233
// sessions or loading a model is seen. The session lock is held, so the
3234
// slot can only be this turn's. The guard clears it on every return.
3235
let cancellation = Arc::new(PromptCancellation::default());
3236
+ tools::reset_abort(&session_id.to_string());
3237
self.prompt_cancellations
3238
.lock()
3239
.unwrap()
@@ -3917,9 +3920,9 @@ impl SiGitAgent {
3920
tool_call_id: tc.id.clone(),
3921
content: output,
3922
});
3920
- // Cancellation cannot preempt a synchronous foreground tool:
3921
- // it is observed here as soon as that tool exits. Background
3922
- // commands remain cancellable through `kill_command`.
3923
+ // A cancel aborts a running foreground command (see
3924
+ // `tools::abort_session_tools`), so it exits and is observed
3925
+ // here. Background commands are stopped with `kill_command`.
3926
if cancellation.cancelled.load(Ordering::Acquire) {
3927
for pending in &result.tool_calls[call_index + 1..] {
3928
tool_results.push(BackendToolResult {
@@ -4279,6 +4282,7 @@ impl SiGitAgent {
4282
cancellation.cancelled.store(true, Ordering::Release);
4283
cancellation.notify.notify_one();
4284
}
4285
+ tools::abort_session_tools(&args.session_id.to_string());
4286
Ok(())
4287
}
4288
src/tools.rs
+106
@@ -2295,6 +2295,8 @@ fn exec_delete_file(arguments: &str) -> String {
2295
2296
// ── run_command ──────────────────────────────────────────────────────────────
2297
2298
+/// What a foreground command reports when the user cancelled the turn under it.
2299
+const CANCELLED_BY_USER: &str = "Error: command was cancelled by the user and killed.";
2300
const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
2301
const COMMAND_OUTPUT_LIMIT: usize = 50_000;
2302
/// How long to keep waiting for the output drains once the command itself has
@@ -2679,11 +2681,16 @@ fn exec_run_command(arguments: &str, owner: Option<&str>) -> String {
2681
drain(1, child.stderr.take().map(|pipe| Box::new(pipe) as _));
2682
drop(chunks_tx);
2683
2684
+ let abort = owner.map(abort_signal);
2685
let start = std::time::Instant::now();
2686
let status = loop {
2687
match child.try_wait() {
2688
Ok(Some(status)) => break status,
2689
Ok(None) => {
2690
+ if abort.as_ref().is_some_and(|signal| signal.is_aborted()) {
2691
+ kill_shell_tree(&mut child);
2692
+ return CANCELLED_BY_USER.to_string();
2693
+ }
2694
if start.elapsed() >= COMMAND_TIMEOUT {
2695
kill_shell_tree(&mut child);
2696
return format!(
@@ -2809,6 +2816,18 @@ async fn exec_run_command_via_client(
2816
}
2817
(result, terminal_id)
2818
}
2819
+ Run::Cancelled {
2820
+ terminal_id,
2821
+ output,
2822
+ } => {
2823
+ let mut result = CANCELLED_BY_USER.to_string();
2824
+ let combined = client_output_text(output);
2825
+ if !combined.is_empty() {
2826
+ result.push_str("\nOutput before it was killed:\n");
2827
+ result.push_str(&combined);
2828
+ }
2829
+ (result, terminal_id)
2830
+ }
2831
Run::Failed { terminal_id, error } => (format!("Error: {error}"), terminal_id),
2832
};
2833
@@ -2880,6 +2899,64 @@ fn active_session() -> Option<String> {
2899
.clone()
2900
}
2901
2902
+/// Raised when a session's turn is cancelled or its session closed, so a
2903
+/// foreground tool does not run on to its own end with nobody waiting for it.
2904
+#[derive(Default)]
2905
+pub struct AbortSignal {
2906
+ aborted: std::sync::atomic::AtomicBool,
2907
+ notify: tokio::sync::Notify,
2908
+}
2909
+
2910
+impl AbortSignal {
2911
+ pub fn is_aborted(&self) -> bool {
2912
+ self.aborted.load(Ordering::Acquire)
2913
+ }
2914
+
2915
+ /// Resolves once the signal is raised. `notify_one` keeps a permit when
2916
+ /// nothing is waiting, so a signal raised just before this is polled still
2917
+ /// wakes it.
2918
+ pub async fn aborted(&self) {
2919
+ while !self.is_aborted() {
2920
+ self.notify.notified().await;
2921
+ }
2922
+ }
2923
+}
2924
+
2925
+fn aborts() -> &'static Mutex<HashMap<String, Arc<AbortSignal>>> {
2926
+ static ABORTS: OnceLock<Mutex<HashMap<String, Arc<AbortSignal>>>> = OnceLock::new();
2927
+ ABORTS.get_or_init(|| Mutex::new(HashMap::new()))
2928
+}
2929
+
2930
+fn lock_aborts() -> std::sync::MutexGuard<'static, HashMap<String, Arc<AbortSignal>>> {
2931
+ aborts()
2932
+ .lock()
2933
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
2934
+}
2935
+
2936
+/// The abort signal of `session`'s current turn. Created on first use, so a
2937
+/// signal raised before any tool asks for it is still there when one does.
2938
+pub fn abort_signal(session: &str) -> Arc<AbortSignal> {
2939
+ Arc::clone(lock_aborts().entry(session.to_string()).or_default())
2940
+}
2941
+
2942
+/// Start a turn with a clean slate: a cancel aimed at an earlier turn must not
2943
+/// kill this turn's first command.
2944
+pub fn reset_abort(session: &str) {
2945
+ lock_aborts().insert(session.to_string(), Arc::new(AbortSignal::default()));
2946
+}
2947
+
2948
+/// Tell `session`'s running foreground tool to stop.
2949
+pub fn abort_session_tools(session: &str) {
2950
+ let signal = abort_signal(session);
2951
+ signal.aborted.store(true, Ordering::Release);
2952
+ signal.notify.notify_one();
2953
+}
2954
+
2955
+/// Forget `session`'s signal once the session is gone.
2956
+pub fn forget_abort(session: &str) {
2957
+ lock_aborts().remove(session);
2958
+}
2959
+
2960
/// Process-global background task table (same pattern as `mcp::MCP`).
2961
fn tasks() -> &'static Mutex<HashMap<u64, BackgroundTask>> {
2962
static TASKS: OnceLock<Mutex<HashMap<u64, BackgroundTask>>> = OnceLock::new();
@@ -4028,6 +4105,35 @@ mod tests {
4105
);
4106
}
4107
4108
+ #[cfg(unix)]
4109
+ #[test]
4110
+ fn a_cancel_kills_a_running_foreground_command() {
4111
+ reset_abort("tools-abort");
4112
+ let trigger = std::thread::spawn(|| {
4113
+ std::thread::sleep(std::time::Duration::from_millis(300));
4114
+ abort_session_tools("tools-abort");
4115
+ });
4116
+ let started = std::time::Instant::now();
4117
+ let result = exec_run_command(r#"{"command": "sleep 30"}"#, Some("tools-abort"));
4118
+ trigger.join().unwrap();
4119
+
4120
+ assert_eq!(result, CANCELLED_BY_USER);
4121
+ assert!(
4122
+ started.elapsed() < std::time::Duration::from_secs(10),
4123
+ "the command was not killed: {:?}",
4124
+ started.elapsed()
4125
+ );
4126
+ }
4127
+
4128
+ #[cfg(unix)]
4129
+ #[test]
4130
+ fn a_new_turn_is_not_aborted_by_an_earlier_cancel() {
4131
+ abort_session_tools("tools-stale");
4132
+ reset_abort("tools-stale");
4133
+ let result = exec_run_command(r#"{"command": "echo fine"}"#, Some("tools-stale"));
4134
+ assert!(result.contains("fine"), "{result}");
4135
+ }
4136
+
4137
#[test]
4138
fn test_run_command_success() {
4139
let result = exec_run_command(r#"{"command": "echo hello"}"#, None);
tests/acp_client_terminal.rs
+85
@@ -294,6 +294,56 @@ impl AgentUnderTest {
294
.to_string()
295
}
296
297
+ /// Send a prompt, then `session/cancel` as soon as the agent starts
298
+ /// waiting on the editor's terminal, which never answers. Returns the
299
+ /// prompt's response.
300
+ fn prompt_cancelled_mid_command(&mut self, session_id: &str, editor: &mut Editor) -> Value {
301
+ let id = self.request(
302
+ "session/prompt",
303
+ json!({"sessionId": session_id, "prompt": [{"type": "text", "text": "go"}]}),
304
+ );
305
+ let deadline = Instant::now() + TIMEOUT;
306
+ loop {
307
+ let remaining = deadline.saturating_duration_since(Instant::now());
308
+ let Ok(message) = self.incoming.recv_timeout(remaining) else {
309
+ panic!("timed out: the cancel did not end the prompt");
310
+ };
311
+ if message["id"] == id && message.get("method").is_none() {
312
+ return message;
313
+ }
314
+ let Some(method) = message["method"].as_str() else {
315
+ continue;
316
+ };
317
+ if method == "session/update" {
318
+ editor.updates.push(message.clone());
319
+ continue;
320
+ }
321
+ if !method.starts_with("terminal/") {
322
+ continue;
323
+ }
324
+ editor.requests.push(message.clone());
325
+ let request_id = message["id"].clone();
326
+ let result = match method {
327
+ "terminal/create" => json!({"terminalId": "term-1"}),
328
+ "terminal/wait_for_exit" => {
329
+ // The command is still running; the user presses stop.
330
+ self.send(json!({
331
+ "jsonrpc": "2.0",
332
+ "method": "session/cancel",
333
+ "params": {"sessionId": session_id},
334
+ }));
335
+ continue;
336
+ }
337
+ "terminal/output" => json!({
338
+ "output": "partial\n",
339
+ "truncated": false,
340
+ }),
341
+ _ => json!({}),
342
+ };
343
+ self.send(json!({"jsonrpc": "2.0", "id": request_id, "result": result}));
344
+ }
345
+ }
346
+
347
fn prompt(&mut self, session_id: &str, editor: &mut Editor) {
348
let id = self.request(
349
"session/prompt",
@@ -502,3 +552,38 @@ fn sigit_client_terminal_off_keeps_commands_local() {
552
assert!(result.contains("ran-locally"), "run_command said: {result}");
553
assert!(editor.requests.is_empty(), "{:?}", editor.requests);
554
}
555
+
556
+#[test]
557
+fn a_cancel_mid_command_kills_the_client_terminal_and_ends_the_turn_cancelled() {
558
+ let scratch = Scratch::new("cancel");
559
+ let endpoint = start_fake_endpoint(vec![
560
+ echo_call("call_1", &scratch.work, false),
561
+ sse_text("never asked for"),
562
+ ]);
563
+ let mut agent = spawn_agent(endpoint.port, &scratch.config, None);
564
+ let session_id = agent.open_session(true, &scratch.work);
565
+
566
+ let mut editor = Editor::default();
567
+ let response = agent.prompt_cancelled_mid_command(&session_id, &mut editor);
568
+
569
+ assert_eq!(response["result"]["stopReason"], "cancelled", "{response}");
570
+ assert_eq!(
571
+ editor.methods(),
572
+ [
573
+ "terminal/create",
574
+ "terminal/wait_for_exit",
575
+ "terminal/kill",
576
+ "terminal/output",
577
+ "terminal/release",
578
+ ]
579
+ );
580
+ assert_eq!(
581
+ editor.request("terminal/kill")["params"]["terminalId"],
582
+ "term-1"
583
+ );
584
+ assert_eq!(
585
+ endpoint.requests.lock().unwrap().len(),
586
+ 1,
587
+ "a cancelled turn must not ask the model for another round"
588
+ );
589
+}