feat(#665): harness-internal async bash task runner (option B)

This commit is contained in:
damocles 2026-06-01 13:07:11 +02:00 committed by mara
commit 1178bb2999
5 changed files with 512 additions and 3 deletions

View file

@ -410,6 +410,60 @@ pub struct RecvArgs {
pub max: Option<u32>,
}
/// MCP tool args for `bash_run`.
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct BashRunArgs {
/// Shell command to run (passed to `sh -c`).
pub cmd: String,
/// Timeout in seconds. Defaults to 180. Task is killed and marked
/// `timed_out` when the limit is exceeded.
#[serde(default)]
pub timeout_secs: Option<u64>,
}
/// MCP tool args for `bash_status`.
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct BashStatusArgs {
/// Task ID returned by `bash_run`.
pub id: String,
}
/// Format the result of `bash_status` from a task ID.
#[must_use]
fn format_bash_status(id: &str) -> String {
use std::fmt::Write as _;
let Some(task) = crate::bash_runner::read_task(id) else {
return format!("bash_status: unknown task id `{id}`");
};
let mut out = format!(
"task `{id}`: status={status:?}",
status = task.status
);
if let Some(code) = task.exit_code {
let _ = write!(out, ", exit={code}");
}
if let Some(t) = task.started_at {
let age = crate::serve_common::now_unix() - t;
let _ = write!(out, ", running for {age}s");
}
if let Some(t) = task.completed_at {
if let Some(s) = task.started_at {
let _ = write!(out, ", took {}s", t - s);
}
}
if let Some(ref stdout) = task.stdout_tail {
if !stdout.trim().is_empty() {
let _ = write!(out, "\n\nstdout:\n```\n{}\n```", stdout.trim());
}
}
if let Some(ref stderr) = task.stderr_tail {
if !stderr.trim().is_empty() {
let _ = write!(out, "\n\nstderr:\n```\n{}\n```", stderr.trim());
}
}
out
}
/// MCP tool args for `remind`. Exactly one of `delay_seconds` or
/// `at_unix_timestamp` must be set; both / neither is a tool-side error.
/// Hides the tagged `ReminderTiming` enum behind a flatter schema so the
@ -732,6 +786,38 @@ impl AgentServer {
.await
}
#[tool(
description = "Run a shell command in the background. Returns a task ID immediately — \
do NOT wait inline. When the command finishes, the harness fires a wake with \
`from: \"bash-task-<id>\"` and the exit code + last stdout lines in the body; \
handle it on a future turn. Use `bash_status` to poll the task status within \
the same turn if needed. `timeout_secs` defaults to 180."
)]
async fn bash_run(&self, Parameters(args): Parameters<BashRunArgs>) -> String {
let log = format!("{args:?}");
run_tool_envelope("bash_run", log, async move {
match crate::bash_runner::submit_task(args.cmd, args.timeout_secs) {
Ok(id) => format!("task started: id={id}"),
Err(e) => format!("bash_run failed: {e:#}"),
}
})
.await
}
#[tool(
description = "Check the status of a background bash task by its ID (from `bash_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 \
`harness/bash-tasks/<id>.out` / `.err`."
)]
async fn bash_status(&self, Parameters(args): Parameters<BashStatusArgs>) -> String {
let log = format!("{args:?}");
run_tool_envelope("bash_status", log, async move {
format_bash_status(&args.id)
})
.await
}
#[tool(
description = "Ask the harness to start another turn immediately after this one \
completes, even if the inbox is empty. Use this when you have ongoing work that \
@ -1740,8 +1826,8 @@ const TOOL_GROUPS_ENV: &str = "HIVE_TOOL_GROUPS";
/// Reads `HIVE_TOOL_GROUPS` from the environment first. Each comma-separated
/// token is matched (case-insensitive) against the `ToolGroup` serde names
/// (`messaging`, `meta`, `inbox`, `lifecycle`, `approvals`, `scheduling`,
/// `diagnostics`). Unrecognised tokens are logged and skipped. Falls back to
/// the flavor default when the env var is absent or empty.
/// `diagnostics`, `execution`). Unrecognised tokens are logged and skipped.
/// Falls back to the flavor default when the env var is absent or empty.
fn effective_tool_groups(flavor: Flavor) -> Vec<hive_sh4re::ToolGroup> {
let raw = match std::env::var(TOOL_GROUPS_ENV) {
Ok(v) if !v.trim().is_empty() => v,