refactor(hive-ag3nt): split hive bin into hive-agent / hive-agent-mcp / hive-agent-wake
This commit is contained in:
parent
2486251b32
commit
5b062dca55
14 changed files with 182 additions and 144 deletions
|
|
@ -32,8 +32,9 @@ hand-maintained per-file tree drifts out of sync with the code.
|
|||
the meta flake, lifecycle (`nixos-container` shellouts), gateway /
|
||||
forge / matrix provisioning, per-container stats, and the axum
|
||||
operator dashboard (`dashboard.rs`). Largest crate.
|
||||
- **`hive-ag3nt/`** — in-container harness; one `hive` binary for every
|
||||
agent. Turn-loop *policy* layer (`turn.rs`) over the `hive-claude`
|
||||
- **`hive-ag3nt/`** — in-container harness; three sibling binaries for
|
||||
every agent (`hive-agent` serve loop, `hive-agent-mcp`,
|
||||
`hive-agent-wake`). Turn-loop *policy* layer (`turn.rs`) over the `hive-claude`
|
||||
driver, embedded MCP server (`mcp.rs`) + its claude launch-config layer
|
||||
(`mcp_config.rs`: tool-group/capability → `--allowedTools`, `--mcp-config`
|
||||
render), per-agent web UI (`web_ui/` module dir), event + turn-stats
|
||||
|
|
|
|||
|
|
@ -214,7 +214,8 @@ nspawn agent. Open questions, not yet wired:
|
|||
|
||||
## Harness systemd unit shape
|
||||
|
||||
One harness binary (`hive`), one `harness-base.nix` template, one
|
||||
One harness serve binary (`hive-agent`, with its `hive-agent-mcp` /
|
||||
`hive-agent-wake` siblings), one `harness-base.nix` template, one
|
||||
service unit (`systemd.services.hive-ag3nt`) for all agents. There
|
||||
is no longer a separate manager service name or role distinction in
|
||||
the harness — privilege differences live server-side in the broker
|
||||
|
|
@ -259,7 +260,8 @@ bit set` regardless of `hyperhive.user.passwordlessSudo`.
|
|||
|
||||
### `serviceConfig` highlights
|
||||
|
||||
- `ExecStart = pkgs.hyperhive/bin/hive serve` — single binary.
|
||||
- `ExecStart = pkgs.hyperhive/bin/hive-agent` — same binary for
|
||||
every agent.
|
||||
- `Restart = on-failure`, `RestartSec = 2` — keeps the harness
|
||||
resilient across transient crashes without thundering retries.
|
||||
- `RuntimeDirectory = "hive-config"` → `/run/hive-config/` owned by
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ claude has access to in return.
|
|||
|
||||
## The loop
|
||||
|
||||
Each agent harness (`hive serve` — one binary for all agents) runs:
|
||||
Each agent harness (`hive-agent` — one serve-loop binary for all
|
||||
agents) runs:
|
||||
|
||||
1. Long-poll `Recv` on its socket. The host-side broker
|
||||
(`broker.rs::recv_blocking_batch`) returns immediately if there's
|
||||
|
|
@ -51,38 +52,37 @@ Each agent harness (`hive serve` — one binary for all agents) runs:
|
|||
|
||||
## Harness binary shape
|
||||
|
||||
One `hive` binary for all agents. The earlier split into
|
||||
`hive-ag3nt` + `hive-m1nd` was collapsed because the privilege
|
||||
boundary lives server-side at the broker socket
|
||||
(`/run/hive/mcp.sock`): `ManagerRequest` calls are refused by the
|
||||
standard agent socket regardless of who sends them.
|
||||
Three sibling binaries out of the one `hive-ag3nt` crate, all
|
||||
role-agnostic. (The earlier split into `hive-ag3nt` + `hive-m1nd`
|
||||
was collapsed because the privilege boundary lives server-side at
|
||||
the broker socket (`/run/hive/mcp.sock`): `ManagerRequest` calls are
|
||||
refused by the standard agent socket regardless of who sends them.)
|
||||
|
||||
Three subcommands:
|
||||
|
||||
- `serve` — long-running harness loop (the inbox poll +
|
||||
- `hive-agent` — long-running harness loop (the inbox poll +
|
||||
claude-pump + ack/requeue cycle described above).
|
||||
- `mcp` — MCP server. Default: stdio child claude spawns via
|
||||
`--mcp-config` per turn. With `--http <addr>`, runs as a persistent
|
||||
streamable-HTTP daemon instead (used by the `hive-mcp-http`
|
||||
systemd unit when `hyperhive.mcp.httpPort` is set).
|
||||
- `wake --from <name> --body <body>` — push a message into our own
|
||||
inbox so the next turn fires with the given body. Used by
|
||||
co-process daemons (matrix bridge, scraper, webhook listeners)
|
||||
to nudge claude on external events. `--body -` reads from stdin.
|
||||
- `hive-agent-mcp` — MCP server. Default: stdio child claude spawns
|
||||
via `--mcp-config` per turn (the serve loop renders the config to
|
||||
point at this sibling of its own `/proc/self/exe`). With
|
||||
`--http <addr>`, runs as a persistent streamable-HTTP daemon
|
||||
instead (used by the `hive-mcp-http` systemd unit when
|
||||
`hyperhive.mcp.httpPort` is set).
|
||||
- `hive-agent-wake --from <name> --body <body>` — push a message into
|
||||
our own inbox so the next turn fires with the given body. Used by
|
||||
co-process helpers (scrapers, webhook listeners) to nudge claude on
|
||||
external events. `--body -` reads from stdin.
|
||||
|
||||
### `Surface` trait + zero-sized type tags
|
||||
|
||||
`AgentRequest` / `AgentResponse` (= `ManagerRequest` / `ManagerResponse` —
|
||||
type aliases) are the wire types. There is one role: agent.
|
||||
`bin/hive.rs` factors the turn loop through a `Surface` trait with one
|
||||
zero-sized impl (`AgentSurface`) wrapping:
|
||||
`bin/hive-agent.rs` factors the turn loop through a `Surface` trait
|
||||
with one zero-sized impl (`AgentSurface`) wrapping:
|
||||
|
||||
- One async method per wire op: `ack_turn`, `requeue_inflight`,
|
||||
`inbox_unread`, `post_turn_counts`, `send_to_parent`,
|
||||
`recv_next`, `wake_external`.
|
||||
`inbox_unread`, `post_turn_counts`, `send_to_parent`, `recv_next`.
|
||||
|
||||
`main()` calls `serve_main::<AgentSurface>` for all roles. The turn
|
||||
loop (`serve_loop` / `handle_turn` / `wake`) has no per-role branches.
|
||||
loop (`serve_loop` / `handle_turn`) has no per-role branches.
|
||||
|
||||
### Boot wiring
|
||||
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ External MCP servers (and any other in-container process) can
|
|||
inject a wake-up event into the agent's inbox via the per-agent
|
||||
socket at `/run/hive/mcp.sock`. Two equivalent paths:
|
||||
|
||||
- **Shell out to `hive wake --from <label> --body <text>`**
|
||||
- **Shell out to `hive-agent-wake --from <label> --body <text>`**
|
||||
(use `--body -` to read body from stdin). Already on the
|
||||
container's `PATH` since the harness binary is in
|
||||
`systemPackages`. Convenient for shell-script integrations and
|
||||
|
|
|
|||
|
|
@ -28,9 +28,8 @@ tracing-subscriber.workspace = true
|
|||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[[bin]]
|
||||
# Unified harness binary for all agents. Privilege boundary is
|
||||
# Three sibling harness binaries for all agents (auto-discovered from
|
||||
# `src/bin/`): `hive-agent` (serve loop), `hive-agent-mcp` (MCP
|
||||
# server), `hive-agent-wake` (wake CLI). Privilege boundary is
|
||||
# enforced server-side at the socket (tool groups / manager surface).
|
||||
# See `docs/turn-loop.md::Harness binary shape`.
|
||||
name = "hive"
|
||||
path = "src/bin/hive.rs"
|
||||
|
|
|
|||
43
hive-ag3nt/src/bin/hive-agent-mcp.rs
Normal file
43
hive-ag3nt/src/bin/hive-agent-mcp.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
//! MCP-server binary. Default is stdio — spawned by `claude` via
|
||||
//! `--mcp-config`; tools dispatch through `/run/hive/mcp.sock` back into
|
||||
//! the hyperhive broker. Pass `--http <addr>` to instead run a long-lived
|
||||
//! streamable-http listener (persistent daemon, the `hive-mcp-http`
|
||||
//! systemd unit) that claude reconnects to each turn, avoiding the
|
||||
//! per-turn stdio re-registration race. Sibling of `hive-agent` (the
|
||||
//! serve loop that renders the `--mcp-config` blob pointing here) and
|
||||
//! `hive-agent-wake`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use hive_ag3nt::{DEFAULT_SOCKET, mcp};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "hive-agent-mcp", about = "hyperhive MCP server")]
|
||||
struct Cli {
|
||||
/// Path to the per-agent MCP socket (bind-mounted from the host).
|
||||
#[arg(long, default_value = DEFAULT_SOCKET)]
|
||||
socket: PathBuf,
|
||||
|
||||
/// Serve over streamable-http on this address (e.g.
|
||||
/// `127.0.0.1:8790`) instead of stdio. Bind loopback only.
|
||||
#[arg(long)]
|
||||
http: Option<std::net::SocketAddr>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
match cli.http {
|
||||
Some(addr) => mcp::serve_http(cli.socket, addr).await,
|
||||
None => mcp::serve_agent_stdio(cli.socket).await,
|
||||
}
|
||||
}
|
||||
60
hive-ag3nt/src/bin/hive-agent-wake.rs
Normal file
60
hive-ag3nt/src/bin/hive-agent-wake.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
//! Wake CLI: inject a wake-up event into this container's harness inbox
|
||||
//! so the next turn fires with the given body. Intended for extra MCP
|
||||
//! servers / helpers (scraper, webhook listener, etc.) that need to
|
||||
//! nudge claude on external events; the built-in daemons (matrix, bash)
|
||||
//! talk to the socket directly instead. Sibling of `hive-agent` and
|
||||
//! `hive-agent-mcp`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use hive_ag3nt::{DEFAULT_SOCKET, client};
|
||||
use hive_sh4re::{AgentRequest, AgentResponse};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "hive-agent-wake", about = "hyperhive harness wake signal")]
|
||||
struct Cli {
|
||||
/// Path to the per-agent MCP socket (bind-mounted from the host).
|
||||
#[arg(long, default_value = DEFAULT_SOCKET)]
|
||||
socket: PathBuf,
|
||||
|
||||
#[arg(long)]
|
||||
from: String,
|
||||
|
||||
/// Body of the wake message. Pass `-` to read from stdin.
|
||||
#[arg(long)]
|
||||
body: String,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
let body = if cli.body == "-" {
|
||||
let mut buf = String::new();
|
||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
|
||||
buf
|
||||
} else {
|
||||
cli.body
|
||||
};
|
||||
let resp: AgentResponse = client::request(
|
||||
&cli.socket,
|
||||
&AgentRequest::Wake {
|
||||
from: cli.from,
|
||||
body,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
match resp {
|
||||
AgentResponse::Ok => Ok(()),
|
||||
AgentResponse::Err { message } => anyhow::bail!("wake: {message}"),
|
||||
other => anyhow::bail!("wake: unexpected response {other:?}"),
|
||||
}
|
||||
}
|
||||
663
hive-ag3nt/src/bin/hive-agent.rs
Normal file
663
hive-ag3nt/src/bin/hive-agent.rs
Normal file
|
|
@ -0,0 +1,663 @@
|
|||
//! Harness serve-loop binary. Long-polls the broker inbox and drives one
|
||||
//! claude turn per message. There is one role: agent. The `Surface`
|
||||
//! trait + `AgentSurface` zero-sized type tag keeps the turn loop
|
||||
//! generic and testable. Siblings: `hive-agent-mcp` (the MCP server this
|
||||
//! loop points claude at) and `hive-agent-wake` (external wake CLI).
|
||||
//! Architecture lives in
|
||||
//! [`docs/turn-loop.md::Harness binary shape`](../../../docs/turn-loop.md).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use hive_ag3nt::events::{Bus, LiveEvent, TurnState};
|
||||
use hive_ag3nt::login::{self, LoginState};
|
||||
use hive_ag3nt::turn_stats::TurnStats;
|
||||
use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, plugins, serve_common, turn, web_ui};
|
||||
use hive_sh4re::{AgentRequest, AgentResponse, HelperEvent, SYSTEM_SENDER};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "hive-agent", about = "hyperhive harness serve loop")]
|
||||
struct Cli {
|
||||
/// Path to the per-agent MCP socket (bind-mounted from the host).
|
||||
#[arg(long, default_value = DEFAULT_SOCKET)]
|
||||
socket: PathBuf,
|
||||
|
||||
/// Inbox poll interval in milliseconds.
|
||||
#[arg(long, default_value_t = 1000)]
|
||||
poll_ms: u64,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
serve_main::<AgentSurface>(&cli.socket, cli.poll_ms).await
|
||||
}
|
||||
|
||||
// ---------- shared turn helpers ----------
|
||||
|
||||
/// Surface a `SYSTEM_SENDER` message in the live event bus + tracing
|
||||
/// log. Both agents and the manager receive `QuestionAnswered`,
|
||||
/// `ContainerCrash`, reparent notifications, and friends; the parse
|
||||
/// and log path is identical. Quiet no-op when `from` isn't
|
||||
/// `SYSTEM_SENDER`.
|
||||
fn log_system_event(bus: &Bus, from: &str, body: &str) {
|
||||
if from != SYSTEM_SENDER {
|
||||
return;
|
||||
}
|
||||
let parsed = serde_json::from_str::<HelperEvent>(body).ok();
|
||||
if let Some(event) = parsed {
|
||||
tracing::info!(?event, "helper event");
|
||||
} else {
|
||||
tracing::info!(%from, %body, "system message");
|
||||
}
|
||||
bus.emit(LiveEvent::Note {
|
||||
text: format!("[system] {body}"),
|
||||
});
|
||||
}
|
||||
|
||||
/// Body string for the turn-failure notification we route to
|
||||
/// `<parent>` on `TurnError::Failed`. Reads the hive-qualified
|
||||
/// identity so the receiver sees `agent@hive` rather than relying on
|
||||
/// the caller threading a `label` through every turn-handling layer.
|
||||
/// Falls back to `<unknown>` when `HIVE_LABEL` is missing so a
|
||||
/// misconfigured harness still produces a parseable line.
|
||||
fn format_turn_failure(err: &anyhow::Error) -> String {
|
||||
let who = hive_ag3nt::identity::qualified_label();
|
||||
let who = if who.is_empty() {
|
||||
"<unknown>".to_owned()
|
||||
} else {
|
||||
who
|
||||
};
|
||||
format!("[system] `{who}` claude turn failed:\n{err:#}")
|
||||
}
|
||||
|
||||
/// Check for the `hyperhive-continue` sentinel under the state dir
|
||||
/// (dropped by the `request_next_turn` MCP tool). Returns true and
|
||||
/// consumes the file when present; false otherwise. Caller fires
|
||||
/// the role-specific `Wake` request — the sentinel itself is wire-
|
||||
/// agnostic so this helper lives outside both surfaces.
|
||||
fn consume_continue_sentinel() -> bool {
|
||||
let sentinel = hive_ag3nt::paths::state_dir().join("hyperhive-continue");
|
||||
if !sentinel.exists() {
|
||||
return false;
|
||||
}
|
||||
if let Err(e) = std::fs::remove_file(&sentinel) {
|
||||
tracing::warn!(error = %e, "consume_continue_sentinel: remove sentinel failed");
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// What a finished turn tells the serve loop to do next. Replaces the
|
||||
/// bare `auth_failed` bool so the loop can also act on a pending
|
||||
/// `request_next_turn` without round-tripping a synthetic message
|
||||
/// through the broker.
|
||||
struct TurnControl {
|
||||
/// The turn ended in `AuthFailed` — caller parks on login.
|
||||
auth_failed: bool,
|
||||
/// `request_next_turn` was called during the turn (the
|
||||
/// `hyperhive-continue` sentinel was dropped + consumed).
|
||||
continue_requested: bool,
|
||||
/// Inbox unread count observed right after the turn. Used to
|
||||
/// decide whether a self-continue is actually needed.
|
||||
pending: u64,
|
||||
}
|
||||
|
||||
/// Decide whether the serve loop should drive a self-continue turn
|
||||
/// in-process. A continue is only "needed" when nothing else will
|
||||
/// wake the agent: if real messages are already pending they drive
|
||||
/// the next turn(s) and the continue is dropped (matches the
|
||||
/// `request_next_turn` contract — "no effect if a new inbox message
|
||||
/// arrives before this turn ends"). Auth-failed parks the loop on
|
||||
/// login, so it suppresses the continue too.
|
||||
fn should_self_continue(ctrl: &TurnControl) -> bool {
|
||||
ctrl.continue_requested && !ctrl.auth_failed && ctrl.pending == 0
|
||||
}
|
||||
|
||||
/// Synthesize the `from: "self"` / `body: "continue"` message that a
|
||||
/// `request_next_turn` self-continue drives. Built in-process rather
|
||||
/// than fetched from the broker — it never touches the send/recv
|
||||
/// path, so it doesn't persist to sqlite or pollute the inbox.
|
||||
/// `id = 0` is a non-broker sentinel: the synthetic message
|
||||
/// has no DB row, and `AckTurn` keys off the recipient's in-flight
|
||||
/// list (which is empty here) rather than this id.
|
||||
fn synthetic_continue() -> hive_sh4re::DeliveredMessage {
|
||||
hive_sh4re::DeliveredMessage {
|
||||
from: "self".into(),
|
||||
body: "continue".into(),
|
||||
id: 0,
|
||||
redelivered: false,
|
||||
in_reply_to: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Synthetic message that drives the single stop-checkpoint turn when c0re
|
||||
/// signals a graceful stop. The agent gets one final turn to flush durable
|
||||
/// `/state` before the container is stopped; new inbound is already fenced.
|
||||
fn graceful_stop_message() -> hive_sh4re::DeliveredMessage {
|
||||
hive_sh4re::DeliveredMessage {
|
||||
from: "graceful-stop".into(),
|
||||
body: "You are being gracefully stopped — the container will shut down after this turn, \
|
||||
and new inbound messages are already fenced. This is your one checkpoint turn: \
|
||||
flush anything worth keeping into your durable /state files now — update your \
|
||||
notes / CLAUDE.md / TODO.md with in-flight task state, decisions made, important \
|
||||
file paths, and whatever you'd need to resume cleanly later with only a summary \
|
||||
of this conversation to go on. Do not start new work or reply to anyone; just \
|
||||
write your notes and end your turn. The session may be compacted after this turn \
|
||||
so a later cold start resumes cheaply."
|
||||
.into(),
|
||||
id: 0,
|
||||
redelivered: false,
|
||||
in_reply_to: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- surface trait ----------
|
||||
|
||||
/// What a `Recv` long-poll returned. Decoupled from the per-role
|
||||
/// Response enum so `serve_loop` can pattern-match without seeing
|
||||
/// either `AgentResponse` or `ManagerResponse` directly.
|
||||
enum RecvOutcome {
|
||||
/// Long-poll returned at least one message; first one is detached.
|
||||
Message(hive_sh4re::DeliveredMessage),
|
||||
/// Long-poll timed out cleanly (empty `Messages` response). Caller
|
||||
/// sleeps then retries.
|
||||
Empty,
|
||||
/// Wire returned an error / unexpected variant. Caller logs +
|
||||
/// retries; the surface impl is responsible for tracing the
|
||||
/// detail before returning this.
|
||||
TransportError,
|
||||
/// c0re signalled a graceful stop for this agent. The serve loop runs
|
||||
/// one stop-checkpoint turn (flush durable `/state`), reports
|
||||
/// `GracefulStopComplete`, and exits so the container can be stopped.
|
||||
GracefulStop,
|
||||
}
|
||||
|
||||
/// Wire surface abstraction. `AgentSurface` is the only impl — the trait
|
||||
/// exists to keep the turn loop generic and testable. Every function that
|
||||
/// talks to the broker goes through this so there are zero hard-coded
|
||||
/// `AgentRequest` / `AgentResponse` references in the turn loop itself.
|
||||
trait Surface {
|
||||
/// Ack the in-flight turn. Logs warnings on transport/broker
|
||||
/// errors but never propagates — turn loop continues either way.
|
||||
fn ack_turn(socket: &Path) -> impl Future<Output = ()>;
|
||||
|
||||
/// Requeue any messages that were "in-flight" (delivered but not
|
||||
/// ack'd) — fires on harness boot to recover from a crash mid-turn.
|
||||
fn requeue_inflight(socket: &Path) -> impl Future<Output = ()>;
|
||||
|
||||
/// Current inbox unread count via `Status`. Returns 0 on any
|
||||
/// transport/wire error so the caller falls through cleanly.
|
||||
fn inbox_unread(socket: &Path) -> impl Future<Output = u64>;
|
||||
|
||||
/// `(open_threads, open_reminders)` for the post-turn stats row.
|
||||
/// Either field is `None` when the underlying request errors.
|
||||
fn post_turn_counts(socket: &Path) -> impl Future<Output = (Option<u64>, Option<u64>)>;
|
||||
|
||||
/// Tell c0re the graceful-stop checkpoint is done and the harness is
|
||||
/// exiting its serve loop (fire-and-forget; logs on error). Lets the
|
||||
/// `GracefulStop` orchestration stop the container without waiting out
|
||||
/// its timeout fallback.
|
||||
fn graceful_stop_complete(socket: &Path) -> impl Future<Output = ()>;
|
||||
|
||||
/// Send a message addressed to `<parent>` (broker resolves the
|
||||
/// sentinel via `topology::parent_of` at delivery time; root
|
||||
/// agents/manager fall through to operator).
|
||||
fn send_to_parent(socket: &Path, body: String) -> impl Future<Output = ()>;
|
||||
|
||||
/// Long-poll the broker for the next message. Wraps the
|
||||
/// `Messages`/empty/error trichotomy in `RecvOutcome` so the
|
||||
/// generic `serve_loop` doesn't need the per-role Response enum
|
||||
/// at all.
|
||||
fn recv_next(socket: &Path) -> impl Future<Output = RecvOutcome>;
|
||||
}
|
||||
|
||||
// ---------- AgentSurface ----------
|
||||
|
||||
/// Zero-sized type tag for the agent wire surface.
|
||||
/// Talks `AgentRequest` / `AgentResponse`.
|
||||
struct AgentSurface;
|
||||
|
||||
/// Issue an `Ok`-expecting fire-and-forget broker request, logging any
|
||||
/// rejection / unexpected response / transport error under `label`. Shared by
|
||||
/// the `Surface` methods that don't need the reply (`ack_turn`,
|
||||
/// `requeue_inflight`, `graceful_stop_complete`).
|
||||
async fn fire_and_forget(socket: &Path, req: AgentRequest, label: &str) {
|
||||
match client::request::<_, AgentResponse>(socket, &req).await {
|
||||
Ok(AgentResponse::Ok) => {}
|
||||
Ok(AgentResponse::Err { message }) => {
|
||||
tracing::warn!(%message, "{label} rejected by broker");
|
||||
}
|
||||
Ok(other) => tracing::warn!(?other, "{label} unexpected response"),
|
||||
Err(e) => tracing::warn!(error = ?e, "{label} transport error"),
|
||||
}
|
||||
}
|
||||
|
||||
impl Surface for AgentSurface {
|
||||
async fn ack_turn(socket: &Path) {
|
||||
fire_and_forget(socket, AgentRequest::AckTurn, "ack_turn").await;
|
||||
}
|
||||
|
||||
async fn requeue_inflight(socket: &Path) {
|
||||
fire_and_forget(socket, AgentRequest::RequeueInflight, "requeue_inflight").await;
|
||||
}
|
||||
|
||||
async fn graceful_stop_complete(socket: &Path) {
|
||||
fire_and_forget(
|
||||
socket,
|
||||
AgentRequest::GracefulStopComplete,
|
||||
"graceful_stop_complete",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn inbox_unread(socket: &Path) -> u64 {
|
||||
match client::request::<_, AgentResponse>(socket, &AgentRequest::Status).await {
|
||||
Ok(AgentResponse::Status { unread }) => unread,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
async fn post_turn_counts(socket: &Path) -> (Option<u64>, Option<u64>) {
|
||||
let threads = match client::request::<_, AgentResponse>(
|
||||
socket,
|
||||
&AgentRequest::GetLooseEnds { agent: None },
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(AgentResponse::LooseEnds { loose_ends }) => u64::try_from(loose_ends.len()).ok(),
|
||||
_ => None,
|
||||
};
|
||||
let reminders = match client::request::<_, AgentResponse>(
|
||||
socket,
|
||||
&AgentRequest::CountPendingReminders { agent: None },
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(AgentResponse::PendingRemindersCount { count }) => Some(count),
|
||||
_ => None,
|
||||
};
|
||||
(threads, reminders)
|
||||
}
|
||||
|
||||
async fn send_to_parent(socket: &Path, body: String) {
|
||||
let res = client::request::<_, AgentResponse>(
|
||||
socket,
|
||||
&AgentRequest::Send {
|
||||
to: hive_sh4re::PARENT_RECIPIENT.into(),
|
||||
body,
|
||||
in_reply_to: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = res {
|
||||
tracing::warn!(error = ?e, "failed to notify parent of turn failure");
|
||||
}
|
||||
}
|
||||
|
||||
async fn recv_next(socket: &Path) -> RecvOutcome {
|
||||
let recv: Result<AgentResponse> = client::request(
|
||||
socket,
|
||||
&AgentRequest::Recv {
|
||||
wait_seconds: Some(180),
|
||||
max: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
match recv {
|
||||
Ok(AgentResponse::Messages { messages }) if !messages.is_empty() => {
|
||||
let first = messages.into_iter().next().expect("checked non-empty");
|
||||
RecvOutcome::Message(first)
|
||||
}
|
||||
Ok(AgentResponse::Messages { .. }) => RecvOutcome::Empty,
|
||||
Ok(AgentResponse::GracefulStop) => RecvOutcome::GracefulStop,
|
||||
Ok(AgentResponse::Err { message }) => {
|
||||
tracing::warn!(%message, "recv error");
|
||||
RecvOutcome::TransportError
|
||||
}
|
||||
Ok(other) => {
|
||||
tracing::warn!(?other, "recv produced unexpected response kind");
|
||||
RecvOutcome::TransportError
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "recv failed; retrying");
|
||||
RecvOutcome::TransportError
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- generic turn loop ----------
|
||||
|
||||
/// Boot — wires up the web UI, login state, stats, plugins, forge
|
||||
/// notifier, and either drops into `serve_loop` directly (`Online`) or
|
||||
/// parks on the login flow first (`NeedsLogin`). See
|
||||
/// `docs/turn-loop.md::Boot wiring`.
|
||||
async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
|
||||
let port = std::env::var("HIVE_PORT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u16>().ok())
|
||||
.unwrap_or(DEFAULT_WEB_PORT);
|
||||
// `HIVE_LABEL` is set unconditionally by the meta-flake envelope
|
||||
// for any container-deployed agent; the `"hive"` fallback here
|
||||
// covers standalone `nix run .#hive` invocations and pre-meta
|
||||
// dev shells. Role-independent: no semantic reason for the
|
||||
// fallback to differ when the env var is missing.
|
||||
let label = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "hive".into());
|
||||
let claude_dir = login::default_dir();
|
||||
let initial = LoginState::from_dir(&claude_dir);
|
||||
tracing::info!(state = ?initial, claude_dir = %claude_dir.display(), "harness boot");
|
||||
let login_state = Arc::new(Mutex::new(initial));
|
||||
let bus = Bus::new();
|
||||
let stats = TurnStats::open_default();
|
||||
if let Some(s) = &stats {
|
||||
let (ctx, cost) = s.last_usage();
|
||||
if ctx.is_some() || cost.is_some() {
|
||||
bus.seed_usage(ctx, cost);
|
||||
}
|
||||
}
|
||||
let files = turn::TurnFiles::prepare(socket, &label).await?;
|
||||
// Plugin install failures come back as a Vec<String> — route each
|
||||
// through `<parent>` via the `send_to_parent` failure-notify path.
|
||||
// The broker resolves `<parent>` per `topology::parent_of`;
|
||||
// root agents fall through to operator.
|
||||
for failure in plugins::install_configured().await {
|
||||
S::send_to_parent(socket, failure).await;
|
||||
}
|
||||
tokio::spawn(hive_ag3nt::forge_notify::run(socket.to_path_buf()));
|
||||
// Agent-side cleanup of this agent's own harness artifacts (completed
|
||||
// bash-task files + verbose event rows). Runs here, not host-side in
|
||||
// hive-c0re, because the files are agent-owned — see `vacuum` module docs.
|
||||
tokio::spawn(hive_ag3nt::vacuum::run());
|
||||
// Log web_ui::serve's error instead of dropping it. A bare
|
||||
// `tokio::spawn(web_ui::serve(...))` discards the JoinHandle, so
|
||||
// any Err (e.g. EACCES from `bind_unix` when HIVE_WEB_SOCKET points
|
||||
// at a dir the agent user can't write) vanishes — leaving an
|
||||
// operator with no log line and no socket, debuggable only by
|
||||
// staring at lifecycle.rs.
|
||||
let web_ui_args = (
|
||||
label.clone(),
|
||||
port,
|
||||
login_state.clone(),
|
||||
bus.clone(),
|
||||
socket.to_path_buf(),
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
let (label, port, login_state, bus, socket) = web_ui_args;
|
||||
if let Err(e) = web_ui::serve(label, port, login_state, bus, socket).await {
|
||||
tracing::error!(error = %e, "web_ui::serve exited with error");
|
||||
}
|
||||
});
|
||||
if matches!(initial, LoginState::NeedsLogin) {
|
||||
login::wait_for_login(&claude_dir, login_state.clone(), &bus, poll_ms).await;
|
||||
} else {
|
||||
// Clear any stale `hyperhive-needs-login` sentinel left over
|
||||
// from a prior boot — `online` status writes the sentinel
|
||||
// cleanup in `Bus::emit_status`.
|
||||
bus.emit_status("online");
|
||||
}
|
||||
serve_loop::<S>(
|
||||
socket,
|
||||
Duration::from_millis(poll_ms),
|
||||
login_state,
|
||||
claude_dir,
|
||||
bus,
|
||||
stats,
|
||||
&files,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// The long-running message loop. Long-polls the broker via
|
||||
/// `S::recv_next`, drives a turn per message, parks on auth-failed,
|
||||
/// otherwise retries.
|
||||
#[allow(
|
||||
clippy::too_many_arguments,
|
||||
reason = "the harness's long-lived deps threaded into one serve loop, \
|
||||
wired once from main; bundling into a struct would just move the \
|
||||
same fields one level out (cf. Coordinator::open)"
|
||||
)]
|
||||
async fn serve_loop<S: Surface>(
|
||||
socket: &Path,
|
||||
interval: Duration,
|
||||
login_state: Arc<Mutex<LoginState>>,
|
||||
claude_dir: std::path::PathBuf,
|
||||
bus: Bus,
|
||||
stats: Option<TurnStats>,
|
||||
files: &turn::TurnFiles,
|
||||
) -> Result<()> {
|
||||
tracing::info!(socket = %socket.display(), "harness serve");
|
||||
S::requeue_inflight(socket).await;
|
||||
// The durable claude session, built once and reused for every turn +
|
||||
// idle compaction below (it's effectively stateless).
|
||||
let session = turn::make_session(&bus);
|
||||
// Set when a turn calls `request_next_turn` and no real work is
|
||||
// pending — the next iteration drives this synthetic message
|
||||
// in-process instead of long-polling the broker. Never
|
||||
// persisted: it lives entirely in this loop's stack.
|
||||
let mut self_continue: Option<hive_sh4re::DeliveredMessage> = None;
|
||||
loop {
|
||||
let next = match self_continue.take() {
|
||||
Some(msg) => msg,
|
||||
None => match S::recv_next(socket).await {
|
||||
RecvOutcome::Message(first) => first,
|
||||
RecvOutcome::Empty => {
|
||||
// Idle: no message this poll. Service a queued operator
|
||||
// `/compact` here so it runs even when no turn is driving
|
||||
// (the in-flight case is handled at the end of drive_turn).
|
||||
let compacted = turn::run_pending_compact(files, &bus, &session).await;
|
||||
if !compacted {
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
RecvOutcome::TransportError => {
|
||||
// `recv_next` already logged the detail; just retry.
|
||||
// No backoff: the long-poll wait is itself the throttle.
|
||||
continue;
|
||||
}
|
||||
RecvOutcome::GracefulStop => {
|
||||
// c0re fenced our inbox and wants a clean stop. Run one
|
||||
// checkpoint turn so the agent flushes durable /state,
|
||||
// report completion, then exit the loop → the harness
|
||||
// process ends and the container can be stopped.
|
||||
tracing::info!(
|
||||
"graceful stop signalled — running stop-checkpoint turn, then exiting"
|
||||
);
|
||||
let _ = handle_turn::<S>(
|
||||
socket,
|
||||
&bus,
|
||||
stats.as_ref(),
|
||||
files,
|
||||
&session,
|
||||
graceful_stop_message(),
|
||||
)
|
||||
.await;
|
||||
S::graceful_stop_complete(socket).await;
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
};
|
||||
let ctrl = handle_turn::<S>(socket, &bus, stats.as_ref(), files, &session, next).await;
|
||||
if ctrl.auth_failed {
|
||||
*login_state.lock().unwrap() = LoginState::NeedsLogin;
|
||||
login::wait_for_login(
|
||||
&claude_dir,
|
||||
login_state.clone(),
|
||||
&bus,
|
||||
u64::try_from(interval.as_millis()).unwrap_or(2000),
|
||||
)
|
||||
.await;
|
||||
} else if should_self_continue(&ctrl) {
|
||||
tracing::info!("request_next_turn: driving self-continue turn in-process");
|
||||
self_continue = Some(synthetic_continue());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive a single turn: emit boot-of-turn events, run claude, ack on
|
||||
/// success / requeue on rate-limit-or-401 / notify parent on failure,
|
||||
/// record stats, then pick up the `request_next_turn` sentinel if it's
|
||||
/// been dropped during the turn. Returns a `TurnControl` carrying the
|
||||
/// auth-failed flag, whether a self-continue was requested, and the
|
||||
/// post-turn inbox count — the serve loop decides what to do next.
|
||||
async fn handle_turn<S: Surface>(
|
||||
socket: &Path,
|
||||
bus: &Bus,
|
||||
stats: Option<&TurnStats>,
|
||||
files: &turn::TurnFiles,
|
||||
session: &turn::AgentSession,
|
||||
first: hive_sh4re::DeliveredMessage,
|
||||
) -> TurnControl {
|
||||
let from = first.from;
|
||||
let body = first.body;
|
||||
let redelivered = first.redelivered;
|
||||
let msg_id = first.id;
|
||||
log_system_event(bus, &from, &body);
|
||||
tracing::info!(%from, %body, %redelivered, "inbox");
|
||||
let unread = S::inbox_unread(socket).await;
|
||||
bus.emit(LiveEvent::TurnStart {
|
||||
from: from.clone(),
|
||||
body: body.clone(),
|
||||
unread,
|
||||
});
|
||||
bus.set_state(TurnState::Thinking);
|
||||
let started_at = serve_common::now_unix();
|
||||
let started_instant = std::time::Instant::now();
|
||||
let model_at_start = bus.model();
|
||||
let prompt = serve_common::format_wake_prompt(msg_id, &from, &body, unread, redelivered);
|
||||
let outcome = turn::drive_turn(&prompt, files, bus, session).await;
|
||||
turn::emit_turn_end(bus, &outcome);
|
||||
bus.set_state(TurnState::Idle);
|
||||
if outcome.is_ok() {
|
||||
S::ack_turn(socket).await;
|
||||
}
|
||||
if matches!(outcome, Err(turn::TurnError::RateLimited)) {
|
||||
let secs = turn::rate_limit_sleep_secs();
|
||||
bus.emit_status("rate_limited");
|
||||
bus.emit(LiveEvent::Note {
|
||||
text: format!("API rate-limited — sleeping {secs}s before retry"),
|
||||
});
|
||||
tracing::warn!(sleep_secs = secs, "rate-limited; parking");
|
||||
tokio::time::sleep(Duration::from_secs(secs)).await;
|
||||
S::requeue_inflight(socket).await;
|
||||
bus.emit_status("online");
|
||||
}
|
||||
if matches!(outcome, Err(turn::TurnError::AuthFailed)) {
|
||||
bus.emit_status("needs_login_idle");
|
||||
bus.emit(LiveEvent::Note {
|
||||
text: "API 401 — waiting for re-login via web UI".into(),
|
||||
});
|
||||
tracing::warn!("auth-failed; parking until re-login");
|
||||
S::requeue_inflight(socket).await;
|
||||
}
|
||||
if matches!(outcome, Err(turn::TurnError::PromptTooLong)) {
|
||||
// `drive_turn` already archived the session; requeue the message so it
|
||||
// redelivers into the fresh session (which fits — the wake prompt is
|
||||
// tiny, the overflow was the now-cleared context). No status park: the
|
||||
// agent is healthy, it just needs one more delivery.
|
||||
tracing::warn!("prompt-too-long; session archived, requeueing message for a fresh turn");
|
||||
S::requeue_inflight(socket).await;
|
||||
}
|
||||
if matches!(outcome, Err(turn::TurnError::SessionNotFound)) {
|
||||
// "Shouldn't happen": resume missed and the lib's create self-heal
|
||||
// didn't resolve it. Requeue rather than ack-and-drop so the wake
|
||||
// message isn't silently lost; the next turn creates the session fresh.
|
||||
tracing::warn!("session-not-found; requeueing message for a fresh turn");
|
||||
S::requeue_inflight(socket).await;
|
||||
}
|
||||
if let Err(turn::TurnError::Failed(e)) = &outcome {
|
||||
S::send_to_parent(socket, format_turn_failure(e)).await;
|
||||
}
|
||||
if let Some(stats) = stats {
|
||||
// Fresh session this turn → mint a `sessions` row and set its id on
|
||||
// the bus so this turn (and subsequent ones until the next fresh
|
||||
// start) stamp `turn_stats.session_id`. Takes the one-shot flag
|
||||
// `run_claude` set when it suppressed `--continue`.
|
||||
if bus.take_fresh_session() {
|
||||
let sid = stats.start_session(started_at, &model_at_start);
|
||||
bus.set_session_id(sid);
|
||||
}
|
||||
let ended_at = serve_common::now_unix();
|
||||
let duration_ms = i64::try_from(started_instant.elapsed().as_millis()).unwrap_or(i64::MAX);
|
||||
let (open_threads, open_reminders) = S::post_turn_counts(socket).await;
|
||||
let row = serve_common::build_row(serve_common::TurnRowArgs {
|
||||
started_at,
|
||||
ended_at,
|
||||
duration_ms,
|
||||
model: model_at_start,
|
||||
wake_from: from.clone(),
|
||||
outcome: &outcome,
|
||||
bus,
|
||||
open_threads_count: open_threads,
|
||||
open_reminders_count: open_reminders,
|
||||
});
|
||||
stats.record(&row);
|
||||
}
|
||||
let pending = S::inbox_unread(socket).await;
|
||||
if pending > 0 {
|
||||
tracing::info!(%pending, "pending messages after turn; fetching next");
|
||||
}
|
||||
TurnControl {
|
||||
auth_failed: matches!(outcome, Err(turn::TurnError::AuthFailed)),
|
||||
continue_requested: consume_continue_sentinel(),
|
||||
pending,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod continue_tests {
|
||||
use super::{TurnControl, should_self_continue, synthetic_continue};
|
||||
|
||||
fn ctrl(auth_failed: bool, continue_requested: bool, pending: u64) -> TurnControl {
|
||||
TurnControl {
|
||||
auth_failed,
|
||||
continue_requested,
|
||||
pending,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_continue_when_requested_and_inbox_empty() {
|
||||
assert!(should_self_continue(&ctrl(false, true, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_self_continue_when_not_requested() {
|
||||
assert!(!should_self_continue(&ctrl(false, false, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_self_continue_when_real_messages_pending() {
|
||||
// A real message will drive the next turn via recv — the
|
||||
// continue is superseded, not needed (request_next_turn contract).
|
||||
assert!(!should_self_continue(&ctrl(false, true, 3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_self_continue_when_auth_failed() {
|
||||
// Auth-failed parks the loop on login; a queued continue must
|
||||
// not jump the gate.
|
||||
assert!(!should_self_continue(&ctrl(true, true, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_continue_shape() {
|
||||
let m = synthetic_continue();
|
||||
assert_eq!(m.from, "self");
|
||||
assert_eq!(m.body, "continue");
|
||||
assert_eq!(m.id, 0);
|
||||
assert!(!m.redelivered);
|
||||
assert!(m.in_reply_to.is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -52,7 +52,7 @@ const TOKEN_RETRY_MAX: u32 = 20;
|
|||
/// claim it's "new" — see docs/forge.md, "new vs activity on".
|
||||
const NEW_ITEM_TOLERANCE_SECS: i64 = 120;
|
||||
|
||||
/// Spawn point: called once from `hive serve`. Returns immediately if the forge is not
|
||||
/// Spawn point: called once from the `hive-agent` serve loop. Returns immediately if the forge is not
|
||||
/// configured. Otherwise loops forever, polling every
|
||||
/// `POLL_INTERVAL_SECS` seconds. Errors are never fatal.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! Shared in-container harness code for the single `hive` binary that
|
||||
//! serves every agent role (the manager is just an agent role, not a
|
||||
//! separate binary).
|
||||
//! Shared in-container harness code for the sibling `hive-agent` /
|
||||
//! `hive-agent-mcp` / `hive-agent-wake` binaries, which serve every
|
||||
//! agent role (the manager is just an agent role, not a separate set
|
||||
//! of binaries).
|
||||
|
||||
pub mod client;
|
||||
pub mod events;
|
||||
|
|
|
|||
|
|
@ -330,13 +330,13 @@ fn load_extra_mcp() -> std::collections::BTreeMap<String, ExtraMcpServer> {
|
|||
}
|
||||
|
||||
/// Render the MCP config blob claude reads from `--mcp-config <path>`.
|
||||
/// `agent_binary` is the path (or PATH-resolvable name) of the `hive-ag3nt`
|
||||
/// executable; `socket` is the hyperhive per-agent socket bind-mounted into
|
||||
/// the container (forwarded to the child as `--socket <path>`). Merges in
|
||||
/// any extra MCP servers declared via `hyperhive.extraMcpServers` in the
|
||||
/// agent's NixOS config.
|
||||
/// `mcp_binary` is the path (or PATH-resolvable name) of the
|
||||
/// `hive-agent-mcp` bridge executable; `socket` is the hyperhive per-agent
|
||||
/// socket bind-mounted into the container (forwarded to the child as
|
||||
/// `--socket <path>`). Merges in any extra MCP servers declared via
|
||||
/// `hyperhive.extraMcpServers` in the agent's NixOS config.
|
||||
#[must_use]
|
||||
pub fn render_claude_config(agent_binary: &str, socket: &std::path::Path) -> String {
|
||||
pub fn render_claude_config(mcp_binary: &str, socket: &std::path::Path) -> String {
|
||||
let mut servers = serde_json::Map::new();
|
||||
// When the harness is configured to run the built-in server as a
|
||||
// persistent streamable-http daemon (loopback port in
|
||||
|
|
@ -353,8 +353,8 @@ pub fn render_claude_config(agent_binary: &str, socket: &std::path::Path) -> Str
|
|||
"url": format!("http://127.0.0.1:{port}/mcp"),
|
||||
}),
|
||||
None => serde_json::json!({
|
||||
"command": agent_binary,
|
||||
"args": ["--socket", socket.display().to_string(), "mcp"],
|
||||
"command": mcp_binary,
|
||||
"args": ["--socket", socket.display().to_string()],
|
||||
"env": {}
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
//! Pure helpers factored out of the `hive` serve loop (`bin/hive.rs`).
|
||||
//! Pure helpers factored out of the harness serve loop
|
||||
//! (`bin/hive-agent.rs`).
|
||||
//! Only functions with no wire-type dependency live here;
|
||||
//! request/response-flavored helpers (`requeue_inflight`, `ack_turn`, etc.)
|
||||
//! stay in the binary because they touch the request enum variants directly.
|
||||
|
|
@ -60,7 +61,7 @@ pub struct TurnRowArgs<'a> {
|
|||
}
|
||||
|
||||
/// Assemble a `TurnStatRow` from the harness's per-turn state. Lives here
|
||||
/// (rather than inline in the `hive` serve loop) so it stays wire-type-free
|
||||
/// (rather than inline in the serve loop) so it stays wire-type-free
|
||||
/// and unit-testable; the binary just feeds it the post-turn counts.
|
||||
#[must_use]
|
||||
pub fn build_row(args: TurnRowArgs<'_>) -> TurnStatRow {
|
||||
|
|
|
|||
|
|
@ -108,8 +108,9 @@ impl TurnFiles {
|
|||
|
||||
/// Drop the MCP config blob claude reads from `--mcp-config <path>`.
|
||||
/// `socket` is the hyperhive per-container socket (forwarded to the child
|
||||
/// as `--socket <path>`). The MCP subcommand is always `mcp` on the single
|
||||
/// `hive` binary resolved from `/proc/self/exe`.
|
||||
/// as `--socket <path>`). The MCP server is the `hive-agent-mcp` binary
|
||||
/// installed next to the running `hive-agent` (resolved as a sibling of
|
||||
/// `/proc/self/exe`; PATH-resolvable name as the fallback).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
|
|
@ -120,7 +121,8 @@ pub async fn write_mcp_config(socket: &Path) -> Result<PathBuf> {
|
|||
let path = parent.join("claude-mcp-config.json");
|
||||
let exe = std::env::current_exe()
|
||||
.ok()
|
||||
.map_or_else(|| "hive".into(), |p| p.display().to_string());
|
||||
.and_then(|p| Some(p.parent()?.join("hive-agent-mcp")))
|
||||
.map_or_else(|| "hive-agent-mcp".into(), |p| p.display().to_string());
|
||||
let body = mcp_config::render_claude_config(&exe, socket);
|
||||
tokio::fs::write(&path, body).await?;
|
||||
tracing::info!(path = %path.display(), "wrote claude MCP config");
|
||||
|
|
@ -146,7 +148,7 @@ pub async fn write_system_prompt(socket: &Path, label: &str) -> Result<PathBuf>
|
|||
/// `result_kind = "compacted"` in turn stats so the stats page can distinguish
|
||||
/// those turns. Both `Ok(true)` and `Ok(false)` are ack'd; the error cases
|
||||
/// each map to a distinct serve-loop action (see [`emit_turn_end`] and the
|
||||
/// `hive` serve loop).
|
||||
/// `hive-agent` serve loop).
|
||||
pub type TurnOutcome = std::result::Result<bool, TurnError>;
|
||||
|
||||
/// The ways a turn can end without a usable result. Each is deliberately *not*
|
||||
|
|
@ -287,7 +289,7 @@ pub fn make_session(bus: &Bus) -> AgentSession {
|
|||
/// the whole turn is retried a single time before bubbling `AuthFailed` to
|
||||
/// the serve loop (which parks for re-login).
|
||||
///
|
||||
/// Called once per turn by the `hive` serve loop, which owns the shared
|
||||
/// Called once per turn by the `hive-agent` serve loop, which owns the shared
|
||||
/// `session` ([`make_session`]) and threads it in.
|
||||
pub async fn drive_turn(
|
||||
prompt: &str,
|
||||
|
|
|
|||
|
|
@ -898,12 +898,13 @@ in
|
|||
streamable-http daemon on this loopback port instead of the
|
||||
default per-turn stdio child.
|
||||
|
||||
When null (the default) claude spawns a fresh `hive … mcp` stdio
|
||||
subprocess every turn — the shape that carries the per-turn MCP
|
||||
re-registration race (a resumed turn can emit its first tool call
|
||||
before that turn's async `initialize`/`tools-list` completes,
|
||||
When null (the default) claude spawns a fresh `hive-agent-mcp`
|
||||
stdio subprocess every turn — the shape that carries the per-turn
|
||||
MCP re-registration race (a resumed turn can emit its first tool
|
||||
call before that turn's async `initialize`/`tools-list` completes,
|
||||
stranding the agent with `No such tool`). When set, a long-lived
|
||||
`hive-mcp-http` systemd unit runs `hive mcp --http 127.0.0.1:<port>`
|
||||
`hive-mcp-http` systemd unit runs
|
||||
`hive-agent-mcp --http 127.0.0.1:<port>`
|
||||
and `render_claude_config` points claude at the stable
|
||||
`http://127.0.0.1:<port>/mcp` URL, which survives the per-turn
|
||||
claude re-spawn (and a host-side hive-c0re restart — each tool call
|
||||
|
|
@ -1846,7 +1847,7 @@ in
|
|||
# `render_claude_config`). Long-lived so claude reconnects to the
|
||||
# stable URL each turn instead of respawning + re-registering a stdio
|
||||
# subprocess (the per-turn MCP registration race). It dials the
|
||||
# control socket (`/run/hive/mcp.sock`, the `hive` binary default)
|
||||
# control socket (`/run/hive/mcp.sock`, the harness binaries' default)
|
||||
# fresh on every tool call, so a host-side hive-c0re restart is
|
||||
# transparent. `before = hive-ag3nt` so the URL is already listening
|
||||
# by the time the harness renders the first turn's config; the
|
||||
|
|
@ -1858,7 +1859,7 @@ in
|
|||
before = [ "hive-ag3nt.service" ];
|
||||
environment.RUST_LOG = "info";
|
||||
serviceConfig = {
|
||||
ExecStart = "${pkgs.hyperhive}/bin/hive mcp --http 127.0.0.1:${toString config.hyperhive.mcp.httpPort}";
|
||||
ExecStart = "${pkgs.hyperhive}/bin/hive-agent-mcp --http 127.0.0.1:${toString config.hyperhive.mcp.httpPort}";
|
||||
SyslogIdentifier = "hive-mcp-http";
|
||||
# `always` (not `on-failure`): this endpoint is load-bearing when
|
||||
# `httpPort` is set — a down window is total hyperhive-MCP loss with
|
||||
|
|
@ -1936,7 +1937,7 @@ in
|
|||
# appends /bin to every entry.
|
||||
systemd.services.hive-ag3nt =
|
||||
let
|
||||
binary = "hive";
|
||||
binary = "hive-agent";
|
||||
# OTEL is shipped declaratively via the managed claude settings
|
||||
# json (`environment.etc."claude-code/managed-settings.json"`,
|
||||
# `otelSettingsEnv` in the top-level let) — claude reads it for
|
||||
|
|
@ -1982,7 +1983,7 @@ in
|
|||
HIVE_GUI_VNC_PORT = toString config.hyperhive.gui.vncPort;
|
||||
};
|
||||
serviceConfig = {
|
||||
ExecStart = "${pkgs.hyperhive}/bin/${binary} serve";
|
||||
ExecStart = "${pkgs.hyperhive}/bin/${binary}";
|
||||
# Pin the journal identity to the binary name (otherwise systemd
|
||||
# derives SyslogIdentifier from the ExecStart basename).
|
||||
SyslogIdentifier = binary;
|
||||
|
|
|
|||
Loading…
Reference in a new issue