feat(#2659): serve hive-bash-mcp over persistent streamable-http, drop stdio bridge
This commit is contained in:
parent
ecc2ebe682
commit
c4fcf7fbf1
7 changed files with 376 additions and 574 deletions
|
|
@ -1,11 +1,11 @@
|
|||
//! Shared library for `hive-bash-daemon` and `hive-bash-mcp`.
|
||||
//! Shared library for the `hive-bash-daemon` binary.
|
||||
//!
|
||||
//! The daemon owns the subprocess runner loop and unix socket server.
|
||||
//! The stdio MCP bridge is a thin client that forwards each tool call
|
||||
//! to the daemon over the unix socket.
|
||||
//! The daemon owns the subprocess runner loop and serves its MCP tools
|
||||
//! (`run`/`status`/`kill`) directly over streamable-http — no stdio
|
||||
//! bridge, no round-trip socket.
|
||||
|
||||
pub mod mcp;
|
||||
pub mod paths;
|
||||
pub mod protocol;
|
||||
pub mod runner;
|
||||
pub mod socket;
|
||||
pub mod stats;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,21 @@
|
|||
//! `hive-bash-daemon` binary — long-running per-agent bash task runner.
|
||||
//! Spawns `sh -c` subprocesses, monitors completion, writes task state
|
||||
//! files, and surfaces task state to the agent as todos on the harness's
|
||||
//! in-agent socket. Listens on a unix socket for tool-call requests from
|
||||
//! the `hive-bash-mcp` stdio bridge.
|
||||
//! in-agent socket. Serves its MCP tools (`run`/`status`/`kill`) directly
|
||||
//! over streamable-http on `--http <addr>` — no stdio bridge, no separate
|
||||
//! bin claude has to respawn every turn.
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "hive-bash-daemon", about = "bash-task runner + MCP daemon")]
|
||||
struct Cli {
|
||||
/// Serve the MCP tools over streamable-http on this address (e.g.
|
||||
/// `127.0.0.1:8791`). Bind loopback only.
|
||||
#[arg(long)]
|
||||
http: std::net::SocketAddr,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
|
|
@ -15,11 +26,11 @@ async fn main() -> Result<()> {
|
|||
)
|
||||
.init();
|
||||
|
||||
let socket_path = hive_bash_mcp::paths::daemon_socket();
|
||||
let cli = Cli::parse();
|
||||
let todo_socket = hive_bash_mcp::paths::agent_socket();
|
||||
|
||||
tracing::info!(
|
||||
socket = %socket_path.display(),
|
||||
http = %cli.http,
|
||||
todo = %todo_socket.display(),
|
||||
"hive-bash-daemon starting"
|
||||
);
|
||||
|
|
@ -28,6 +39,6 @@ async fn main() -> Result<()> {
|
|||
// spawns them, pushing todos to the harness on task transitions.
|
||||
hive_bash_mcp::runner::spawn_loop(todo_socket);
|
||||
|
||||
// Serve the unix socket forever.
|
||||
hive_bash_mcp::socket::serve(&socket_path).await
|
||||
// Serve the MCP tools over streamable-http forever.
|
||||
hive_bash_mcp::mcp::serve_http(cli.http).await
|
||||
}
|
||||
|
|
|
|||
321
hive-bash-mcp/src/mcp.rs
Normal file
321
hive-bash-mcp/src/mcp.rs
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
//! MCP tool surface for `hive-bash-daemon`, served directly over
|
||||
//! streamable-http — no stdio bridge, no round-trip socket. The daemon
|
||||
//! already owns the subprocess runner in-process, so the tool handlers
|
||||
//! below call straight into [`crate::runner`]. Mirrors
|
||||
//! `hive-agent-mcp::mcp::serve_http`'s shape (persistent daemon, stable
|
||||
//! URL claude reconnects to every turn instead of respawning a stdio
|
||||
//! child).
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use rmcp::{
|
||||
ServerHandler,
|
||||
handler::server::wrapper::Parameters,
|
||||
schemars::{self, JsonSchema},
|
||||
tool, tool_handler, tool_router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::protocol::{TaskFile, TaskStatus};
|
||||
use crate::runner::{self, SUMMARY_BYTES};
|
||||
|
||||
fn status_str(status: &TaskStatus) -> &'static str {
|
||||
match status {
|
||||
TaskStatus::Pending => "pending",
|
||||
TaskStatus::Running => "running",
|
||||
TaskStatus::Done => "done",
|
||||
TaskStatus::TimedOut => "timed_out",
|
||||
TaskStatus::Interrupted => "interrupted",
|
||||
TaskStatus::Killed => "killed",
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a `TaskFile` as the human-readable status string claude sees.
|
||||
fn format_task(task: &TaskFile) -> String {
|
||||
let mut out = format!("task `{}`: status={}", task.id, status_str(&task.status));
|
||||
|
||||
if let Some(code) = task.exit_code {
|
||||
let _ = write!(out, ", exit={code}");
|
||||
}
|
||||
if let (Some(started), None) = (task.started_at, task.completed_at) {
|
||||
let now = hive_sh4re::wire_time::now_unix();
|
||||
let _ = write!(out, ", running for {}s", now - started);
|
||||
}
|
||||
if let (Some(completed), Some(started)) = (task.completed_at, task.started_at) {
|
||||
let _ = write!(out, ", took {}s", completed - started);
|
||||
}
|
||||
|
||||
let out_file = crate::paths::task_out(&task.id);
|
||||
let err_file = crate::paths::task_err(&task.id);
|
||||
let out_len = std::fs::metadata(&out_file).map_or(0, |m| m.len());
|
||||
let err_len = std::fs::metadata(&err_file).map_or(0, |m| m.len());
|
||||
|
||||
if let Some(stdout) = &task.stdout_tail {
|
||||
let s = stdout.trim();
|
||||
if !s.is_empty() {
|
||||
let _ = write!(out, "\n\nstdout:\n```\n{s}\n```");
|
||||
}
|
||||
}
|
||||
if out_len > SUMMARY_BYTES as u64 {
|
||||
let _ = write!(out, "\n\nFull stdout lives in `{}`", out_file.display());
|
||||
}
|
||||
|
||||
if let Some(stderr) = &task.stderr_tail {
|
||||
let s = stderr.trim();
|
||||
if !s.is_empty() {
|
||||
let _ = write!(out, "\n\nstderr:\n```\n{s}\n```");
|
||||
}
|
||||
}
|
||||
if err_len > SUMMARY_BYTES as u64 {
|
||||
let _ = write!(out, "\n\nFull stderr lives in `{}`", err_file.display());
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Appended to a `status` result when the caller parked on a
|
||||
/// `wait_seconds` poll that expired while the task was still running —
|
||||
/// nudges the model to spend the idle time on other work instead of
|
||||
/// immediately re-waiting on the same task.
|
||||
const BASH_IDLE_WAIT_HINT: &str = "\n\nThe task is still running — your wait timed out before it \
|
||||
finished. If you have other useful work, do that and check back later (the task keeps running, and \
|
||||
it surfaces in your loose-ends when it completes) rather than immediately re-waiting.";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct BashRunArgs {
|
||||
/// Shell command to run (passed to `bash -c`).
|
||||
cmd: String,
|
||||
/// Timeout in seconds. Defaults to `None` (no timeout) — task runs until
|
||||
/// natural exit. Pass an explicit value to kill the task after N seconds
|
||||
/// and mark it `timed_out`.
|
||||
#[serde(default)]
|
||||
timeout_secs: Option<u64>,
|
||||
/// Optional inline wait: `run` polls for up to `wait_seconds`
|
||||
/// (capped at 30) before returning. When the task finishes within the
|
||||
/// window the full status is returned immediately (nothing to handle
|
||||
/// later); when the timeout expires the task keeps running and the
|
||||
/// normal `task started: id=<id>` response is returned. Defaults to 3s.
|
||||
/// Pass `0` to disable inline waiting and always get the immediate response.
|
||||
#[serde(default = "default_wait")]
|
||||
wait_seconds: Option<u64>,
|
||||
/// Optional task name. When set it becomes the task id, so it appears
|
||||
/// in `status` lookups and your loose-ends list — handy for recognising
|
||||
/// a task later instead of an opaque hex id. A name can be reused once
|
||||
/// its previous task has finished; reusing a name whose task is still
|
||||
/// running is rejected. Allowed chars: `[a-z0-9-]` (a valid
|
||||
/// identifier — lowercase, digits, hyphen; max 63). Omit to get the
|
||||
/// auto-generated id.
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::unnecessary_wraps,
|
||||
reason = "serde `#[serde(default = ...)]` requires the default fn's return type to match the field's `Option<u64>`, so the `Some` wrap is mandatory even though the value is constant"
|
||||
)]
|
||||
fn default_wait() -> Option<u64> {
|
||||
Some(3)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct BashStatusArgs {
|
||||
/// Task ID returned by `run`.
|
||||
id: String,
|
||||
/// Optional inline wait: `status` polls for up to `wait_seconds`
|
||||
/// (capped at 30) before returning. Useful to avoid a separate
|
||||
/// round-trip when the task is expected to finish soon.
|
||||
#[serde(default)]
|
||||
wait_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct BashKillArgs {
|
||||
/// Task ID (from `run`) to kill.
|
||||
id: String,
|
||||
/// `false` (default): SIGINT to the task's process group — graceful, the
|
||||
/// process can clean up. `true`: SIGKILL. 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. Fire-and-forget: the
|
||||
/// call sends the signal and returns without waiting. If a SIGINT'd task
|
||||
/// doesn't exit, call `kill` again with `force: true` to send SIGKILL.
|
||||
#[serde(default)]
|
||||
force: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct BashMcp;
|
||||
|
||||
#[tool_router]
|
||||
impl BashMcp {
|
||||
#[tool(
|
||||
description = "Run a shell command in the background. Returns a task ID immediately — \
|
||||
do NOT wait inline. When the command finishes it surfaces as a todo in your \
|
||||
loose-ends (the exit status plus a `Read(<path>)` pointer to the captured \
|
||||
`.out`/`.err` files — read only what you need); handle it on a future turn. Use \
|
||||
`status` to poll within the same turn if needed. `timeout_secs` defaults to \
|
||||
`None` (no timeout) — task runs until natural exit; pass a value to kill after \
|
||||
N seconds. Pass `wait_seconds` (capped at 30) to wait inline for fast commands: \
|
||||
if the task finishes within the window you get the full status immediately \
|
||||
(nothing to handle later); otherwise it keeps running and you get \
|
||||
`task started: id=<id>`. Defaults to 3; pass `0` to disable inline waiting. \
|
||||
Pass `name` to label the task with a memorable id (shown in `status` lookups \
|
||||
and your loose-ends) instead of an opaque hex id; reusable once the prior task \
|
||||
has finished, rejected while one is still running."
|
||||
)]
|
||||
async fn run(&self, Parameters(args): Parameters<BashRunArgs>) -> String {
|
||||
let id = match runner::submit_task(args.cmd, args.timeout_secs, args.name) {
|
||||
Ok(id) => id,
|
||||
Err(e) => return format!("bash_run error: {e:#}"),
|
||||
};
|
||||
let wait = args.wait_seconds.unwrap_or(0);
|
||||
if wait > 0
|
||||
&& let Some(task) = runner::wait_for_task(&id, wait).await
|
||||
&& matches!(
|
||||
task.status,
|
||||
TaskStatus::Done
|
||||
| TaskStatus::TimedOut
|
||||
| TaskStatus::Interrupted
|
||||
| TaskStatus::Killed
|
||||
)
|
||||
{
|
||||
return format_task(&task);
|
||||
}
|
||||
format!("task started: id={id}")
|
||||
}
|
||||
|
||||
#[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/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 \
|
||||
avoid a separate round-trip when the task is expected to finish soon."
|
||||
)]
|
||||
async fn status(&self, Parameters(args): Parameters<BashStatusArgs>) -> String {
|
||||
let waited = args.wait_seconds.is_some_and(|w| w > 0);
|
||||
let wait = args.wait_seconds.unwrap_or(0);
|
||||
let task = if wait > 0 {
|
||||
runner::wait_for_task(&args.id, wait).await
|
||||
} else {
|
||||
runner::read_task(&args.id)
|
||||
};
|
||||
match task {
|
||||
Some(t) => {
|
||||
let mut out = format_task(&t);
|
||||
if waited && matches!(t.status, TaskStatus::Pending | TaskStatus::Running) {
|
||||
out.push_str(BASH_IDLE_WAIT_HINT);
|
||||
}
|
||||
out
|
||||
}
|
||||
None => format!("unknown task id `{}`", args.id),
|
||||
}
|
||||
}
|
||||
|
||||
#[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. Either way the task's whole process group is \
|
||||
signalled, so a runaway child (cargo/nix/etc.) is stopped too, not just the shell. \
|
||||
Fire-and-forget: sends the signal and returns without waiting. If a SIGINT'd task \
|
||||
doesn't exit, call kill again with `force: true` to SIGKILL. A still-pending task \
|
||||
is cancelled before it starts. The task ends as `killed` and surfaces in your \
|
||||
loose-ends like any completion."
|
||||
)]
|
||||
async fn kill(&self, Parameters(args): Parameters<BashKillArgs>) -> String {
|
||||
let (killed, was_running) = runner::kill_task(&args.id, args.force);
|
||||
if !killed {
|
||||
return format!(
|
||||
"no running or pending task with id `{}` (already finished or unknown)",
|
||||
args.id
|
||||
);
|
||||
}
|
||||
if was_running {
|
||||
let sig = if args.force { "SIGKILL" } else { "SIGINT" };
|
||||
format!(
|
||||
"task `{}`: {sig} sent to its process group; it transitions to `killed` \
|
||||
once the process exits and then surfaces in your loose-ends.",
|
||||
args.id
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"task `{}` was pending — cancelled before it started.",
|
||||
args.id
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_handler]
|
||||
impl ServerHandler for BashMcp {}
|
||||
|
||||
/// Run the MCP server over HTTP (rmcp streamable-http transport) on `addr`.
|
||||
///
|
||||
/// Sole transport — there is no stdio mode. Long-lived so claude
|
||||
/// reconnects to the stable URL each turn instead of respawning a stdio
|
||||
/// child; since the daemon already owns [`crate::runner`] in-process,
|
||||
/// tool calls need no round-trip to anywhere.
|
||||
///
|
||||
/// Binds loopback only in practice; the default `allowed_hosts`
|
||||
/// (`localhost`/`127.0.0.1`/`::1`) rejects Host headers from anywhere else.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the listener cannot bind `addr` or the HTTP server
|
||||
/// exits with a fatal error.
|
||||
pub async fn serve_http(addr: std::net::SocketAddr) -> anyhow::Result<()> {
|
||||
use rmcp::transport::streamable_http_server::{
|
||||
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
|
||||
};
|
||||
let session_manager = std::sync::Arc::new(LocalSessionManager::default());
|
||||
let service = StreamableHttpService::new(
|
||||
|| Ok(BashMcp),
|
||||
session_manager,
|
||||
StreamableHttpServerConfig::default(),
|
||||
);
|
||||
let app = axum::Router::new().nest_service("/mcp", service);
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
tracing::info!(%addr, "serving hive-bash MCP over streamable-http at /mcp");
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod status_hint_tests {
|
||||
use super::{BASH_IDLE_WAIT_HINT, format_task};
|
||||
use crate::protocol::{TaskFile, TaskStatus};
|
||||
|
||||
fn task(status: TaskStatus) -> TaskFile {
|
||||
TaskFile {
|
||||
id: "t1".to_owned(),
|
||||
cmd: "echo hi".to_owned(),
|
||||
timeout_secs: None,
|
||||
status,
|
||||
created_at: 1,
|
||||
started_at: Some(1),
|
||||
completed_at: None,
|
||||
exit_code: None,
|
||||
stdout_tail: None,
|
||||
stderr_tail: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_task_after_wait_appends_idle_hint() {
|
||||
let t = task(TaskStatus::Running);
|
||||
let mut out = format_task(&t);
|
||||
out.push_str(BASH_IDLE_WAIT_HINT);
|
||||
assert!(out.contains(BASH_IDLE_WAIT_HINT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn done_task_formats_without_hint() {
|
||||
let out = format_task(&task(TaskStatus::Done));
|
||||
assert!(!out.contains(BASH_IDLE_WAIT_HINT));
|
||||
assert!(out.contains("status=done"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +1,10 @@
|
|||
//! Per-agent filesystem paths used by both `hive-bash-daemon` and the
|
||||
//! stdio MCP bridge.
|
||||
//! Per-agent filesystem paths used by `hive-bash-daemon`.
|
||||
//!
|
||||
//! All paths are overridable via env vars so the operator can redirect
|
||||
//! them in agent.nix when needed.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Default unix socket path the daemon listens on inside the agent
|
||||
/// container. Lives under systemd's `RuntimeDirectory=hive-bash`
|
||||
/// (a tmpfs path that disappears on container restart — the daemon
|
||||
/// recreates the socket on its own boot) so the agent unix user can
|
||||
/// bind without root in `/run`.
|
||||
pub const DEFAULT_DAEMON_SOCKET: &str = "/run/hive-bash/socket";
|
||||
|
||||
/// Resolve the daemon's unix socket path. Override via `HIVE_BASH_SOCKET`.
|
||||
#[must_use]
|
||||
pub fn daemon_socket() -> PathBuf {
|
||||
std::env::var_os("HIVE_BASH_SOCKET")
|
||||
.map_or_else(|| PathBuf::from(DEFAULT_DAEMON_SOCKET), PathBuf::from)
|
||||
}
|
||||
|
||||
/// Base harness directory. Shared resolution lives in
|
||||
/// `hive_sh4re::paths::harness_dir` so the harness + every MCP daemon
|
||||
/// agree on the layout. Re-exported here as the base for the per-agent
|
||||
|
|
|
|||
|
|
@ -1,102 +1,10 @@
|
|||
//! Wire types for the unix socket protocol between `hive-bash-daemon`
|
||||
//! and `hive-bash-mcp`. One JSON request line in, one JSON response line
|
||||
//! out per connection. Connections are short-lived (per tool call).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task state (shared between runner and protocol)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// `TaskFile` + `TaskStatus` are the bash-task on-disk schema. They live in
|
||||
// `hive-sh4re` (the shared wire-types crate) so the agent web UI in
|
||||
// `hive-agent` can deserialize the same canonical type when reading the
|
||||
// tasks dir for its running-tasks panel — no parallel copy to drift. Both
|
||||
// are re-exported here so existing `crate::protocol::{TaskFile, TaskStatus}`
|
||||
// imports across this crate keep compiling unchanged.
|
||||
//! Bash-task on-disk schema, shared by the runner and the MCP tool
|
||||
//! layer.
|
||||
//!
|
||||
//! `TaskFile` + `TaskStatus` live in `hive-sh4re` (the shared wire-types
|
||||
//! crate) so the agent web UI in `hive-agent` can deserialize the same
|
||||
//! canonical type when reading the tasks dir for its running-tasks
|
||||
//! panel — no parallel copy to drift. Re-exported here so existing
|
||||
//! `crate::protocol::{TaskFile, TaskStatus}` imports across this crate
|
||||
//! keep compiling unchanged.
|
||||
pub use hive_sh4re::{TaskFile, TaskStatus};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request / response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Requests the MCP bridge sends to the daemon.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum DaemonRequest {
|
||||
/// Liveness probe — fast round-trip that doesn't touch any subprocess.
|
||||
Ping,
|
||||
|
||||
/// Submit a new bash task. Returns the task ID on success.
|
||||
/// `wait_seconds`: optional inline poll (capped at 30s); when the
|
||||
/// task finishes within the window the response carries the full
|
||||
/// status payload. When it doesn't, the response carries just the
|
||||
/// task ID so the caller can check back with `BashStatus`.
|
||||
BashRun {
|
||||
cmd: String,
|
||||
#[serde(default)]
|
||||
timeout_secs: Option<u64>,
|
||||
/// Inline wait cap: 30s. Pass `None` or `0` to get the
|
||||
/// task-started-id response immediately.
|
||||
#[serde(default)]
|
||||
wait_seconds: Option<u64>,
|
||||
/// Optional caller-chosen task name, used as the task id (so it
|
||||
/// flows into the wake `from`, status lookups, and the loose-ends
|
||||
/// summary). Must be filesystem-safe. Reusable once any prior task
|
||||
/// of the same name has finished; rejected while one is still
|
||||
/// running. `None` falls back to the auto-generated id.
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
},
|
||||
|
||||
/// Query the current status of a task. Returns the full `TaskFile`
|
||||
/// (formatted as text by the bridge). `wait_seconds`: optional
|
||||
/// inline poll (capped at 30s) — daemon returns as soon as the
|
||||
/// task reaches a terminal state or the window expires.
|
||||
BashStatus {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
wait_seconds: Option<u64>,
|
||||
},
|
||||
|
||||
/// Return all tasks currently in `Pending` or `Running` state.
|
||||
/// 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`
|
||||
/// carries a human-readable message.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum DaemonResponse {
|
||||
Ok { payload: serde_json::Value },
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
impl DaemonResponse {
|
||||
/// Build an Ok response from any serialisable value.
|
||||
pub fn ok<T: Serialize>(payload: &T) -> Self {
|
||||
let payload = serde_json::to_value(payload)
|
||||
.unwrap_or_else(|e| serde_json::json!({ "serialise_error": e.to_string() }));
|
||||
Self::Ok { payload }
|
||||
}
|
||||
|
||||
/// Build an Error response from any `Display` value.
|
||||
pub fn error(msg: impl std::fmt::Display) -> Self {
|
||||
Self::Error {
|
||||
message: msg.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,9 +63,9 @@ struct RunningHandle {
|
|||
}
|
||||
|
||||
/// 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.
|
||||
/// for the duration of execution and read by [`kill_task`] from the MCP
|
||||
/// tool-call handler — 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()))
|
||||
|
|
@ -308,29 +308,6 @@ pub fn submit_task(cmd: String, timeout_secs: Option<u64>, name: Option<String>)
|
|||
Ok(id)
|
||||
}
|
||||
|
||||
/// Return all tasks currently in `Pending` or `Running` state.
|
||||
#[must_use]
|
||||
pub fn active_tasks() -> Vec<TaskFile> {
|
||||
let Ok(rd) = std::fs::read_dir(paths::tasks_dir()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for entry in rd.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
|
||||
continue;
|
||||
};
|
||||
let Some(task) = read_task(&id) else { continue };
|
||||
if matches!(task.status, TaskStatus::Pending | TaskStatus::Running) {
|
||||
out.push(task);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Inline wait: poll `read_task(id)` until terminal state or deadline.
|
||||
/// Returns the final task on success, or `None` if it never completed.
|
||||
///
|
||||
|
|
@ -554,7 +531,7 @@ async fn run_task(mut task: TaskFile, socket: &Path) {
|
|||
let err_path = paths::task_err(&id);
|
||||
|
||||
// Register a kill handle for the duration of execution so `kill_task`
|
||||
// (called from the socket dispatch path) can signal this task.
|
||||
// (called from the MCP tool-call handler) can signal this task.
|
||||
let cancel = Arc::new(Notify::new());
|
||||
let force = Arc::new(AtomicBool::new(false));
|
||||
running().lock().unwrap().insert(
|
||||
|
|
|
|||
|
|
@ -1,135 +0,0 @@
|
|||
//! Unix socket server: the daemon listens here, the stdio MCP bridge
|
||||
//! `connect()`s on every tool call. One JSON request line in, one
|
||||
//! JSON response line out. Connections are short-lived (per tool call)
|
||||
//! so the loop is just accept → dispatch → reply → close.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader};
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
|
||||
use crate::protocol::{DaemonRequest, DaemonResponse, TaskStatus};
|
||||
use crate::runner;
|
||||
|
||||
/// Start listening on `socket_path` and serve forever. Removes any
|
||||
/// stale socket file first so a daemon restart doesn't hit EADDRINUSE.
|
||||
pub async fn serve(socket_path: &Path) -> Result<()> {
|
||||
let _ = tokio::fs::remove_file(socket_path).await;
|
||||
if let Some(parent) = socket_path.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.with_context(|| format!("mkdir {}", parent.display()))?;
|
||||
}
|
||||
let listener = UnixListener::bind(socket_path)
|
||||
.with_context(|| format!("bind unix socket {}", socket_path.display()))?;
|
||||
tracing::info!(path = %socket_path.display(), "bash daemon socket up");
|
||||
loop {
|
||||
let (stream, _) = listener
|
||||
.accept()
|
||||
.await
|
||||
.context("accept on bash daemon socket")?;
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(stream).await {
|
||||
tracing::warn!(error = %e, "bash socket connection error");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_connection(stream: UnixStream) -> Result<()> {
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut lines = BufReader::new(reader).lines();
|
||||
while let Some(line) = lines.next_line().await? {
|
||||
let response = match serde_json::from_str::<DaemonRequest>(&line) {
|
||||
Ok(req) => dispatch(req).await,
|
||||
Err(e) => DaemonResponse::error(format!("parse request: {e}")),
|
||||
};
|
||||
let mut json = serde_json::to_string(&response)?;
|
||||
json.push('\n');
|
||||
writer.write_all(json.as_bytes()).await?;
|
||||
writer.flush().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn dispatch(req: DaemonRequest) -> DaemonResponse {
|
||||
match req {
|
||||
DaemonRequest::Ping => DaemonResponse::ok(&serde_json::json!({"ok": true})),
|
||||
|
||||
DaemonRequest::BashRun {
|
||||
cmd,
|
||||
timeout_secs,
|
||||
wait_seconds,
|
||||
name,
|
||||
} => {
|
||||
let id = match runner::submit_task(cmd, timeout_secs, name) {
|
||||
Ok(id) => id,
|
||||
Err(e) => return DaemonResponse::error(format!("submit_task: {e:#}")),
|
||||
};
|
||||
// Inline wait: if requested and the task finishes quickly,
|
||||
// return the full status instead of just the task ID.
|
||||
let wait = wait_seconds.unwrap_or(0);
|
||||
if wait > 0
|
||||
&& let Some(task) = runner::wait_for_task(&id, wait).await
|
||||
&& matches!(
|
||||
task.status,
|
||||
TaskStatus::Done
|
||||
| TaskStatus::TimedOut
|
||||
| TaskStatus::Interrupted
|
||||
| TaskStatus::Killed
|
||||
)
|
||||
{
|
||||
return DaemonResponse::ok(&serde_json::json!({
|
||||
"id": id,
|
||||
"finished": true,
|
||||
"task": task,
|
||||
}));
|
||||
}
|
||||
DaemonResponse::ok(&serde_json::json!({ "id": id, "finished": false }))
|
||||
}
|
||||
|
||||
DaemonRequest::BashStatus { id, wait_seconds } => {
|
||||
let wait = wait_seconds.unwrap_or(0);
|
||||
let task = if wait > 0 {
|
||||
runner::wait_for_task(&id, wait).await
|
||||
} else {
|
||||
runner::read_task(&id)
|
||||
};
|
||||
match task {
|
||||
Some(t) => DaemonResponse::ok(&t),
|
||||
None => DaemonResponse::error(format!("unknown task id `{id}`")),
|
||||
}
|
||||
}
|
||||
|
||||
DaemonRequest::ActiveTasks => {
|
||||
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