Add background command execution to run_command
Every command used to be killed at the 120 second timeout, so builds, test suites, and dev servers could not run at all. run_command now takes run_in_background: the child goes into a process-global task table and the tool returns its task id right away, while reader threads collect stdout and stderr into a capped buffer. Two new tools work with that table. command_output returns the output accumulated since the last poll plus the task status, and kill_command stops a task and reports the tail of its output. Foreground commands behave exactly as before, background children die with the sigit process, and polling is classified read-only so checking on a build never triggers a permission prompt.
paydii committed
Jul 4, 2026 at 22:20 UTC
5c23b5d4fd95d39ac073556b53af46b8ec348a70
3 files changed
+525
-29
src/main.rs
+2
-2
@@ -250,8 +250,8 @@ fn tool_kind_for(tool_name: &str) -> ToolKind {
250
ToolKind::Edit
251
}
252
"delete_file" => ToolKind::Delete,
253
- "run_command" => ToolKind::Execute,
254
- "read_file" | "list_directory" => ToolKind::Read,
253
+ "run_command" | "kill_command" => ToolKind::Execute,
254
+ "read_file" | "list_directory" | "command_output" => ToolKind::Read,
255
"search_files" | "glob" => ToolKind::Search,
256
"read_website" => ToolKind::Fetch,
257
"write_todos" => ToolKind::Think,
src/permissions.rs
+3
-1
@@ -67,7 +67,7 @@ pub enum Decision {
67
pub fn classify(tool_name: &str) -> ToolRisk {
68
match tool_name {
69
"read_file" | "list_directory" | "search_files" | "glob" | "read_website"
70
- | "write_todos" | "skill" => ToolRisk::ReadOnly,
70
+ | "write_todos" | "skill" | "command_output" => ToolRisk::ReadOnly,
71
_ => ToolRisk::Mutating,
72
}
73
}
@@ -247,6 +247,7 @@ mod tests {
247
"read_website",
248
"write_todos",
249
"skill",
250
+ "command_output",
251
] {
252
assert_eq!(classify(tool), ToolRisk::ReadOnly, "{tool}");
253
assert_eq!(decision_for("t-ro", tool), Decision::Allow, "{tool}");
@@ -262,6 +263,7 @@ mod tests {
263
"create_directory",
264
"delete_file",
265
"run_command",
266
+ "kill_command",
267
"remember",
268
"mcp__server__anything",
269
"totally_unknown_tool",
src/tools.rs
+520
-26
@@ -2,9 +2,12 @@
2
3
use regex::Regex;
4
use serde_json::{Value, json};
5
+use std::collections::HashMap;
6
use std::fs;
7
use std::path::{Path, PathBuf};
8
use std::process::Command;
9
+use std::sync::atomic::{AtomicU64, Ordering};
10
+use std::sync::{Arc, Mutex, OnceLock};
11
12
const WEBSITE_READ_CHAR_LIMIT: usize = 20_000;
13
const WEBSITE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
@@ -231,8 +234,10 @@ pub fn all_tools() -> Vec<AgentTool> {
234
use this for `git clone`, `git init`, and normal repo setup steps. \
235
In smbCloud repos, prefer existing workspace commands, Rails \
236
conventions, and deploy flows over inventing new command sequences. \
234
- Commands that run indefinitely (servers, watchers) will be killed \
235
- after 120 seconds.",
237
+ Foreground commands are killed after 120 seconds — run servers, \
238
+ watchers, builds, test suites, and anything that may exceed two \
239
+ minutes with run_in_background set to true, then poll with \
240
+ command_output.",
241
parameters_schema: json!({
242
"type": "object",
243
"properties": {
@@ -243,6 +248,10 @@ pub fn all_tools() -> Vec<AgentTool> {
248
"cwd": {
249
"type": "string",
250
"description": "Working directory for the command. Defaults to \".\" (current directory)."
251
+ },
252
+ "run_in_background": {
253
+ "type": "boolean",
254
+ "description": "Start the command as a background task and return a task id immediately instead of waiting for it to finish. Set this to true for servers, watchers, builds, test suites, and anything that may run longer than two minutes (foreground commands are killed after 120 seconds). Poll the task's output and status with command_output, and stop it with kill_command. Background tasks are killed when sigit exits. Defaults to false."
255
}
256
},
257
"required": ["command"],
@@ -373,6 +382,45 @@ pub fn all_tools() -> Vec<AgentTool> {
382
"additionalProperties": false
383
}),
384
},
385
+ AgentTool {
386
+ name: "command_output",
387
+ description: "Get the output a background task (started with run_command's \
388
+ run_in_background) has produced since your last check, plus its \
389
+ status: still running, or exited with an exit code. Poll this \
390
+ periodically to follow builds, test suites, and servers. Between \
391
+ polls output is buffered up to 50 000 bytes per task; older \
392
+ output beyond that is dropped and the truncation is noted.",
393
+ parameters_schema: json!({
394
+ "type": "object",
395
+ "properties": {
396
+ "task_id": {
397
+ "type": "integer",
398
+ "description": "Id of the background task, as returned by run_command with run_in_background."
399
+ }
400
+ },
401
+ "required": ["task_id"],
402
+ "additionalProperties": false
403
+ }),
404
+ },
405
+ AgentTool {
406
+ name: "kill_command",
407
+ description: "Kill a background task started with run_command's run_in_background. \
408
+ Reports the tail of the task's unread output and confirms it was \
409
+ killed. Use this to stop servers or watchers you no longer need and \
410
+ runaway commands. Background tasks are also killed automatically \
411
+ when sigit exits.",
412
+ parameters_schema: json!({
413
+ "type": "object",
414
+ "properties": {
415
+ "task_id": {
416
+ "type": "integer",
417
+ "description": "Id of the background task, as returned by run_command with run_in_background."
418
+ }
419
+ },
420
+ "required": ["task_id"],
421
+ "additionalProperties": false
422
+ }),
423
+ },
424
]
425
}
426
@@ -399,6 +447,8 @@ pub async fn execute_tool(name: &str, arguments: &str) -> String {
447
"remember" => exec_remember(arguments),
448
"delete_file" => exec_delete_file(arguments),
449
"run_command" => exec_run_command(arguments),
450
+ "command_output" => exec_command_output(arguments),
451
+ "kill_command" => exec_kill_command(arguments),
452
"skill" => crate::skills::activate_skill(arguments),
453
// Tools discovered from MCP servers are namespaced `mcp__<server>__<tool>`
454
// and forwarded to the owning server.
@@ -1379,7 +1429,27 @@ fn exec_delete_file(arguments: &str) -> String {
1429
const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
1430
const COMMAND_OUTPUT_LIMIT: usize = 50_000;
1431
1382
-/// runs via `sh -c` / `cmd /C`; killed after COMMAND_TIMEOUT.
1432
+/// Spawn `command_str` through the platform shell with piped stdout/stderr.
1433
+/// Shared by the foreground and background paths of `run_command`.
1434
+fn spawn_shell(command_str: &str, cwd_path: &Path) -> std::io::Result<std::process::Child> {
1435
+ #[cfg(unix)]
1436
+ let (shell, flag) = ("sh", "-c");
1437
+ #[cfg(windows)]
1438
+ let (shell, flag) = ("cmd", "/C");
1439
+
1440
+ Command::new(shell)
1441
+ .arg(flag)
1442
+ .arg(command_str)
1443
+ .current_dir(cwd_path)
1444
+ .stdout(std::process::Stdio::piped())
1445
+ .stderr(std::process::Stdio::piped())
1446
+ .spawn()
1447
+}
1448
+
1449
+/// runs via `sh -c` / `cmd /C`; killed after COMMAND_TIMEOUT unless
1450
+/// `run_in_background` is set, in which case the child is registered as a
1451
+/// background task and polled with `command_output` / stopped with
1452
+/// `kill_command`.
1453
fn exec_run_command(arguments: &str) -> String {
1454
let args: Value = match serde_json::from_str(arguments) {
1455
Ok(v) => v,
@@ -1403,30 +1473,18 @@ fn exec_run_command(arguments: &str) -> String {
1473
return format!("Error: working directory does not exist: {cwd_str}");
1474
}
1475
1406
- log::info!("run_command: `{command_str}` in `{cwd_str}`");
1476
+ let run_in_background = args
1477
+ .get("run_in_background")
1478
+ .and_then(Value::as_bool)
1479
+ .unwrap_or(false);
1480
1408
- #[cfg(unix)]
1409
- let mut child = match Command::new("sh")
1410
- .arg("-c")
1411
- .arg(command_str)
1412
- .current_dir(&cwd_path)
1413
- .stdout(std::process::Stdio::piped())
1414
- .stderr(std::process::Stdio::piped())
1415
- .spawn()
1416
- {
1417
- Ok(c) => c,
1418
- Err(err) => return format!("Error: failed to spawn command: {err}"),
1419
- };
1481
+ log::info!("run_command: `{command_str}` in `{cwd_str}` (background: {run_in_background})");
1482
1421
- #[cfg(windows)]
1422
- let mut child = match Command::new("cmd")
1423
- .arg("/C")
1424
- .arg(command_str)
1425
- .current_dir(&cwd_path)
1426
- .stdout(std::process::Stdio::piped())
1427
- .stderr(std::process::Stdio::piped())
1428
- .spawn()
1429
- {
1483
+ if run_in_background {
1484
+ return start_background_task(command_str, &cwd_path);
1485
+ }
1486
+
1487
+ let mut child = match spawn_shell(command_str, &cwd_path) {
1488
Ok(c) => c,
1489
Err(err) => return format!("Error: failed to spawn command: {err}"),
1490
};
@@ -1477,6 +1535,252 @@ fn exec_run_command(arguments: &str) -> String {
1535
}
1536
}
1537
1538
+// ── background tasks (command_output / kill_command) ─────────────────────────
1539
+
1540
+/// A command started with `run_in_background`. The child is never detached,
1541
+/// so background tasks die with the sigit process.
1542
+struct BackgroundTask {
1543
+ command: String,
1544
+ child: std::process::Child,
1545
+ /// Combined stdout+stderr drained by the reader threads, shared with them.
1546
+ output: Arc<Mutex<TaskOutput>>,
1547
+ /// Exit code cached once observed; `-1` when there is no code (killed by
1548
+ /// a signal). `None` while the task is still running.
1549
+ exit_code: Option<i32>,
1550
+ /// Set when the task was stopped via `kill_command`.
1551
+ killed: bool,
1552
+}
1553
+
1554
+/// Output accumulated for a background task since the last poll.
1555
+#[derive(Default)]
1556
+struct TaskOutput {
1557
+ buf: String,
1558
+ /// Whether the oldest output was dropped because `buf` hit the cap.
1559
+ dropped: bool,
1560
+}
1561
+
1562
+/// Process-global background task table (same pattern as `mcp::MCP`).
1563
+fn tasks() -> &'static Mutex<HashMap<u64, BackgroundTask>> {
1564
+ static TASKS: OnceLock<Mutex<HashMap<u64, BackgroundTask>>> = OnceLock::new();
1565
+ TASKS.get_or_init(|| Mutex::new(HashMap::new()))
1566
+}
1567
+
1568
+fn lock_tasks() -> std::sync::MutexGuard<'static, HashMap<u64, BackgroundTask>> {
1569
+ tasks()
1570
+ .lock()
1571
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
1572
+}
1573
+
1574
+fn next_task_id() -> u64 {
1575
+ static NEXT_ID: AtomicU64 = AtomicU64::new(1);
1576
+ NEXT_ID.fetch_add(1, Ordering::Relaxed)
1577
+}
1578
+
1579
+/// Drain a child's stream into the task's shared buffer on a plain std thread,
1580
+/// capping the buffer at COMMAND_OUTPUT_LIMIT by dropping the oldest output.
1581
+fn spawn_output_reader<R: std::io::Read + Send + 'static>(
1582
+ mut stream: R,
1583
+ output: Arc<Mutex<TaskOutput>>,
1584
+) {
1585
+ std::thread::spawn(move || {
1586
+ let mut chunk = [0u8; 8192];
1587
+ loop {
1588
+ match stream.read(&mut chunk) {
1589
+ Ok(0) | Err(_) => break,
1590
+ Ok(n) => {
1591
+ let text = String::from_utf8_lossy(&chunk[..n]).into_owned();
1592
+ let mut out = output
1593
+ .lock()
1594
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
1595
+ out.buf.push_str(&text);
1596
+ if out.buf.len() > COMMAND_OUTPUT_LIMIT {
1597
+ let mut cut = out.buf.len() - COMMAND_OUTPUT_LIMIT;
1598
+ while !out.buf.is_char_boundary(cut) {
1599
+ cut += 1;
1600
+ }
1601
+ out.buf.drain(..cut);
1602
+ out.dropped = true;
1603
+ }
1604
+ }
1605
+ }
1606
+ }
1607
+ });
1608
+}
1609
+
1610
+/// Background branch of `run_command`: spawn, register, return immediately.
1611
+fn start_background_task(command_str: &str, cwd_path: &Path) -> String {
1612
+ let mut child = match spawn_shell(command_str, cwd_path) {
1613
+ Ok(c) => c,
1614
+ Err(err) => return format!("Error: failed to spawn command: {err}"),
1615
+ };
1616
+
1617
+ let output = Arc::new(Mutex::new(TaskOutput::default()));
1618
+ if let Some(stdout) = child.stdout.take() {
1619
+ spawn_output_reader(stdout, Arc::clone(&output));
1620
+ }
1621
+ if let Some(stderr) = child.stderr.take() {
1622
+ spawn_output_reader(stderr, Arc::clone(&output));
1623
+ }
1624
+
1625
+ let task_id = next_task_id();
1626
+ lock_tasks().insert(
1627
+ task_id,
1628
+ BackgroundTask {
1629
+ command: command_str.to_string(),
1630
+ child,
1631
+ output,
1632
+ exit_code: None,
1633
+ killed: false,
1634
+ },
1635
+ );
1636
+
1637
+ format!(
1638
+ "Started background task {task_id}: `{command_str}`. Poll its output and status \
1639
+ with command_output (task_id: {task_id}); stop it with kill_command. The task is \
1640
+ killed when sigit exits."
1641
+ )
1642
+}
1643
+
1644
+/// Check (and cache) whether a task's child has exited. Returns the exit code,
1645
+/// or `None` while it is still running.
1646
+fn poll_exit_code(task: &mut BackgroundTask) -> Option<i32> {
1647
+ if task.exit_code.is_none()
1648
+ && let Ok(Some(status)) = task.child.try_wait()
1649
+ {
1650
+ task.exit_code = Some(status.code().unwrap_or(-1));
1651
+ }
1652
+ task.exit_code
1653
+}
1654
+
1655
+/// Take everything the task has printed since the last poll, plus whether
1656
+/// older output was dropped at the buffer cap.
1657
+fn drain_task_output(task: &BackgroundTask) -> (String, bool) {
1658
+ let mut out = task
1659
+ .output
1660
+ .lock()
1661
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
1662
+ (
1663
+ std::mem::take(&mut out.buf),
1664
+ std::mem::take(&mut out.dropped),
1665
+ )
1666
+}
1667
+
1668
+fn parse_task_id(arguments: &str) -> Result<u64, String> {
1669
+ let args: Value = serde_json::from_str(arguments)
1670
+ .map_err(|err| format!("Error: failed to parse arguments: {err}"))?;
1671
+ args.get("task_id")
1672
+ .and_then(Value::as_u64)
1673
+ .ok_or_else(|| "Error: missing required parameter \"task_id\"".to_string())
1674
+}
1675
+
1676
+fn unknown_task(task_id: u64) -> String {
1677
+ format!(
1678
+ "Error: no background task with id {task_id}. Start one with run_command and \
1679
+ run_in_background set to true."
1680
+ )
1681
+}
1682
+
1683
+fn dropped_note(dropped: bool) -> &'static str {
1684
+ if dropped {
1685
+ "\n(note: earlier output was dropped after exceeding the 50000-byte buffer)"
1686
+ } else {
1687
+ ""
1688
+ }
1689
+}
1690
+
1691
+/// `command_output` tool: output since the last poll + running/exited status.
1692
+fn exec_command_output(arguments: &str) -> String {
1693
+ let task_id = match parse_task_id(arguments) {
1694
+ Ok(id) => id,
1695
+ Err(err) => return err,
1696
+ };
1697
+
1698
+ let mut map = lock_tasks();
1699
+ let Some(task) = map.get_mut(&task_id) else {
1700
+ return unknown_task(task_id);
1701
+ };
1702
+
1703
+ let was_running = task.exit_code.is_none();
1704
+ let exit_code = poll_exit_code(task);
1705
+ if was_running && exit_code.is_some() {
1706
+ // The child just exited; give the reader threads a moment to flush
1707
+ // the final output through the pipes before draining the buffer.
1708
+ std::thread::sleep(std::time::Duration::from_millis(100));
1709
+ }
1710
+ let (new_output, dropped) = drain_task_output(task);
1711
+
1712
+ let command = &task.command;
1713
+ let status = match exit_code {
1714
+ None => format!("Task {task_id} (`{command}`) is still running."),
1715
+ Some(_) if task.killed => format!("Task {task_id} (`{command}`) was killed."),
1716
+ Some(code) => format!("Task {task_id} (`{command}`) exited with code {code}."),
1717
+ };
1718
+
1719
+ if new_output.is_empty() {
1720
+ format!(
1721
+ "{status} No new output since the last check.{}",
1722
+ dropped_note(dropped)
1723
+ )
1724
+ } else {
1725
+ format!(
1726
+ "{status} New output since the last check:{}\n{new_output}",
1727
+ dropped_note(dropped)
1728
+ )
1729
+ }
1730
+}
1731
+
1732
+/// `kill_command` tool: stop a background task and report its output tail.
1733
+fn exec_kill_command(arguments: &str) -> String {
1734
+ let task_id = match parse_task_id(arguments) {
1735
+ Ok(id) => id,
1736
+ Err(err) => return err,
1737
+ };
1738
+
1739
+ let mut map = lock_tasks();
1740
+ let Some(task) = map.get_mut(&task_id) else {
1741
+ return unknown_task(task_id);
1742
+ };
1743
+
1744
+ if let Some(code) = poll_exit_code(task) {
1745
+ let (new_output, dropped) = drain_task_output(task);
1746
+ let command = &task.command;
1747
+ let tail = if new_output.is_empty() {
1748
+ String::new()
1749
+ } else {
1750
+ format!(" Unread output:\n{new_output}")
1751
+ };
1752
+ return format!(
1753
+ "Task {task_id} (`{command}`) had already exited with code {code}; nothing to \
1754
+ kill.{}{tail}",
1755
+ dropped_note(dropped)
1756
+ );
1757
+ }
1758
+
1759
+ if let Err(err) = task.child.kill() {
1760
+ return format!("Error: failed to kill task {task_id}: {err}");
1761
+ }
1762
+ match task.child.wait() {
1763
+ Ok(status) => task.exit_code = Some(status.code().unwrap_or(-1)),
1764
+ Err(_) => task.exit_code = Some(-1),
1765
+ }
1766
+ task.killed = true;
1767
+
1768
+ // Let the reader threads flush whatever was in flight before reporting.
1769
+ std::thread::sleep(std::time::Duration::from_millis(100));
1770
+ let (new_output, dropped) = drain_task_output(task);
1771
+
1772
+ let command = &task.command;
1773
+ let tail = if new_output.is_empty() {
1774
+ " It produced no unread output.".to_string()
1775
+ } else {
1776
+ format!(" Last output:\n{new_output}")
1777
+ };
1778
+ format!(
1779
+ "Killed task {task_id} (`{command}`).{}{tail}",
1780
+ dropped_note(dropped)
1781
+ )
1782
+}
1783
+
1784
#[cfg(test)]
1785
mod tests {
1786
use super::*;
@@ -1596,7 +1900,7 @@ mod tests {
1900
#[test]
1901
fn test_all_tools_count() {
1902
let tools = all_tools();
1599
- assert_eq!(tools.len(), 13);
1903
+ assert_eq!(tools.len(), 15);
1904
assert_eq!(tools[0].name, "read_file");
1905
assert_eq!(tools[1].name, "create_directory");
1906
assert_eq!(tools[2].name, "list_directory");
@@ -1610,6 +1914,8 @@ mod tests {
1914
assert_eq!(tools[10].name, "glob");
1915
assert_eq!(tools[11].name, "write_todos");
1916
assert_eq!(tools[12].name, "remember");
1917
+ assert_eq!(tools[13].name, "command_output");
1918
+ assert_eq!(tools[14].name, "kill_command");
1919
}
1920
1921
#[test]
@@ -2201,4 +2507,192 @@ mod tests {
2507
let result = exec_run_command(&args);
2508
assert!(result.contains("err"), "got: {result}");
2509
}
2510
+
2511
+ // ── background command tests ─────────────────────────────────────────
2512
+
2513
+ /// Extract the task id from a "Started background task {id}: ..." result.
2514
+ fn background_task_id(result: &str) -> u64 {
2515
+ let digits: String = result
2516
+ .strip_prefix("Started background task ")
2517
+ .unwrap_or_else(|| panic!("unexpected spawn result: {result}"))
2518
+ .chars()
2519
+ .take_while(char::is_ascii_digit)
2520
+ .collect();
2521
+ digits.parse().expect("task id")
2522
+ }
2523
+
2524
+ /// Whether the task's child has exited, without draining its output.
2525
+ fn background_task_exited(task_id: u64) -> bool {
2526
+ let mut map = lock_tasks();
2527
+ match map.get_mut(&task_id) {
2528
+ Some(task) => poll_exit_code(task).is_some(),
2529
+ None => true,
2530
+ }
2531
+ }
2532
+
2533
+ /// Wait (bounded) for a background task's child to exit.
2534
+ fn wait_for_background_exit(task_id: u64) {
2535
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
2536
+ while !background_task_exited(task_id) {
2537
+ assert!(
2538
+ std::time::Instant::now() < deadline,
2539
+ "task {task_id} did not exit in time"
2540
+ );
2541
+ std::thread::sleep(std::time::Duration::from_millis(50));
2542
+ }
2543
+ }
2544
+
2545
+ #[test]
2546
+ fn test_run_command_background_lifecycle() {
2547
+ #[cfg(unix)]
2548
+ let command = "echo start; sleep 2; echo done";
2549
+ #[cfg(windows)]
2550
+ let command = "echo start&& ping -n 3 127.0.0.1 > nul&& echo done";
2551
+
2552
+ let args = serde_json::json!({
2553
+ "command": command,
2554
+ "cwd": std::env::temp_dir(),
2555
+ "run_in_background": true
2556
+ })
2557
+ .to_string();
2558
+ let spawned = std::time::Instant::now();
2559
+ let result = exec_run_command(&args);
2560
+ // Spawning must return immediately, not wait the ~2s the command takes.
2561
+ assert!(
2562
+ spawned.elapsed() < std::time::Duration::from_secs(1),
2563
+ "background spawn blocked for {:?}",
2564
+ spawned.elapsed()
2565
+ );
2566
+ assert!(result.contains("command_output"), "got: {result}");
2567
+ let task_id = background_task_id(&result);
2568
+
2569
+ // Polling while the command is still sleeping reports it as running.
2570
+ let poll_args = serde_json::json!({ "task_id": task_id }).to_string();
2571
+ let poll = exec_command_output(&poll_args);
2572
+ assert!(poll.contains("still running"), "got: {poll}");
2573
+ let mut combined = poll;
2574
+
2575
+ wait_for_background_exit(task_id);
2576
+ // Grace period so the reader threads finish draining the pipes.
2577
+ std::thread::sleep(std::time::Duration::from_millis(300));
2578
+ let final_poll = exec_command_output(&poll_args);
2579
+ assert!(
2580
+ final_poll.contains("exited with code 0"),
2581
+ "got: {final_poll}"
2582
+ );
2583
+ combined.push_str(&final_poll);
2584
+
2585
+ // Across the polls, all output the command printed was delivered.
2586
+ assert!(combined.contains("start"), "got: {combined}");
2587
+ assert!(combined.contains("done"), "got: {combined}");
2588
+ }
2589
+
2590
+ #[test]
2591
+ fn test_kill_command_stops_background_task() {
2592
+ #[cfg(unix)]
2593
+ let command = "sleep 30";
2594
+ #[cfg(windows)]
2595
+ let command = "ping -n 31 127.0.0.1 > nul";
2596
+
2597
+ let args = serde_json::json!({
2598
+ "command": command,
2599
+ "cwd": std::env::temp_dir(),
2600
+ "run_in_background": true
2601
+ })
2602
+ .to_string();
2603
+ let result = exec_run_command(&args);
2604
+ let task_id = background_task_id(&result);
2605
+
2606
+ let kill_args = serde_json::json!({ "task_id": task_id }).to_string();
2607
+ let killed_at = std::time::Instant::now();
2608
+ let kill_result = exec_kill_command(&kill_args);
2609
+ assert!(
2610
+ kill_result.contains(&format!("Killed task {task_id}")),
2611
+ "got: {kill_result}"
2612
+ );
2613
+ // The kill must not wait out the 30s sleep.
2614
+ assert!(
2615
+ killed_at.elapsed() < std::time::Duration::from_secs(5),
2616
+ "kill blocked for {:?}",
2617
+ killed_at.elapsed()
2618
+ );
2619
+
2620
+ // A later poll reports the task as killed, not still running.
2621
+ let poll = exec_command_output(&serde_json::json!({ "task_id": task_id }).to_string());
2622
+ assert!(poll.contains("was killed"), "got: {poll}");
2623
+ }
2624
+
2625
+ #[test]
2626
+ fn test_command_output_unknown_task() {
2627
+ let result = exec_command_output(r#"{"task_id": 9999999}"#);
2628
+ assert!(
2629
+ result.contains("no background task with id"),
2630
+ "got: {result}"
2631
+ );
2632
+ }
2633
+
2634
+ #[test]
2635
+ fn test_kill_command_unknown_task() {
2636
+ let result = exec_kill_command(r#"{"task_id": 9999999}"#);
2637
+ assert!(
2638
+ result.contains("no background task with id"),
2639
+ "got: {result}"
2640
+ );
2641
+ }
2642
+
2643
+ #[test]
2644
+ fn test_command_output_missing_task_id() {
2645
+ let result = exec_command_output("{}");
2646
+ assert!(
2647
+ result.contains("missing required parameter"),
2648
+ "got: {result}"
2649
+ );
2650
+ }
2651
+
2652
+ #[test]
2653
+ fn test_background_output_is_capped() {
2654
+ // Print well over COMMAND_OUTPUT_LIMIT (50 000) bytes without polling,
2655
+ // so the buffer must drop the oldest output and note the truncation.
2656
+ #[cfg(unix)]
2657
+ let command = r#"i=0; while [ $i -lt 200 ]; do printf 'line-%04d %s\n' "$i" \
2658
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
2659
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
2660
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
2661
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
2662
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; \
2663
+ i=$((i+1)); done"#;
2664
+ #[cfg(windows)]
2665
+ let command = "powershell -NoProfile -Command \"1..200 | ForEach-Object { \
2666
+ ('line-{0:d4} ' -f $_) + ('a' * 330) }\"";
2667
+
2668
+ let args = serde_json::json!({
2669
+ "command": command,
2670
+ "cwd": std::env::temp_dir(),
2671
+ "run_in_background": true
2672
+ })
2673
+ .to_string();
2674
+ let result = exec_run_command(&args);
2675
+ let task_id = background_task_id(&result);
2676
+
2677
+ wait_for_background_exit(task_id);
2678
+ // Grace period so the reader threads finish draining the pipes.
2679
+ std::thread::sleep(std::time::Duration::from_millis(300));
2680
+
2681
+ let poll = exec_command_output(&serde_json::json!({ "task_id": task_id }).to_string());
2682
+ assert!(poll.contains("exited with code 0"), "got: {poll}");
2683
+ assert!(
2684
+ poll.contains("earlier output was dropped"),
2685
+ "expected truncation note, got: {poll}"
2686
+ );
2687
+ // The oldest lines were dropped; the newest survived.
2688
+ assert!(!poll.contains("line-0000"), "oldest output not dropped");
2689
+ assert!(poll.contains("line-0199"), "newest output missing");
2690
+ // Buffer stayed within the cap, plus the framing: the status line
2691
+ // (which quotes the command) and the truncation note.
2692
+ assert!(
2693
+ poll.len() <= COMMAND_OUTPUT_LIMIT + command.len() + 500,
2694
+ "poll result too large: {} bytes",
2695
+ poll.len()
2696
+ );
2697
+ }
2698
}