Compare commits

...
17 changed files with 271 additions and 281 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-core-agent-sock", "hive-agent-sock",
"hive-sh4re", "hive-sh4re",
"libc", "libc",
"rmcp", "rmcp",

View file

@ -233,13 +233,12 @@ 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.
- `mcp-loose-ends/` — JSON files published by external MCP daemons - `hyperhive-todos.sqlite` — loose-ends-v2 todo store. In-container
(e.g. `hive-bash-mcp`, `hive-matrix-mcp`) listing their active MCP daemons (`hive-bash-mcp`, `hive-matrix-mcp`) and `forge_notify`
loose-end summary strings. Each file is `<daemon>.json` containing upsert keyed todos here over the harness's in-agent socket
a JSON array of plain-text lines. Read by `get_loose_ends` to (`HIVE_AGENT_SOCKET`); the harness merges them into `get_loose_ends`
surface active background work without hardcoding per-MCP output and clears a row on `mark_todo_done`. Replaced the old
knowledge in the harness. Files are created/removed by the file-based `mcp-loose-ends/` scanner.
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), the When the task completes (or times out, or the process errors), it
harness fires a wake with `from: "bash-task-<id>"`; the body contains surfaces as a todo in the agent's loose-ends (via `get_loose_ends`),
the exit code and last stdout lines. Handle the completion on a future carrying the exit code and a `Read(<path>)` pointer to the captured
turn — unless `wait_seconds` already delivered the terminal result output. Handle it on a future turn — unless `wait_seconds` already
inline, in which case the wake is suppressed (see `status` below). delivered the terminal result inline, in which case no todo is created
(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 wake is fired; when the window expires returned immediately and no todo is created; 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 the wake `from` auto-generated hex id, so it surfaces in `status(<name>)` lookups and
(`bash-task-<name>`), in `status(<name>)` lookups, and in the the loose-ends list — a memorable label instead of an opaque id. A name
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,15 +52,14 @@ 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 Any `status` call (waited or not) that observes a terminal task clears
suppresses that task's completion wake — you already have the result that task's completion todo — you already have the result in this
in this response, so no redundant `bash-task-<id>` inbox message response, so no redundant loose-end follows. Narrow best-effort race: a
follows (#2270). Narrow best-effort race: a `status`/`run` inline wait `status`/`run` inline wait that resolves in the same instant the task
that resolves in the same instant the task actually finishes can still actually finishes can still occasionally get both.
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 wake was still sent so the agent is not restart; a best-effort todo is still surfaced so the agent is not
silently blocked. silently blocked.
Exposed as `mcp__bash__status`. Exposed as `mcp__bash__status`.
@ -68,8 +67,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 — handle the completion sends the signal and returns without waiting — the completion surfaces
wake (`from: "bash-task-<id>"`) on a future turn. in the agent's loose-ends; handle it 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
@ -79,7 +78,7 @@ wake (`from: "bash-task-<id>"`) 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 fires the usual completion wake. `killed` and surfaces in the loose-ends like any completion.
Exposed as `mcp__bash__kill`. Exposed as `mcp__bash__kill`.
@ -97,7 +96,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, `mcp-loose-ends/` state, and wake signal delivery. output file writing, and todo delivery on the harness's in-agent socket.
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
@ -108,13 +107,17 @@ 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.
### Transient wake (bypass broker sqlite) ### Completion as a todo (loose-ends v2)
When a bash task finishes, `hive-bash-daemon` sends the wake signal via When a bash task changes state, `hive-bash-daemon` upserts a single keyed
the agent's per-agent socket as a **transient wake** (`AgentRequest::Wake` todo (`key = task id`) on the harness's in-agent socket (`HIVE_AGENT_SOCKET`)
with `transient: true`). This bypasses broker sqlite for lower latency — — "running" at start, then the completion summary when it finishes. The
the same mechanism used by matrix events. The message is delivered summary change signals the harness turn loop directly (in-process, no broker
directly to the harness without touching the persistent message store. round-trip), so the agent is driven a turn to handle it via `get_loose_ends`,
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,17 +90,16 @@ 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 writes the invite to `m.room.member` invite event, it upserts a todo (keyed `invite:<room>`)
`mcp-loose-ends/matrix.json` and fires a hyperhive wake. The daemon on the harness's in-agent socket, which drives a turn. The daemon does
does **not** auto-join — the agent calls `list_invites` to see pending **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 written to **Pending invites as loose ends**: pending invites are upserted as
`mcp-loose-ends/matrix.json` and appear in `get_loose_ends` output as keyed todos 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 file is updated atomically resolve_invite to accept or reject`. The keyed todo is cleared when a
after each invite event and after each `resolve_invite` (or `resolve_invite` (or `join_room`) call resolves the invite.
`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

@ -1,42 +0,0 @@
//! 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,7 +18,6 @@ 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,20 +339,7 @@ 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);
} }
let mut out = annotate_retries(render_loose_ends(&loose_ends), retries); 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), 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__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__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,6 +64,8 @@ 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,
@ -1064,33 +1066,40 @@ async fn poll_once(
continue; continue;
}; };
let req = hive_core_agent_sock::Request::Wake { // Upsert a *todo* (loose-ends v2) on the harness's in-agent socket,
from: "forge".to_owned(), // keyed by the forge thread id, instead of firing a direct wake. A
body, // new/changed summary makes the harness signal its turn loop; the
// 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 = let deliver_result = crate::client::request::<_, hive_agent_sock::Response>(socket, &req)
crate::client::request::<_, hive_core_agent_sock::Response>(socket, &req) .await
.await .map(|_| ());
.map(|_| ());
match deliver_result { match deliver_result {
Ok(()) => { Ok(()) => {
debug!(%id, "forge_notify: delivered"); debug!(%id, "forge_notify: todo upserted");
// Mark the thread read on forge immediately after a // Mark the thread read on forge immediately after upserting
// successful broker delivery. The broker inbox is the // the todo. The todo is the durable work item now (it stays
// durable work queue now (each row has its own ack // in `get_loose_ends` until the agent marks it done), so the
// lifecycle), so the forge unread flag no longer needs to // forge unread flag no longer needs to track agent
// track agent processing — clearing it on delivery keeps // processing — clearing it keeps forge's unread set tiny by
// forge's unread set tiny by construction, so a container // construction, so a container rebuild re-scan finds nothing
// rebuild re-scan finds nothing stale to re-deliver. The // stale (and idempotent re-upserts wouldn't re-wake anyway).
// in-memory `delivered` entry below is only a within-process // The in-memory `delivered` entry below is only a
// guard so a transient mark-read failure doesn't re-fire the // within-process guard so a transient mark-read failure
// wake next tick; it is deliberately NOT persisted — forge's // doesn't re-upsert next tick; it is deliberately NOT
// own read-state is the cross-rebuild source of truth. // persisted — forge's own read-state is the cross-rebuild
// 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: deliver failed — leaving unread"); warn!(%id, error = ?e, "forge_notify: todo upsert failed — leaving unread");
} }
} }
} }

View file

@ -421,7 +421,15 @@ 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;
} }
tokio::spawn(crate::forge_notify::run(socket.to_path_buf())); // forge_notify pushes forge notifications as todos on the in-agent
// 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-core-agent-sock.workspace = true hive-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 \
a wake fires when it completes) rather than immediately re-waiting."; it surfaces in your loose-ends 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 the usual completion wake fires." once the process exits and then surfaces in your loose-ends."
) )
} else { } else {
format!("task `{id}` was pending — cancelled before it started.") format!("task `{id}` was pending — cancelled before it started.")
@ -180,17 +180,16 @@ 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 and no wake is fired; /// window the full status is returned immediately (nothing to handle
/// when the timeout expires the task keeps running and the normal /// later); when the timeout expires the task keeps running and the
/// `task started: id=<id>` response is returned. Defaults to 3s. Pass /// normal `task started: id=<id>` response is returned. Defaults to 3s.
/// `0` to disable inline waiting and always get the immediate response. /// Pass `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 the completion wake (`from: "bash-task-<name>"`), in `status` /// in `status` lookups and your loose-ends list — handy for recognising
/// lookups, and in the loose-ends list — handy for recognising a task /// a task later instead of an opaque hex id. A name can be reused once
/// later instead of an opaque hex id. A name can be reused once its /// its previous task has finished; reusing a name whose task is still
/// 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)]
@ -237,21 +236,18 @@ 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, the harness fires a wake with \ do NOT wait inline. When the command finishes it surfaces as a todo in your \
`from: \"bash-task-<id>\"` carrying the exit status plus a `Read(<path>)` \ loose-ends (the exit status plus a `Read(<path>)` pointer to the captured \
pointer to the captured `.out`/`.err` files (not the output text itself \ `.out`/`.err` files read only what you need); handle it on a future turn. Use \
read only what you need); handle it on a future turn. Use `status` to poll the task status within \ `status` to poll within the same turn if needed. `timeout_secs` defaults to \
the same turn if needed. `timeout_secs` defaults to `None` (no timeout) \ `None` (no timeout) task runs until natural exit; pass a value to kill after \
task runs until natural exit; pass an explicit value to kill after N seconds. \ N seconds. Pass `wait_seconds` (capped at 30) to wait inline for fast commands: \
Pass `wait_seconds` (capped at 30) to wait inline for fast commands: when the \ if the task finishes within the window you get the full status immediately \
task finishes within the window the full status is returned immediately and no \ (nothing to handle later); otherwise it keeps running and you get \
wake is fired; when the timeout expires the task keeps running and the normal \ `task started: id=<id>`. Defaults to 3; pass `0` to disable inline waiting. \
`task started: id=<id>` response is returned. `wait_seconds` defaults to 3; \ Pass `name` to label the task with a memorable id (shown in `status` lookups \
pass `wait_seconds: 0` to disable inline waiting and always get the immediate \ and your loose-ends) instead of an opaque hex id; reusable once the prior task \
response. Pass `name` to label the task with a memorable id (used in the wake \ has finished, rejected while one is still running."
`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 {
@ -297,8 +293,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 fires the usual \ is cancelled before it starts. The task ends as `killed` and surfaces in your \
completion wake." loose-ends like any completion."
)] )]
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,7 +1,8 @@
//! `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 fires hyperhive wake signals. Listens on a unix socket //! files, and surfaces task state to the agent as todos on the harness's
//! for tool-call requests from the `hive-bash-mcp` stdio bridge. //! in-agent socket. Listens on a unix socket for tool-call requests from
//! the `hive-bash-mcp` stdio bridge.
use anyhow::Result; use anyhow::Result;
@ -15,17 +16,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 wake_socket = hive_bash_mcp::paths::hyperhive_socket(); let todo_socket = hive_bash_mcp::paths::agent_socket();
tracing::info!( tracing::info!(
socket = %socket_path.display(), socket = %socket_path.display(),
wake = %wake_socket.display(), todo = %todo_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, sends wake signals on completion. // spawns them, pushing todos to the harness on task transitions.
hive_bash_mcp::runner::spawn_loop(wake_socket); hive_bash_mcp::runner::spawn_loop(todo_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,21 +43,17 @@ pub fn turn_stats_db() -> PathBuf {
harness_dir().join("hyperhive-turn-stats.sqlite") harness_dir().join("hyperhive-turn-stats.sqlite")
} }
/// Hyperhive control socket — the daemon writes wake signals here so /// The harness-served in-agent todo socket (loose-ends v2). The runner
/// the harness drives a new claude turn on bash task completion. /// pushes bash-task todos here — `upsert_todo` while a task is active,
/// Mirrors the path used by `forge_notify` and `hive-matrix-mcp`. /// a keyless `done` todo on completion — instead of firing a direct
/// 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 hyperhive_socket() -> PathBuf { pub fn agent_socket() -> PathBuf {
std::env::var_os("HIVE_CONTROL_SOCKET") std::env::var_os("HIVE_AGENT_SOCKET").map_or_else(
.map_or_else(|| PathBuf::from("/run/hive/mcp.sock"), PathBuf::from) || PathBuf::from(hive_agent_sock::DEFAULT_AGENT_SOCKET),
} 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,15 +1,22 @@
//! 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 fires hyperhive wake signals //! files under `harness/bash-tasks/`, and surfaces task state to the agent
//! on completion. //! as *todos* on the harness's in-agent socket (loose-ends v2).
//! //!
//! 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). A best-effort wake is //! (the process died with the previous daemon). The todo is still updated to
//! still sent so the agent is not silently blocked. //! its interrupted-done summary 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`.
@ -21,6 +28,7 @@ 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;
@ -157,41 +165,84 @@ pub fn read_task(id: &str) -> Option<TaskFile> {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Loose-ends file (generic MCP loose-ends protocol) // Todo delivery (loose-ends v2)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
//
// 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`.
/// Rewrite `mcp-loose-ends/bash.json` with a summary of all currently /// Send one todo request to the harness in-agent socket. Best-effort: any
/// active (Pending or Running) tasks. The harness scans this directory /// connect / write error is logged and swallowed — the harness self-heals
/// generically in `get_loose_ends` — no bash-specific code needed there. /// on the next task transition, and a standalone daemon without the socket
/// /// simply has nowhere to push.
/// File format: a JSON array of plain-text summary strings, one per async fn send_todo(socket: &Path, req: &TodoReq) {
/// loose-end item. The harness includes them verbatim in the output. use tokio::io::{AsyncBufReadExt as _, BufReader};
/// Atomic write (tmp + rename) so the harness never reads a partial file. use tokio::net::UnixStream;
fn refresh_loose_ends() { let line = match serde_json::to_string(req) {
let active = active_tasks(); Ok(mut s) => {
let dir = paths::mcp_loose_ends_dir(); s.push('\n');
if let Err(e) = std::fs::create_dir_all(&dir) { s
tracing::warn!(error = ?e, "bash_runner: create mcp-loose-ends dir failed"); }
return; Err(e) => {
} tracing::warn!(error = ?e, "bash_runner: serialise todo failed");
let items: Vec<String> = active return;
.iter() }
.map(|t| { };
let age = now_unix() - t.created_at; match UnixStream::connect(socket).await {
format!( Ok(stream) => {
"bash task `{}` status={:?}, cmd: `{}`, age {}s", let (read, mut write) = stream.into_split();
t.id, t.status, t.cmd, age if write.write_all(line.as_bytes()).await.is_err() {
) tracing::warn!("bash_runner: write todo failed");
}) return;
.collect(); }
let dest = dir.join("bash.json"); let _ = write.shutdown().await;
let tmp = dest.with_extension("json.tmp"); // Drain the response so the server doesn't get ECONNRESET.
let json = serde_json::to_string(&items).unwrap_or_else(|_| "[]".to_owned()); let mut resp = String::new();
if let Err(e) = std::fs::write(&tmp, &json).and_then(|()| std::fs::rename(&tmp, &dest)) { let _ = BufReader::new(read).read_line(&mut resp).await;
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;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Public API used by daemon dispatch // Public API used by daemon dispatch
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -265,7 +316,10 @@ 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)?;
refresh_loose_ends(); // No todo yet: the keyed "active" todo is upserted when the runner
// 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)
} }
@ -385,7 +439,9 @@ 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);
refresh_loose_ends(); // A Pending task never reached Running, so it has no keyed "active"
// 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)
@ -395,9 +451,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` /// Spawn the background runner loop as a detached tokio task. `socket` is
/// is the path to the per-agent broker socket used to deliver completion /// the harness's in-agent todo socket (`HIVE_AGENT_SOCKET`), where task
/// wake signals. Call once at daemon startup. /// transitions are pushed as todos. 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;
@ -443,8 +499,12 @@ 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");
} }
refresh_loose_ends(); upsert_bash_todo(
send_wake(socket, &id, "interrupted (daemon restarted)", None).await; socket,
&id,
done_summary(&id, "interrupted (daemon restarted)", None),
)
.await;
} }
} }
@ -494,7 +554,12 @@ 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");
} }
refresh_loose_ends(); upsert_bash_todo(
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.
@ -562,25 +627,33 @@ 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");
} }
refresh_loose_ends(); // If an inline `status`/`run` wait already handed this exact terminal
// result to the caller in a tool response (see `wait_for_task` /
// Skip the wake if a `status`/`run` inline wait already handed this // `observe_terminal`), there's nothing left to surface — retire the
// exact terminal result to the caller in a tool response — see // keyed todo without a `done` upsert so the agent gets no redundant
// `wait_for_task` / `observe_terminal`. Narrow race: an inline waiter // wake. Narrow race: an inline waiter polling in the few hundred ms
// that polls in the few hundred ms right around this point may lose the // around this point may lose the race and still get a `done` todo
// race and still get a wake alongside its inline result; best-effort, // alongside its inline result; best-effort, same tolerance as the rest
// same tolerance as the rest of this daemon's delivery guarantees. // of this daemon's guarantees.
if take_wake_suppressed(&id) { if take_wake_suppressed(&id) {
tracing::debug!(id = %id, "bash_runner: wake suppressed (already observed via status)"); clear_bash_todo(socket, &id).await;
tracing::debug!(id = %id, "bash_runner: done todo suppressed (already observed via status)");
return; return;
} }
// Pass only whether each stream produced (trimmed) output — `send_wake` // Transition the SAME keyed todo from "running" to its done summary: the
// emits a `Read(<path>)` pointer to the captured `.out`/`.err` files // summary change signals the turn loop, and the agent clears the todo
// rather than inlining the tail into the wake body. // with `mark_todo_done` after reading the output. Pass only whether each
// 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();
send_wake(socket, &id, &summary, Some((has_stdout, has_stderr))).await; upsert_bash_todo(
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.
@ -726,24 +799,20 @@ fn tail_file(path: &Path, max_bytes: usize) -> Option<String> {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Wake delivery // Completion summary
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
pub(crate) async fn send_wake( /// Build the completion-summary line carried by a `done` todo: the status
socket: &Path, /// `summary` plus `Read(<path>)` pointers to any captured output.
id: &str, ///
summary: &str, /// `output` is `(has_stdout, has_stderr)` — whether the task produced
// `(has_stdout, has_stderr)` — whether the task produced non-empty /// non-empty stdout / stderr; `None` for the interrupted path, which has no
// stdout / stderr. `None` for the interrupted path (no captured output /// captured output. Deliberately NOT the output text: the todo gives the
// to point at). Deliberately NOT the output text: the wake gives the /// agent the status + a `Read(<path>)` pointer to the captured files rather
// agent the status + a `Read(<path>)` pointer to the captured files /// than inlining the tail, which would flood the agent's context on every
// rather than inlining the tail, which would flood the agent's context /// task completion.
// on every task completion. fn done_summary(id: &str, summary: &str, output: Option<(bool, bool)>) -> String {
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)
@ -760,38 +829,7 @@ pub(crate) async fn send_wake(
); );
} }
} }
let req = hive_core_agent_sock::Request::Wake { body
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,10 +28,3 @@ 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,7 +175,11 @@ in
]; ];
environment = { environment = {
HIVE_BASH_SOCKET = "/run/hive-bash/socket"; HIVE_BASH_SOCKET = "/run/hive-bash/socket";
HIVE_CONTROL_SOCKET = "/run/hive/mcp.sock"; # In-agent todo socket the harness serves (loose-ends v2): the
# 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