Compare commits

..
17 changed files with 281 additions and 271 deletions

2
Cargo.lock generated
View file

@ -1591,7 +1591,7 @@ name = "hive-bash-mcp"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"hive-agent-sock", "hive-core-agent-sock",
"hive-sh4re", "hive-sh4re",
"libc", "libc",
"rmcp", "rmcp",

View file

@ -233,12 +233,13 @@ Under `/var/lib/hyperhive/agents/<name>/`:
(full captured output). `hive-c0re::bash_tasks_vacuum` runs (full captured output). `hive-c0re::bash_tasks_vacuum` runs
hourly and deletes terminal task trios older than 48 hours; hourly and deletes terminal task trios older than 48 hours;
non-terminal (still-running) tasks are never deleted by vacuum. non-terminal (still-running) tasks are never deleted by vacuum.
- `hyperhive-todos.sqlite` — loose-ends-v2 todo store. In-container - `mcp-loose-ends/` — JSON files published by external MCP daemons
MCP daemons (`hive-bash-mcp`, `hive-matrix-mcp`) and `forge_notify` (e.g. `hive-bash-mcp`, `hive-matrix-mcp`) listing their active
upsert keyed todos here over the harness's in-agent socket loose-end summary strings. Each file is `<daemon>.json` containing
(`HIVE_AGENT_SOCKET`); the harness merges them into `get_loose_ends` a JSON array of plain-text lines. Read by `get_loose_ends` to
output and clears a row on `mark_todo_done`. Replaced the old surface active background work without hardcoding per-MCP
file-based `mcp-loose-ends/` scanner. knowledge in the harness. Files are created/removed by the
external daemons themselves.
- `hyperhive-events.sqlite` — turn-loop event log. - `hyperhive-events.sqlite` — turn-loop event log.
- `hyperhive-turn-stats.sqlite` — per-turn timing stats. - `hyperhive-turn-stats.sqlite` — per-turn timing stats.
- `hyperhive-model` — single-line model name override file. - `hyperhive-model` — single-line model name override file.

View file

@ -13,24 +13,24 @@ invocation regardless of tool groups.
Submit a shell command for background execution (runs via `bash`). Submit a shell command for background execution (runs via `bash`).
Stdout and stderr stream to `harness/bash-tasks/<id>.{out,err}`. Stdout and stderr stream to `harness/bash-tasks/<id>.{out,err}`.
When the task completes (or times out, or the process errors), it When the task completes (or times out, or the process errors), the
surfaces as a todo in the agent's loose-ends (via `get_loose_ends`), harness fires a wake with `from: "bash-task-<id>"`; the body contains
carrying the exit code and a `Read(<path>)` pointer to the captured the exit code and last stdout lines. Handle the completion on a future
output. Handle it on a future turn — unless `wait_seconds` already turn — unless `wait_seconds` already delivered the terminal result
delivered the terminal result inline, in which case no todo is created inline, in which case the wake is suppressed (see `status` below).
(see `status` below).
* `timeout_secs` — kill the task after N seconds and mark it * `timeout_secs` — kill the task after N seconds and mark it
`timed_out`. Omit for no timeout (runs until natural exit). `timed_out`. Omit for no timeout (runs until natural exit).
* `wait_seconds` — inline poll before returning (capped at 30). * `wait_seconds` — inline poll before returning (capped at 30).
When the task finishes within the window the full status is When the task finishes within the window the full status is
returned immediately and no todo is created; when the window expires returned immediately and no wake is fired; when the window expires
the task keeps running and the normal `task started: id=<id>` the task keeps running and the normal `task started: id=<id>`
response is returned. **Defaults to 3** — pass `wait_seconds: 0` response is returned. **Defaults to 3** — pass `wait_seconds: 0`
to disable inline waiting and always get the immediate response. to disable inline waiting and always get the immediate response.
* `name` — optional caller-chosen task id. When set it replaces the * `name` — optional caller-chosen task id. When set it replaces the
auto-generated hex id, so it surfaces in `status(<name>)` lookups and auto-generated hex id, so it surfaces in the wake `from`
the loose-ends list — a memorable label instead of an opaque id. A name (`bash-task-<name>`), in `status(<name>)` lookups, and in the
loose-ends list — a memorable label instead of an opaque id. A name
is **reusable once its previous task has finished**; submitting a is **reusable once its previous task has finished**; submitting a
name whose task is still `pending`/`running` is rejected. Allowed name whose task is still `pending`/`running` is rejected. Allowed
characters: ASCII letters, digits, `.`, `_`, `-` (max 64). Omit for characters: ASCII letters, digits, `.`, `_`, `-` (max 64). Omit for
@ -52,14 +52,15 @@ finishes within the window the full status is returned immediately.
Useful to avoid a separate round-trip when the task is expected to Useful to avoid a separate round-trip when the task is expected to
finish soon. finish soon.
Any `status` call (waited or not) that observes a terminal task clears Any `status` call (waited or not) that observes a terminal task
that task's completion todo — you already have the result in this suppresses that task's completion wake — you already have the result
response, so no redundant loose-end follows. Narrow best-effort race: a in this response, so no redundant `bash-task-<id>` inbox message
`status`/`run` inline wait that resolves in the same instant the task follows (#2270). Narrow best-effort race: a `status`/`run` inline wait
actually finishes can still occasionally get both. that resolves in the same instant the task actually finishes can still
occasionally get both.
Tasks marked `interrupted` had their process killed by a harness Tasks marked `interrupted` had their process killed by a harness
restart; a best-effort todo is still surfaced so the agent is not restart; a best-effort wake was still sent so the agent is not
silently blocked. silently blocked.
Exposed as `mcp__bash__status`. Exposed as `mcp__bash__status`.
@ -67,8 +68,8 @@ Exposed as `mcp__bash__status`.
### `kill(id, force?)` ### `kill(id, force?)`
Stop a running or pending task by its ID (from `run`). Fire-and-forget: Stop a running or pending task by its ID (from `run`). Fire-and-forget:
sends the signal and returns without waiting — the completion surfaces sends the signal and returns without waiting — handle the completion
in the agent's loose-ends; handle it on a future turn. wake (`from: "bash-task-<id>"`) on a future turn.
- `force: false` (default) — SIGINT to the task's **process group** - `force: false` (default) — SIGINT to the task's **process group**
(graceful; lets the process clean up). The whole process group is (graceful; lets the process clean up). The whole process group is
@ -78,7 +79,7 @@ in the agent's loose-ends; handle it on a future turn.
If a SIGINT'd task doesn't exit, call `kill` again with `force: true`. If a SIGINT'd task doesn't exit, call `kill` again with `force: true`.
A still-pending task is cancelled before it starts. The task ends as A still-pending task is cancelled before it starts. The task ends as
`killed` and surfaces in the loose-ends like any completion. `killed` and fires the usual completion wake.
Exposed as `mcp__bash__kill`. Exposed as `mcp__bash__kill`.
@ -96,7 +97,7 @@ matrix MCP:
- **`hive-bash-daemon`** — long-running process (one per agent container, - **`hive-bash-daemon`** — long-running process (one per agent container,
systemd service in `nix/agent-modules/mcp.nix`). Owns subprocess management, systemd service in `nix/agent-modules/mcp.nix`). Owns subprocess management,
output file writing, and todo delivery on the harness's in-agent socket. output file writing, `mcp-loose-ends/` state, and wake signal delivery.
Listens on `/run/hive-bash/socket` inside the container. Listens on `/run/hive-bash/socket` inside the container.
- **`hive-bash-mcp`** — stdio bridge spawned by claude per turn (declared - **`hive-bash-mcp`** — stdio bridge spawned by claude per turn (declared
@ -107,17 +108,13 @@ matrix MCP:
This split keeps claude's turn-local MCP bridge lightweight while the This split keeps claude's turn-local MCP bridge lightweight while the
daemon tracks long-running tasks that outlive a single turn. daemon tracks long-running tasks that outlive a single turn.
### Completion as a todo (loose-ends v2) ### Transient wake (bypass broker sqlite)
When a bash task changes state, `hive-bash-daemon` upserts a single keyed When a bash task finishes, `hive-bash-daemon` sends the wake signal via
todo (`key = task id`) on the harness's in-agent socket (`HIVE_AGENT_SOCKET`) the agent's per-agent socket as a **transient wake** (`AgentRequest::Wake`
— "running" at start, then the completion summary when it finishes. The with `transient: true`). This bypasses broker sqlite for lower latency —
summary change signals the harness turn loop directly (in-process, no broker the same mechanism used by matrix events. The message is delivered
round-trip), so the agent is driven a turn to handle it via `get_loose_ends`, directly to the harness without touching the persistent message store.
then clears the todo with `mark_todo_done`. Same mechanism the matrix daemon
uses for unread rooms. An inline `wait_seconds` / `status` observation that
already delivered the result instead clears the keyed todo, so no redundant
loose-end follows.
## Relationship to the `execution` tool group ## Relationship to the `execution` tool group

View file

@ -90,16 +90,17 @@ returned by `get_loose_ends` so unread rooms surface in the
loose-ends list between turns. loose-ends list between turns.
**Invite wakes**: when the daemon's sync loop receives an **Invite wakes**: when the daemon's sync loop receives an
`m.room.member` invite event, it upserts a todo (keyed `invite:<room>`) `m.room.member` invite event, it writes the invite to
on the harness's in-agent socket, which drives a turn. The daemon does `mcp-loose-ends/matrix.json` and fires a hyperhive wake. The daemon
**not** auto-join — the agent calls `list_invites` to see pending does **not** auto-join — the agent calls `list_invites` to see pending
invites and `resolve_invite` to accept or reject them. invites and `resolve_invite` to accept or reject them.
**Pending invites as loose ends**: pending invites are upserted as **Pending invites as loose ends**: pending invites are written to
keyed todos and appear in `get_loose_ends` output as `mcp-loose-ends/matrix.json` and appear in `get_loose_ends` output as
`[matrix] pending invite: <room> — use list_invites to see, `[matrix] pending invite: <room> — use list_invites to see,
resolve_invite to accept or reject`. The keyed todo is cleared when a resolve_invite to accept or reject`. The file is updated atomically
`resolve_invite` (or `join_room`) call resolves the invite. after each invite event and after each `resolve_invite` (or
`join_room`) call clears the entry.
See [`docs/matrix.md`](../matrix.md) for the homeserver setup, See [`docs/matrix.md`](../matrix.md) for the homeserver setup,
provisioning flow, and federation config. provisioning flow, and federation config.

View file

@ -0,0 +1,42 @@
//! Generic scanner for MCP loose-end summary files.
//!
//! External MCP daemons (hive-bash-mcp, hive-matrix-mcp, etc.) write
//! JSON files to `$HYPERHIVE_HARNESS_DIR/mcp-loose-ends/<name>.json`.
//! Each file contains a JSON array of plain-text summary strings.
//!
//! The harness reads all files in this directory in `get_loose_ends` to
//! surface active background work from any MCP without hardcoding
//! per-MCP knowledge here.
use std::path::PathBuf;
/// Resolution lives in `hive_sh4re::paths` so the harness + every MCP
/// daemon agree on where loose-end summary files are written.
fn loose_ends_dir() -> PathBuf {
hive_sh4re::paths::mcp_loose_ends_dir()
}
/// Collect all loose-end summary strings published by external MCP daemons.
/// Each string is a single line suitable for inclusion in `get_loose_ends`
/// output. Returns an empty vec if the directory doesn't exist or is empty.
#[must_use]
pub fn collect() -> Vec<String> {
let Ok(rd) = std::fs::read_dir(loose_ends_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 Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(items) = serde_json::from_str::<Vec<String>>(&content) else {
continue;
};
out.extend(items);
}
out
}

View file

@ -18,6 +18,7 @@ use anyhow::Result;
use clap::Parser; use clap::Parser;
mod client; mod client;
mod loose_ends;
mod mcp; mod mcp;
mod paths; mod paths;
mod send_allow; mod send_allow;

View file

@ -339,7 +339,20 @@ impl AgentServer {
if is_self_query && let Some(todos) = local_todos().await { if is_self_query && let Some(todos) = local_todos().await {
loose_ends.extend(todos); loose_ends.extend(todos);
} }
annotate_retries(render_loose_ends(&loose_ends), retries) let mut out = annotate_retries(render_loose_ends(&loose_ends), retries);
// Append loose-end items published by external MCP daemons
// (e.g. active bash tasks from hive-bash-mcp). Generic — no
// per-MCP knowledge needed here.
let mcp_items = crate::loose_ends::collect();
if !mcp_items.is_empty() {
use std::fmt::Write as _;
let n = mcp_items.len();
let _ = write!(out, "\n\n{n} local task(s):");
for item in &mcp_items {
let _ = write!(out, "\n- {item}");
}
}
out
}) })
.await .await
} }

View file

@ -8,7 +8,7 @@ Tools (hyperhive surface):
- (some agents only) **extra MCP tools** surfaced as `mcp__<server>__<tool>` — these are agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. Treat them as first-class tools alongside the hyperhive surface; the operator already auto-approved them at deploy time. - (some agents only) **extra MCP tools** surfaced as `mcp__<server>__<tool>` — these are agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. Treat them as first-class tools alongside the hyperhive surface; the operator already auto-approved them at deploy time.
- `mcp__hyperhive__ask(question, options?, multi?, ttl_seconds?, to?)` — surface a structured question to the human operator (default, or `to: "operator"`) OR a peer agent (`to: "<agent-name>"`). Returns immediately with a question id — do NOT wait inline. When the recipient answers, a system message with event `question_answered { id, question, answer, answerer }` lands in your inbox; handle it on a future turn. Use this for clarifications, permission for risky actions, choice between options, or peer Q&A without burning regular inbox slots. `options` is advisory: a short fixed-choice list when applicable, otherwise leave empty for free text. `multi: true` lets the answerer pick multiple (checkboxes), answer comes back comma-joined. `ttl_seconds` auto-cancels with answer `[expired]` (and `answerer: "ttl-watchdog"`) when the decision becomes moot. - `mcp__hyperhive__ask(question, options?, multi?, ttl_seconds?, to?)` — surface a structured question to the human operator (default, or `to: "operator"`) OR a peer agent (`to: "<agent-name>"`). Returns immediately with a question id — do NOT wait inline. When the recipient answers, a system message with event `question_answered { id, question, answer, answerer }` lands in your inbox; handle it on a future turn. Use this for clarifications, permission for risky actions, choice between options, or peer Q&A without burning regular inbox slots. `options` is advisory: a short fixed-choice list when applicable, otherwise leave empty for free text. `multi: true` lets the answerer pick multiple (checkboxes), answer comes back comma-joined. `ttl_seconds` auto-cancels with answer `[expired]` (and `answerer: "ttl-watchdog"`) when the decision becomes moot.
- `mcp__hyperhive__answer(id, answer)` — answer a question that was routed to YOU. You'll see one in your inbox as a `question_asked { id, asker, question, options, multi }` system event when a peer or the operator calls `ask(to: "<your-name>", ...)`. The answer surfaces in the asker's inbox as a `question_answered` event. Strict authorisation: you can only answer questions where you are the declared target. - `mcp__hyperhive__answer(id, answer)` — answer a question that was routed to YOU. You'll see one in your inbox as a `question_asked { id, asker, question, options, multi }` system event when a peer or the operator calls `ask(to: "<your-name>", ...)`. The answer surfaces in the asker's inbox as a `question_answered` event. Strict authorisation: you can only answer questions where you are the declared target.
- `mcp__hyperhive__get_loose_ends(agent?)` — list your loose ends: unanswered questions where you're asker (waiting on someone) or target (owing a reply), reminders you've scheduled that haven't fired, plus in-container todos surfaced by your MCP daemons (finished/running background bash tasks, unread matrix rooms, forge threads). No args to list your own threads — cheap server-side sweep useful at turn start. Pass `agent: "<name>"` to inspect a peer agent's threads. Direct child agents are always accessible. For non-children, the `query_agent_state` capability is required — without it the request is rejected with an error. - `mcp__hyperhive__get_loose_ends(agent?)` — list your loose ends: unanswered questions where you're asker (waiting on someone) or target (owing a reply), plus reminders you've scheduled that haven't fired. No args to list your own threads — cheap server-side sweep useful at turn start. Pass `agent: "<name>"` to inspect a peer agent's threads. Direct child agents are always accessible. For non-children, the `query_agent_state` capability is required — without it the request is rejected with an error.
- `mcp__hyperhive__cancel_loose_end(kind, id)` — cancel one of your own open threads. `kind` is `"question"` (the asker — you, in this case — gets a `[cancelled by <you>]` answer so the waiter unblocks), `"reminder"` (hard-deleted before it fires), or `"approval"` (withdraws a pending approval you submitted that got superseded — root agent only; the server rejects this kind for all other callers). `id` from the matching `get_loose_ends` row or the original submission reply. - `mcp__hyperhive__cancel_loose_end(kind, id)` — cancel one of your own open threads. `kind` is `"question"` (the asker — you, in this case — gets a `[cancelled by <you>]` answer so the waiter unblocks), `"reminder"` (hard-deleted before it fires), or `"approval"` (withdraws a pending approval you submitted that got superseded — root agent only; the server rejects this kind for all other callers). `id` from the matching `get_loose_ends` row or the original submission reply.
- `mcp__hyperhive__remind(message, delay_seconds? | at_unix_timestamp?, file_path?)` — schedule a message to land in your _own_ inbox at a future time (sender shows as `reminder`). Set exactly one of `delay_seconds` (relative) or `at_unix_timestamp` (absolute). Use for self-paced follow-ups instead of blocking a whole turn on a long `recv` wait. A large `message` auto-spills to a file under `/agents/{label}/state/reminders/`; pass `file_path` to point at one yourself. Each agent's pending-reminder count is capped (default 50) — the tool will error if the cap is already reached. - `mcp__hyperhive__remind(message, delay_seconds? | at_unix_timestamp?, file_path?)` — schedule a message to land in your _own_ inbox at a future time (sender shows as `reminder`). Set exactly one of `delay_seconds` (relative) or `at_unix_timestamp` (absolute). Use for self-paced follow-ups instead of blocking a whole turn on a long `recv` wait. A large `message` auto-spills to a file under `/agents/{label}/state/reminders/`; pass `file_path` to point at one yourself. Each agent's pending-reminder count is capped (default 50) — the tool will error if the cap is already reached.
- `mcp__hyperhive__set_status(text)` — set a free-text status visible on the operator dashboard. **Call this at the start of every task** to say what you're working on (e.g. `"processing matrix messages"`, `"fixing #319 model priority"`, `"idle"`). Single line, ≤200 chars — the dashboard renders this as a short chip, so longer multi-line text is rejected. Pass an empty string to clear. Persists across harness restarts. - `mcp__hyperhive__set_status(text)` — set a free-text status visible on the operator dashboard. **Call this at the start of every task** to say what you're working on (e.g. `"processing matrix messages"`, `"fixing #319 model priority"`, `"idle"`). Single line, ≤200 chars — the dashboard renders this as a short chip, so longer multi-line text is rejected. Pass an empty string to clear. Persists across harness restarts.

View file

@ -64,8 +64,6 @@ const NEW_ITEM_TOLERANCE_SECS: i64 = 120;
/// configured. Otherwise loops forever, polling every /// configured. Otherwise loops forever, polling every
/// `POLL_INTERVAL_SECS` seconds. Errors are never fatal. /// `POLL_INTERVAL_SECS` seconds. Errors are never fatal.
/// ///
/// `socket` is the harness's in-agent todo socket (`HIVE_AGENT_SOCKET`):
/// each forge notification is pushed as an `upsert_todo`, not a direct wake.
pub async fn run(socket: PathBuf) { pub async fn run(socket: PathBuf) {
let forge_url = match std::env::var("HIVE_FORGE_URL") { let forge_url = match std::env::var("HIVE_FORGE_URL") {
Ok(u) if !u.is_empty() => u, Ok(u) if !u.is_empty() => u,
@ -1066,40 +1064,33 @@ async fn poll_once(
continue; continue;
}; };
// Upsert a *todo* (loose-ends v2) on the harness's in-agent socket, let req = hive_core_agent_sock::Request::Wake {
// keyed by the forge thread id, instead of firing a direct wake. A from: "forge".to_owned(),
// new/changed summary makes the harness signal its turn loop; the body,
// agent clears the todo (`mark_todo_done`) once it has handled the
// thread. Re-scanning the same thread is an idempotent no-op.
let req = hive_agent_sock::Request::UpsertTodo {
subsystem: "forge".to_owned(),
key: Some(id.to_string()),
summary: body,
source: None,
}; };
let deliver_result = crate::client::request::<_, hive_agent_sock::Response>(socket, &req) let deliver_result =
.await crate::client::request::<_, hive_core_agent_sock::Response>(socket, &req)
.map(|_| ()); .await
.map(|_| ());
match deliver_result { match deliver_result {
Ok(()) => { Ok(()) => {
debug!(%id, "forge_notify: todo upserted"); debug!(%id, "forge_notify: delivered");
// Mark the thread read on forge immediately after upserting // Mark the thread read on forge immediately after a
// the todo. The todo is the durable work item now (it stays // successful broker delivery. The broker inbox is the
// in `get_loose_ends` until the agent marks it done), so the // durable work queue now (each row has its own ack
// forge unread flag no longer needs to track agent // lifecycle), so the forge unread flag no longer needs to
// processing — clearing it keeps forge's unread set tiny by // track agent processing — clearing it on delivery keeps
// construction, so a container rebuild re-scan finds nothing // forge's unread set tiny by construction, so a container
// stale (and idempotent re-upserts wouldn't re-wake anyway). // rebuild re-scan finds nothing stale to re-deliver. The
// The in-memory `delivered` entry below is only a // in-memory `delivered` entry below is only a within-process
// within-process guard so a transient mark-read failure // guard so a transient mark-read failure doesn't re-fire the
// doesn't re-upsert next tick; it is deliberately NOT // wake next tick; it is deliberately NOT persisted — forge's
// persisted — forge's own read-state is the cross-rebuild // own read-state is the cross-rebuild source of truth.
// source of truth.
mark_read(forge, id).await; mark_read(forge, id).await;
delivered.insert(id, updated_at); delivered.insert(id, updated_at);
} }
Err(e) => { Err(e) => {
warn!(%id, error = ?e, "forge_notify: todo upsert failed — leaving unread"); warn!(%id, error = ?e, "forge_notify: deliver failed — leaving unread");
} }
} }
} }

View file

@ -421,15 +421,7 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
for failure in plugins::install_configured().await { for failure in plugins::install_configured().await {
S::send_to_parent(socket, failure).await; S::send_to_parent(socket, failure).await;
} }
// forge_notify pushes forge notifications as todos on the in-agent tokio::spawn(crate::forge_notify::run(socket.to_path_buf()));
// socket (loose-ends v2), not direct wakes — so it dials
// `HIVE_AGENT_SOCKET`, not the host mcp.sock.
tokio::spawn(crate::forge_notify::run(
std::env::var_os("HIVE_AGENT_SOCKET").map_or_else(
|| std::path::PathBuf::from(hive_agent_sock::DEFAULT_AGENT_SOCKET),
std::path::PathBuf::from,
),
));
// Agent-side cleanup of this agent's own harness artifacts (completed // Agent-side cleanup of this agent's own harness artifacts (completed
// bash-task files + verbose event rows). Runs here, not host-side in // bash-task files + verbose event rows). Runs here, not host-side in
// hive-c0re, because the files are agent-owned — see `vacuum` module docs. // hive-c0re, because the files are agent-owned — see `vacuum` module docs.

View file

@ -8,7 +8,7 @@ workspace = true
[dependencies] [dependencies]
anyhow.workspace = true anyhow.workspace = true
hive-agent-sock.workspace = true hive-core-agent-sock.workspace = true
hive-sh4re.workspace = true hive-sh4re.workspace = true
libc.workspace = true libc.workspace = true
rmcp.workspace = true rmcp.workspace = true

View file

@ -125,7 +125,7 @@ fn render_bash_run(id: &str, resp: Result<DaemonResponse>) -> String {
/// immediately re-waiting on the same task. /// 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 \ 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 \ 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."; a wake fires when it completes) rather than immediately re-waiting.";
/// Turn a `DaemonResponse` from a `BashStatus` call into the string /// Turn a `DaemonResponse` from a `BashStatus` call into the string
/// claude sees as the tool result. When `waited` is set (the call /// claude sees as the tool result. When `waited` is set (the call
@ -154,7 +154,7 @@ fn render_bash_kill(resp: Result<DaemonResponse>) -> String {
let sig = payload["signal"].as_str().unwrap_or("SIGINT"); let sig = payload["signal"].as_str().unwrap_or("SIGINT");
format!( format!(
"task `{id}`: {sig} sent to its process group; it transitions to `killed` \ "task `{id}`: {sig} sent to its process group; it transitions to `killed` \
once the process exits and then surfaces in your loose-ends." once the process exits and the usual completion wake fires."
) )
} else { } else {
format!("task `{id}` was pending — cancelled before it started.") format!("task `{id}` was pending — cancelled before it started.")
@ -180,16 +180,17 @@ struct BashRunArgs {
timeout_secs: Option<u64>, timeout_secs: Option<u64>,
/// Optional inline wait: `run` polls for up to `wait_seconds` /// Optional inline wait: `run` polls for up to `wait_seconds`
/// (capped at 30) before returning. When the task finishes within the /// (capped at 30) before returning. When the task finishes within the
/// window the full status is returned immediately (nothing to handle /// window the full status is returned immediately and no wake is fired;
/// later); when the timeout expires the task keeps running and the /// when the timeout expires the task keeps running and the normal
/// normal `task started: id=<id>` response is returned. Defaults to 3s. /// `task started: id=<id>` response is returned. Defaults to 3s. Pass
/// Pass `0` to disable inline waiting and always get the immediate response. /// `0` to disable inline waiting and always get the immediate response.
#[serde(default = "default_wait")] #[serde(default = "default_wait")]
wait_seconds: Option<u64>, wait_seconds: Option<u64>,
/// Optional task name. When set it becomes the task id, so it appears /// Optional task name. When set it becomes the task id, so it appears
/// in `status` lookups and your loose-ends list — handy for recognising /// in the completion wake (`from: "bash-task-<name>"`), in `status`
/// a task later instead of an opaque hex id. A name can be reused once /// lookups, and in the loose-ends list — handy for recognising a task
/// its previous task has finished; reusing a name whose task is still /// 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: ASCII letters, digits, `.`, /// running is rejected. Allowed chars: ASCII letters, digits, `.`,
/// `_`, `-` (max 64). Omit to get the auto-generated id. /// `_`, `-` (max 64). Omit to get the auto-generated id.
#[serde(default)] #[serde(default)]
@ -236,18 +237,21 @@ struct BashMcp;
impl BashMcp { impl BashMcp {
#[tool( #[tool(
description = "Run a shell command in the background. Returns a task ID immediately — \ 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 \ do NOT wait inline. When the command finishes, the harness fires a wake with \
loose-ends (the exit status plus a `Read(<path>)` pointer to the captured \ `from: \"bash-task-<id>\"` carrying the exit status plus a `Read(<path>)` \
`.out`/`.err` files read only what you need); handle it on a future turn. Use \ pointer to the captured `.out`/`.err` files (not the output text itself \
`status` to poll within the same turn if needed. `timeout_secs` defaults to \ read only what you need); handle it on a future turn. Use `status` to poll the task status within \
`None` (no timeout) task runs until natural exit; pass a value to kill after \ the same turn if needed. `timeout_secs` defaults to `None` (no timeout) \
N seconds. Pass `wait_seconds` (capped at 30) to wait inline for fast commands: \ task runs until natural exit; pass an explicit value to kill after N seconds. \
if the task finishes within the window you get the full status immediately \ Pass `wait_seconds` (capped at 30) to wait inline for fast commands: when the \
(nothing to handle later); otherwise it keeps running and you get \ task finishes within the window the full status is returned immediately and no \
`task started: id=<id>`. Defaults to 3; pass `0` to disable inline waiting. \ wake is fired; when the timeout expires the task keeps running and the normal \
Pass `name` to label the task with a memorable id (shown in `status` lookups \ `task started: id=<id>` response is returned. `wait_seconds` defaults to 3; \
and your loose-ends) instead of an opaque hex id; reusable once the prior task \ pass `wait_seconds: 0` to disable inline waiting and always get the immediate \
has finished, rejected while one is still running." response. Pass `name` to label the task with a memorable id (used in the wake \
`from`, `status` lookups, and the loose-ends list) instead of an opaque hex id; \
a name is reusable once its prior task has finished, and rejected while one is \
still running."
)] )]
async fn run(&self, Parameters(args): Parameters<BashRunArgs>) -> String { async fn run(&self, Parameters(args): Parameters<BashRunArgs>) -> String {
let req = DaemonRequest::BashRun { let req = DaemonRequest::BashRun {
@ -293,8 +297,8 @@ impl BashMcp {
signalled, so a runaway child (cargo/nix/etc.) is stopped too, not just the shell. \ 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 \ 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 \ 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 \ is cancelled before it starts. The task ends as `killed` and fires the usual \
loose-ends like any completion." completion wake."
)] )]
async fn kill(&self, Parameters(args): Parameters<BashKillArgs>) -> String { async fn kill(&self, Parameters(args): Parameters<BashKillArgs>) -> String {
let req = DaemonRequest::BashKill { let req = DaemonRequest::BashKill {

View file

@ -1,8 +1,7 @@
//! `hive-bash-daemon` binary — long-running per-agent bash task runner. //! `hive-bash-daemon` binary — long-running per-agent bash task runner.
//! Spawns `sh -c` subprocesses, monitors completion, writes task state //! Spawns `sh -c` subprocesses, monitors completion, writes task state
//! files, and surfaces task state to the agent as todos on the harness's //! files, and fires hyperhive wake signals. Listens on a unix socket
//! in-agent socket. Listens on a unix socket for tool-call requests from //! for tool-call requests from the `hive-bash-mcp` stdio bridge.
//! the `hive-bash-mcp` stdio bridge.
use anyhow::Result; use anyhow::Result;
@ -16,17 +15,17 @@ async fn main() -> Result<()> {
.init(); .init();
let socket_path = hive_bash_mcp::paths::daemon_socket(); let socket_path = hive_bash_mcp::paths::daemon_socket();
let todo_socket = hive_bash_mcp::paths::agent_socket(); let wake_socket = hive_bash_mcp::paths::hyperhive_socket();
tracing::info!( tracing::info!(
socket = %socket_path.display(), socket = %socket_path.display(),
todo = %todo_socket.display(), wake = %wake_socket.display(),
"hive-bash-daemon starting" "hive-bash-daemon starting"
); );
// Start the background runner loop — scans for pending tasks and // Start the background runner loop — scans for pending tasks and
// spawns them, pushing todos to the harness on task transitions. // spawns them, sends wake signals on completion.
hive_bash_mcp::runner::spawn_loop(todo_socket); hive_bash_mcp::runner::spawn_loop(wake_socket);
// Serve the unix socket forever. // Serve the unix socket forever.
hive_bash_mcp::socket::serve(&socket_path).await hive_bash_mcp::socket::serve(&socket_path).await

View file

@ -43,17 +43,21 @@ pub fn turn_stats_db() -> PathBuf {
harness_dir().join("hyperhive-turn-stats.sqlite") harness_dir().join("hyperhive-turn-stats.sqlite")
} }
/// The harness-served in-agent todo socket (loose-ends v2). The runner /// Hyperhive control socket — the daemon writes wake signals here so
/// pushes bash-task todos here — `upsert_todo` while a task is active, /// the harness drives a new claude turn on bash task completion.
/// a keyless `done` todo on completion — instead of firing a direct /// Mirrors the path used by `forge_notify` and `hive-matrix-mcp`.
/// c0re wake. Override via `HIVE_AGENT_SOCKET`; mirrors the path the
/// harness binds (`agent-service.nix`) and the matrix producer dials.
#[must_use] #[must_use]
pub fn agent_socket() -> PathBuf { pub fn hyperhive_socket() -> PathBuf {
std::env::var_os("HIVE_AGENT_SOCKET").map_or_else( std::env::var_os("HIVE_CONTROL_SOCKET")
|| PathBuf::from(hive_agent_sock::DEFAULT_AGENT_SOCKET), .map_or_else(|| PathBuf::from("/run/hive/mcp.sock"), PathBuf::from)
PathBuf::from, }
)
/// Directory where MCP daemons write loose-end summary files for the harness.
/// Each daemon writes `<name>.json` here; the harness scans the dir in
/// `get_loose_ends` to surface active work from all MCPs generically.
#[must_use]
pub fn mcp_loose_ends_dir() -> PathBuf {
hive_sh4re::paths::mcp_loose_ends_dir()
} }
/// Full path for a task's JSON metadata file. /// Full path for a task's JSON metadata file.

View file

@ -1,22 +1,15 @@
//! Bash subprocess runner: spawns `bash -c <cmd>` tasks, writes status //! Bash subprocess runner: spawns `bash -c <cmd>` tasks, writes status
//! files under `harness/bash-tasks/`, and surfaces task state to the agent //! files under `harness/bash-tasks/`, and fires hyperhive wake signals
//! as *todos* on the harness's in-agent socket (loose-ends v2). //! on completion.
//! //!
//! Files under `tasks_dir()`: //! Files under `tasks_dir()`:
//! - `<id>.json` — task metadata + status (pending → running → done) //! - `<id>.json` — task metadata + status (pending → running → done)
//! - `<id>.out` — captured stdout (streamed while running) //! - `<id>.out` — captured stdout (streamed while running)
//! - `<id>.err` — captured stderr (streamed while running) //! - `<id>.err` — captured stderr (streamed while running)
//! //!
//! Todos (loose-ends v2): one keyed todo (`key = task id`) tracks the task
//! across its lifetime — upserted with a "running" summary at start, then
//! upserted again with the completion summary when it finishes. That summary
//! change signals the harness turn loop the same way the old completion wake
//! did; the agent clears the todo with `mark_todo_done` once it has read the
//! output.
//!
//! Tasks with status `running` on daemon boot are marked `interrupted` //! Tasks with status `running` on daemon boot are marked `interrupted`
//! (the process died with the previous daemon). The todo is still updated to //! (the process died with the previous daemon). A best-effort wake is
//! its interrupted-done summary so the agent is not silently blocked. //! still sent so the agent is not silently blocked.
//! //!
//! The runner kills the child process on timeout — `tokio::process::Child::drop()` //! The runner kills the child process on timeout — `tokio::process::Child::drop()`
//! does not kill children, so we explicitly call `child.kill().await`. //! does not kill children, so we explicitly call `child.kill().await`.
@ -28,7 +21,6 @@ use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use hive_agent_sock::Request as TodoReq;
use tokio::io::AsyncWriteExt as _; use tokio::io::AsyncWriteExt as _;
use tokio::sync::Notify; use tokio::sync::Notify;
@ -165,82 +157,39 @@ pub fn read_task(id: &str) -> Option<TaskFile> {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Todo delivery (loose-ends v2) // Loose-ends file (generic MCP loose-ends protocol)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
//
// Bash-task state is surfaced to the agent as todos on the harness's
// in-agent socket (`HIVE_AGENT_SOCKET`), not as direct c0re wakes: one keyed
// todo (`key = task id`) tracks the task across its whole lifetime. It's
// upserted with a stable "running" summary when the task starts (an
// idempotent no-op that never re-wakes), then upserted again with the
// completion summary when it finishes — that summary change signals the
// harness turn loop exactly like the old completion wake did. The agent
// reads the output and clears the todo with `mark_todo_done`.
/// Send one todo request to the harness in-agent socket. Best-effort: any /// Rewrite `mcp-loose-ends/bash.json` with a summary of all currently
/// connect / write error is logged and swallowed — the harness self-heals /// active (Pending or Running) tasks. The harness scans this directory
/// on the next task transition, and a standalone daemon without the socket /// generically in `get_loose_ends` — no bash-specific code needed there.
/// simply has nowhere to push. ///
async fn send_todo(socket: &Path, req: &TodoReq) { /// File format: a JSON array of plain-text summary strings, one per
use tokio::io::{AsyncBufReadExt as _, BufReader}; /// loose-end item. The harness includes them verbatim in the output.
use tokio::net::UnixStream; /// Atomic write (tmp + rename) so the harness never reads a partial file.
let line = match serde_json::to_string(req) { fn refresh_loose_ends() {
Ok(mut s) => { let active = active_tasks();
s.push('\n'); let dir = paths::mcp_loose_ends_dir();
s if let Err(e) = std::fs::create_dir_all(&dir) {
} tracing::warn!(error = ?e, "bash_runner: create mcp-loose-ends dir failed");
Err(e) => { return;
tracing::warn!(error = ?e, "bash_runner: serialise todo failed"); }
return; let items: Vec<String> = active
} .iter()
}; .map(|t| {
match UnixStream::connect(socket).await { let age = now_unix() - t.created_at;
Ok(stream) => { format!(
let (read, mut write) = stream.into_split(); "bash task `{}` status={:?}, cmd: `{}`, age {}s",
if write.write_all(line.as_bytes()).await.is_err() { t.id, t.status, t.cmd, age
tracing::warn!("bash_runner: write todo failed"); )
return; })
} .collect();
let _ = write.shutdown().await; let dest = dir.join("bash.json");
// Drain the response so the server doesn't get ECONNRESET. let tmp = dest.with_extension("json.tmp");
let mut resp = String::new(); let json = serde_json::to_string(&items).unwrap_or_else(|_| "[]".to_owned());
let _ = BufReader::new(read).read_line(&mut resp).await; if let Err(e) = std::fs::write(&tmp, &json).and_then(|()| std::fs::rename(&tmp, &dest)) {
} tracing::warn!(error = ?e, "bash_runner: write mcp-loose-ends/bash.json failed");
Err(e) => {
tracing::warn!(error = ?e, socket = %socket.display(), "bash_runner: connect todo socket failed");
}
} }
}
/// Upsert the task's keyed todo (`key = id`) with `summary`. Used for both
/// the "running" surface at start and the "done" summary at completion — a
/// changed summary on the same row signals the turn loop, an unchanged
/// re-push is an idempotent no-op.
async fn upsert_bash_todo(socket: &Path, id: &str, summary: String) {
send_todo(
socket,
&TodoReq::UpsertTodo {
subsystem: "bash".to_owned(),
key: Some(id.to_owned()),
summary,
source: None,
},
)
.await;
}
/// Clear a task's keyed todo — used when an inline `status`/`run` wait
/// already delivered the terminal result, so no `done` todo is warranted.
async fn clear_bash_todo(socket: &Path, id: &str) {
send_todo(
socket,
&TodoReq::ClearTodo {
subsystem: "bash".to_owned(),
key: Some(id.to_owned()),
all: false,
},
)
.await;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -316,10 +265,7 @@ pub fn submit_task(cmd: String, timeout_secs: Option<u64>, name: Option<String>)
stderr_tail: None, stderr_tail: None,
}; };
write_task(&task)?; write_task(&task)?;
// No todo yet: the keyed "active" todo is upserted when the runner refresh_loose_ends();
// loop flips the task to Running (`run_task`), which has the in-agent
// socket handle. Surfacing a Pending task would only race the ~200ms
// until it starts and double-wake (pending→running).
Ok(id) Ok(id)
} }
@ -439,9 +385,7 @@ pub fn kill_task(id: &str, force: bool) -> (bool, bool) {
task.status = TaskStatus::Killed; task.status = TaskStatus::Killed;
task.completed_at = Some(now_unix()); task.completed_at = Some(now_unix());
let _ = write_task(&task); let _ = write_task(&task);
// A Pending task never reached Running, so it has no keyed "active" refresh_loose_ends();
// todo to clear and (like the old code) fires no completion signal —
// the caller that killed it already knows.
return (true, false); return (true, false);
} }
(false, false) (false, false)
@ -451,9 +395,9 @@ pub fn kill_task(id: &str, force: bool) -> (bool, bool) {
// Runner background loop // Runner background loop
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Spawn the background runner loop as a detached tokio task. `socket` is /// Spawn the background runner loop as a detached tokio task. `socket`
/// the harness's in-agent todo socket (`HIVE_AGENT_SOCKET`), where task /// is the path to the per-agent broker socket used to deliver completion
/// transitions are pushed as todos. Call once at daemon startup. /// wake signals. Call once at daemon startup.
pub fn spawn_loop(socket: PathBuf) { pub fn spawn_loop(socket: PathBuf) {
tokio::spawn(async move { tokio::spawn(async move {
run_loop(socket).await; run_loop(socket).await;
@ -499,12 +443,8 @@ async fn mark_interrupted(socket: &Path) {
if let Err(e) = write_task(&task) { if let Err(e) = write_task(&task) {
tracing::warn!(id = %id, error = ?e, "bash_runner: write interrupted state failed"); tracing::warn!(id = %id, error = ?e, "bash_runner: write interrupted state failed");
} }
upsert_bash_todo( refresh_loose_ends();
socket, send_wake(socket, &id, "interrupted (daemon restarted)", None).await;
&id,
done_summary(&id, "interrupted (daemon restarted)", None),
)
.await;
} }
} }
@ -554,12 +494,7 @@ async fn run_task(mut task: TaskFile, socket: &Path) {
if let Err(e) = write_task(&task) { if let Err(e) = write_task(&task) {
tracing::warn!(id = %id, error = ?e, "bash_runner: write running state failed"); tracing::warn!(id = %id, error = ?e, "bash_runner: write running state failed");
} }
upsert_bash_todo( refresh_loose_ends();
socket,
&id,
format!("bash task `{id}` running: `{}`", task.cmd),
)
.await;
// Best-effort: tally the normalised command head for the /stats // Best-effort: tally the normalised command head for the /stats
// "favorite tools" view. Counted once per execution, regardless of // "favorite tools" view. Counted once per execution, regardless of
// exit status. Never fails the task. // exit status. Never fails the task.
@ -627,33 +562,25 @@ async fn run_task(mut task: TaskFile, socket: &Path) {
if let Err(e) = write_task(&task) { if let Err(e) = write_task(&task) {
tracing::warn!(id = %id, error = ?e, "bash_runner: write done state failed"); tracing::warn!(id = %id, error = ?e, "bash_runner: write done state failed");
} }
// If an inline `status`/`run` wait already handed this exact terminal refresh_loose_ends();
// result to the caller in a tool response (see `wait_for_task` /
// `observe_terminal`), there's nothing left to surface — retire the // Skip the wake if a `status`/`run` inline wait already handed this
// keyed todo without a `done` upsert so the agent gets no redundant // exact terminal result to the caller in a tool response — see
// wake. Narrow race: an inline waiter polling in the few hundred ms // `wait_for_task` / `observe_terminal`. Narrow race: an inline waiter
// around this point may lose the race and still get a `done` todo // that polls in the few hundred ms right around this point may lose the
// alongside its inline result; best-effort, same tolerance as the rest // race and still get a wake alongside its inline result; best-effort,
// of this daemon's guarantees. // same tolerance as the rest of this daemon's delivery guarantees.
if take_wake_suppressed(&id) { if take_wake_suppressed(&id) {
clear_bash_todo(socket, &id).await; tracing::debug!(id = %id, "bash_runner: wake suppressed (already observed via status)");
tracing::debug!(id = %id, "bash_runner: done todo suppressed (already observed via status)");
return; return;
} }
// Transition the SAME keyed todo from "running" to its done summary: the // Pass only whether each stream produced (trimmed) output — `send_wake`
// summary change signals the turn loop, and the agent clears the todo // emits a `Read(<path>)` pointer to the captured `.out`/`.err` files
// with `mark_todo_done` after reading the output. Pass only whether each // rather than inlining the tail into the wake body.
// stream produced (trimmed) output — the summary carries a `Read(<path>)`
// pointer to the captured `.out`/`.err` files rather than inlining the tail.
let has_stdout = !stdout_tail.as_deref().unwrap_or("").trim().is_empty(); let has_stdout = !stdout_tail.as_deref().unwrap_or("").trim().is_empty();
let has_stderr = !stderr_tail.as_deref().unwrap_or("").trim().is_empty(); let has_stderr = !stderr_tail.as_deref().unwrap_or("").trim().is_empty();
upsert_bash_todo( send_wake(socket, &id, &summary, Some((has_stdout, has_stderr))).await;
socket,
&id,
done_summary(&id, &summary, Some((has_stdout, has_stderr))),
)
.await;
} }
/// Run `bash -c cmd` in its own process group, streaming output to files. /// Run `bash -c cmd` in its own process group, streaming output to files.
@ -799,20 +726,24 @@ fn tail_file(path: &Path, max_bytes: usize) -> Option<String> {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Completion summary // Wake delivery
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Build the completion-summary line carried by a `done` todo: the status pub(crate) async fn send_wake(
/// `summary` plus `Read(<path>)` pointers to any captured output. socket: &Path,
/// id: &str,
/// `output` is `(has_stdout, has_stderr)` — whether the task produced summary: &str,
/// non-empty stdout / stderr; `None` for the interrupted path, which has no // `(has_stdout, has_stderr)` — whether the task produced non-empty
/// captured output. Deliberately NOT the output text: the todo gives the // stdout / stderr. `None` for the interrupted path (no captured output
/// agent the status + a `Read(<path>)` pointer to the captured files rather // to point at). Deliberately NOT the output text: the wake gives the
/// than inlining the tail, which would flood the agent's context on every // agent the status + a `Read(<path>)` pointer to the captured files
/// task completion. // rather than inlining the tail, which would flood the agent's context
fn done_summary(id: &str, summary: &str, output: Option<(bool, bool)>) -> String { // on every task completion.
output: Option<(bool, bool)>,
) {
use std::fmt::Write as _; use std::fmt::Write as _;
use tokio::io::{AsyncBufReadExt as _, BufReader};
use tokio::net::UnixStream;
let mut body = format!("bash task `{id}` finished: {summary}"); let mut body = format!("bash task `{id}` finished: {summary}");
if let Some((has_stdout, has_stderr)) = output if let Some((has_stdout, has_stderr)) = output
&& (has_stdout || has_stderr) && (has_stdout || has_stderr)
@ -829,7 +760,38 @@ fn done_summary(id: &str, summary: &str, output: Option<(bool, bool)>) -> String
); );
} }
} }
body let req = hive_core_agent_sock::Request::Wake {
from: format!("bash-task-{id}"),
body,
};
match UnixStream::connect(socket).await {
Ok(stream) => {
let (read, mut write) = stream.into_split();
let line = match serde_json::to_string(&req) {
Ok(mut s) => {
s.push('\n');
s
}
Err(e) => {
tracing::warn!(id = %id, error = ?e, "bash_runner: serialise wake failed");
return;
}
};
if write.write_all(line.as_bytes()).await.is_err() {
tracing::warn!(id = %id, "bash_runner: write wake failed");
return;
}
let _ = write.shutdown().await;
// Drain the response so the server doesn't get ECONNRESET.
let mut resp = String::new();
let _ = BufReader::new(read).read_line(&mut resp).await;
tracing::info!(id = %id, "bash_runner: wake delivered");
}
Err(e) => {
tracing::warn!(id = %id, error = ?e, "bash_runner: connect wake socket failed");
}
}
} }
#[cfg(test)] #[cfg(test)]

View file

@ -28,3 +28,10 @@ pub fn harness_dir() -> PathBuf {
let label = std::env::var("HIVE_LABEL").unwrap_or_default(); let label = std::env::var("HIVE_LABEL").unwrap_or_default();
PathBuf::from(format!("/agents/{label}/harness")) PathBuf::from(format!("/agents/{label}/harness"))
} }
/// Directory where out-of-process MCP daemons write loose-end summary
/// files (`<name>.json`) for the harness to scan in `get_loose_ends`.
#[must_use]
pub fn mcp_loose_ends_dir() -> PathBuf {
harness_dir().join("mcp-loose-ends")
}

View file

@ -175,11 +175,7 @@ in
]; ];
environment = { environment = {
HIVE_BASH_SOCKET = "/run/hive-bash/socket"; HIVE_BASH_SOCKET = "/run/hive-bash/socket";
# In-agent todo socket the harness serves (loose-ends v2): the HIVE_CONTROL_SOCKET = "/run/hive/mcp.sock";
# runner pushes bash-task todos here (upsert while active, keyless
# 'done' on completion) instead of firing a c0re wake. Must match
# the harness's HIVE_AGENT_SOCKET (agent-service.nix).
HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock";
RUST_LOG = "info"; RUST_LOG = "info";
# HYPERHIVE_HARNESS_DIR and HYPERHIVE_STATE_DIR are already # HYPERHIVE_HARNESS_DIR and HYPERHIVE_STATE_DIR are already
# injected via systemd.globalEnvironment by the meta flake # injected via systemd.globalEnvironment by the meta flake