diff --git a/hive-bash-mcp/src/bin/mcp.rs b/hive-bash-mcp/src/bin/mcp.rs index e69b44d8..26a75e1a 100644 --- a/hive-bash-mcp/src/bin/mcp.rs +++ b/hive-bash-mcp/src/bin/mcp.rs @@ -138,8 +138,9 @@ fn render_bash_status(id: &str, resp: Result) -> String { struct BashRunArgs { /// Shell command to run (passed to `sh -c`). cmd: String, - /// Timeout in seconds. Defaults to 180. Task is killed and marked - /// `timed_out` when the limit is exceeded. + /// 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, /// Optional inline wait: `run` polls for up to `wait_seconds` @@ -177,12 +178,14 @@ impl BashMcp { do NOT wait inline. When the command finishes, the harness fires a wake with \ `from: \"bash-task-\"` and the exit code + last stdout lines in the body; \ handle it on a future turn. Use `status` to poll the task status within \ - the same turn if needed. `timeout_secs` defaults to 180. Pass `wait_seconds` \ - (capped at 30) to wait inline for fast commands: when the task finishes within \ - the window the full status is returned immediately and no wake is fired; when \ - the timeout expires the task keeps running and the normal `task started: id=` \ - response is returned. `wait_seconds` defaults to 3; pass `wait_seconds: 0` to \ - disable inline waiting and always get the immediate response." + the same turn if needed. `timeout_secs` defaults to `None` (no timeout) — \ + task runs until natural exit; pass an explicit value to kill after N seconds. \ + Pass `wait_seconds` (capped at 30) to wait inline for fast commands: when the \ + task finishes within the window the full status is returned immediately and no \ + wake is fired; when the timeout expires the task keeps running and the normal \ + `task started: id=` response is returned. `wait_seconds` defaults to 3; \ + pass `wait_seconds: 0` to disable inline waiting and always get the immediate \ + response." )] async fn run(&self, Parameters(args): Parameters) -> String { let req = DaemonRequest::BashRun { diff --git a/hive-bash-mcp/src/protocol.rs b/hive-bash-mcp/src/protocol.rs index 91e74f57..cb239b55 100644 --- a/hive-bash-mcp/src/protocol.rs +++ b/hive-bash-mcp/src/protocol.rs @@ -25,7 +25,11 @@ pub enum TaskStatus { pub struct TaskFile { pub id: String, pub cmd: String, - pub timeout_secs: u64, + /// Kill timeout in seconds. `None` means no timeout — task runs until + /// natural exit. Old task files with a numeric value are still readable + /// (serde coerces `u64` → `Some(u64)` is handled by the caller). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_secs: Option, pub status: TaskStatus, pub created_at: i64, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/hive-bash-mcp/src/runner.rs b/hive-bash-mcp/src/runner.rs index f6e901eb..97bd00cb 100644 --- a/hive-bash-mcp/src/runner.rs +++ b/hive-bash-mcp/src/runner.rs @@ -33,9 +33,6 @@ const POLL_INTERVAL: Duration = Duration::from_millis(200); /// Full output always lives in the `.out`/`.err` files. pub const SUMMARY_BYTES: usize = 4096; -/// Default task timeout. -pub const DEFAULT_TIMEOUT_SECS: u64 = 180; - /// Maximum inline wait (cap on `wait_seconds`). pub const MAX_WAIT_SECS: u64 = 30; @@ -136,7 +133,7 @@ pub fn submit_task(cmd: String, timeout_secs: Option) -> Result { let task = TaskFile { id: id.clone(), cmd, - timeout_secs: timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS), + timeout_secs, status: TaskStatus::Pending, created_at: now_unix(), started_at: None, @@ -308,9 +305,8 @@ async fn run_task(mut task: TaskFile, socket: &Path) { let out_path = paths::task_out(&id); let err_path = paths::task_err(&id); - let timeout = Duration::from_secs(task.timeout_secs); - let (timed_out, exit_code) = match exec_cmd(&task.cmd, &out_path, &err_path, timeout).await { + 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"); @@ -341,7 +337,8 @@ async fn run_task(mut task: TaskFile, socket: &Path) { refresh_loose_ends(); let summary = if timed_out { - format!("timed out after {}s", task.timeout_secs) + let secs = task.timeout_secs.unwrap_or(0); + format!("timed out after {secs}s") } else { format!("exit={}", exit_code.unwrap_or(-1)) }; @@ -351,11 +348,12 @@ async fn run_task(mut task: TaskFile, socket: &Path) { } /// Run `sh -c cmd`, streaming output to files. Returns `(exit_code, timed_out)`. +/// `timeout_secs = None` means no timeout — run until natural exit. async fn exec_cmd( cmd: &str, out_path: &Path, err_path: &Path, - timeout: Duration, + timeout_secs: Option, ) -> Result<(i32, bool)> { use tokio::process::Command; // SAFETY: `nice` is async-signal-safe and modifies only the calling @@ -389,19 +387,31 @@ async fn exec_cmd( err_path, )); - match tokio::time::timeout(timeout, child.wait()).await { - Ok(Ok(status)) => { - let _ = copy_out.await; - let _ = copy_err.await; - Ok((status.code().unwrap_or(-1), false)) + 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)) + } + 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)) + } } - 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)) + } 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()), } } }