hive-bash-mcp: add kill tool to stop a running or pending bash task
This commit is contained in:
parent
e1b7f7cbec
commit
b16629801b
4 changed files with 291 additions and 60 deletions
|
|
@ -145,6 +145,26 @@ fn render_bash_status(id: &str, resp: Result<DaemonResponse>, waited: bool) -> S
|
|||
}
|
||||
}
|
||||
|
||||
/// Turn a `DaemonResponse` from a `BashKill` call into the string claude sees.
|
||||
fn render_bash_kill(resp: Result<DaemonResponse>) -> String {
|
||||
match resp {
|
||||
Ok(DaemonResponse::Ok { payload }) => {
|
||||
let id = payload["id"].as_str().unwrap_or("unknown");
|
||||
if payload["was_running"].as_bool().unwrap_or(false) {
|
||||
let sig = payload["signal"].as_str().unwrap_or("SIGINT");
|
||||
format!(
|
||||
"task `{id}`: {sig} sent to its process group; it transitions to `killed` \
|
||||
once the process exits and the usual completion wake fires."
|
||||
)
|
||||
} else {
|
||||
format!("task `{id}` was pending — cancelled before it started.")
|
||||
}
|
||||
}
|
||||
Ok(DaemonResponse::Error { message }) => format!("kill error: {message}"),
|
||||
Err(e) => format!("bash bridge error: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -196,6 +216,18 @@ struct BashStatusArgs {
|
|||
wait_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct BashKillArgs {
|
||||
/// Task ID (from `run`) to kill.
|
||||
id: String,
|
||||
/// `false` (default) sends SIGINT to the task's process group — graceful,
|
||||
/// the process can clean up. `true` sends SIGKILL — immediate. Either way
|
||||
/// the whole process group is signalled, so children of the shell (e.g. a
|
||||
/// `cargo`/`nix` invocation) are stopped too, not just the shell itself.
|
||||
#[serde(default)]
|
||||
force: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct BashMcp;
|
||||
|
||||
|
|
@ -238,8 +270,8 @@ impl BashMcp {
|
|||
|
||||
#[tool(
|
||||
description = "Check the status of a background bash task by its ID (from `run`). \
|
||||
Returns the current status (pending/running/done/timed_out/interrupted), exit code \
|
||||
if finished, and a tail of stdout/stderr. Full output lives in \
|
||||
Returns the current status (pending/running/done/timed_out/interrupted/killed), exit \
|
||||
code if finished, and a tail of stdout/stderr. Full output lives in \
|
||||
`harness/bash-tasks/<id>.out` / `.err`. \
|
||||
Pass `wait_seconds` (capped at 30) to wait inline for the task to finish: when the \
|
||||
task finishes within the window the full status is returned immediately. Useful to \
|
||||
|
|
@ -254,6 +286,23 @@ impl BashMcp {
|
|||
};
|
||||
render_bash_status(&id, round_trip(req).await, waited)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Kill a background bash task you started (by its ID from `run`). \
|
||||
`force: false` (default) sends SIGINT — graceful, lets the process clean up; \
|
||||
`force: true` sends SIGKILL — immediate. Either way the task's whole process \
|
||||
group is signalled, so a runaway child (cargo/nix/etc.) is stopped too, not just \
|
||||
the shell. A still-pending task is cancelled before it starts. The task ends as \
|
||||
`killed` and fires the usual completion wake. SIGINT relies on the process \
|
||||
honouring it — pass `force` if it won't stop."
|
||||
)]
|
||||
async fn kill(&self, Parameters(args): Parameters<BashKillArgs>) -> String {
|
||||
let req = DaemonRequest::BashKill {
|
||||
id: args.id,
|
||||
force: args.force,
|
||||
};
|
||||
render_bash_kill(round_trip(req).await)
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_handler]
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ pub enum TaskStatus {
|
|||
TimedOut,
|
||||
/// Daemon was restarted while the task was running; process is gone.
|
||||
Interrupted,
|
||||
/// Killed on request via `BashKill` (SIGINT or SIGKILL to the task's
|
||||
/// process group). Distinct from `Interrupted` (daemon-restart) and
|
||||
/// `TimedOut` (exceeded `timeout_secs`).
|
||||
Killed,
|
||||
}
|
||||
|
||||
/// Task metadata + result written to `<id>.json` under the tasks dir.
|
||||
|
|
@ -93,6 +97,17 @@ pub enum DaemonRequest {
|
|||
/// Used by the harness `get_loose_ends` to surface active
|
||||
/// background work.
|
||||
ActiveTasks,
|
||||
|
||||
/// Kill a running (or still-pending) task. `force = false` sends
|
||||
/// `SIGINT` to the task's process group (graceful — the process can
|
||||
/// clean up); `force = true` sends `SIGKILL` (immediate). Signalling
|
||||
/// the whole process group reaps `sh -c` plus any children it spawned,
|
||||
/// so a runaway grandchild (e.g. `cargo`/`nix`) is actually stopped.
|
||||
BashKill {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
force: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Response shape from the daemon. `Ok` carries a JSON payload; `Error`
|
||||
|
|
|
|||
|
|
@ -14,14 +14,15 @@
|
|||
//! The runner kills the child process on timeout — `tokio::process::Child::drop()`
|
||||
//! does not kill children, so we explicitly call `child.kill().await`.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use tokio::io::AsyncWriteExt as _;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
use crate::paths;
|
||||
use crate::protocol::{TaskFile, TaskStatus};
|
||||
|
|
@ -41,6 +42,50 @@ const POLL_MS: u64 = 100;
|
|||
|
||||
static TASK_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Running-task registry (for on-demand kill)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Handle to a currently-executing task, used by [`kill_task`] to signal it.
|
||||
/// `exec_cmd` selects on `cancel`; when notified it signals the task's
|
||||
/// process group with `SIGKILL` (if `force`) or `SIGINT`.
|
||||
struct RunningHandle {
|
||||
cancel: Arc<Notify>,
|
||||
force: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// Global registry of running tasks (id → handle). Populated by `run_task`
|
||||
/// for the duration of execution and read by [`kill_task`] from the socket
|
||||
/// dispatch path — a separate async context from the runner loop, so a
|
||||
/// shared global (rather than the loop-local `claimed` set) is needed.
|
||||
fn running() -> &'static Mutex<HashMap<String, RunningHandle>> {
|
||||
static RUNNING: OnceLock<Mutex<HashMap<String, RunningHandle>>> = OnceLock::new();
|
||||
RUNNING.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Outcome of one `exec_cmd` run.
|
||||
enum ExecOutcome {
|
||||
/// Process exited on its own with this status code.
|
||||
Exited(i32),
|
||||
/// Killed by the per-task timeout (`timeout_secs`).
|
||||
TimedOut,
|
||||
/// Killed on request via [`kill_task`]. `forced` = SIGKILL vs SIGINT.
|
||||
Killed { forced: bool },
|
||||
}
|
||||
|
||||
/// Send `sig` to the task's process group. A negative pgid targets the whole
|
||||
/// group, so `bash -c` plus any children it spawned all receive the signal.
|
||||
/// No-op if the child had no pid (already reaped).
|
||||
fn signal_group(pgid: Option<i32>, sig: i32) {
|
||||
if let Some(pgid) = pgid {
|
||||
// SAFETY: plain libc `kill(2)`; `-pgid` targets the child's own
|
||||
// process group (it called `setpgid(0, 0)` so it leads its group).
|
||||
unsafe {
|
||||
libc::kill(-pgid, sig);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -232,7 +277,10 @@ pub async fn wait_for_task(id: &str, wait_secs: u64) -> Option<TaskFile> {
|
|||
Some(task) => {
|
||||
if matches!(
|
||||
task.status,
|
||||
TaskStatus::Done | TaskStatus::TimedOut | TaskStatus::Interrupted
|
||||
TaskStatus::Done
|
||||
| TaskStatus::TimedOut
|
||||
| TaskStatus::Interrupted
|
||||
| TaskStatus::Killed
|
||||
) {
|
||||
return Some(task);
|
||||
}
|
||||
|
|
@ -246,6 +294,42 @@ pub async fn wait_for_task(id: &str, wait_secs: u64) -> Option<TaskFile> {
|
|||
read_task(id)
|
||||
}
|
||||
|
||||
/// Kill a running or still-pending task.
|
||||
///
|
||||
/// - **Running** (in the registry): signals its process group via the
|
||||
/// `exec_cmd` cancel path — `SIGKILL` if `force`, else `SIGINT`. The task
|
||||
/// transitions to [`TaskStatus::Killed`] once the process exits and a
|
||||
/// completion wake fires as usual. A `SIGINT` relies on the process
|
||||
/// honouring it; pass `force` for a guaranteed stop.
|
||||
/// - **Pending** (queued, not yet started): marked `Killed` directly so the
|
||||
/// runner loop never starts it. No process exists yet, so `force` is moot.
|
||||
/// - **Terminal or unknown id**: no-op.
|
||||
///
|
||||
/// Returns `(killed, was_running)`: `killed` = a kill was issued (signal sent
|
||||
/// or pending task cancelled); `was_running` = the task was actively running.
|
||||
pub fn kill_task(id: &str, force: bool) -> (bool, bool) {
|
||||
// Running task: signal via the registry.
|
||||
{
|
||||
let guard = running().lock().unwrap();
|
||||
if let Some(handle) = guard.get(id) {
|
||||
handle.force.store(force, Ordering::SeqCst);
|
||||
handle.cancel.notify_one();
|
||||
return (true, true);
|
||||
}
|
||||
}
|
||||
// Pending task: mark Killed so the poll loop never starts it.
|
||||
if let Some(mut task) = read_task(id)
|
||||
&& task.status == TaskStatus::Pending
|
||||
{
|
||||
task.status = TaskStatus::Killed;
|
||||
task.completed_at = Some(now_unix());
|
||||
let _ = write_task(&task);
|
||||
refresh_loose_ends();
|
||||
return (true, false);
|
||||
}
|
||||
(false, false)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runner background loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -358,27 +442,57 @@ async fn run_task(mut task: TaskFile, socket: &Path) {
|
|||
let out_path = paths::task_out(&id);
|
||||
let err_path = paths::task_err(&id);
|
||||
|
||||
let (timed_out, exit_code) =
|
||||
match exec_cmd(&task.cmd, &out_path, &err_path, task.timeout_secs).await {
|
||||
Ok((code, false)) => (false, Some(code)),
|
||||
Ok((_, true)) => {
|
||||
tracing::warn!(id = %id, "bash_runner: task timed out");
|
||||
(true, None)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(id = %id, error = ?e, "bash_runner: exec error");
|
||||
(false, None)
|
||||
}
|
||||
};
|
||||
// Register a kill handle for the duration of execution so `kill_task`
|
||||
// (called from the socket dispatch path) can signal this task.
|
||||
let cancel = Arc::new(Notify::new());
|
||||
let force = Arc::new(AtomicBool::new(false));
|
||||
running().lock().unwrap().insert(
|
||||
id.clone(),
|
||||
RunningHandle {
|
||||
cancel: cancel.clone(),
|
||||
force: force.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
let outcome = exec_cmd(
|
||||
&task.cmd,
|
||||
&out_path,
|
||||
&err_path,
|
||||
task.timeout_secs,
|
||||
&cancel,
|
||||
&force,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Deregister before writing terminal state — the task is no longer killable.
|
||||
running().lock().unwrap().remove(&id);
|
||||
|
||||
let stdout_tail = tail_file(&out_path, SUMMARY_BYTES);
|
||||
let stderr_tail = tail_file(&err_path, SUMMARY_BYTES);
|
||||
|
||||
task.status = if timed_out {
|
||||
TaskStatus::TimedOut
|
||||
} else {
|
||||
TaskStatus::Done
|
||||
let (status, exit_code, summary) = match outcome {
|
||||
Ok(ExecOutcome::Exited(code)) => (TaskStatus::Done, Some(code), format!("exit={code}")),
|
||||
Ok(ExecOutcome::TimedOut) => {
|
||||
tracing::warn!(id = %id, "bash_runner: task timed out");
|
||||
let secs = task.timeout_secs.unwrap_or(0);
|
||||
(
|
||||
TaskStatus::TimedOut,
|
||||
None,
|
||||
format!("timed out after {secs}s"),
|
||||
)
|
||||
}
|
||||
Ok(ExecOutcome::Killed { forced }) => {
|
||||
let sig = if forced { "SIGKILL" } else { "SIGINT" };
|
||||
tracing::warn!(id = %id, sig, "bash_runner: task killed");
|
||||
(TaskStatus::Killed, None, format!("killed ({sig})"))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(id = %id, error = ?e, "bash_runner: exec error");
|
||||
(TaskStatus::Done, None, "exec error".to_owned())
|
||||
}
|
||||
};
|
||||
|
||||
task.status = status;
|
||||
task.completed_at = Some(now_unix());
|
||||
task.exit_code = exit_code;
|
||||
task.stdout_tail = stdout_tail.clone().filter(|s| !s.is_empty());
|
||||
|
|
@ -389,33 +503,31 @@ async fn run_task(mut task: TaskFile, socket: &Path) {
|
|||
}
|
||||
refresh_loose_ends();
|
||||
|
||||
let summary = if timed_out {
|
||||
let secs = task.timeout_secs.unwrap_or(0);
|
||||
format!("timed out after {secs}s")
|
||||
} else {
|
||||
format!("exit={}", exit_code.unwrap_or(-1))
|
||||
};
|
||||
let out_snippet = stdout_tail.as_deref().unwrap_or("").trim();
|
||||
let err_snippet = stderr_tail.as_deref().unwrap_or("").trim();
|
||||
send_wake(socket, &id, &summary, Some((out_snippet, err_snippet))).await;
|
||||
}
|
||||
|
||||
/// Run `bash -c cmd`, streaming output to files. Returns `(exit_code, timed_out)`.
|
||||
/// Run `bash -c cmd` in its own process group, streaming output to files.
|
||||
/// `timeout_secs = None` means no timeout — run until natural exit.
|
||||
///
|
||||
/// Tasks run under `bash`, not `sh`: on NixOS `/bin/sh` is bash in POSIX
|
||||
/// mode, which disables bashisms (arrays, `[[ … ]]`, `local`, process
|
||||
/// substitution, …). Agents write bash, so we invoke `bash` (via
|
||||
/// `/usr/bin/env bash`).
|
||||
/// Tasks run under `bash`, not `sh` (on NixOS `/bin/sh` is bash in POSIX
|
||||
/// mode, which disables bashisms — arrays, `[[ … ]]`, `local`, process
|
||||
/// substitution), invoked via `/usr/bin/env bash`.
|
||||
///
|
||||
/// The task is cancellable via `cancel` (set `force` first): on cancel the
|
||||
/// whole process group is signalled — `SIGKILL` if `force`, else `SIGINT` —
|
||||
/// so children of the shell die too, not just the shell. The timeout path
|
||||
/// likewise `SIGKILL`s the group.
|
||||
async fn exec_cmd(
|
||||
cmd: &str,
|
||||
out_path: &Path,
|
||||
err_path: &Path,
|
||||
timeout_secs: Option<u64>,
|
||||
) -> Result<(i32, bool)> {
|
||||
cancel: &Notify,
|
||||
force: &AtomicBool,
|
||||
) -> Result<ExecOutcome> {
|
||||
use tokio::process::Command;
|
||||
// SAFETY: `nice` is async-signal-safe and modifies only the calling
|
||||
// process's scheduling priority before exec. No allocations, no locks.
|
||||
// `/usr/bin/env bash` rather than a bare `bash`: `/usr/bin/env` is at a
|
||||
// fixed absolute path (coreutils, present on NixOS), and it resolves
|
||||
// `bash` via PATH — the same controlled PATH the daemon's systemd unit
|
||||
|
|
@ -427,13 +539,20 @@ async fn exec_cmd(
|
|||
.arg(cmd)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped());
|
||||
// SAFETY: `setpgid`/`nice` are async-signal-safe and only touch the
|
||||
// calling (child) process before exec — no allocations, no locks.
|
||||
// `setpgid(0, 0)` makes the child its own process-group leader so the
|
||||
// whole subtree can be signalled as a group (`kill(-pgid, …)`).
|
||||
unsafe {
|
||||
cmd_builder.pre_exec(|| {
|
||||
libc::setpgid(0, 0);
|
||||
libc::nice(10);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
let mut child = cmd_builder.spawn()?;
|
||||
// With `setpgid(0, 0)` the child's pgid equals its pid.
|
||||
let pgid = child.id().map(u32_to_i32);
|
||||
|
||||
let stdout = child.stdout.take().expect("stdout piped");
|
||||
let stderr = child.stderr.take().expect("stderr piped");
|
||||
|
|
@ -450,33 +569,53 @@ async fn exec_cmd(
|
|||
err_path,
|
||||
));
|
||||
|
||||
if let Some(secs) = timeout_secs {
|
||||
match tokio::time::timeout(Duration::from_secs(secs), child.wait()).await {
|
||||
Ok(Ok(status)) => {
|
||||
let _ = copy_out.await;
|
||||
let _ = copy_err.await;
|
||||
Ok((status.code().unwrap_or(-1), false))
|
||||
// A future that fires when the timeout elapses, or never if `None`.
|
||||
let timeout_fut = async {
|
||||
match timeout_secs {
|
||||
Some(secs) => tokio::time::sleep(Duration::from_secs(secs)).await,
|
||||
None => std::future::pending::<()>().await,
|
||||
}
|
||||
};
|
||||
|
||||
let mut timed_out = false;
|
||||
let mut killed: Option<bool> = None;
|
||||
let wait_res = {
|
||||
let wait = child.wait();
|
||||
tokio::pin!(wait);
|
||||
tokio::pin!(timeout_fut);
|
||||
tokio::select! {
|
||||
res = &mut wait => res,
|
||||
() = &mut timeout_fut => {
|
||||
signal_group(pgid, libc::SIGKILL);
|
||||
timed_out = true;
|
||||
(&mut wait).await
|
||||
}
|
||||
Ok(Err(e)) => Err(e.into()),
|
||||
Err(_elapsed) => {
|
||||
let _ = child.kill().await;
|
||||
let _ = child.wait().await;
|
||||
let _ = copy_out.await;
|
||||
let _ = copy_err.await;
|
||||
Ok((-1, true))
|
||||
() = cancel.notified() => {
|
||||
let forced = force.load(Ordering::SeqCst);
|
||||
signal_group(pgid, if forced { libc::SIGKILL } else { libc::SIGINT });
|
||||
killed = Some(forced);
|
||||
(&mut wait).await
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let _ = copy_out.await;
|
||||
let _ = copy_err.await;
|
||||
|
||||
let status = wait_res?;
|
||||
let outcome = if let Some(forced) = killed {
|
||||
ExecOutcome::Killed { forced }
|
||||
} else if timed_out {
|
||||
ExecOutcome::TimedOut
|
||||
} else {
|
||||
// No timeout — wait for natural exit.
|
||||
match child.wait().await {
|
||||
Ok(status) => {
|
||||
let _ = copy_out.await;
|
||||
let _ = copy_err.await;
|
||||
Ok((status.code().unwrap_or(-1), false))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
ExecOutcome::Exited(status.code().unwrap_or(-1))
|
||||
};
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// `u32` pid → `i32` for `kill(2)`. Pids fit in `i32`; saturates defensively.
|
||||
fn u32_to_i32(v: u32) -> i32 {
|
||||
i32::try_from(v).unwrap_or(i32::MAX)
|
||||
}
|
||||
|
||||
async fn copy_stream_to_file<R>(mut reader: R, path: PathBuf)
|
||||
|
|
|
|||
|
|
@ -74,7 +74,10 @@ async fn dispatch(req: DaemonRequest) -> DaemonResponse {
|
|||
&& let Some(task) = runner::wait_for_task(&id, wait).await
|
||||
&& matches!(
|
||||
task.status,
|
||||
TaskStatus::Done | TaskStatus::TimedOut | TaskStatus::Interrupted
|
||||
TaskStatus::Done
|
||||
| TaskStatus::TimedOut
|
||||
| TaskStatus::Interrupted
|
||||
| TaskStatus::Killed
|
||||
)
|
||||
{
|
||||
return DaemonResponse::ok(&serde_json::json!({
|
||||
|
|
@ -103,5 +106,30 @@ async fn dispatch(req: DaemonRequest) -> DaemonResponse {
|
|||
let tasks = runner::active_tasks();
|
||||
DaemonResponse::ok(&tasks)
|
||||
}
|
||||
|
||||
DaemonRequest::BashKill { id, force } => {
|
||||
let (killed, was_running) = runner::kill_task(&id, force);
|
||||
if !killed {
|
||||
return DaemonResponse::error(format!(
|
||||
"no running or pending task with id `{id}` (already finished or unknown)"
|
||||
));
|
||||
}
|
||||
let payload = if was_running {
|
||||
serde_json::json!({
|
||||
"id": id,
|
||||
"killed": true,
|
||||
"was_running": true,
|
||||
"signal": if force { "SIGKILL" } else { "SIGINT" },
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"id": id,
|
||||
"killed": true,
|
||||
"was_running": false,
|
||||
"note": "task was pending — cancelled before it started",
|
||||
})
|
||||
};
|
||||
DaemonResponse::ok(&payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue