refactor(#2464): rename hive-ag3nt crate to hive-agent, collapse lib into main
This commit is contained in:
parent
7b54e7aa50
commit
3f1643c594
57 changed files with 101 additions and 130 deletions
167
hive-agent/src/client.rs
Normal file
167
hive-agent/src/client.rs
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
|
||||
/// Backoff schedule between attempts. Five entries → up to 5 retries on
|
||||
/// top of the initial attempt; total wall-clock cap = 2+4+8+16+30 = 60s.
|
||||
/// Sized to ride out a hive-c0re restart (systemd usually has the unix
|
||||
/// socket back inside ~5s) without the agent-side claude session having
|
||||
/// to handle the transient itself — burning tokens on a tool-error retry
|
||||
/// loop is more expensive than 60s of in-harness sleep.
|
||||
const RETRY_BACKOFFS_MS: &[u64] = &[2_000, 4_000, 8_000, 16_000, 30_000];
|
||||
|
||||
/// Transparent retry wrapper around [`request_retried`] that throws away
|
||||
/// the retry count. Use this from non-tool callers (the harness serve
|
||||
/// loop, web UI, CLI subcommands) where we just want the socket-restart
|
||||
/// resilience without surfacing the bookkeeping.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the socket is unreachable after all retries, or if
|
||||
/// serialization / deserialization of the request or response fails.
|
||||
pub async fn request<Req, Resp>(socket: &Path, req: &Req) -> Result<Resp>
|
||||
where
|
||||
Req: Serialize + ?Sized,
|
||||
Resp: DeserializeOwned,
|
||||
{
|
||||
request_retried(socket, req).await.map(|(resp, _)| resp)
|
||||
}
|
||||
|
||||
/// Same wire shape as [`request`], but reports how many retries it took
|
||||
/// past the initial attempt (0 = succeeded first try). MCP tool handlers
|
||||
/// use this so they can append a one-line hint to the tool result when
|
||||
/// retries happened — that way claude knows the prior socket flake
|
||||
/// wasn't a content error and shouldn't trigger an LLM-level retry of
|
||||
/// its own.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if all retries are exhausted, or on a fatal protocol
|
||||
/// error (serialization / deserialization failure).
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `RETRY_BACKOFFS_MS.len()` does not fit in a `u32`, which
|
||||
/// cannot happen with the current compile-time constant.
|
||||
pub async fn request_retried<Req, Resp>(socket: &Path, req: &Req) -> Result<(Resp, u32)>
|
||||
where
|
||||
Req: Serialize + ?Sized,
|
||||
Resp: DeserializeOwned,
|
||||
{
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
let max_retries = u32::try_from(RETRY_BACKOFFS_MS.len()).unwrap();
|
||||
for attempt in 0..=max_retries {
|
||||
match try_once::<Req, Resp>(socket, req).await {
|
||||
Ok(resp) => return Ok((resp, attempt)),
|
||||
Err(RequestError::Fatal(e)) => return Err(e),
|
||||
Err(RequestError::Transient(e)) => {
|
||||
if attempt < max_retries {
|
||||
let sleep_ms = RETRY_BACKOFFS_MS[attempt as usize];
|
||||
tracing::warn!(
|
||||
attempt = attempt + 1,
|
||||
sleep_ms,
|
||||
error = %e,
|
||||
"hive socket attempt failed; retrying"
|
||||
);
|
||||
last_err = Some(e);
|
||||
tokio::time::sleep(Duration::from_millis(sleep_ms)).await;
|
||||
} else {
|
||||
last_err = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reaching here means the final attempt returned `Transient`, which always
|
||||
// sets `last_err` — so this is infallible.
|
||||
Err(last_err.expect("a transient failure on the final attempt set last_err"))
|
||||
}
|
||||
|
||||
/// Transient = connect / IO error worth a retry (server restart, broken
|
||||
/// pipe). Fatal = serialization / deserialization / protocol error
|
||||
/// where retrying would just repeat the same failure.
|
||||
enum RequestError {
|
||||
Transient(anyhow::Error),
|
||||
Fatal(anyhow::Error),
|
||||
}
|
||||
|
||||
async fn try_once<Req, Resp>(socket: &Path, req: &Req) -> Result<Resp, RequestError>
|
||||
where
|
||||
Req: Serialize + ?Sized,
|
||||
Resp: DeserializeOwned,
|
||||
{
|
||||
let stream = match UnixStream::connect(socket).await {
|
||||
Ok(stream) => stream,
|
||||
Err(e) => {
|
||||
// A refused or missing socket usually means hive-c0re is
|
||||
// mid-restart (operator redeploy / rebuild) — the socket is
|
||||
// recreated on its boot and `request_retried` rides it out. When
|
||||
// the error *does* surface (retries exhausted, or a non-retried
|
||||
// caller) add that context so claude reads it as a likely
|
||||
// transient rather than a hard failure worth escalating.
|
||||
let restarting = matches!(
|
||||
e.kind(),
|
||||
std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound
|
||||
);
|
||||
let mut err = anyhow::Error::new(e).context(format!("connect to {}", socket.display()));
|
||||
if restarting {
|
||||
err = err.context(
|
||||
"hive-c0re may be restarting (e.g. an operator redeploy); \
|
||||
the harness already retried ~60s before surfacing this",
|
||||
);
|
||||
}
|
||||
return Err(RequestError::Transient(err));
|
||||
}
|
||||
};
|
||||
let (read, mut write) = stream.into_split();
|
||||
|
||||
let mut payload = serde_json::to_string(req).map_err(|e| RequestError::Fatal(e.into()))?;
|
||||
payload.push('\n');
|
||||
write
|
||||
.write_all(payload.as_bytes())
|
||||
.await
|
||||
.map_err(|e| RequestError::Transient(e.into()))?;
|
||||
write
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| RequestError::Transient(e.into()))?;
|
||||
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut line = String::new();
|
||||
let read_bytes = reader
|
||||
.read_line(&mut line)
|
||||
.await
|
||||
.map_err(|e| RequestError::Transient(e.into()))?;
|
||||
if read_bytes == 0 || line.is_empty() {
|
||||
return Err(RequestError::Transient(anyhow!(
|
||||
"server closed connection without responding"
|
||||
)));
|
||||
}
|
||||
serde_json::from_str(line.trim()).map_err(|e| RequestError::Fatal(e.into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{RequestError, try_once};
|
||||
|
||||
/// A connect to a non-existent socket path (ENOENT → `NotFound`) is
|
||||
/// classified transient AND annotated with the "hive-c0re is restarting"
|
||||
/// hint, so a surfaced tool error reads as the expected transient.
|
||||
#[tokio::test]
|
||||
async fn missing_socket_connect_is_transient_with_restart_hint() {
|
||||
let bogus = std::path::Path::new("/nonexistent/hive/mcp.sock");
|
||||
match try_once::<(), serde_json::Value>(bogus, &()).await {
|
||||
Err(RequestError::Transient(e)) => {
|
||||
let msg = format!("{e:#}");
|
||||
assert!(msg.contains("restarting"), "missing restart hint: {msg}");
|
||||
assert!(msg.contains("connect to"), "missing connect context: {msg}");
|
||||
}
|
||||
Err(RequestError::Fatal(e)) => panic!("expected transient, got fatal: {e:#}"),
|
||||
Ok(_) => panic!("expected connect failure to a non-existent socket"),
|
||||
}
|
||||
}
|
||||
}
|
||||
872
hive-agent/src/events.rs
Normal file
872
hive-agent/src/events.rs
Normal file
|
|
@ -0,0 +1,872 @@
|
|||
//! Live event stream for the per-agent web UI. The harness emits one
|
||||
//! `LiveEvent` per interesting thing that happens during a turn — wake-up
|
||||
//! (the popped inbox message), every line claude prints on stdout
|
||||
//! (parsed from `--output-format stream-json`), and the turn-end summary.
|
||||
//! The web UI subscribes via SSE and renders rows live.
|
||||
//!
|
||||
//! Channel type is `tokio::sync::broadcast`. New subscribers see only
|
||||
//! future events; the dashboard JS deals with the cold-start case by
|
||||
//! showing "connecting…" until the first event arrives.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use hive_claude::TokenUsage;
|
||||
use hive_sh4re::wire_time::now_unix;
|
||||
use rusqlite::{Connection, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::harness_state::{
|
||||
DEFAULT_EFFORT, DEFAULT_MODEL, configured_effort, configured_model, context_window_tokens,
|
||||
load_effort, load_model, persist_effort, persist_model, read_harness_state,
|
||||
write_harness_state,
|
||||
};
|
||||
|
||||
const CHANNEL_CAPACITY: usize = 256;
|
||||
/// Max `LiveEvent`s the `Bus` returns from `history()` and keeps in
|
||||
/// sqlite. Older rows are vacuumed on a periodic sweep.
|
||||
pub const HISTORY_CAPACITY: usize = 2000;
|
||||
/// Path to the persisted event db. Overridable via `HYPERHIVE_EVENTS_DB`
|
||||
/// for dev / tests; otherwise derived from the agent's harness dir.
|
||||
fn events_db_path() -> PathBuf {
|
||||
std::env::var_os("HYPERHIVE_EVENTS_DB").map_or_else(
|
||||
|| crate::paths::harness_dir().join("hyperhive-events.sqlite"),
|
||||
PathBuf::from,
|
||||
)
|
||||
}
|
||||
|
||||
const SCHEMA: &str = "
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_ts ON events (ts);
|
||||
";
|
||||
|
||||
/// Envelope carried over the broadcast channel: the `LiveEvent` itself
|
||||
/// plus a monotonic per-process seq stamped by `Bus::emit`. SSE consumers
|
||||
/// serialize this directly (seq becomes a sibling of the `kind` tag);
|
||||
/// clients use seq to dedupe their buffered live traffic against the
|
||||
/// snapshot/history responses (drop anything with `seq <= snapshot.seq`).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BusEvent {
|
||||
pub seq: u64,
|
||||
/// Unix seconds at emit time. Serialized as a sibling of the `kind`
|
||||
/// tag so the agent terminal can render turn start/end times (and
|
||||
/// turn duration) on the live stream; history rows carry the same
|
||||
/// `ts` field sourced from the persisted `events.ts` column, so the
|
||||
/// renderer reads `ts` identically for live + scrollback.
|
||||
pub ts: i64,
|
||||
#[serde(flatten)]
|
||||
pub event: LiveEvent,
|
||||
}
|
||||
|
||||
/// A persisted event paired with its stored unix-seconds timestamp.
|
||||
/// Serializes with `ts` as a sibling of the `kind` tag — same wire shape
|
||||
/// as a live [`BusEvent`] minus `seq` — so the agent terminal reads `ts`
|
||||
/// identically whether an event arrives live or is replayed from history.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct StoredEvent {
|
||||
pub ts: i64,
|
||||
#[serde(flatten)]
|
||||
pub event: LiveEvent,
|
||||
}
|
||||
|
||||
/// One row of the agent's live stream. Serialised to JSON for SSE delivery.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum LiveEvent {
|
||||
/// Harness popped a wake-up message and is about to invoke claude.
|
||||
/// `unread` is the count of *other* messages still in the inbox at
|
||||
/// that moment — surfaced as a badge in the live panel header.
|
||||
TurnStart {
|
||||
from: String,
|
||||
body: String,
|
||||
unread: u64,
|
||||
},
|
||||
/// One line of claude's `--output-format stream-json` stdout, parsed as
|
||||
/// a generic JSON value (so we don't have to track every claude-code
|
||||
/// event variant). The frontend pretty-prints by `type` field.
|
||||
Stream(serde_json::Value),
|
||||
/// Free-form note from the harness (e.g. "claude exited 0",
|
||||
/// "stream-json parse error: ..."). Useful when stream-json itself
|
||||
/// fails so the UI doesn't just go silent.
|
||||
///
|
||||
/// Must be a struct variant (not `Note(String)`): internally-tagged
|
||||
/// enums can't flatten a tag onto a primitive newtype, and serde
|
||||
/// fails serialization at runtime — silently, because the SSE
|
||||
/// handler's `filter_map(... .ok()? ...)` swallows the error. From
|
||||
/// 2025-08 through 2026-05 every `Note` emission was a no-op + the
|
||||
/// sqlite history persisted them as the literal string `"null"`.
|
||||
/// The web UI's `note` renderer already reads `ev.text`, so the
|
||||
/// wire shape matches without a JS change.
|
||||
Note { text: String },
|
||||
/// Turn finished. `ok=false` means claude exited non-zero or the
|
||||
/// harness hit a transport error.
|
||||
TurnEnd { ok: bool, note: Option<String> },
|
||||
/// Harness reachability flipped: `"online"` /
|
||||
/// `"needs_login_idle"` / `"needs_login_in_progress"`. The web UI
|
||||
/// drives the alive badge from this so the operator sees a login
|
||||
/// land (or get revoked) without polling. Session detail
|
||||
/// (`url`/`output`/`finished`) is still served by `/api/state`
|
||||
/// during the short-lived in-progress window — the client
|
||||
/// re-fetches only while that flow is active.
|
||||
StatusChanged { status: String },
|
||||
/// `/api/model` switched the active claude model. The web UI
|
||||
/// updates the chip + the per-turn stats sink will key off this
|
||||
/// to mark the boundary in its log.
|
||||
ModelChanged { model: String },
|
||||
/// `/api/effort` switched the active claude effort level. Applies on
|
||||
/// the next session start; the web UI updates the picker chip to
|
||||
/// reflect it.
|
||||
EffortChanged { effort: String },
|
||||
/// Token usage for the turn just ended. Carries two snapshots:
|
||||
/// - `ctx` is the LAST inference's usage block (the actual context
|
||||
/// window in use right now — what the operator needs to decide
|
||||
/// whether to compact / reset).
|
||||
/// - `cost` is the cumulative usage across every inference in the
|
||||
/// turn (sum of per-call billed tokens — the cost signal). For
|
||||
/// tool-heavy turns the cumulative blows past the model's window
|
||||
/// because each tool call's prompt is rebilled.
|
||||
TokenUsageChanged { ctx: TokenUsage, cost: TokenUsage },
|
||||
/// Harness's `TurnState` transitioned (idle / thinking /
|
||||
/// compacting). `since_unix` matches `Bus::state_snapshot().1`
|
||||
/// so the client's elapsed-time ticker keeps progressing across
|
||||
/// SSE reconnects without drift.
|
||||
TurnStateChanged { state: TurnState, since_unix: i64 },
|
||||
}
|
||||
|
||||
/// sqlite-backed event log. Wraps a `Connection` behind a `Mutex` so the
|
||||
/// `Bus` (which clones cheaply) shares one writer.
|
||||
struct EventStore {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl EventStore {
|
||||
fn open(path: &Path) -> rusqlite::Result<Self> {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let conn = Connection::open(path)?;
|
||||
conn.execute_batch(SCHEMA)?;
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
fn append(&self, event: &LiveEvent) -> rusqlite::Result<()> {
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()
|
||||
.and_then(|d| i64::try_from(d.as_secs()).ok())
|
||||
.unwrap_or(0);
|
||||
let kind = match event {
|
||||
LiveEvent::TurnStart { .. } => "turn_start",
|
||||
LiveEvent::Stream(_) => "stream",
|
||||
LiveEvent::Note { .. } => "note",
|
||||
LiveEvent::TurnEnd { .. } => "turn_end",
|
||||
LiveEvent::StatusChanged { .. } => "status_changed",
|
||||
LiveEvent::ModelChanged { .. } => "model_changed",
|
||||
LiveEvent::EffortChanged { .. } => "effort_changed",
|
||||
LiveEvent::TokenUsageChanged { .. } => "token_usage_changed",
|
||||
LiveEvent::TurnStateChanged { .. } => "turn_state_changed",
|
||||
};
|
||||
let payload = serde_json::to_string(event).unwrap_or_else(|_| "null".into());
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO events (ts, kind, payload_json) VALUES (?1, ?2, ?3)",
|
||||
params![ts, kind, payload],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch up to `limit` events with id < `before_id` (or the most recent
|
||||
/// `limit` events when `before_id` is `None`). Returns
|
||||
/// `(events_oldest_first, min_row_id, has_more)`.
|
||||
fn page(
|
||||
&self,
|
||||
before_id: Option<i64>,
|
||||
limit: usize,
|
||||
) -> rusqlite::Result<(Vec<StoredEvent>, Option<i64>, bool)> {
|
||||
let limit_i = i64::try_from(limit).unwrap_or(i64::MAX);
|
||||
let conn = self.conn.lock().unwrap();
|
||||
// Fetch one extra row so we can tell whether more exist.
|
||||
let fetch = limit_i.saturating_add(1);
|
||||
// `ts` is the persisted emit-time unix-seconds stamp; carried out
|
||||
// alongside each event so history replay shows the same turn
|
||||
// start/end times the live stream did.
|
||||
let rows: Vec<(i64, StoredEvent)> = if let Some(bid) = before_id {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, ts, payload_json FROM events
|
||||
WHERE id < ?1
|
||||
ORDER BY id DESC
|
||||
LIMIT ?2",
|
||||
)?;
|
||||
stmt.query_map(params![bid, fetch], |row| {
|
||||
let id: i64 = row.get(0)?;
|
||||
let ts: i64 = row.get(1)?;
|
||||
let s: String = row.get(2)?;
|
||||
Ok(serde_json::from_str::<LiveEvent>(&s)
|
||||
.ok()
|
||||
.map(|event| (id, StoredEvent { ts, event })))
|
||||
})?
|
||||
.flatten()
|
||||
.flatten()
|
||||
.collect()
|
||||
} else {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, ts, payload_json FROM events
|
||||
ORDER BY id DESC
|
||||
LIMIT ?1",
|
||||
)?;
|
||||
stmt.query_map(params![fetch], |row| {
|
||||
let id: i64 = row.get(0)?;
|
||||
let ts: i64 = row.get(1)?;
|
||||
let s: String = row.get(2)?;
|
||||
Ok(serde_json::from_str::<LiveEvent>(&s)
|
||||
.ok()
|
||||
.map(|event| (id, StoredEvent { ts, event })))
|
||||
})?
|
||||
.flatten()
|
||||
.flatten()
|
||||
.collect()
|
||||
};
|
||||
let has_more = rows.len() > limit;
|
||||
let mut rows: Vec<(i64, StoredEvent)> = rows.into_iter().take(limit).collect();
|
||||
rows.reverse(); // oldest first
|
||||
let min_id = rows.first().map(|(id, _)| *id);
|
||||
let events = rows.into_iter().map(|(_, e)| e).collect();
|
||||
Ok((events, min_id, has_more))
|
||||
}
|
||||
}
|
||||
|
||||
/// Authoritative turn-loop state. The harness owns it; the web UI
|
||||
/// reads via `/api/state` and renders. Lives alongside the bus
|
||||
/// because everyone who has a `Bus` already has the right handle to
|
||||
/// poke the state on transitions.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TurnState {
|
||||
/// Inbox is empty / waiting on `Recv`.
|
||||
Idle,
|
||||
/// `claude --print` is running for a turn.
|
||||
Thinking,
|
||||
/// Operator-triggered `/compact` is running on the persistent
|
||||
/// session.
|
||||
Compacting,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bus {
|
||||
tx: Arc<broadcast::Sender<BusEvent>>,
|
||||
/// Monotonic per-process counter stamped onto every `BusEvent`.
|
||||
/// Persisted nowhere — a harness restart resets seq to 0; clients
|
||||
/// always treat reconnect as "fresh state, fresh stream of seqs."
|
||||
/// Historical events served from sqlite carry no seq (they predate
|
||||
/// the live channel the seq is meant to dedupe against).
|
||||
event_seq: Arc<AtomicU64>,
|
||||
/// Persistent event log. `None` only if opening the sqlite db failed
|
||||
/// at construction — we keep going so the harness doesn't die on a
|
||||
/// missing state dir mount in dev / test scenarios.
|
||||
store: Option<Arc<EventStore>>,
|
||||
/// Current turn-loop state + since-when (unix seconds).
|
||||
state: Arc<Mutex<(TurnState, i64)>>,
|
||||
/// Model name passed to `claude --model`. Default `haiku`; the
|
||||
/// operator can override at runtime via `POST /api/model`.
|
||||
model: Arc<Mutex<String>>,
|
||||
effort: Arc<Mutex<String>>,
|
||||
/// Last-inference token usage from the most recent turn's final
|
||||
/// `assistant` event. Represents the actual context window size at
|
||||
/// turn-end — the number the operator watches to decide whether to
|
||||
/// compact. `None` until the first turn completes.
|
||||
last_ctx_usage: Arc<Mutex<Option<TokenUsage>>>,
|
||||
/// Cumulative token usage from the most recent turn's `result`
|
||||
/// event (sum across every inference in the turn). This is the cost
|
||||
/// signal — tool-heavy turns rebill the cached prompt per call and
|
||||
/// blow past the model window. `None` until the first turn completes.
|
||||
last_cost_usage: Arc<Mutex<Option<TokenUsage>>>,
|
||||
/// True while the harness is parked after a rate-limit response.
|
||||
/// Set by `emit_status("rate_limited")`, cleared by
|
||||
/// `emit_status("online")`. Also mirrored to a sentinel file at
|
||||
/// `{state_dir}/hyperhive-rate-limited` so the host-side
|
||||
/// `container_view` can surface the status on the dashboard without
|
||||
/// a live socket call.
|
||||
rate_limited: Arc<AtomicBool>,
|
||||
/// One-shot: operator asked to reset the claude session (`POST
|
||||
/// /api/new-session`). Consumed at the *next* turn boundary by
|
||||
/// `turn::drive_turn`, which archives the current session so the turn
|
||||
/// starts fresh. Deferred (not applied in the handler) so the archive
|
||||
/// never races a claude process mid-write — one claude per container,
|
||||
/// serialized by the serve loop, so the turn boundary is the only point
|
||||
/// where no session file is open.
|
||||
session_reset_pending: Arc<AtomicBool>,
|
||||
/// One-shot: run `/compact` after the next turn ends. Consumed at the end
|
||||
/// of the current/next turn by `turn::drive_turn`. Deferring to the turn
|
||||
/// boundary keeps compaction from racing a live claude process mid-turn.
|
||||
compact_pending: Arc<AtomicBool>,
|
||||
/// Current fresh-claude-session id (FK to `sessions.id`). Set by the
|
||||
/// bin loop after minting a session row on a fresh start; stamped onto
|
||||
/// every `turn_stats` row until the next fresh session. `None` before
|
||||
/// the first fresh turn or when the stats db is unavailable.
|
||||
session_id: Arc<Mutex<Option<i64>>>,
|
||||
/// One-shot: `run_claude` flips this true when it creates a fresh claude
|
||||
/// session (`--name <title>` on a title miss). The bin loop takes-and-
|
||||
/// clears it after the turn to decide whether to mint a new `sessions`
|
||||
/// row. Session identity itself is handled entirely in `turn.rs` via the
|
||||
/// constant title + archive — this flag is purely a stats signal.
|
||||
fresh_session: Arc<AtomicBool>,
|
||||
/// Per-turn tool-call counter. Reset by the bin loop between
|
||||
/// turns via `take_tool_calls`. Populated by `observe_stream` as
|
||||
/// the stdout pump parses each stream-json line. Powers the
|
||||
/// `tool_call_count` + `tool_call_breakdown_json` columns on the
|
||||
/// per-turn stats sink.
|
||||
tool_calls: Arc<Mutex<std::collections::HashMap<String, u64>>>,
|
||||
/// Unix timestamp of the most recent completed turn (set by
|
||||
/// `record_turn_usage`). Used by the auto-reset heuristic in
|
||||
/// `turn.rs` to compute how long the session has been idle and
|
||||
/// whether the prompt cache has gone cold. `0` = no turn yet.
|
||||
last_turn_ended_unix: Arc<AtomicI64>,
|
||||
/// Per-inference context-window size as reported by the Anthropic API
|
||||
/// in the stream-json `result` event (`modelUsage.*.contextWindow`).
|
||||
/// Set by the stdout pump on every completed turn. Takes precedence
|
||||
/// over the Nix-configured `HIVE_CONTEXT_WINDOW_TOKENS_*` env vars
|
||||
/// for compaction watermark calculations — it reflects the actual
|
||||
/// limit the model enforces, which may differ from what the operator
|
||||
/// configured (e.g. 200 k active window on a 1 M cache-enabled model).
|
||||
api_context_window: Arc<Mutex<Option<u64>>>,
|
||||
/// Resolved model id from the most recent turn's `assistant` events
|
||||
/// (the API-echoed `message.model`, e.g. `claude-opus-4-8`), as
|
||||
/// opposed to the requested `--model` name which may be a short
|
||||
/// alias. Set once per turn by the stdout pump at result-time;
|
||||
/// `None` until the first assistant event of a turn is seen. The
|
||||
/// per-turn stats sink prefers this over the requested name so the
|
||||
/// model-mix + cost rollup reflect the concrete version that ran.
|
||||
last_resolved_model: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
impl Bus {
|
||||
/// Open the events db (path from `events_db_path()`). On failure, fall back
|
||||
/// to a no-store bus — the harness still works, just without persistent history.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
let path = events_db_path();
|
||||
let store = match EventStore::open(&path) {
|
||||
Ok(s) => Some(Arc::new(s)),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, path = %path.display(), "events db open failed; running without history");
|
||||
None
|
||||
}
|
||||
};
|
||||
let (tx, _) = broadcast::channel(CHANNEL_CAPACITY);
|
||||
// Priority: HIVE_DEFAULT_MODEL (from hyperhive.model in agent.nix) >
|
||||
// persisted runtime override > compiled-in DEFAULT_MODEL.
|
||||
// The nix config always wins on rebuild; the persisted file is kept
|
||||
// for within-session tracking only (see persist_model / set_model).
|
||||
let initial_model = configured_model().map_or_else(
|
||||
|| load_model().unwrap_or_else(|| DEFAULT_MODEL.to_owned()),
|
||||
str::to_owned,
|
||||
);
|
||||
// Effort precedence is the inverse of model: a persisted operator
|
||||
// pick wins over the nix `HIVE_DEFAULT_EFFORT` baseline so the
|
||||
// runtime choice survives harness restart (it only resets on
|
||||
// `--purge`). nix is the default for a never-touched agent.
|
||||
let initial_effort = load_effort()
|
||||
.or_else(|| configured_effort().map(str::to_owned))
|
||||
.unwrap_or_else(|| DEFAULT_EFFORT.to_owned());
|
||||
// Restore rate_limited (and needs_login) from the consolidated
|
||||
// harness state file so the dashboard shows the correct status
|
||||
// on cold load if the harness crashed while parked.
|
||||
let (was_rate_limited, was_needs_login, _) = read_harness_state();
|
||||
// Write the resolved active model to hyperhive-harness.json on
|
||||
// startup so hive-c0re can surface the model badge without reading
|
||||
// hyperhive-model directly. Written once here; updated on every
|
||||
// set_model() call (runtime override) and emit_status() call.
|
||||
write_harness_state(was_rate_limited, was_needs_login, Some(&initial_model));
|
||||
Self {
|
||||
tx: Arc::new(tx),
|
||||
event_seq: Arc::new(AtomicU64::new(0)),
|
||||
store,
|
||||
state: Arc::new(Mutex::new((TurnState::Idle, now_unix()))),
|
||||
model: Arc::new(Mutex::new(initial_model)),
|
||||
effort: Arc::new(Mutex::new(initial_effort)),
|
||||
last_ctx_usage: Arc::new(Mutex::new(None)),
|
||||
last_cost_usage: Arc::new(Mutex::new(None)),
|
||||
rate_limited: Arc::new(AtomicBool::new(was_rate_limited)),
|
||||
session_reset_pending: Arc::new(AtomicBool::new(false)),
|
||||
compact_pending: Arc::new(AtomicBool::new(false)),
|
||||
session_id: Arc::new(Mutex::new(None)),
|
||||
fresh_session: Arc::new(AtomicBool::new(false)),
|
||||
tool_calls: Arc::new(Mutex::new(std::collections::HashMap::new())),
|
||||
last_turn_ended_unix: Arc::new(AtomicI64::new(0)),
|
||||
api_context_window: Arc::new(Mutex::new(None)),
|
||||
last_resolved_model: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Current high-water seq. Snapshot endpoints read this before
|
||||
/// gathering state so the resulting (snapshot.seq, snapshot) pair
|
||||
/// satisfies: any live event with seq > snapshot.seq is post-snapshot
|
||||
/// (not yet reflected). Clients dedupe buffered SSE traffic against
|
||||
/// this value.
|
||||
#[must_use]
|
||||
pub fn current_seq(&self) -> u64 {
|
||||
self.event_seq.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
fn next_seq(&self) -> u64 {
|
||||
self.event_seq.fetch_add(1, Ordering::SeqCst) + 1
|
||||
}
|
||||
|
||||
/// Request a session reset (operator `POST /api/new-session`). Deferred:
|
||||
/// the flag is consumed at the next turn boundary by `drive_turn`, which
|
||||
/// archives the current session so no claude process is mid-write when the
|
||||
/// file is renamed. Idempotent — two clicks before the next turn still
|
||||
/// archive once.
|
||||
pub fn request_session_reset(&self) {
|
||||
self.session_reset_pending.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Take + clear the session-reset one-shot. Returns true iff `drive_turn`
|
||||
/// should archive the current session before this turn.
|
||||
#[must_use]
|
||||
pub fn take_session_reset(&self) -> bool {
|
||||
self.session_reset_pending.swap(false, Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Request a compaction after the next turn ends (deferred to the turn
|
||||
/// boundary). Idempotent.
|
||||
pub fn request_compact(&self) {
|
||||
self.compact_pending.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Take + clear the compact one-shot. Returns true iff `drive_turn` should
|
||||
/// compact at the end of this turn.
|
||||
#[must_use]
|
||||
pub fn take_compact(&self) -> bool {
|
||||
self.compact_pending.swap(false, Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Mark that the current turn started a fresh claude session.
|
||||
/// `run_claude` calls this when it creates a new titled session.
|
||||
pub fn mark_fresh_session(&self) {
|
||||
self.fresh_session.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Take + clear the fresh-session one-shot. The bin loop calls this
|
||||
/// after the turn to decide whether to mint a new `sessions` row.
|
||||
#[must_use]
|
||||
pub fn take_fresh_session(&self) -> bool {
|
||||
self.fresh_session.swap(false, Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Currently-active session id (FK to `sessions.id`), or `None`.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn current_session_id(&self) -> Option<i64> {
|
||||
*self.session_id.lock().unwrap()
|
||||
}
|
||||
|
||||
/// Set the active session id after minting a `sessions` row on a
|
||||
/// fresh start.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
pub fn set_session_id(&self, id: Option<i64>) {
|
||||
*self.session_id.lock().unwrap() = id;
|
||||
}
|
||||
|
||||
/// Currently-selected claude model name. Read on every turn so a
|
||||
/// `/model <name>` flip takes effect on the next turn.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn model(&self) -> String {
|
||||
self.model.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Switch the model for future turns. The current turn (if any)
|
||||
/// keeps the model it was already running. Persisted to the agent's
|
||||
/// state dir (`hyperhive-model`) so the override survives harness
|
||||
/// restart and container rebuild (gone on `--purge`, matching
|
||||
/// every other piece of agent state).
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
pub fn set_model(&self, name: impl Into<String>) {
|
||||
let value: String = name.into();
|
||||
self.model.lock().unwrap().clone_from(&value);
|
||||
if let Err(e) = persist_model(&value) {
|
||||
tracing::warn!(error = ?e, "model: persist failed");
|
||||
}
|
||||
// Mirror the resolved model into hyperhive-harness.json so
|
||||
// hive-c0re can surface it on the dashboard badge without reading
|
||||
// the hyperhive-model override file directly.
|
||||
let (rate_limited, needs_login, _) = read_harness_state();
|
||||
write_harness_state(rate_limited, needs_login, Some(&value));
|
||||
self.emit(LiveEvent::ModelChanged { model: value });
|
||||
}
|
||||
|
||||
/// Currently-selected claude effort level. Read at session start to
|
||||
/// build the `--effort` arg.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn effort(&self) -> String {
|
||||
self.effort.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Switch the effort level for future sessions. Applies on the next
|
||||
/// claude launch (no mid-session swap). Persisted to the agent's
|
||||
/// state dir (`hyperhive-effort`) so the override survives harness
|
||||
/// restart and container rebuild (gone on `--purge`). Callers must
|
||||
/// pre-validate with [`crate::harness_state::is_valid_effort`].
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
pub fn set_effort(&self, level: impl Into<String>) {
|
||||
let value: String = level.into();
|
||||
self.effort.lock().unwrap().clone_from(&value);
|
||||
if let Err(e) = persist_effort(&value) {
|
||||
tracing::warn!(error = ?e, "effort: persist failed");
|
||||
}
|
||||
self.emit(LiveEvent::EffortChanged { effort: value });
|
||||
}
|
||||
|
||||
/// Seed `last_ctx_usage` + `last_cost_usage` at startup without
|
||||
/// emitting a SSE event. Used by the bin entrypoints to backfill
|
||||
/// from the most recent `turn_stats` row so the per-agent web UI's
|
||||
/// ctx + cost badges paint real numbers on cold load.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if an internal lock is poisoned.
|
||||
pub fn seed_usage(&self, ctx: Option<TokenUsage>, cost: Option<TokenUsage>) {
|
||||
if ctx.is_some() {
|
||||
*self.last_ctx_usage.lock().unwrap() = ctx;
|
||||
}
|
||||
if cost.is_some() {
|
||||
*self.last_cost_usage.lock().unwrap() = cost;
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the just-ended turn's usage. `ctx` is the last inference's
|
||||
/// usage (current context size); `cost` is the cumulative across
|
||||
/// every inference in the turn (cost signal). One SSE event fires
|
||||
/// per turn carrying both.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if an internal lock is poisoned.
|
||||
pub fn record_turn_usage(&self, ctx: TokenUsage, cost: TokenUsage) {
|
||||
*self.last_ctx_usage.lock().unwrap() = Some(ctx);
|
||||
*self.last_cost_usage.lock().unwrap() = Some(cost);
|
||||
self.last_turn_ended_unix
|
||||
.store(now_unix(), Ordering::Relaxed);
|
||||
self.emit(LiveEvent::TokenUsageChanged { ctx, cost });
|
||||
}
|
||||
|
||||
/// Unix timestamp of the most recent completed turn (`record_turn_usage`
|
||||
/// call), or `0` if no turn has finished yet.
|
||||
#[must_use]
|
||||
pub fn last_turn_ended_unix(&self) -> i64 {
|
||||
self.last_turn_ended_unix.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Record the resolved model id observed for the just-ended turn
|
||||
/// (from `assistant` events' `message.model`). `None` clears it so a
|
||||
/// degenerate turn that produced no assistant event doesn't inherit a
|
||||
/// stale id — the stats sink then falls back to the requested name.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
pub fn set_resolved_model(&self, model: Option<String>) {
|
||||
*self.last_resolved_model.lock().unwrap() = model;
|
||||
}
|
||||
|
||||
/// Resolved model id from the most recent turn, if an `assistant`
|
||||
/// event reported one. The per-turn stats sink prefers this over the
|
||||
/// requested `--model` name.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn last_resolved_model(&self) -> Option<String> {
|
||||
self.last_resolved_model.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Update the API-reported context-window size from the stream-json
|
||||
/// `result` event's `modelUsage.*.contextWindow` field. Called by the
|
||||
/// stdout pump once per completed turn. `0` is ignored (sentinel for
|
||||
/// "not reported").
|
||||
pub fn set_api_context_window(&self, window: u64) {
|
||||
if window > 0 {
|
||||
*self.api_context_window.lock().unwrap() = Some(window);
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the API-reported per-inference context-window size, if the
|
||||
/// harness has seen at least one completed turn for this session.
|
||||
/// `None` until the first result event is processed.
|
||||
#[must_use]
|
||||
pub fn api_context_window(&self) -> Option<u64> {
|
||||
*self.api_context_window.lock().unwrap()
|
||||
}
|
||||
|
||||
/// The effective context window for `model`: the API-reported window if a
|
||||
/// turn has completed ([`api_context_window`]), else the per-model default
|
||||
/// ([`context_window_tokens`]). Single accessor so the state + dashboard
|
||||
/// endpoints agree by construction.
|
||||
#[must_use]
|
||||
pub fn effective_context_window(&self, model: &str) -> u64 {
|
||||
self.api_context_window()
|
||||
.unwrap_or_else(|| context_window_tokens(model))
|
||||
}
|
||||
|
||||
/// Walk a stream-json value for `tool_use` blocks and bump the
|
||||
/// per-turn counter for each one we find. Called by the stdout
|
||||
/// pump on every parsed line. Cheap when the line isn't an
|
||||
/// assistant message — the field-check short-circuits.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
pub fn observe_stream(&self, v: &serde_json::Value) {
|
||||
if v.get("type").and_then(|t| t.as_str()) != Some("assistant") {
|
||||
return;
|
||||
}
|
||||
let Some(content) = v
|
||||
.get("message")
|
||||
.and_then(|m| m.get("content"))
|
||||
.and_then(|c| c.as_array())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut counts = self.tool_calls.lock().unwrap();
|
||||
for block in content {
|
||||
if block.get("type").and_then(|t| t.as_str()) != Some("tool_use") {
|
||||
continue;
|
||||
}
|
||||
let name = block
|
||||
.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("<unnamed>")
|
||||
.to_owned();
|
||||
*counts.entry(name).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot + clear the per-turn tool-call counter. The harness
|
||||
/// calls this between turns to fold the breakdown into a
|
||||
/// `turn_stats` row, then start the next turn with an empty map.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn take_tool_calls(&self) -> std::collections::HashMap<String, u64> {
|
||||
std::mem::take(&mut *self.tool_calls.lock().unwrap())
|
||||
}
|
||||
|
||||
/// Last context-size snapshot (last inference of the most recent
|
||||
/// turn), or `None` if no turn has completed yet.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn last_ctx_usage(&self) -> Option<TokenUsage> {
|
||||
*self.last_ctx_usage.lock().unwrap()
|
||||
}
|
||||
|
||||
/// Last cumulative cost snapshot (sum across the most recent turn's
|
||||
/// inferences), or `None` if no turn has completed yet.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn last_cost_usage(&self) -> Option<TokenUsage> {
|
||||
*self.last_cost_usage.lock().unwrap()
|
||||
}
|
||||
|
||||
/// Update the harness's authoritative turn-loop state. Records
|
||||
/// the transition time so `state_snapshot` can return a since-age.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
pub fn set_state(&self, next: TurnState) {
|
||||
let since;
|
||||
{
|
||||
let mut guard = self.state.lock().unwrap();
|
||||
if guard.0 == next {
|
||||
return;
|
||||
}
|
||||
*guard = (next, now_unix());
|
||||
since = guard.1;
|
||||
}
|
||||
self.emit(LiveEvent::TurnStateChanged {
|
||||
state: next,
|
||||
since_unix: since,
|
||||
});
|
||||
}
|
||||
|
||||
/// Broadcast a status flip (online / `needs_login_*` / `rate_limited`).
|
||||
/// Called by the bin entry points + `turn::wait_for_login` + the
|
||||
/// `post_login_*` handlers — every site that mutates the
|
||||
/// `Arc<Mutex<LoginState>>` should also call this so the web UI
|
||||
/// drops its periodic /api/state poll while a turn loop is running.
|
||||
///
|
||||
/// `hyperhive-harness.json` persists across harness restarts so the
|
||||
/// host-side dashboard can render the status without a live socket call:
|
||||
/// - `"rate_limited"` sets `rate_limited: true` in the JSON.
|
||||
/// - `"needs_login_idle"` sets `needs_login: true` in the JSON.
|
||||
/// - `"online"` clears both fields — the agent is healthy again.
|
||||
/// - Other statuses clear `rate_limited` only; `needs_login` is sticky
|
||||
/// until `"online"` (re-auth completed successfully).
|
||||
///
|
||||
/// Writes are atomic (`.tmp` + `rename`) so hive-c0re never reads a
|
||||
/// partial file during its ~10s sweep.
|
||||
pub fn emit_status(&self, status: impl Into<String>) {
|
||||
let status = status.into();
|
||||
let new_rate_limited = status == "rate_limited";
|
||||
if new_rate_limited {
|
||||
self.rate_limited.store(true, Ordering::Relaxed);
|
||||
} else {
|
||||
self.rate_limited.store(false, Ordering::Relaxed);
|
||||
}
|
||||
// Read the current persisted needs_login so we don't flip it on
|
||||
// statuses that shouldn't touch it (e.g. `needs_login_in_progress`
|
||||
// is a transient mid-flow status; only `needs_login_idle` and
|
||||
// `online` should change the persistent flag).
|
||||
//
|
||||
// Known non-atomicity: this is a read-modify-write. Two concurrent
|
||||
// `emit_status` calls could clobber each other's `needs_login`
|
||||
// change if they raced between the read and the write. In practice
|
||||
// this is safe: the turn loop is sequential and the login flow
|
||||
// (`needs_login_idle` / `online`) only fires outside of active
|
||||
// turns, so the two callers never overlap. Documented rather than
|
||||
// locked because adding a Mutex here would be overkill for the
|
||||
// actual call pattern.
|
||||
let (_, current_needs_login, _) = read_harness_state();
|
||||
let new_needs_login = if status == "needs_login_idle" {
|
||||
true
|
||||
} else if status == "online" {
|
||||
false
|
||||
} else {
|
||||
current_needs_login
|
||||
};
|
||||
let current_model = self.model.lock().unwrap().clone();
|
||||
write_harness_state(new_rate_limited, new_needs_login, Some(¤t_model));
|
||||
self.emit(LiveEvent::StatusChanged { status });
|
||||
}
|
||||
|
||||
/// Returns true while the harness is parked after a rate-limit response.
|
||||
#[must_use]
|
||||
pub fn is_rate_limited(&self) -> bool {
|
||||
self.rate_limited.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Current state + since-when (unix seconds). Snapshot copy, no lock held.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn state_snapshot(&self) -> (TurnState, i64) {
|
||||
*self.state.lock().unwrap()
|
||||
}
|
||||
|
||||
pub fn emit(&self, event: LiveEvent) {
|
||||
if let Some(store) = &self.store
|
||||
&& let Err(e) = store.append(&event)
|
||||
{
|
||||
tracing::warn!(error = ?e, "events: append failed");
|
||||
}
|
||||
let envelope = BusEvent {
|
||||
seq: self.next_seq(),
|
||||
ts: now_unix(),
|
||||
event,
|
||||
};
|
||||
// Lagged subscribers drop events — fine; the UI is a tail, not a log.
|
||||
let _ = self.tx.send(envelope);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<BusEvent> {
|
||||
self.tx.subscribe()
|
||||
}
|
||||
|
||||
/// Paginated history: up to `limit` events before `before_id`
|
||||
/// (or the most recent `limit` when `before_id` is `None`).
|
||||
/// Returns `(events_oldest_first, min_row_id, has_more)`.
|
||||
/// `min_row_id` is the cursor for the next page; pass it as
|
||||
/// `before_id` on the next call.
|
||||
#[must_use]
|
||||
pub fn history_page(
|
||||
&self,
|
||||
before_id: Option<i64>,
|
||||
limit: usize,
|
||||
) -> (Vec<StoredEvent>, Option<i64>, bool) {
|
||||
let Some(store) = &self.store else {
|
||||
return (Vec::new(), None, false);
|
||||
};
|
||||
store.page(before_id, limit).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Bus {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{BusEvent, LiveEvent, StoredEvent};
|
||||
|
||||
#[test]
|
||||
fn stored_event_serializes_ts_beside_kind() {
|
||||
// History-row wire shape: `ts` is a flattened sibling of `kind`,
|
||||
// which is what the agent terminal reads to time turn boundaries.
|
||||
let v = serde_json::to_value(StoredEvent {
|
||||
ts: 1_700_000_000,
|
||||
event: LiveEvent::Note { text: "hi".into() },
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(v["ts"], 1_700_000_000_i64);
|
||||
assert_eq!(v["kind"], "note");
|
||||
assert_eq!(v["text"], "hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bus_event_serializes_ts_and_seq_beside_kind() {
|
||||
// Live SSE frame: same `ts` sibling as history (plus `seq`), so the
|
||||
// renderer is path-agnostic between live + scrollback.
|
||||
let v = serde_json::to_value(BusEvent {
|
||||
seq: 7,
|
||||
ts: 1_700_000_000,
|
||||
event: LiveEvent::Note { text: "yo".into() },
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(v["seq"], 7);
|
||||
assert_eq!(v["ts"], 1_700_000_000_i64);
|
||||
assert_eq!(v["kind"], "note");
|
||||
}
|
||||
}
|
||||
1312
hive-agent/src/forge_notify.rs
Normal file
1312
hive-agent/src/forge_notify.rs
Normal file
File diff suppressed because it is too large
Load diff
332
hive-agent/src/harness_state.rs
Normal file
332
hive-agent/src/harness_state.rs
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
//! File-backed harness state, split out of [`crate::events`] (which owns
|
||||
//! the live event bus + sqlite store). This module holds the runtime
|
||||
//! model/effort selection, the consolidated `hyperhive-harness.json`
|
||||
//! state file, and the `forge_notify` delivery-dedupe cursor — the
|
||||
//! persisted knobs the harness reads/writes across turns, none of which
|
||||
//! are about the live event stream.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Path to the persisted model file. Overridable via `HYPERHIVE_MODEL_FILE`
|
||||
/// for dev / tests; otherwise derived from the agent's harness dir.
|
||||
fn model_file_path() -> PathBuf {
|
||||
std::env::var_os("HYPERHIVE_MODEL_FILE").map_or_else(
|
||||
|| crate::paths::harness_dir().join("hyperhive-model"),
|
||||
PathBuf::from,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn load_model() -> Option<String> {
|
||||
let s = std::fs::read_to_string(model_file_path()).ok()?;
|
||||
let name = s.trim();
|
||||
if name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(name.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn persist_model(name: &str) -> std::io::Result<()> {
|
||||
let path = model_file_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
std::fs::write(path, format!("{name}\n"))
|
||||
}
|
||||
|
||||
/// Path to the persisted effort-level file. Sibling of `hyperhive-model`,
|
||||
/// overridable via `HYPERHIVE_EFFORT_FILE` for dev / tests; otherwise
|
||||
/// derived from the agent's harness dir.
|
||||
fn effort_file_path() -> PathBuf {
|
||||
std::env::var_os("HYPERHIVE_EFFORT_FILE").map_or_else(
|
||||
|| crate::paths::harness_dir().join("hyperhive-effort"),
|
||||
PathBuf::from,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn load_effort() -> Option<String> {
|
||||
let s = std::fs::read_to_string(effort_file_path()).ok()?;
|
||||
let level = s.trim();
|
||||
if level.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(level.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn persist_effort(level: &str) -> std::io::Result<()> {
|
||||
let path = effort_file_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
std::fs::write(path, format!("{level}\n"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Consolidated harness state file
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// `hyperhive-harness.json` replaces the two legacy boolean sentinel files
|
||||
// (`hyperhive-rate-limited`, `hyperhive-needs-login`) that grew organically
|
||||
// and had no shared schema. A single JSON file is self-documenting, atomic
|
||||
// to write, and cheaper for hive-c0re to read on each sweep (one fopen vs
|
||||
// two stat calls). See `docs/persistence.md::Harness state files`.
|
||||
//
|
||||
// Legacy sentinel files written by older harness builds are still honoured
|
||||
// by `read_harness_state` so in-place upgrades don't lose state (the new
|
||||
// harness re-normalises on first write). Old files are not deleted — they
|
||||
// expire naturally when the state dir is purged. `hive-c0re::container_view`
|
||||
// also checks the legacy paths as a fallback during the transition window.
|
||||
|
||||
const HARNESS_JSON: &str = "hyperhive-harness.json";
|
||||
|
||||
fn harness_json_path() -> PathBuf {
|
||||
crate::paths::state_dir().join(HARNESS_JSON)
|
||||
}
|
||||
|
||||
// Serialises the read-modify-write of `hyperhive-harness.json`. Two
|
||||
// harness tasks touch it in the same process — the turn loop (rate-limit
|
||||
// / needs-login / active-model) and the forge_notify poller (the
|
||||
// delivery-dedupe cursor) — writing disjoint fields, so each writer must
|
||||
// preserve the other's. The lock closes the lost-update window between a
|
||||
// writer's read and its rename.
|
||||
static HARNESS_JSON_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Read the consolidated state file as a JSON object, or an empty object
|
||||
/// when it is missing / unparseable / not an object.
|
||||
fn read_harness_json() -> serde_json::Value {
|
||||
std::fs::read_to_string(harness_json_path())
|
||||
.ok()
|
||||
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
|
||||
.filter(serde_json::Value::is_object)
|
||||
.unwrap_or_else(|| serde_json::json!({}))
|
||||
}
|
||||
|
||||
/// Atomically overwrite the state file (`.tmp` + rename) so hive-c0re
|
||||
/// never reads a partial file.
|
||||
fn write_harness_json(v: &serde_json::Value) {
|
||||
let path = harness_json_path();
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
if std::fs::write(&tmp, v.to_string()).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, &path);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn read_harness_state() -> (bool, bool, Option<String>) {
|
||||
// Try the new consolidated file first.
|
||||
if let Ok(raw) = std::fs::read_to_string(harness_json_path())
|
||||
&& let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw)
|
||||
{
|
||||
let rate_limited = v
|
||||
.get("rate_limited")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let needs_login = v
|
||||
.get("needs_login")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let active_model = v
|
||||
.get("active_model")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned);
|
||||
return (rate_limited, needs_login, active_model);
|
||||
}
|
||||
// Fall back to legacy sentinel files written by older harness builds.
|
||||
let state_dir = crate::paths::state_dir();
|
||||
let rate_limited = state_dir.join("hyperhive-rate-limited").exists();
|
||||
let needs_login = state_dir.join("hyperhive-needs-login").exists();
|
||||
(rate_limited, needs_login, None)
|
||||
}
|
||||
|
||||
/// Write the turn-loop's harness state fields via a read-modify-write so
|
||||
/// any other writer's fields (e.g. `forge_notify`'s `forge_cursor`) survive.
|
||||
/// Pass `active_model: Some(s)` to update the resolved model (surfaced in
|
||||
/// the dashboard badge); `None` leaves the stored value untouched.
|
||||
pub(crate) fn write_harness_state(
|
||||
rate_limited: bool,
|
||||
needs_login: bool,
|
||||
active_model: Option<&str>,
|
||||
) {
|
||||
let _guard = HARNESS_JSON_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut v = read_harness_json();
|
||||
v["rate_limited"] = rate_limited.into();
|
||||
v["needs_login"] = needs_login.into();
|
||||
if let Some(model) = active_model {
|
||||
v["active_model"] = model.into();
|
||||
}
|
||||
write_harness_json(&v);
|
||||
}
|
||||
|
||||
/// Parse the `forge_notify` delivery-dedupe cursor (notification thread id
|
||||
/// -> last-delivered `updated_at`) out of a harness-state JSON value.
|
||||
/// Empty when the field is absent (first boot) or malformed.
|
||||
fn forge_cursor_from_json(v: &serde_json::Value) -> std::collections::HashMap<u64, String> {
|
||||
v.get("forge_cursor")
|
||||
.cloned()
|
||||
.and_then(|c| serde_json::from_value(c).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Restore the `forge_notify` delivery-dedupe cursor from the consolidated
|
||||
/// state file so a container rebuild/restart doesn't re-deliver the whole
|
||||
/// currently-unread backlog.
|
||||
pub fn read_forge_cursor() -> std::collections::HashMap<u64, String> {
|
||||
forge_cursor_from_json(&read_harness_json())
|
||||
}
|
||||
|
||||
/// Persist the `forge_notify` delivery-dedupe cursor into the consolidated
|
||||
/// state file, read-modify-write under the shared lock so the turn-loop's
|
||||
/// own fields survive. Best-effort: a serialize failure is a no-op.
|
||||
pub fn write_forge_cursor<S: std::hash::BuildHasher>(
|
||||
cursor: &std::collections::HashMap<u64, String, S>,
|
||||
) {
|
||||
let Ok(value) = serde_json::to_value(cursor) else {
|
||||
return;
|
||||
};
|
||||
let _guard = HARNESS_JSON_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut v = read_harness_json();
|
||||
v["forge_cursor"] = value;
|
||||
write_harness_json(&v);
|
||||
}
|
||||
|
||||
/// Compiled-in fallback model used when neither `HIVE_DEFAULT_MODEL` nor a
|
||||
/// persisted runtime override is present.
|
||||
pub const DEFAULT_MODEL: &str = "haiku";
|
||||
|
||||
/// Return the model declared in `HIVE_DEFAULT_MODEL` (set from
|
||||
/// `hyperhive.model` in `agent.nix`), or `None` if the env var is absent /
|
||||
/// empty. When `Some`, this takes precedence over any persisted runtime
|
||||
/// override so that nix config changes always take effect on rebuild.
|
||||
#[must_use]
|
||||
pub fn configured_model() -> Option<&'static str> {
|
||||
// Leak once at startup — acceptable for a single config value.
|
||||
std::env::var("HIVE_DEFAULT_MODEL")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.map(|s| &*Box::leak(s.into_boxed_str()))
|
||||
}
|
||||
|
||||
/// Compiled-in fallback effort level — matches the `effortLevel` baked
|
||||
/// into `prompts/claude-settings.json`.
|
||||
pub const DEFAULT_EFFORT: &str = "medium";
|
||||
|
||||
/// Valid claude `--effort` levels, ascending. The operator picker is
|
||||
/// constrained to these; [`is_valid_effort`] guards the persist path.
|
||||
pub const EFFORT_LEVELS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
|
||||
|
||||
/// True iff `level` is one of [`EFFORT_LEVELS`].
|
||||
#[must_use]
|
||||
pub fn is_valid_effort(level: &str) -> bool {
|
||||
EFFORT_LEVELS.contains(&level)
|
||||
}
|
||||
|
||||
/// Return the effort level declared in `HIVE_DEFAULT_EFFORT` (set from
|
||||
/// `hyperhive.effortLevel` in `agent.nix`), or `None` if absent / empty.
|
||||
/// Mirrors [`configured_model`]'s env shape. Unlike model, the persisted
|
||||
/// runtime override takes precedence over this baseline (see `Bus::new`):
|
||||
/// the operator's effort pick sticks across harness restart, with the nix
|
||||
/// value only the default when no override was ever set.
|
||||
#[must_use]
|
||||
pub fn configured_effort() -> Option<&'static str> {
|
||||
std::env::var("HIVE_DEFAULT_EFFORT")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.map(|s| &*Box::leak(s.into_boxed_str()))
|
||||
}
|
||||
|
||||
/// Context-window size in tokens for a given model name.
|
||||
///
|
||||
/// Canonical per-model sizes are declared in the harness nix modules as
|
||||
/// `hyperhive.contextWindowTokens` and injected as
|
||||
/// `HIVE_CONTEXT_WINDOW_TOKENS_<KEY_UPPER>` env vars — so this function
|
||||
/// normally just reads them. The Rust code carries no model knowledge;
|
||||
/// updating model families only requires a Nix change.
|
||||
///
|
||||
/// Resolution order (first match wins):
|
||||
/// 1. `HIVE_CONTEXT_WINDOW_TOKENS_<KEY>` — key (lowercased) is a
|
||||
/// substring of the active model name. Populated by the Nix default
|
||||
/// map for all known families; add/override in `agent.nix`.
|
||||
/// 2. `HIVE_CONTEXT_WINDOW_TOKENS` — single global override (any model).
|
||||
/// 3. Hard fallback: `200_000` (conservative; only hit outside NixOS).
|
||||
#[must_use]
|
||||
pub fn context_window_tokens(model: &str) -> u64 {
|
||||
let m = model.to_ascii_lowercase();
|
||||
// Per-model env vars set by `hyperhive.contextWindowTokens` in Nix.
|
||||
for (key, val) in std::env::vars() {
|
||||
if let Some(suffix) = key.strip_prefix("HIVE_CONTEXT_WINDOW_TOKENS_")
|
||||
&& !suffix.is_empty()
|
||||
&& m.contains(&suffix.to_ascii_lowercase())
|
||||
&& let Ok(v) = val.trim().parse::<u64>()
|
||||
&& v > 0
|
||||
{
|
||||
return v;
|
||||
}
|
||||
}
|
||||
// Global override (single value, any model).
|
||||
if let Ok(s) = std::env::var("HIVE_CONTEXT_WINDOW_TOKENS")
|
||||
&& let Ok(v) = s.trim().parse::<u64>()
|
||||
&& v > 0
|
||||
{
|
||||
return v;
|
||||
}
|
||||
// Hard fallback for dev/test outside NixOS where env vars aren't set.
|
||||
200_000
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{DEFAULT_EFFORT, EFFORT_LEVELS, forge_cursor_from_json, is_valid_effort};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn forge_cursor_absent_field_is_empty() {
|
||||
// First boot / a state file that only carries the turn-loop fields:
|
||||
// no cursor yet, so we re-deliver the currently-unread set once.
|
||||
assert!(forge_cursor_from_json(&json!({ "rate_limited": false })).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forge_cursor_malformed_field_is_empty() {
|
||||
// A wrong-typed / corrupt cursor degrades to empty rather than
|
||||
// aborting the poller.
|
||||
assert!(forge_cursor_from_json(&json!({ "forge_cursor": "nonsense" })).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forge_cursor_roundtrips_u64_keys() {
|
||||
// serde_json stringifies integer map keys; confirm the u64 thread
|
||||
// ids the cursor is keyed on survive the JSON round-trip.
|
||||
let v = json!({
|
||||
"forge_cursor": { "42": "2026-06-22T16:00:00Z", "99": "2026-06-22T17:30:00Z" }
|
||||
});
|
||||
let cursor = forge_cursor_from_json(&v);
|
||||
assert_eq!(cursor.len(), 2);
|
||||
assert_eq!(cursor.get(&42), Some(&"2026-06-22T16:00:00Z".to_owned()));
|
||||
assert_eq!(cursor.get(&99), Some(&"2026-06-22T17:30:00Z".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effort_validation_accepts_only_known_levels() {
|
||||
for level in EFFORT_LEVELS {
|
||||
assert!(is_valid_effort(level), "{level} should be valid");
|
||||
}
|
||||
// Out-of-set, empty, and wrong-case inputs are rejected so a bad
|
||||
// picker POST never reaches `claude --effort`.
|
||||
for bad in ["lowest", "", "MEDIUM", "ultra", "high "] {
|
||||
assert!(!is_valid_effort(bad), "{bad:?} should be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compiled_default_effort_is_selectable() {
|
||||
// The fallback must itself be a valid level, and match the value
|
||||
// baked into prompts/claude-settings.json.
|
||||
assert!(EFFORT_LEVELS.contains(&DEFAULT_EFFORT));
|
||||
assert_eq!(DEFAULT_EFFORT, "medium");
|
||||
}
|
||||
}
|
||||
255
hive-agent/src/identity.rs
Normal file
255
hive-agent/src/identity.rs
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
//! Agent identity helpers — short label + hive-qualified long name +
|
||||
//! human display names for the hive and swarm. Full env var surface +
|
||||
//! domain-vs-name distinction documented in
|
||||
//! `docs/conventions.md::Hive identity (label + domain + display names)`.
|
||||
|
||||
use std::env;
|
||||
|
||||
/// Short, hive-local agent label. Read from `HIVE_LABEL`; falls back to an
|
||||
/// empty string when the env var is missing, so callers downstream can decide
|
||||
/// how to surface "unknown agent" rather than getting a panic from this
|
||||
/// module.
|
||||
#[must_use]
|
||||
pub fn label() -> String {
|
||||
env::var("HIVE_LABEL").unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `env::var(key)` reduced to `Some(value)` only when the var is set and
|
||||
/// non-empty — the shared shape of the hive/swarm display-name lookups below.
|
||||
fn non_empty_env(key: &str) -> Option<String> {
|
||||
env::var(key).ok().filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// The hive's canonical DNS domain when set, otherwise None. Single-hive
|
||||
/// deployments where `HYPERHIVE_HIVE_DOMAIN` is unset return None — callers
|
||||
/// then degrade gracefully to the short label.
|
||||
#[must_use]
|
||||
pub fn hive_domain() -> Option<String> {
|
||||
non_empty_env("HYPERHIVE_HIVE_DOMAIN")
|
||||
}
|
||||
|
||||
/// Human display name of this hive (e.g. `pr1ma`). Distinct from
|
||||
/// [`hive_domain`] — the domain is the machine-readable DNS address;
|
||||
/// this is the prose label humans use in conversation. Returns None
|
||||
/// when the host-side `services.hyperhive.hiveName` option is unset,
|
||||
/// in which case callers fall back to the domain or the short label
|
||||
/// at their discretion.
|
||||
#[must_use]
|
||||
pub fn hive_name() -> Option<String> {
|
||||
non_empty_env("HYPERHIVE_HIVE_NAME")
|
||||
}
|
||||
|
||||
/// Human display name of the wider swarm this hive belongs to (e.g.
|
||||
/// `constellat1on`). Federated hives at different DNS domains can
|
||||
/// share a swarm name. Returns None when the host-side
|
||||
/// `services.hyperhive.swarmName` option is unset.
|
||||
#[must_use]
|
||||
pub fn swarm_name() -> Option<String> {
|
||||
non_empty_env("HYPERHIVE_SWARM_NAME")
|
||||
}
|
||||
|
||||
/// Hive-qualified agent identity. When the hive domain is configured, returns
|
||||
/// `${label}@${domain}` (e.g. `iris@darkest.space`); when not, returns just
|
||||
/// the short label so callers can render a single string regardless of
|
||||
/// deployment shape. Callers that want to know whether the result is
|
||||
/// qualified should check [`hive_domain`] directly.
|
||||
#[must_use]
|
||||
pub fn qualified_label() -> String {
|
||||
qualify(&label())
|
||||
}
|
||||
|
||||
/// Apply hive qualification to an arbitrary agent label (e.g. a peer name
|
||||
/// from the broker). Mirrors [`qualified_label`] but lets a caller qualify
|
||||
/// names it didn't read from the env. Returns `${label}@${domain}` when the
|
||||
/// hive domain is set, else just `label`.
|
||||
#[must_use]
|
||||
pub fn qualify(label: &str) -> String {
|
||||
match hive_domain() {
|
||||
Some(domain) if !label.is_empty() => format!("{label}@{domain}"),
|
||||
_ => label.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// cargo's test runner parallelises by default, so a `with_env`
|
||||
/// helper that mutates process-wide env vars races between tests in
|
||||
/// this module. Serialise on a module-scope mutex so each `with_env`
|
||||
/// call holds the lock for its set / run / restore window. Cheap
|
||||
/// (each test body is microseconds) and avoids pulling in
|
||||
/// `serial_test` for just one module.
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Helper: run `f` with a clean env, restoring previous values on exit.
|
||||
/// Acquires `ENV_LOCK` first so concurrent tests don't race the env-var
|
||||
/// state. If a previous test panicked while holding the lock the
|
||||
/// mutex would be poisoned — we use `lock().unwrap_or_else(|e| e.into_inner())`
|
||||
/// to recover so a single test failure doesn't cascade through the
|
||||
/// whole module.
|
||||
fn with_env<F: FnOnce()>(label: Option<&str>, domain: Option<&str>, f: F) {
|
||||
with_full_env(label, domain, None, None, f);
|
||||
}
|
||||
|
||||
/// Extended form of [`with_env`] covering the display-name env vars
|
||||
/// (hive name + swarm name) alongside label + domain. Same SAFETY
|
||||
/// contract — serialised on `ENV_LOCK`, restore in scope.
|
||||
fn with_full_env<F: FnOnce()>(
|
||||
label: Option<&str>,
|
||||
domain: Option<&str>,
|
||||
hive_name: Option<&str>,
|
||||
swarm_name: Option<&str>,
|
||||
f: F,
|
||||
) {
|
||||
let _guard = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let prev_label = env::var("HIVE_LABEL").ok();
|
||||
let prev_domain = env::var("HYPERHIVE_HIVE_DOMAIN").ok();
|
||||
let prev_hive_name = env::var("HYPERHIVE_HIVE_NAME").ok();
|
||||
let prev_swarm_name = env::var("HYPERHIVE_SWARM_NAME").ok();
|
||||
// SAFETY: serialised by ENV_LOCK above; restore in the same scope.
|
||||
unsafe {
|
||||
match label {
|
||||
Some(v) => env::set_var("HIVE_LABEL", v),
|
||||
None => env::remove_var("HIVE_LABEL"),
|
||||
}
|
||||
match domain {
|
||||
Some(v) => env::set_var("HYPERHIVE_HIVE_DOMAIN", v),
|
||||
None => env::remove_var("HYPERHIVE_HIVE_DOMAIN"),
|
||||
}
|
||||
match hive_name {
|
||||
Some(v) => env::set_var("HYPERHIVE_HIVE_NAME", v),
|
||||
None => env::remove_var("HYPERHIVE_HIVE_NAME"),
|
||||
}
|
||||
match swarm_name {
|
||||
Some(v) => env::set_var("HYPERHIVE_SWARM_NAME", v),
|
||||
None => env::remove_var("HYPERHIVE_SWARM_NAME"),
|
||||
}
|
||||
}
|
||||
f();
|
||||
unsafe {
|
||||
match prev_label {
|
||||
Some(v) => env::set_var("HIVE_LABEL", v),
|
||||
None => env::remove_var("HIVE_LABEL"),
|
||||
}
|
||||
match prev_domain {
|
||||
Some(v) => env::set_var("HYPERHIVE_HIVE_DOMAIN", v),
|
||||
None => env::remove_var("HYPERHIVE_HIVE_DOMAIN"),
|
||||
}
|
||||
match prev_hive_name {
|
||||
Some(v) => env::set_var("HYPERHIVE_HIVE_NAME", v),
|
||||
None => env::remove_var("HYPERHIVE_HIVE_NAME"),
|
||||
}
|
||||
match prev_swarm_name {
|
||||
Some(v) => env::set_var("HYPERHIVE_SWARM_NAME", v),
|
||||
None => env::remove_var("HYPERHIVE_SWARM_NAME"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualified_label_with_domain_set() {
|
||||
with_env(Some("iris"), Some("darkest.space"), || {
|
||||
assert_eq!(qualified_label(), "iris@darkest.space");
|
||||
assert_eq!(hive_domain().as_deref(), Some("darkest.space"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualified_label_falls_back_to_short_when_domain_unset() {
|
||||
with_env(Some("iris"), None, || {
|
||||
assert_eq!(qualified_label(), "iris");
|
||||
assert!(hive_domain().is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualified_label_falls_back_to_short_when_domain_empty() {
|
||||
// Empty string is treated the same as unset — a misconfigured
|
||||
// module shouldn't surface `iris@` (no domain) to the operator.
|
||||
with_env(Some("iris"), Some(""), || {
|
||||
assert_eq!(qualified_label(), "iris");
|
||||
assert!(hive_domain().is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualify_takes_arbitrary_label() {
|
||||
with_env(Some("iris"), Some("darkest.space"), || {
|
||||
// Local label gets local hive applied; useful for rendering
|
||||
// a peer's name when the caller knows it's hive-local.
|
||||
assert_eq!(qualify("damocles"), "damocles@darkest.space");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualify_empty_label_stays_empty() {
|
||||
with_env(Some("iris"), Some("darkest.space"), || {
|
||||
assert_eq!(qualify(""), "");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_returns_empty_when_unset() {
|
||||
with_env(None, None, || {
|
||||
assert_eq!(label(), "");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hive_name_returns_some_when_env_set() {
|
||||
with_full_env(Some("iris"), None, Some("pr1ma"), None, || {
|
||||
assert_eq!(hive_name().as_deref(), Some("pr1ma"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hive_name_returns_none_when_env_unset_or_empty() {
|
||||
with_full_env(Some("iris"), None, None, None, || {
|
||||
assert!(hive_name().is_none());
|
||||
});
|
||||
with_full_env(Some("iris"), None, Some(""), None, || {
|
||||
assert!(hive_name().is_none(), "empty string treated as unset");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swarm_name_returns_some_when_env_set() {
|
||||
with_full_env(Some("iris"), None, None, Some("constellat1on"), || {
|
||||
assert_eq!(swarm_name().as_deref(), Some("constellat1on"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swarm_name_returns_none_when_env_unset_or_empty() {
|
||||
with_full_env(Some("iris"), None, None, None, || {
|
||||
assert!(swarm_name().is_none());
|
||||
});
|
||||
with_full_env(Some("iris"), None, None, Some(""), || {
|
||||
assert!(swarm_name().is_none(), "empty string treated as unset");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_accessors_independent_from_domain() {
|
||||
// hive_name + swarm_name surface without HYPERHIVE_HIVE_DOMAIN
|
||||
// being set — the names are display labels, not derived from
|
||||
// the DNS domain.
|
||||
with_full_env(
|
||||
Some("iris"),
|
||||
None,
|
||||
Some("pr1ma"),
|
||||
Some("constellat1on"),
|
||||
|| {
|
||||
assert!(hive_domain().is_none());
|
||||
assert_eq!(hive_name().as_deref(), Some("pr1ma"));
|
||||
assert_eq!(swarm_name().as_deref(), Some("constellat1on"));
|
||||
// qualified_label still degrades to short label without domain.
|
||||
assert_eq!(qualified_label(), "iris");
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
348
hive-agent/src/login.rs
Normal file
348
hive-agent/src/login.rs
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
//! Login-state probe for the bind-mounted `~/.claude/` dir. The dir is
|
||||
//! provided by hive-c0re and persists across container destroy/recreate so
|
||||
//! OAuth tokens survive.
|
||||
//!
|
||||
//! "Has session" means the dir contains at least one of the credential files
|
||||
//! in [`CRED_FILE_NAMES`] — the same set `/logout` (`web_ui::auth`) deletes to
|
||||
//! force re-login. Keying both off one constant keeps boot detection and
|
||||
//! logout in agreement: logout deliberately preserves session-history files,
|
||||
//! so a "contains any regular file" check would wrongly report `Online` after
|
||||
//! a logout + container recreate and burn a turn 401-ing before it reroutes.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::events::Bus;
|
||||
|
||||
/// Returns the Claude credentials directory for this agent. Delegates
|
||||
/// to `paths::claude_dir`, which reads `$HOME/.claude`. The service
|
||||
/// runs as a non-root unix user named after the agent so `$HOME`
|
||||
/// resolves to `/home/<agent>` and the OAuth dir lives at
|
||||
/// `/home/<agent>/.claude`. Overridable via `HYPERHIVE_CLAUDE_DIR`.
|
||||
#[must_use]
|
||||
pub fn default_dir() -> PathBuf {
|
||||
crate::paths::claude_dir()
|
||||
}
|
||||
|
||||
/// The credential files that constitute a logged-in claude session inside
|
||||
/// [`default_dir`]. A session exists iff at least one is present; a login
|
||||
/// "refresh" is a change to one of them. `/logout` (`web_ui::auth`) deletes
|
||||
/// exactly these to force re-login while preserving session-history files —
|
||||
/// so boot detection ([`has_session`]) and logout agree by construction.
|
||||
/// Rationale + the previous wholesale-wipe shape we replaced live in
|
||||
/// [`docs/web-ui/agent.md::Per-agent endpoints`](../../docs/web-ui/agent.md)
|
||||
/// (the `/api/logout` bullet).
|
||||
///
|
||||
/// `.credentials.json` is the actual OAuth session; `mcp-needs-auth-cache.json`
|
||||
/// is claude-code's MCP-auth cache and a weaker signal. Both are kept in the
|
||||
/// set only because `/logout` deletes both, so the "either present ⇒ logged
|
||||
/// in" check can never disagree with a logout. (If a future edit ever removes
|
||||
/// `.credentials.json` without the cache — a state `/logout` doesn't produce —
|
||||
/// keying purely on `.credentials.json` would be the stronger boot signal.)
|
||||
pub const CRED_FILE_NAMES: &[&str] = &[".credentials.json", "mcp-needs-auth-cache.json"];
|
||||
|
||||
/// Is `entry` a regular file whose name is one of [`CRED_FILE_NAMES`]?
|
||||
fn is_cred_file(entry: &std::fs::DirEntry) -> bool {
|
||||
entry.file_type().is_ok_and(|t| t.is_file())
|
||||
&& entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|n| CRED_FILE_NAMES.contains(&n))
|
||||
}
|
||||
|
||||
/// Returns `true` if `dir` exists and holds at least one credential file
|
||||
/// (see [`CRED_FILE_NAMES`]). Used at startup to decide whether to enter the
|
||||
/// turn loop (logged in) or stay in the partial-run "needs login" state.
|
||||
#[must_use]
|
||||
pub fn has_session(dir: &Path) -> bool {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
entries.flatten().any(|e| is_cred_file(&e))
|
||||
}
|
||||
|
||||
/// Outcome of [`clear_session`]: which credential files were removed and any
|
||||
/// non-fatal per-file errors (e.g. permission denied). A file that was already
|
||||
/// absent is not reported — deletion is idempotent.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ClearedSession {
|
||||
pub wiped: Vec<&'static str>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Delete the credential files (see [`CRED_FILE_NAMES`]) from `dir`, forcing a
|
||||
/// re-login on the next turn, while preserving the session-history files
|
||||
/// alongside them so `claude --continue` keeps working after a fresh login.
|
||||
/// Idempotent: an already-absent file is skipped, not reported. This is the
|
||||
/// write-side counterpart to [`has_session`]; `/logout` (`web_ui::auth`) drives
|
||||
/// it.
|
||||
pub async fn clear_session(dir: &Path) -> ClearedSession {
|
||||
let mut cleared = ClearedSession::default();
|
||||
for name in CRED_FILE_NAMES {
|
||||
match tokio::fs::remove_file(dir.join(name)).await {
|
||||
Ok(()) => cleared.wiped.push(name),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => cleared.warnings.push(format!("{name}: {e}")),
|
||||
}
|
||||
}
|
||||
cleared
|
||||
}
|
||||
|
||||
/// Login state the harness reports to its web UI.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LoginState {
|
||||
/// `~/.claude/` has credentials; turn loop is running.
|
||||
Online,
|
||||
/// `~/.claude/` is empty; harness is up, web UI is bound, turn loop is NOT
|
||||
/// running. Operator needs to complete login from the web UI.
|
||||
NeedsLogin,
|
||||
}
|
||||
|
||||
impl LoginState {
|
||||
#[must_use]
|
||||
pub fn from_dir(dir: &Path) -> Self {
|
||||
if has_session(dir) {
|
||||
Self::Online
|
||||
} else {
|
||||
Self::NeedsLogin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Block until the bound `~/.claude/` dir contains a session that
|
||||
/// post-dates this call, polling on a `poll_ms` interval (min 2s).
|
||||
/// Flips `state` to `Online` when login lands; caller resumes its
|
||||
/// serve loop. Snapshots the dir at entry and only resumes when the
|
||||
/// snapshot advances (mtime OR file-count change), avoiding the
|
||||
/// infinite-401 loop a bare-existence check would produce when stale
|
||||
/// credentials are already on disk. Mtime-snapshot resumption rationale
|
||||
/// and `DirSnapshot` two-axis design: see
|
||||
/// [`docs/turn-loop.md::The loop`](../../docs/turn-loop.md).
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal login-state lock is poisoned.
|
||||
pub async fn wait_for_login(
|
||||
claude_dir: &Path,
|
||||
state: Arc<Mutex<LoginState>>,
|
||||
bus: &Bus,
|
||||
poll_ms: u64,
|
||||
) {
|
||||
tracing::warn!(
|
||||
claude_dir = %claude_dir.display(),
|
||||
"no claude session — staying in partial-run mode (web UI only)"
|
||||
);
|
||||
// Announce `needs_login_idle` to the bus so the sentinel file
|
||||
// (`{state_dir}/hyperhive-needs-login`) gets written on every entry
|
||||
// path — cold-boot, 401-mid-turn, and `/api/logout`. The host's
|
||||
// `auth_failed_sentinel` reads that file to surface `needs_login`
|
||||
// on the dashboard. Idempotent — `emit_status` is a `write` on a
|
||||
// small empty file, so re-entering this function after a transient
|
||||
// operator action is a no-op for the on-disk state.
|
||||
bus.emit_status("needs_login_idle");
|
||||
let snapshot = snapshot_dir(claude_dir);
|
||||
let probe = Duration::from_millis(poll_ms.max(2000));
|
||||
loop {
|
||||
tokio::time::sleep(probe).await;
|
||||
if session_refreshed(snapshot, snapshot_dir(claude_dir)) {
|
||||
tracing::info!("claude session refreshed — entering turn loop");
|
||||
*state.lock().unwrap() = LoginState::Online;
|
||||
bus.emit_status("online");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot of the credential files (see [`CRED_FILE_NAMES`]) in the dir at a
|
||||
/// point in time: how many are present + newest `mtime` across them. The two
|
||||
/// axes are both load-bearing for `wait_for_login`'s refresh check
|
||||
/// (`session_refreshed`): mtime catches the common case (re-login overwrites
|
||||
/// an existing credentials file in-place), `file_count` catches the
|
||||
/// pathological case where `meta.modified()` errors on every file (exotic fs,
|
||||
/// NFS quirks) so the mtime axis stays `None` forever but a new credential
|
||||
/// file still triggers a resume. Defaults to `{0, None}` on `read_dir` failure
|
||||
/// (missing or unreadable dir) — `wait_for_login` then resumes when a
|
||||
/// credential file first appears.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
struct DirSnapshot {
|
||||
file_count: usize,
|
||||
newest_mtime: Option<std::time::SystemTime>,
|
||||
}
|
||||
|
||||
fn snapshot_dir(dir: &Path) -> DirSnapshot {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return DirSnapshot::default();
|
||||
};
|
||||
let mut snap = DirSnapshot::default();
|
||||
for entry in entries.flatten() {
|
||||
if !is_cred_file(&entry) {
|
||||
continue;
|
||||
}
|
||||
snap.file_count += 1;
|
||||
let Ok(meta) = entry.metadata() else { continue };
|
||||
let Ok(mtime) = meta.modified() else { continue };
|
||||
if snap.newest_mtime.is_none_or(|cur| mtime > cur) {
|
||||
snap.newest_mtime = Some(mtime);
|
||||
}
|
||||
}
|
||||
snap
|
||||
}
|
||||
|
||||
/// Has the credentials dir been written since `prev`? Used as the
|
||||
/// exit condition for `wait_for_login`:
|
||||
///
|
||||
/// - `file_count` changed → something was added or removed, treat as
|
||||
/// refresh (covers the "all files have unreadable mtime" edge case).
|
||||
/// - `newest_mtime` advanced → existing file was rewritten in place
|
||||
/// (the common claude re-login path).
|
||||
/// - prev had no mtime (empty or all-unreadable) and now has one →
|
||||
/// first useful signal we've seen, treat as refresh.
|
||||
fn session_refreshed(prev: DirSnapshot, now: DirSnapshot) -> bool {
|
||||
if now.file_count != prev.file_count {
|
||||
return true;
|
||||
}
|
||||
match (prev.newest_mtime, now.newest_mtime) {
|
||||
(None, Some(_)) => true,
|
||||
(Some(p), Some(n)) => n > p,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use super::{DirSnapshot, has_session, session_refreshed, snapshot_dir};
|
||||
|
||||
#[test]
|
||||
fn has_session_only_counts_credential_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// Session-history files (what `/logout` preserves) must NOT read as a
|
||||
// logged-in session — this is the logout+recreate 401 bug.
|
||||
fs::write(dir.path().join("history.jsonl"), b"{}").unwrap();
|
||||
fs::write(dir.path().join("some-project-uuid.json"), b"{}").unwrap();
|
||||
assert!(!has_session(dir.path()));
|
||||
// A real credential file does.
|
||||
fs::write(dir.path().join(".credentials.json"), b"{}").unwrap();
|
||||
assert!(has_session(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_dir_ignores_non_credential_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(dir.path().join("history.jsonl"), b"{}").unwrap();
|
||||
let snap = snapshot_dir(dir.path());
|
||||
assert_eq!(
|
||||
snap.file_count, 0,
|
||||
"history files must not count as session"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_dir_empty_dir_is_default() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let snap = snapshot_dir(dir.path());
|
||||
assert_eq!(snap.file_count, 0);
|
||||
assert!(snap.newest_mtime.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_dir_missing_dir_is_default() {
|
||||
// Defensive: a nonexistent dir must NOT panic. Bind mounts that
|
||||
// disappear mid-poll (host purge during operator intervention)
|
||||
// would otherwise crash the harness.
|
||||
let missing = tempfile::tempdir()
|
||||
.unwrap()
|
||||
.path()
|
||||
.join("never-created-subdir");
|
||||
let snap = snapshot_dir(&missing);
|
||||
assert_eq!(snap, DirSnapshot::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_dir_picks_latest_mtime_and_counts_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(dir.path().join(".credentials.json"), b"{}").unwrap();
|
||||
// Sleep so the second file's mtime is strictly greater than
|
||||
// the first on filesystems with low timestamp resolution.
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
let newer_path = dir.path().join("mcp-needs-auth-cache.json");
|
||||
fs::write(&newer_path, b"{}").unwrap();
|
||||
let snap = snapshot_dir(dir.path());
|
||||
assert_eq!(snap.file_count, 2);
|
||||
let newer_meta = fs::metadata(&newer_path).unwrap().modified().unwrap();
|
||||
assert_eq!(snap.newest_mtime, Some(newer_meta));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_refreshed_first_login_flips_on_cred_file() {
|
||||
// Empty-dir snapshot → a credential file appearing means a fresh
|
||||
// login landed. First-time login semantics.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let snapshot = snapshot_dir(dir.path());
|
||||
assert!(!session_refreshed(snapshot, snapshot_dir(dir.path())));
|
||||
fs::write(dir.path().join(".credentials.json"), b"{}").unwrap();
|
||||
assert!(session_refreshed(snapshot, snapshot_dir(dir.path())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_refreshed_stale_creds_dont_flip_immediately() {
|
||||
// Stale credentials.json already exists at entry; wait_for_login
|
||||
// must NOT immediately return — it would loop straight into
|
||||
// another 401-failing turn.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(dir.path().join(".credentials.json"), b"{}").unwrap();
|
||||
let snapshot = snapshot_dir(dir.path());
|
||||
assert_eq!(snapshot.file_count, 1);
|
||||
// No change to the file → loop must NOT exit.
|
||||
assert!(!session_refreshed(snapshot, snapshot_dir(dir.path())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_refreshed_after_creds_rewrite_flips() {
|
||||
// After the stale-creds snapshot, the operator's `/login/code`
|
||||
// flow lands a refreshed credentials file — its mtime bumps
|
||||
// strictly past the snapshot and wait_for_login resumes.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(dir.path().join(".credentials.json"), b"{}").unwrap();
|
||||
let snapshot = snapshot_dir(dir.path());
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
fs::write(dir.path().join(".credentials.json"), b"{\"v\":2}").unwrap();
|
||||
assert!(session_refreshed(snapshot, snapshot_dir(dir.path())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_refreshed_snapshot_with_future_mtime_doesnt_flip() {
|
||||
// Defensive: a snapshot set to a future timestamp (e.g. clock
|
||||
// skew between snapshot and probe) must keep waiting until a
|
||||
// file's mtime actually exceeds it, not return on first poll.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(dir.path().join(".credentials.json"), b"{}").unwrap();
|
||||
let snapshot = DirSnapshot {
|
||||
file_count: 1,
|
||||
newest_mtime: Some(SystemTime::now() + Duration::from_hours(1)),
|
||||
};
|
||||
assert!(!session_refreshed(snapshot, snapshot_dir(dir.path())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_refreshed_count_change_flips_when_mtime_unreadable() {
|
||||
// Defensive: if all files have unreadable `meta.modified()`
|
||||
// (exotic fs / NFS), newest_mtime stays `None` forever — but
|
||||
// file_count axis still catches new files appearing. Simulated
|
||||
// here by forging a snapshot with file_count=1 + no mtime, then
|
||||
// writing a second file.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(dir.path().join(".credentials.json"), b"{}").unwrap();
|
||||
let forged = DirSnapshot {
|
||||
file_count: 1,
|
||||
newest_mtime: None,
|
||||
};
|
||||
fs::write(dir.path().join("mcp-needs-auth-cache.json"), b"{}").unwrap();
|
||||
// Real snapshot has file_count=2, so refresh fires even
|
||||
// though the mtime axis would be inconclusive.
|
||||
assert!(session_refreshed(forged, snapshot_dir(dir.path())));
|
||||
}
|
||||
}
|
||||
304
hive-agent/src/login_session.rs
Normal file
304
hive-agent/src/login_session.rs
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
//! `claude auth login` driver. Spawns the login command under plain stdio pipes,
|
||||
//! accumulates stdout+stderr in a shared buffer (so the web UI can show
|
||||
//! whatever URL/prompt claude emits), and writes paste-back codes from the
|
||||
//! UI into the child's stdin.
|
||||
//!
|
||||
//! No PTY — we're betting `claude` produces a parseable URL on stdout and
|
||||
//! accepts a code on stdin even when not on a terminal. If it refuses or
|
||||
//! garbles, we'll redo this module backed by `portable-pty` (see PLAN.md
|
||||
//! Phase 8).
|
||||
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, ChildStdin, Command};
|
||||
|
||||
const DEFAULT_CMD: &str = "claude";
|
||||
const DEFAULT_ARGS: &[&str] = &["auth", "login"];
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
/// Concatenated stdout+stderr as it streams from the child.
|
||||
output: String,
|
||||
/// First URL-looking substring we saw in the output. Surface this on the
|
||||
/// web UI as the link the operator should open.
|
||||
url: Option<String>,
|
||||
/// Set when the child has exited. The web UI uses this to know whether
|
||||
/// the operator can still paste a code.
|
||||
finished: bool,
|
||||
/// Exit status note (e.g. "exited with code 0", "killed by signal 15"),
|
||||
/// shown next to a "finished" badge once the child returns.
|
||||
exit_note: Option<String>,
|
||||
}
|
||||
|
||||
/// A running `claude auth login` subprocess.
|
||||
pub struct LoginSession {
|
||||
child: Mutex<Child>,
|
||||
/// Tokio mutex because we hold the guard across the `write_all().await`
|
||||
/// in `submit_code`. The other locks are blocking-only and stay on
|
||||
/// `std::sync::Mutex`.
|
||||
stdin: tokio::sync::Mutex<Option<ChildStdin>>,
|
||||
state: Arc<Mutex<State>>,
|
||||
}
|
||||
|
||||
impl LoginSession {
|
||||
/// Spawn the login command. The exact binary/args are configurable via
|
||||
/// `HYPERHIVE_LOGIN_CMD` (single string, shell-split into argv); by
|
||||
/// default we run `claude auth login`. Failing to spawn returns an error
|
||||
/// before any state is registered.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if spawning the login command fails, or if the child's
|
||||
/// stdio handles cannot be acquired.
|
||||
pub fn start() -> Result<Self> {
|
||||
let (cmd, args) = resolve_command();
|
||||
tracing::info!(%cmd, ?args, "spawning login session");
|
||||
|
||||
let mut child = Command::new(&cmd)
|
||||
.args(&args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
// `claude` reads $HOME/.claude for the credentials dir. The
|
||||
// harness service env sets HOME to /home/<agent> and the
|
||||
// bind-mount lands the OAuth dir at the same path, so the
|
||||
// child inherits the right HOME without any further wiring
|
||||
// here.
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.with_context(|| format!("spawn `{cmd}`"))?;
|
||||
|
||||
let stdin = child.stdin.take().context("child stdin")?;
|
||||
let stdout = child.stdout.take().context("child stdout")?;
|
||||
let stderr = child.stderr.take().context("child stderr")?;
|
||||
|
||||
let state = Arc::new(Mutex::new(State::default()));
|
||||
tokio::spawn(pump(BufReader::new(stdout), state.clone(), "stdout"));
|
||||
tokio::spawn(pump(BufReader::new(stderr), state.clone(), "stderr"));
|
||||
|
||||
Ok(Self {
|
||||
child: Mutex::new(child),
|
||||
stdin: tokio::sync::Mutex::new(Some(stdin)),
|
||||
state,
|
||||
})
|
||||
}
|
||||
|
||||
/// Write `code` (plus a newline) to the child's stdin. Returns an error
|
||||
/// if the stdin has already been closed (e.g. after the child exited or
|
||||
/// after a prior submission consumed it).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the login stdin is already closed, or if writing
|
||||
/// to or flushing the stdin pipe fails.
|
||||
pub async fn submit_code(&self, code: &str) -> Result<()> {
|
||||
let mut guard = self.stdin.lock().await;
|
||||
let stdin = guard.as_mut().context("login stdin already closed")?;
|
||||
let line = format!("{}\n", code.trim());
|
||||
stdin
|
||||
.write_all(line.as_bytes())
|
||||
.await
|
||||
.context("write code to claude stdin")?;
|
||||
stdin.flush().await.context("flush claude stdin")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close stdin so claude sees EOF (useful if it's waiting for more input
|
||||
/// after the code submit).
|
||||
pub async fn close_stdin(&self) {
|
||||
let _ = self.stdin.lock().await.take();
|
||||
}
|
||||
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn output(&self) -> String {
|
||||
self.state.lock().unwrap().output.clone()
|
||||
}
|
||||
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn url(&self) -> Option<String> {
|
||||
self.state.lock().unwrap().url.clone()
|
||||
}
|
||||
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn finished(&self) -> bool {
|
||||
self.state.lock().unwrap().finished
|
||||
}
|
||||
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn exit_note(&self) -> Option<String> {
|
||||
self.state.lock().unwrap().exit_note.clone()
|
||||
}
|
||||
|
||||
/// Best-effort: poll the child once and update `finished`/`exit_note`.
|
||||
/// Called by the web UI on each render so the state stays fresh without
|
||||
/// running a dedicated reaper task.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if an internal lock is poisoned.
|
||||
pub fn poll(&self) {
|
||||
let mut child = self.child.lock().unwrap();
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
let mut s = self.state.lock().unwrap();
|
||||
s.finished = true;
|
||||
s.exit_note = Some(format!("{status}"));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
let mut s = self.state.lock().unwrap();
|
||||
s.finished = true;
|
||||
s.exit_note = Some(format!("try_wait error: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kill the child if it's still running. Idempotent.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
pub fn kill(&self) {
|
||||
if let Err(e) = self.child.lock().unwrap().start_kill() {
|
||||
tracing::warn!(error = ?e, "kill login child");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_command() -> (String, Vec<String>) {
|
||||
if let Ok(raw) = std::env::var("HYPERHIVE_LOGIN_CMD") {
|
||||
// Whitespace-only split — no quote handling. Fine for "claude auth login"
|
||||
// style overrides; if we need anything with embedded spaces we'll
|
||||
// switch to shell-words.
|
||||
let mut parts = raw.split_whitespace().map(str::to_owned);
|
||||
if let Some(cmd) = parts.next() {
|
||||
return (cmd, parts.collect());
|
||||
}
|
||||
}
|
||||
(
|
||||
DEFAULT_CMD.into(),
|
||||
DEFAULT_ARGS.iter().map(|s| (*s).to_owned()).collect(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn pump<R: tokio::io::AsyncRead + Unpin>(
|
||||
mut reader: BufReader<R>,
|
||||
state: Arc<Mutex<State>>,
|
||||
tag: &'static str,
|
||||
) {
|
||||
let mut buf = String::new();
|
||||
loop {
|
||||
buf.clear();
|
||||
// read_line breaks on \n; for claude's TUI output that flushes by
|
||||
// line this is fine. If it ever blasts a single un-newlined blob,
|
||||
// we'll miss it until EOF (acceptable for the URL surface — claude
|
||||
// prints the URL on its own line).
|
||||
match reader.read_line(&mut buf).await {
|
||||
Ok(0) => {
|
||||
state.lock().unwrap().finished = true;
|
||||
break;
|
||||
}
|
||||
Ok(_) => {
|
||||
let mut s = state.lock().unwrap();
|
||||
if s.url.is_none()
|
||||
&& let Some(url) = extract_url(&buf)
|
||||
{
|
||||
tracing::info!(%url, %tag, "login URL detected");
|
||||
s.url = Some(url);
|
||||
}
|
||||
s.output.push_str(&buf);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, %tag, "login pump read error");
|
||||
let mut s = state.lock().unwrap();
|
||||
s.finished = true;
|
||||
s.exit_note = Some(format!("pump {tag} error: {e}"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the first `https://…` substring on the line, terminating at any
|
||||
/// ASCII whitespace. Good enough for capturing claude's OAuth link without a
|
||||
/// regex dependency.
|
||||
fn extract_url(line: &str) -> Option<String> {
|
||||
let start = line.find("https://")?;
|
||||
let tail = &line[start..];
|
||||
let end = tail
|
||||
.find(|c: char| c.is_ascii_whitespace())
|
||||
.unwrap_or(tail.len());
|
||||
let url = tail[..end].trim_end_matches(['.', ',', ')', ']']);
|
||||
if url.len() > "https://".len() {
|
||||
Some(url.to_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper used by the web UI to gate "is there a session running right now"
|
||||
/// without holding both this module's mutex and the `AppState`'s at once.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
pub fn drop_if_finished(slot: &Mutex<Option<Arc<LoginSession>>>) {
|
||||
let mut guard = slot.lock().unwrap();
|
||||
if let Some(s) = guard.as_ref() {
|
||||
s.poll();
|
||||
if s.finished() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LoginSession {
|
||||
fn drop(&mut self) {
|
||||
// kill_on_drop on the Command also ensures the child dies, but we
|
||||
// belt-and-brace it in case the runtime detaches.
|
||||
let _ = self.child.lock().unwrap().start_kill();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::extract_url;
|
||||
|
||||
#[test]
|
||||
fn picks_first_https() {
|
||||
let line = " Go to https://claude.ai/oauth/abc?xyz=1 in your browser.\n";
|
||||
assert_eq!(
|
||||
extract_url(line).as_deref(),
|
||||
Some("https://claude.ai/oauth/abc?xyz=1"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_punctuation_stripped() {
|
||||
let line = "Open https://example.com/abc).\n";
|
||||
assert_eq!(
|
||||
extract_url(line).as_deref(),
|
||||
Some("https://example.com/abc"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_url() {
|
||||
assert_eq!(extract_url("nothing here\n"), None);
|
||||
}
|
||||
}
|
||||
705
hive-agent/src/main.rs
Normal file
705
hive-agent/src/main.rs
Normal file
|
|
@ -0,0 +1,705 @@
|
|||
//! 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).
|
||||
//!
|
||||
//! Single bin crate: the module tree below (formerly this crate's `lib.rs`,
|
||||
//! before lib + bin were collapsed into one) plus the serve loop.
|
||||
|
||||
mod client;
|
||||
mod events;
|
||||
mod forge_notify;
|
||||
mod harness_state;
|
||||
mod identity;
|
||||
mod login;
|
||||
mod login_session;
|
||||
mod mcp_config;
|
||||
mod paths;
|
||||
mod plugins;
|
||||
mod prompt;
|
||||
mod serve_common;
|
||||
mod stats;
|
||||
mod turn;
|
||||
mod turn_stats;
|
||||
mod vacuum;
|
||||
mod web_ui;
|
||||
|
||||
/// Default socket path inside the container — bind-mounted by `hive-c0re`.
|
||||
const DEFAULT_SOCKET: &str = "/run/hive/mcp.sock";
|
||||
|
||||
/// Default web UI port — used when `HIVE_PORT` env is unset.
|
||||
const DEFAULT_WEB_PORT: u16 = 8042;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::events::{Bus, LiveEvent, TurnState};
|
||||
use crate::login::LoginState;
|
||||
use crate::turn_stats::TurnStats;
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
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 = crate::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 = crate::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(crate::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(crate::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::ApiStall)) {
|
||||
// Idle watchdog killed claude on a suspected API stall. Park briefly to
|
||||
// let the API recover, then requeue — same shape as the rate-limit path.
|
||||
let secs = turn::stall_sleep_secs();
|
||||
bus.emit_status("api_stall");
|
||||
bus.emit(LiveEvent::Note {
|
||||
text: format!(
|
||||
"API stall timeout — sleeping {secs}s before retry \
|
||||
(tune HIVE_STALL_SLEEP_SECS; disable the watchdog with HIVE_TURN_IDLE_SECS=0)"
|
||||
),
|
||||
});
|
||||
tracing::warn!(sleep_secs = secs, "API stall; parking before retry");
|
||||
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());
|
||||
}
|
||||
}
|
||||
389
hive-agent/src/mcp_config.rs
Normal file
389
hive-agent/src/mcp_config.rs
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
//! Claude launch-config layer: resolves the agent's tool-group / capability
|
||||
//! set into the `--allowedTools` / `--tools` argument strings and renders the
|
||||
//! `--mcp-config` blob claude reads at spawn (built-in hyperhive server +
|
||||
//! any `hyperhive.extraMcpServers`). Pure config-string generation consumed by
|
||||
//! [`crate::turn`] when it builds the claude command. It never touches the
|
||||
//! running MCP server (a separate binary) — the `send` allow-list check that
|
||||
//! server enforces lives alongside it in the `hive-agent-mcp` crate.
|
||||
|
||||
/// Name of the hyperhive MCP server inside claude's view. Claude prefixes
|
||||
/// tools as `mcp__<this>__<tool>` (e.g. `mcp__hyperhive__send`).
|
||||
pub const SERVER_NAME: &str = "hyperhive";
|
||||
|
||||
/// Default loopback port the built-in hyperhive MCP surface is served on
|
||||
/// (streamable HTTP, via the persistent `hive-mcp-http` daemon). Overridable
|
||||
/// via `hyperhive.mcp.httpPort`; **must match that option's default** in
|
||||
/// `nix/templates/harness/`. Safe as a single fixed value across all
|
||||
/// agents because each container runs in its own private network namespace,
|
||||
/// so `127.0.0.1:<port>` is per-container-private (no cross-agent collision).
|
||||
pub const DEFAULT_MCP_HTTP_PORT: u16 = 8790;
|
||||
|
||||
/// Built-in claude tools always present in every session. Anything not
|
||||
/// in this list (or added by `extra_builtin_tools`) literally doesn't
|
||||
/// exist in the session. Web egress (`WebFetch`/`WebSearch`) are
|
||||
/// tool-group-gated (`web_tools`) — off by default. Nested agents
|
||||
/// (`Task`) are intentionally omitted. `Bash` is disallowed — shell
|
||||
/// execution goes through `mcp__bash__run` (background tasks
|
||||
/// with structured output via `hive-bash-mcp`) instead of a raw interactive shell. `TodoWrite`
|
||||
/// is omitted because the todo list lives in claude's in-process session
|
||||
/// state and silently evaporates on /compact or session reset — agents
|
||||
/// should plan in /state notes instead.
|
||||
pub const ALLOWED_BUILTIN_TOOLS: &[&str] = &["Edit", "Glob", "Grep", "Read", "Write"];
|
||||
|
||||
/// Env var written by the meta renderer with a comma-separated list of
|
||||
/// `hive_sh4re::ToolGroup` `snake_case` names (e.g. `"messaging,inbox,meta"`).
|
||||
/// When present, the harness expands the groups into per-tool allow entries
|
||||
/// instead of using the hardcoded flavor default. See `docs/conventions.md::Tool groups`.
|
||||
const TOOL_GROUPS_ENV: &str = "HIVE_TOOL_GROUPS";
|
||||
|
||||
/// `HIVE_CAPABILITIES` env var injected by `meta::render_flake` when the
|
||||
/// operator grants capabilities to this agent. Comma-separated
|
||||
/// `hive_sh4re::Capability` `snake_case` names. Absent = no extra capabilities.
|
||||
const CAPABILITIES_ENV: &str = "HIVE_CAPABILITIES";
|
||||
|
||||
/// Returns the MCP tool names (without `mcp__hyperhive__` prefix) that are
|
||||
/// unlocked by the agent's current capability set. These are added to the
|
||||
/// `--allowedTools` list so claude can call them without prompting, and
|
||||
/// hive-c0re performs a second server-side capability check before executing.
|
||||
fn allowed_capability_tools() -> Vec<String> {
|
||||
let raw = match std::env::var(CAPABILITIES_ENV) {
|
||||
Ok(v) if !v.trim().is_empty() => v,
|
||||
_ => return vec![],
|
||||
};
|
||||
let mut tools = Vec::new();
|
||||
for token in raw.split(',') {
|
||||
let t = token.trim().to_ascii_lowercase();
|
||||
match t.as_str() {
|
||||
"read_host_journal" => tools.push("get_host_journal".to_owned()),
|
||||
// infra_admin lets an agent restart hive infrastructure
|
||||
// containers (hive-ci / hive-gateway / hive-forge) through the
|
||||
// existing `restart` tool. Unlock it here so agents that hold
|
||||
// the capability without the full `lifecycle` group can still
|
||||
// call it; c0re re-checks the capability server-side and only
|
||||
// honours infra-container names via this path.
|
||||
"infra_admin" => tools.push("restart".to_owned()),
|
||||
// manage_root_agent / query_agent_state don't expose new MCP
|
||||
// tools: manage_root_agent gates existing lifecycle tools via
|
||||
// topology enforcement; query_agent_state unlocks the `agent`
|
||||
// field in get_loose_ends / count_pending_reminders /
|
||||
// reminder_rollup (c0re enforces the cap server-side).
|
||||
"manage_root_agent" | "query_agent_state" => {}
|
||||
unknown => {
|
||||
tracing::warn!(capability = %unknown, "unrecognised capability in HIVE_CAPABILITIES — skipped");
|
||||
}
|
||||
}
|
||||
}
|
||||
tools
|
||||
}
|
||||
|
||||
/// Resolve the active tool groups for a harness session.
|
||||
///
|
||||
/// Reads `HIVE_TOOL_GROUPS` from the environment first. Each comma-separated
|
||||
/// token is matched (case-insensitive) against the `ToolGroup` serde names
|
||||
/// (`messaging`, `meta`, `inbox`, `lifecycle`, `approvals`, `scheduling`,
|
||||
/// `diagnostics`, `execution`). Unrecognised tokens are logged and skipped.
|
||||
/// Falls back to `AGENT_DEFAULT` when the env var is absent or empty.
|
||||
fn effective_tool_groups() -> Vec<hive_sh4re::ToolGroup> {
|
||||
let raw = match std::env::var(TOOL_GROUPS_ENV) {
|
||||
Ok(v) if !v.trim().is_empty() => v,
|
||||
_ => return hive_sh4re::ToolGroup::AGENT_DEFAULT.to_vec(),
|
||||
};
|
||||
let mut groups = Vec::new();
|
||||
for token in raw.split(',') {
|
||||
let t = token.trim().to_ascii_lowercase();
|
||||
// Parse via serde_json (the canonical deserialization path).
|
||||
if let Ok(g) =
|
||||
serde_json::from_value::<hive_sh4re::ToolGroup>(serde_json::Value::String(t.clone()))
|
||||
{
|
||||
groups.push(g);
|
||||
} else {
|
||||
tracing::warn!(token = %t, "{TOOL_GROUPS_ENV}: unknown tool group, skipping");
|
||||
}
|
||||
}
|
||||
if groups.is_empty() {
|
||||
tracing::warn!(
|
||||
"{TOOL_GROUPS_ENV} set but contained no recognised groups; \
|
||||
falling back to AGENT_DEFAULT"
|
||||
);
|
||||
return hive_sh4re::ToolGroup::AGENT_DEFAULT.to_vec();
|
||||
}
|
||||
groups
|
||||
}
|
||||
|
||||
/// Tool group an extra (out-of-process) MCP server is gated behind, if any.
|
||||
///
|
||||
/// Most `hyperhive.extraMcpServers` entries are ungated — available whenever
|
||||
/// the operator declares them. The `bash` server is the exception: raw shell
|
||||
/// execution is a privilege, so it is only exposed when the agent holds the
|
||||
/// `Execution` tool group. Unlike the in-process hyperhive tools (gated at
|
||||
/// dispatch) and the capability tools (re-checked server-side by hive-c0re),
|
||||
/// an out-of-process server has **no** later enforcement point — once it is
|
||||
/// in the claude MCP config the agent can call it. So this gate, applied at
|
||||
/// config-render time, is the security boundary for those servers.
|
||||
fn extra_server_required_group(server: &str) -> Option<hive_sh4re::ToolGroup> {
|
||||
match server {
|
||||
"bash" => Some(hive_sh4re::ToolGroup::Execution),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an extra MCP server should be exposed to claude given the active
|
||||
/// tool `groups`. A gated server (see [`extra_server_required_group`]) is
|
||||
/// suppressed when the agent lacks its required group.
|
||||
fn extra_server_enabled(server: &str, groups: &[hive_sh4re::ToolGroup]) -> bool {
|
||||
extra_server_required_group(server).is_none_or(|required| groups.contains(&required))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod extra_server_gate_tests {
|
||||
use super::{extra_server_enabled, extra_server_required_group};
|
||||
use hive_sh4re::ToolGroup;
|
||||
|
||||
#[test]
|
||||
fn bash_is_gated_behind_execution() {
|
||||
assert_eq!(
|
||||
extra_server_required_group("bash"),
|
||||
Some(ToolGroup::Execution)
|
||||
);
|
||||
// Suppressed without Execution, even if other groups are present.
|
||||
assert!(!extra_server_enabled(
|
||||
"bash",
|
||||
&[ToolGroup::Messaging, ToolGroup::Inbox]
|
||||
));
|
||||
// Available once Execution is granted.
|
||||
assert!(extra_server_enabled("bash", &[ToolGroup::Execution]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_servers_are_ungated() {
|
||||
assert_eq!(extra_server_required_group("matrix"), None);
|
||||
assert_eq!(extra_server_required_group("scraper"), None);
|
||||
// An ungated server is available regardless of (even empty) groups.
|
||||
assert!(extra_server_enabled("matrix", &[]));
|
||||
assert!(extra_server_enabled("scraper", &[ToolGroup::Messaging]));
|
||||
}
|
||||
}
|
||||
|
||||
/// MCP tools claude is allowed to call without prompting, derived from
|
||||
/// the supplied tool groups. Adding a new `#[tool]` fn to a server impl
|
||||
/// requires updating the matching `ToolGroup::tools()` slice in hive-sh4re
|
||||
/// (single source of truth). See `docs/conventions.md::Tool groups`.
|
||||
#[must_use]
|
||||
pub fn allowed_mcp_tools(groups: &[hive_sh4re::ToolGroup]) -> Vec<String> {
|
||||
// Collect all tool names, deduplicating while preserving order.
|
||||
// Always-on tools (e.g. `set_status`) come first so they're present
|
||||
// regardless of which groups the agent is granted — a misconfigured
|
||||
// agent still has to be able to report its dashboard status.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut out: Vec<String> = hive_sh4re::ToolGroup::ALWAYS_ON_TOOLS
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(groups.iter().flat_map(|g| g.tools().iter().copied()))
|
||||
.filter(|t| seen.insert(*t))
|
||||
.map(|t| format!("mcp__{SERVER_NAME}__{t}"))
|
||||
.collect();
|
||||
// Extra MCP servers declared via `hyperhive.extraMcpServers` in
|
||||
// the agent's NixOS config. Each entry maps its `allowedTools`
|
||||
// pattern list to `mcp__<server>__<pattern>` so claude can call
|
||||
// them without per-tool operator approval. `["*"]` (the default)
|
||||
// expands to `mcp__<server>__*` — every tool from that server.
|
||||
for (server, spec) in load_extra_mcp() {
|
||||
if server == SERVER_NAME || !extra_server_enabled(&server, groups) {
|
||||
continue;
|
||||
}
|
||||
for pat in spec.allowed_tools {
|
||||
out.push(format!("mcp__{server}__{pat}"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Combined allow-list passed to `--allowedTools` (auto-approve) — covers
|
||||
/// both the built-ins and the MCP surface.
|
||||
#[must_use]
|
||||
pub fn allowed_tools_arg() -> String {
|
||||
let groups = effective_tool_groups();
|
||||
// Base built-ins always present.
|
||||
let mut all: Vec<String> = ALLOWED_BUILTIN_TOOLS
|
||||
.iter()
|
||||
.map(|s| (*s).to_owned())
|
||||
.collect();
|
||||
// Extra built-ins gated by tool groups (e.g. WebFetch/WebSearch via web_tools).
|
||||
for group in &groups {
|
||||
for tool in group.builtin_tools() {
|
||||
if !all.iter().any(|t| t == *tool) {
|
||||
all.push((*tool).to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
all.extend(allowed_mcp_tools(&groups));
|
||||
// Capability-gated MCP tools: added to --allowedTools when HIVE_CAPABILITIES
|
||||
// includes the corresponding capability. hive-c0re performs a second
|
||||
// server-side check, so this is a usability gate (no annoying prompts),
|
||||
// not the security boundary.
|
||||
for tool in allowed_capability_tools() {
|
||||
all.push(format!("mcp__{SERVER_NAME}__{tool}"));
|
||||
}
|
||||
all.join(",")
|
||||
}
|
||||
|
||||
/// Built-in tools list for `--tools` (which built-ins exist in this
|
||||
/// session). Base set plus any group-gated built-ins (e.g.
|
||||
/// `WebFetch`/`WebSearch` when the `web_tools` group is active).
|
||||
#[must_use]
|
||||
pub fn builtin_tools_arg() -> String {
|
||||
let groups = effective_tool_groups();
|
||||
let mut tools: Vec<&str> = ALLOWED_BUILTIN_TOOLS.to_vec();
|
||||
for group in &groups {
|
||||
for t in group.builtin_tools() {
|
||||
if !tools.contains(t) {
|
||||
tools.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
tools.join(",")
|
||||
}
|
||||
|
||||
/// Where the NixOS module writes the per-agent extra-MCP spec (see
|
||||
/// `nix/templates/harness/`). Each entry becomes an additional
|
||||
/// `mcpServers.<key>` block in the rendered claude config + a
|
||||
/// `mcp__<key>__<tool>` pattern in `--allowedTools`.
|
||||
const EXTRA_MCP_PATH: &str = "/etc/hyperhive/extra-mcp.json";
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct ExtraMcpServer {
|
||||
command: String,
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
#[serde(default)]
|
||||
env: std::collections::BTreeMap<String, String>,
|
||||
#[serde(default = "default_allowed_tools")]
|
||||
#[serde(rename = "allowedTools")]
|
||||
allowed_tools: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_allowed_tools() -> Vec<String> {
|
||||
vec!["*".to_owned()]
|
||||
}
|
||||
|
||||
/// Read + parse the extra-MCP spec. Returns an empty map when
|
||||
/// the file is missing or unparsable (the agent has none configured,
|
||||
/// or the file is malformed — both cases degrade to "no extra servers").
|
||||
fn load_extra_mcp() -> std::collections::BTreeMap<String, ExtraMcpServer> {
|
||||
let Ok(raw) = std::fs::read_to_string(EXTRA_MCP_PATH) else {
|
||||
return std::collections::BTreeMap::new();
|
||||
};
|
||||
serde_json::from_str(&raw).unwrap_or_else(|e| {
|
||||
tracing::warn!(
|
||||
path = EXTRA_MCP_PATH,
|
||||
error = ?e,
|
||||
"extra-mcp spec parse failed; ignoring",
|
||||
);
|
||||
std::collections::BTreeMap::new()
|
||||
})
|
||||
}
|
||||
|
||||
/// Render the MCP config blob claude reads from `--mcp-config <path>`.
|
||||
/// The built-in `hyperhive` surface is an HTTP entry pointing at the
|
||||
/// persistent `hive-mcp-http` daemon (see [`DEFAULT_MCP_HTTP_PORT`]); there
|
||||
/// is no per-turn stdio child for it. Merges in any extra MCP servers
|
||||
/// declared via `hyperhive.extraMcpServers` (those stay stdio bridges).
|
||||
#[must_use]
|
||||
pub fn render_claude_config() -> String {
|
||||
let mut servers = serde_json::Map::new();
|
||||
// The built-in hyperhive surface is served exclusively over streamable
|
||||
// HTTP by the persistent `hive-mcp-http` daemon (loopback, inside the
|
||||
// agent's private network namespace). Point claude at the stable URL
|
||||
// rather than respawning a fresh stdio child each turn: the URL survives
|
||||
// the per-turn claude re-spawn, so there is no per-turn re-registration
|
||||
// race for the hyperhive surface. Extra servers (matrix/bash) stay stdio
|
||||
// bridges. The port comes from `HYPERHIVE_MCP_HTTP_PORT` (always set by
|
||||
// the harness); `DEFAULT_MCP_HTTP_PORT` is the fallback matching the nix
|
||||
// default.
|
||||
let port = std::env::var("HYPERHIVE_MCP_HTTP_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.trim().parse::<u16>().ok())
|
||||
.unwrap_or(DEFAULT_MCP_HTTP_PORT);
|
||||
let hyperhive_entry = serde_json::json!({
|
||||
"type": "http",
|
||||
"url": format!("http://127.0.0.1:{port}/mcp"),
|
||||
});
|
||||
servers.insert(SERVER_NAME.to_owned(), hyperhive_entry);
|
||||
// Auto-inject HYPERHIVE_STATE_DIR so extra MCP servers can resolve the
|
||||
// agent's durable state dir without the agent author hard-coding it.
|
||||
// User-supplied env takes precedence — we only fill in the missing key.
|
||||
let state_dir = crate::paths::state_dir();
|
||||
// Gate tool-group-restricted extra servers (e.g. `bash` → `Execution`).
|
||||
// This is the security boundary for them: an out-of-process server the
|
||||
// agent isn't entitled to must not even appear in the MCP config, or the
|
||||
// agent could call it directly (there is no later enforcement point).
|
||||
let groups = effective_tool_groups();
|
||||
for (name, mut spec) in load_extra_mcp() {
|
||||
if name == SERVER_NAME {
|
||||
tracing::warn!(
|
||||
"extra MCP server name `{SERVER_NAME}` collides with the built-in surface; ignoring",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if !extra_server_enabled(&name, &groups) {
|
||||
tracing::info!(
|
||||
server = %name,
|
||||
"extra MCP server suppressed: agent lacks the required tool group"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
spec.env
|
||||
.entry("HYPERHIVE_STATE_DIR".to_owned())
|
||||
.or_insert_with(|| state_dir.display().to_string());
|
||||
servers.insert(
|
||||
name,
|
||||
serde_json::json!({
|
||||
"command": spec.command,
|
||||
"args": spec.args,
|
||||
"env": spec.env,
|
||||
}),
|
||||
);
|
||||
}
|
||||
let config = serde_json::json!({ "mcpServers": servers });
|
||||
serde_json::to_string_pretty(&config).unwrap_or_else(|_| "{}".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{SERVER_NAME, allowed_mcp_tools};
|
||||
use hive_sh4re::ToolGroup;
|
||||
|
||||
fn qualified(tool: &str) -> String {
|
||||
format!("mcp__{SERVER_NAME}__{tool}")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_status_present_with_no_groups() {
|
||||
// An agent with zero tool groups (or any group set that omits
|
||||
// `meta`) must still be able to report its dashboard status.
|
||||
let tools = allowed_mcp_tools(&[]);
|
||||
assert!(
|
||||
tools.contains(&qualified("set_status")),
|
||||
"set_status missing from empty-group allow-list: {tools:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_status_present_without_meta_group() {
|
||||
let tools = allowed_mcp_tools(&[ToolGroup::Messaging, ToolGroup::Inbox]);
|
||||
assert!(tools.contains(&qualified("set_status")));
|
||||
// get_agent_meta stays gated behind `meta` — only set_status is always-on.
|
||||
assert!(!tools.contains(&qualified("get_agent_meta")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_duplicate_set_status_when_meta_granted() {
|
||||
let tools = allowed_mcp_tools(&[ToolGroup::Meta]);
|
||||
let count = tools
|
||||
.iter()
|
||||
.filter(|t| **t == qualified("set_status"))
|
||||
.count();
|
||||
assert_eq!(count, 1, "set_status duplicated: {tools:?}");
|
||||
assert!(tools.contains(&qualified("get_agent_meta")));
|
||||
}
|
||||
}
|
||||
76
hive-agent/src/paths.rs
Normal file
76
hive-agent/src/paths.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
//! Per-agent path resolution for state, harness, and credential directories.
|
||||
//!
|
||||
//! All agents (including the manager `root`) use `/agents/{label}/state`
|
||||
//! for agent-owned durable notes, and `/agents/{label}/harness` for
|
||||
//! harness-internal files (`hyperhive-events.sqlite`, `hyperhive-model`, etc.)
|
||||
//! that should not clutter what claude sees as "my notes dir".
|
||||
//! Claude credentials live at `$HOME/.claude` (resolves to
|
||||
//! `/home/<agent-name>/.claude` because the harness service runs as a
|
||||
//! non-root unix user matching the agent label — see
|
||||
//! `docs/persistence.md::First-boot agent-user migration`).
|
||||
//!
|
||||
//! All three paths can be overridden via env vars (`HYPERHIVE_STATE_DIR`,
|
||||
//! `HYPERHIVE_HARNESS_DIR`, `HYPERHIVE_CLAUDE_DIR`) for dev / test scenarios.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Durable state directory for the current agent. Reads `HYPERHIVE_STATE_DIR`
|
||||
/// first (always set by the meta flake to `/agents/{label}/state`); falls back
|
||||
/// to the same pattern derived from `HIVE_LABEL` for dev/test environments
|
||||
/// where the env var may not be set.
|
||||
#[must_use]
|
||||
pub fn state_dir() -> PathBuf {
|
||||
if let Some(p) = std::env::var_os("HYPERHIVE_STATE_DIR") {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
let label = std::env::var("HIVE_LABEL").unwrap_or_default();
|
||||
PathBuf::from(format!("/agents/{label}/state"))
|
||||
}
|
||||
|
||||
/// Harness-internal state directory. Holds files the harness owns
|
||||
/// (`hyperhive-events.sqlite`, `hyperhive-turn-stats.sqlite`,
|
||||
/// `hyperhive-model`) so they do not appear inside the agent-visible
|
||||
/// `/agents/{label}/state` tree. Delegates to the shared canonical
|
||||
/// resolver in `hive_sh4re::paths` so the harness + every out-of-process
|
||||
/// MCP daemon resolve this identically (reads `HYPERHIVE_HARNESS_DIR`,
|
||||
/// then a `harness/` sibling of `HYPERHIVE_STATE_DIR`, then
|
||||
/// `/agents/{HIVE_LABEL}/harness`).
|
||||
#[must_use]
|
||||
pub fn harness_dir() -> PathBuf {
|
||||
hive_sh4re::paths::harness_dir()
|
||||
}
|
||||
|
||||
/// Per-turn config dir for the regenerated claude-{mcp-config,settings,
|
||||
/// system-prompt} files the harness drops before each turn. Set by
|
||||
/// systemd via `RuntimeDirectory = "hive-config"`: a per-service runtime
|
||||
/// dir owned by the agent unix user, auto-cleared on stop. Kept separate
|
||||
/// from `/run/hive` (the host-owned mcp.sock bind) so the harness owns
|
||||
/// its own write surface and we don't have to chown a bind-mounted dir.
|
||||
/// Overridable via `HYPERHIVE_CONFIG_DIR` for dev / test scenarios.
|
||||
#[must_use]
|
||||
pub fn config_dir() -> PathBuf {
|
||||
if let Some(p) = std::env::var_os("HYPERHIVE_CONFIG_DIR") {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
PathBuf::from("/run/hive-config")
|
||||
}
|
||||
|
||||
/// Claude credentials directory for the current agent. `$HOME/.claude`
|
||||
/// matches what the `claude` CLI reads at runtime — the harness sees
|
||||
/// the same `$HOME` set by the per-service systemd `environment`
|
||||
/// declaration (`/home/<agent>`). Falls back to `/root/.claude` for
|
||||
/// dev / test environments where `HOME` isn't set so the previous
|
||||
/// root-by-default shape keeps working without env wiring.
|
||||
/// Overridable via `HYPERHIVE_CLAUDE_DIR` for dev / test scenarios.
|
||||
#[must_use]
|
||||
pub fn claude_dir() -> PathBuf {
|
||||
if let Some(p) = std::env::var_os("HYPERHIVE_CLAUDE_DIR") {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
let mut path = PathBuf::from(home);
|
||||
path.push(".claude");
|
||||
return path;
|
||||
}
|
||||
PathBuf::from("/root/.claude")
|
||||
}
|
||||
156
hive-agent/src/plugins.rs
Normal file
156
hive-agent/src/plugins.rs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
//! Boot-time `claude plugin install` driver. Reads the list declared
|
||||
//! via the `hyperhive.claudePlugins` NixOS option (rendered to
|
||||
//! `/etc/hyperhive/claude-plugins.json` by the harness module) and
|
||||
//! shells out `claude plugin install <spec>` for each entry. Runs once
|
||||
//! per harness boot before the turn loop; `claude plugin install`
|
||||
//! is expected to be idempotent so reinstalling on each container
|
||||
//! recreate is fine. Failures log a warning but do not abort boot —
|
||||
//! we'd rather start without a plugin than refuse to serve.
|
||||
//!
|
||||
//! Before installing, all configured marketplaces are updated so that
|
||||
//! plugin specs resolve against current index data. Marketplace update
|
||||
//! failures are non-fatal — stale index is better than no install attempt.
|
||||
|
||||
use tokio::process::Command;
|
||||
|
||||
const PLUGINS_PATH: &str = "/etc/hyperhive/claude-plugins.json";
|
||||
const MARKETPLACES_PATH: &str = "/etc/hyperhive/claude-marketplaces.json";
|
||||
const AUTO_UPDATE_PATH: &str = "/etc/hyperhive/claude-plugins-auto-update.json";
|
||||
|
||||
/// Add every marketplace from `/etc/hyperhive/claude-marketplaces.json`
|
||||
/// via `claude plugin marketplace add <source>`. Idempotent: re-add of
|
||||
/// an existing marketplace is treated as success (claude prints an
|
||||
/// "already exists" message and exits non-zero on some versions).
|
||||
/// Required before any `<plugin>@<marketplace>` install can resolve.
|
||||
async fn add_marketplaces() {
|
||||
let Ok(raw) = tokio::fs::read_to_string(MARKETPLACES_PATH).await else {
|
||||
return;
|
||||
};
|
||||
let sources: Vec<String> = match serde_json::from_str(&raw) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(path = MARKETPLACES_PATH, error = ?e, "claude-marketplaces spec parse failed; skipping");
|
||||
return;
|
||||
}
|
||||
};
|
||||
for source in sources {
|
||||
match Command::new("claude")
|
||||
.args(["plugin", "marketplace", "add", &source])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(out) if out.status.success() => {
|
||||
tracing::info!(source = %source, "claude plugin marketplace add ok");
|
||||
}
|
||||
Ok(out) => {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
if stderr.contains("already") {
|
||||
tracing::debug!(source = %source, "marketplace already added");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
source = %source,
|
||||
status = ?out.status,
|
||||
stderr = %stderr,
|
||||
"claude plugin marketplace add failed (non-fatal)",
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(source = %source, error = ?e, "claude plugin marketplace add spawn failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the `hyperhive.claudePluginsAutoUpdate` flag written by the NixOS
|
||||
/// module. Defaults to `false` when the file is absent or unparseable.
|
||||
async fn auto_update_enabled() -> bool {
|
||||
match tokio::fs::read_to_string(AUTO_UPDATE_PATH).await {
|
||||
Ok(s) => serde_json::from_str::<bool>(s.trim()).unwrap_or(false),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update all configured plugin marketplaces. Non-fatal — logs a warning
|
||||
/// on failure but does not abort the install sequence.
|
||||
async fn update_marketplaces() {
|
||||
match Command::new("claude")
|
||||
.args(["plugin", "marketplace", "update"])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(out) if out.status.success() => {
|
||||
tracing::info!("claude plugin marketplace update ok");
|
||||
}
|
||||
Ok(out) => {
|
||||
tracing::warn!(
|
||||
status = ?out.status,
|
||||
stderr = %String::from_utf8_lossy(&out.stderr),
|
||||
"claude plugin marketplace update failed (non-fatal)",
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "claude plugin marketplace update spawn failed (non-fatal)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Install every plugin in `/etc/hyperhive/claude-plugins.json`.
|
||||
/// Returns a list of human-readable failure messages so the caller can
|
||||
/// route them through their own per-role surface (turn-failure-style
|
||||
/// notification, see `Surface::send_to_parent`). Wire-agnostic: the
|
||||
/// caller picks the recipient via the same `<parent>` sentinel that
|
||||
/// failure-notify uses everywhere else.
|
||||
pub async fn install_configured() -> Vec<String> {
|
||||
let Ok(raw) = tokio::fs::read_to_string(PLUGINS_PATH).await else {
|
||||
return Vec::new();
|
||||
};
|
||||
let specs: Vec<String> = match serde_json::from_str(&raw) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(path = PLUGINS_PATH, error = ?e, "claude-plugins spec parse failed; skipping");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
if specs.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
add_marketplaces().await;
|
||||
if auto_update_enabled().await {
|
||||
update_marketplaces().await;
|
||||
} else {
|
||||
tracing::debug!("claudePluginsAutoUpdate=false, skipping marketplace update");
|
||||
}
|
||||
let mut failures = Vec::new();
|
||||
for spec in specs {
|
||||
match Command::new("claude")
|
||||
.args(["plugin", "install", &spec])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(out) if out.status.success() => {
|
||||
tracing::info!(spec = %spec, "claude plugin install ok");
|
||||
}
|
||||
Ok(out) => {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
||||
tracing::warn!(
|
||||
spec = %spec,
|
||||
status = ?out.status,
|
||||
stderr = %stderr,
|
||||
"claude plugin install failed",
|
||||
);
|
||||
failures.push(format!(
|
||||
"claude plugin install failed for `{spec}`:\n{}",
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(spec = %spec, error = ?e, "claude plugin install spawn failed");
|
||||
failures.push(format!(
|
||||
"claude plugin install spawn failed for `{spec}`: {e}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
failures
|
||||
}
|
||||
384
hive-agent/src/prompt.rs
Normal file
384
hive-agent/src/prompt.rs
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
//! System-prompt renderer. Single `prompts/system.md` with
|
||||
//! HTML-comment markers gating role-specific blocks; this module
|
||||
//! assembles the final prompt (always "agent" role — there is only one
|
||||
//! role). Marker grammar + placeholder substitution rules in
|
||||
//! `docs/turn-loop/claude-invocation.md::On-boot files` (`claude-system-prompt.md`).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
/// Assemble the system prompt for a given label + pronouns + optional hive /
|
||||
/// swarm display names. Pure function — no I/O. Splits out from
|
||||
/// [`write_system_prompt`] so the marker logic + substitution is unit-testable
|
||||
/// in isolation. The caller supplies the template body so tests can pass an
|
||||
/// inline fixture and production reads it once at harness startup via
|
||||
/// [`hive_sh4re::assets::prompt_template`]
|
||||
/// (`$HIVE_ASSETS_DIR/prompts/system.md`). Substitution placeholders +
|
||||
/// marker grammar documented in
|
||||
/// `docs/turn-loop/claude-invocation.md::On-boot files` (`claude-system-prompt.md`).
|
||||
#[must_use]
|
||||
pub fn render(
|
||||
template: &str,
|
||||
label: &str,
|
||||
operator_pronouns: &str,
|
||||
hive_name: Option<&str>,
|
||||
swarm_name: Option<&str>,
|
||||
docs_dir: Option<&str>,
|
||||
) -> String {
|
||||
let body = filter_role_blocks(template, "agent");
|
||||
let qualified = crate::identity::qualify(label);
|
||||
let hive_identity = hive_name
|
||||
.filter(|n| !n.is_empty())
|
||||
.map_or(String::new(), |n| format!(" on hive `{n}`"));
|
||||
let swarm_identity = swarm_name
|
||||
.filter(|n| !n.is_empty())
|
||||
.map_or(String::new(), |n| format!(" in swarm `{n}`"));
|
||||
let rendered = body
|
||||
.replace("{label}", label)
|
||||
.replace("{qualified_label}", &qualified)
|
||||
.replace("{operator_pronouns}", operator_pronouns)
|
||||
.replace("{hive_identity}", &hive_identity)
|
||||
.replace("{swarm_identity}", &swarm_identity);
|
||||
// When the reference docs are mounted in-container (`hyperhive.docs.enable`
|
||||
// wires `HIVE_DOCS_DIR` + `claude --add-dir`), append a single pointer
|
||||
// sentence so the agent knows they exist. Additive: it doesn't replace the
|
||||
// agent's own memory/project instructions. Absent env → no change.
|
||||
match docs_dir.filter(|d| !d.is_empty()) {
|
||||
Some(dir) => format!(
|
||||
"{rendered}\n\nThe hyperhive reference docs (the repo `docs/` tree \
|
||||
describing this live system) are mounted read-only at `{dir}` — read \
|
||||
them (start at the index, then the topic file for the area you're \
|
||||
touching) rather than guessing.\n"
|
||||
),
|
||||
None => rendered,
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk `template` line-by-line, stripping `<!-- role:X -->` /
|
||||
/// `<!-- /role:X -->` marker lines and suppressing the lines inside a block
|
||||
/// unless `X == target`. A close marker ends the current block. Production
|
||||
/// `system.md` carries no markers today (single agent role — see the module
|
||||
/// doc), so this is effectively a passthrough; the marker grammar stays wired
|
||||
/// for a future manager / multi-role prompt.
|
||||
fn filter_role_blocks(template: &str, target: &str) -> String {
|
||||
let mut out = String::with_capacity(template.len());
|
||||
// None = outside any block; Some(role) = inside role-tagged block.
|
||||
let mut active_role: Option<&str> = None;
|
||||
for line in template.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(role) = parse_open_marker(trimmed) {
|
||||
active_role = Some(role);
|
||||
continue;
|
||||
}
|
||||
if parse_close_marker(trimmed).is_some() {
|
||||
active_role = None;
|
||||
continue;
|
||||
}
|
||||
if active_role.is_none_or(|role| role == target) {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `<!-- role:agent -->` → `Some("agent")`. Anything else returns
|
||||
/// None. Whitespace inside the marker is tolerated so a future
|
||||
/// author's `<!--role:foo-->` (no spaces) still parses; the dashboard
|
||||
/// markdown renderer is equally lenient. Close tags (`/role:...`)
|
||||
/// can't accidentally match — the `strip_prefix("role:")` rejects
|
||||
/// the leading slash before we'd ever see it.
|
||||
fn parse_open_marker(line: &str) -> Option<&str> {
|
||||
let inside = line.strip_prefix("<!--")?.strip_suffix("-->")?.trim();
|
||||
let role = inside.strip_prefix("role:")?.trim();
|
||||
Some(role)
|
||||
}
|
||||
|
||||
/// `<!-- /role:agent -->` → `Some("agent")`. Mirror of
|
||||
/// [`parse_open_marker`] for the closing tag.
|
||||
fn parse_close_marker(line: &str) -> Option<&str> {
|
||||
let inside = line.strip_prefix("<!--")?.strip_suffix("-->")?.trim();
|
||||
inside.strip_prefix("/role:").map(str::trim)
|
||||
}
|
||||
|
||||
/// Write the assembled prompt to a stable path next to the harness
|
||||
/// socket and return the path. The Rust harness passes this path to
|
||||
/// `claude --system-prompt-file` so the per-turn prompts only carry
|
||||
/// the role + tools instructions in the system slot; per-turn prompts
|
||||
/// become much smaller (just the wake-message body).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the system prompt file cannot be written.
|
||||
pub async fn write_system_prompt(_socket: &Path, label: &str) -> Result<PathBuf> {
|
||||
let parent = crate::paths::config_dir();
|
||||
tokio::fs::create_dir_all(&parent).await.ok();
|
||||
let pronouns = std::env::var("HIVE_OPERATOR_PRONOUNS").unwrap_or_else(|_| "she/her".to_owned());
|
||||
let template_path = hive_sh4re::assets::prompt_template();
|
||||
let template = tokio::fs::read_to_string(&template_path)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"read claude system prompt template from {}",
|
||||
template_path.display()
|
||||
)
|
||||
})?;
|
||||
// Surface hive + swarm display names in the prompt opener when
|
||||
// configured. Both `None` falls back to the non-identity wording
|
||||
// verbatim (single-hive deployments see no diff).
|
||||
let hive_name = crate::identity::hive_name();
|
||||
let swarm_name = crate::identity::swarm_name();
|
||||
// `hyperhive.docs.enable` sets HIVE_DOCS_DIR (and the harness passes it to
|
||||
// claude via `--add-dir`); when present, render() appends a pointer line.
|
||||
let docs_dir = std::env::var("HIVE_DOCS_DIR")
|
||||
.ok()
|
||||
.filter(|d| !d.is_empty());
|
||||
let body = render(
|
||||
&template,
|
||||
label,
|
||||
&pronouns,
|
||||
hive_name.as_deref(),
|
||||
swarm_name.as_deref(),
|
||||
docs_dir.as_deref(),
|
||||
);
|
||||
let path = parent.join("claude-system-prompt.md");
|
||||
tokio::fs::write(&path, body).await?;
|
||||
tracing::info!(path = %path.display(), "wrote claude system prompt");
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
// The production template lives at `$HIVE_ASSETS_DIR/prompts/system.md`
|
||||
// and is loaded at runtime. The unit tests below want to assert
|
||||
// against the actual production wording (so the renderer + tool
|
||||
// surface stay honest), so they resolve the same path at test
|
||||
// runtime via two fallbacks:
|
||||
// 1. `$HIVE_ASSETS_DIR/prompts/system.md` — the runtime contract
|
||||
// production uses. The flake's `checks.cargo-test` derivation
|
||||
// sets this to the `hyperhive-assets` output so `cargo test`
|
||||
// inside the nix sandbox finds the file without needing
|
||||
// `prompts/` in the cargo source tree. `packages.default`
|
||||
// explicitly does NOT carry the assets dep, so a prompt edit
|
||||
// doesn't bust the binary derivation — only this test check.
|
||||
// 2. `env!("CARGO_MANIFEST_DIR")/prompts/system.md` — for plain
|
||||
// `cargo test --workspace` from a checked-out repo where the
|
||||
// env var isn't set; `env!` is a compile-time string lookup,
|
||||
// no file open at compile, so this still doesn't pull
|
||||
// `prompts/` into the build hash.
|
||||
// The combined effect is that the flake's `cleanSrc` no longer
|
||||
// unions `./hive-agent/prompts` — tweaks to system.md don't bust
|
||||
// the cargo cache anymore.
|
||||
static PRODUCTION_TEMPLATE: LazyLock<String> = LazyLock::new(|| {
|
||||
let path = match std::env::var("HIVE_ASSETS_DIR") {
|
||||
Ok(v) if !v.is_empty() => format!("{v}/prompts/system.md"),
|
||||
_ => concat!(env!("CARGO_MANIFEST_DIR"), "/prompts/system.md").to_owned(),
|
||||
};
|
||||
std::fs::read_to_string(&path)
|
||||
.unwrap_or_else(|e| panic!("read production prompt template at {path}: {e}"))
|
||||
});
|
||||
|
||||
const SAMPLE: &str = "\
|
||||
shared opener
|
||||
<!-- role:agent -->
|
||||
agent-only line
|
||||
<!-- /role:agent -->
|
||||
<!-- role:manager -->
|
||||
manager-only line
|
||||
<!-- /role:manager -->
|
||||
shared closer
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn filter_keeps_shared_and_target_role() {
|
||||
let agent = filter_role_blocks(SAMPLE, "agent");
|
||||
assert!(agent.contains("shared opener"));
|
||||
assert!(agent.contains("agent-only line"));
|
||||
assert!(!agent.contains("manager-only line"));
|
||||
assert!(agent.contains("shared closer"));
|
||||
// Marker lines themselves are stripped — no `<!--` left behind.
|
||||
assert!(!agent.contains("<!--"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_for_manager_picks_manager_block() {
|
||||
let manager = filter_role_blocks(SAMPLE, "manager");
|
||||
assert!(manager.contains("shared opener"));
|
||||
assert!(!manager.contains("agent-only line"));
|
||||
assert!(manager.contains("manager-only line"));
|
||||
assert!(manager.contains("shared closer"));
|
||||
assert!(!manager.contains("<!--"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_open_marker_handles_whitespace_variants() {
|
||||
assert_eq!(parse_open_marker("<!-- role:agent -->"), Some("agent"));
|
||||
assert_eq!(parse_open_marker("<!--role:agent-->"), Some("agent"));
|
||||
assert_eq!(parse_open_marker("<!-- role:manager -->"), Some("manager"));
|
||||
// Close tags must NOT match open-tag parser.
|
||||
assert_eq!(parse_open_marker("<!-- /role:agent -->"), None);
|
||||
// Non-markers pass through (return None).
|
||||
assert_eq!(parse_open_marker("just text"), None);
|
||||
assert_eq!(parse_open_marker("<!-- not a role -->"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_close_marker_handles_whitespace_variants() {
|
||||
assert_eq!(parse_close_marker("<!-- /role:agent -->"), Some("agent"));
|
||||
assert_eq!(parse_close_marker("<!--/role:manager-->"), Some("manager"));
|
||||
// Open tags must NOT match close-tag parser.
|
||||
assert_eq!(parse_close_marker("<!-- role:agent -->"), None);
|
||||
assert_eq!(parse_close_marker("just text"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_substitutes_label_and_pronouns() {
|
||||
// Real template's first agent line — keeps the renderer
|
||||
// honest about the {label} / {operator_pronouns} pair the
|
||||
// harness already relied on.
|
||||
let rendered = render(&PRODUCTION_TEMPLATE, "alice", "they/them", None, None, None);
|
||||
assert!(rendered.contains("hyperhive agent `alice`"));
|
||||
assert!(rendered.contains("**they/them** pronouns"));
|
||||
assert!(!rendered.contains("{label}"));
|
||||
assert!(!rendered.contains("{operator_pronouns}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_no_role_markers_in_output() {
|
||||
// No raw role markers should survive into the rendered prompt.
|
||||
let rendered = render(&PRODUCTION_TEMPLATE, "alice", "she/her", None, None, None);
|
||||
assert!(!rendered.contains("<!-- role:"));
|
||||
assert!(!rendered.contains("<!-- /role:"));
|
||||
// Shared tools appear.
|
||||
assert!(rendered.contains("mcp__hyperhive__recv"));
|
||||
assert!(rendered.contains("mcp__hyperhive__ask"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_uses_agent_opener() {
|
||||
let rendered = render(&PRODUCTION_TEMPLATE, "alice", "she/her", None, None, None);
|
||||
assert!(rendered.starts_with("You are hyperhive agent"));
|
||||
}
|
||||
|
||||
// Inline fixture for the {hive_identity} / {swarm_identity}
|
||||
// placeholders. Cargo's `cargo test` resolves `PRODUCTION_TEMPLATE`
|
||||
// against `$HIVE_ASSETS_DIR/prompts/system.md`, which the flake
|
||||
// builds at derivation time — a fresh placeholder added on the
|
||||
// source side isn't in the shipped asset until the flake rebuilds,
|
||||
// so PRODUCTION_TEMPLATE can't be the fixture here. The string
|
||||
// below carries just enough of the opener shape to exercise the
|
||||
// substitution logic; nothing here depends on the production
|
||||
// template's flavor markers.
|
||||
const IDENTITY_FIXTURE: &str = "\
|
||||
You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity}{swarm_identity} in a multi-agent system. Pronouns: **{operator_pronouns}**.
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn render_substitutes_hive_identity_when_set() {
|
||||
let rendered = render(
|
||||
IDENTITY_FIXTURE,
|
||||
"alice",
|
||||
"she/her",
|
||||
Some("pr1ma"),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(rendered.contains("on hive `pr1ma`"), "{rendered}");
|
||||
// swarm clause stays absent when only hive is set.
|
||||
assert!(!rendered.contains("in swarm"));
|
||||
// No raw placeholder leaks.
|
||||
assert!(!rendered.contains("{hive_identity}"));
|
||||
assert!(!rendered.contains("{swarm_identity}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_substitutes_swarm_identity_when_set() {
|
||||
let rendered = render(
|
||||
IDENTITY_FIXTURE,
|
||||
"ruth",
|
||||
"she/her",
|
||||
None,
|
||||
Some("constellat1on"),
|
||||
None,
|
||||
);
|
||||
assert!(rendered.contains("in swarm `constellat1on`"));
|
||||
assert!(!rendered.contains("on hive"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_substitutes_both_when_both_set() {
|
||||
let rendered = render(
|
||||
IDENTITY_FIXTURE,
|
||||
"iris",
|
||||
"she/her",
|
||||
Some("pr1ma"),
|
||||
Some("constellat1on"),
|
||||
None,
|
||||
);
|
||||
// Order: hive then swarm, both inline before "in a multi-agent
|
||||
// system" — keeps the opener grammar intact.
|
||||
assert!(rendered.contains("on hive `pr1ma` in swarm `constellat1on`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_omits_identity_when_unset() {
|
||||
// None / None must round-trip the non-identity opener verbatim
|
||||
// — single-hive deployments see zero diff.
|
||||
let rendered = render(IDENTITY_FIXTURE, "alice", "she/her", None, None, None);
|
||||
assert!(!rendered.contains("on hive"));
|
||||
assert!(!rendered.contains("in swarm"));
|
||||
assert!(!rendered.contains("{hive_identity}"));
|
||||
assert!(!rendered.contains("{swarm_identity}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_treats_empty_identity_as_none() {
|
||||
// Defensive: an env var set to empty string round-trips
|
||||
// through `identity::hive_name()` as None (the accessor
|
||||
// filters empty), but `render` should still no-op on a
|
||||
// direct `Some("")` from a test fixture or a future caller.
|
||||
let rendered = render(
|
||||
IDENTITY_FIXTURE,
|
||||
"alice",
|
||||
"she/her",
|
||||
Some(""),
|
||||
Some(""),
|
||||
None,
|
||||
);
|
||||
assert!(!rendered.contains("on hive"));
|
||||
assert!(!rendered.contains("in swarm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_appends_docs_pointer_when_docs_dir_set() {
|
||||
let rendered = render(
|
||||
&PRODUCTION_TEMPLATE,
|
||||
"alice",
|
||||
"she/her",
|
||||
None,
|
||||
None,
|
||||
Some("/run/hive-docs"),
|
||||
);
|
||||
// Key on a phrase unique to the pointer sentence — "reference docs"
|
||||
// alone also appears in the /knowledge blurb of the base template.
|
||||
assert!(
|
||||
rendered.contains("mounted read-only at `/run/hive-docs`"),
|
||||
"expected docs pointer sentence with the dir:\n{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_no_docs_pointer_when_docs_dir_absent_or_empty() {
|
||||
for docs in [None, Some("")] {
|
||||
let rendered = render(&PRODUCTION_TEMPLATE, "alice", "she/her", None, None, docs);
|
||||
assert!(
|
||||
!rendered.contains("mounted read-only at"),
|
||||
"docs pointer must not appear for {docs:?}:\n{rendered}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
121
hive-agent/src/serve_common.rs
Normal file
121
hive-agent/src/serve_common.rs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
//! 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.
|
||||
|
||||
use crate::events::Bus;
|
||||
use crate::turn::{TurnError, TurnOutcome};
|
||||
use crate::turn_stats::TurnStatRow;
|
||||
pub use hive_sh4re::wire_time::now_unix;
|
||||
|
||||
/// Assemble the per-turn wake prompt string. The role/tools/etc. live in the
|
||||
/// system prompt; this is just the wake signal body. `id` is the broker row
|
||||
/// id, rendered as a `[msg #<id>]` marker so the agent can reference it in
|
||||
/// `ack_until`. `unread` is the inbox depth after this message was popped.
|
||||
/// `redelivered` prepends a "may already be handled" banner.
|
||||
#[must_use]
|
||||
pub fn format_wake_prompt(
|
||||
id: i64,
|
||||
from: &str,
|
||||
body: &str,
|
||||
unread: u64,
|
||||
redelivered: bool,
|
||||
) -> String {
|
||||
let banner = if redelivered {
|
||||
hive_sh4re::REDELIVERY_HINT
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let tag = if id > 0 {
|
||||
format!("[msg #{id}] ")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let pending = hive_sh4re::pending_hint(unread);
|
||||
format!("{banner}{tag}Incoming message from `{from}`:\n---\n{body}\n---{pending}")
|
||||
}
|
||||
|
||||
/// Field-named args for [`build_row`]. Mirrors the turn-stats row
|
||||
/// columns; `outcome` and `bus` borrow for the duration of the call.
|
||||
pub struct TurnRowArgs<'a> {
|
||||
pub started_at: i64,
|
||||
pub ended_at: i64,
|
||||
pub duration_ms: i64,
|
||||
pub model: String,
|
||||
pub wake_from: String,
|
||||
pub outcome: &'a TurnOutcome,
|
||||
pub bus: &'a Bus,
|
||||
pub open_threads_count: Option<u64>,
|
||||
pub open_reminders_count: Option<u64>,
|
||||
}
|
||||
|
||||
/// Assemble a `TurnStatRow` from the harness's per-turn state. Lives here
|
||||
/// (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 {
|
||||
let TurnRowArgs {
|
||||
started_at,
|
||||
ended_at,
|
||||
duration_ms,
|
||||
model,
|
||||
wake_from,
|
||||
outcome,
|
||||
bus,
|
||||
open_threads_count,
|
||||
open_reminders_count,
|
||||
} = args;
|
||||
// Prefer the API-resolved model id (e.g. `claude-opus-4-8`) captured
|
||||
// from this turn's assistant events over the requested `--model`
|
||||
// name/alias, so the model-mix + cost rollup label the concrete
|
||||
// version that ran. Falls back to the requested name on a degenerate
|
||||
// turn that produced no assistant event.
|
||||
let model = bus.last_resolved_model().unwrap_or(model);
|
||||
let cost = bus.last_cost_usage().unwrap_or_default();
|
||||
let ctx = bus.last_ctx_usage().unwrap_or(cost);
|
||||
let tool_calls = bus.take_tool_calls();
|
||||
let tool_call_count: u64 = tool_calls.values().copied().sum();
|
||||
let tool_call_breakdown_json = if tool_calls.is_empty() {
|
||||
None
|
||||
} else {
|
||||
serde_json::to_string(&tool_calls).ok()
|
||||
};
|
||||
let (result_kind, note) = match outcome {
|
||||
Ok(false) => ("ok", None),
|
||||
Ok(true) => ("compacted", None),
|
||||
Err(TurnError::PromptTooLong) => ("prompt_too_long", None),
|
||||
Err(TurnError::RateLimited) => ("rate_limited", None),
|
||||
Err(TurnError::AuthFailed) => ("auth_failed", None),
|
||||
Err(TurnError::SessionNotFound) => ("session_not_found", None),
|
||||
Err(TurnError::ApiStall) => ("api_stall", None),
|
||||
Err(TurnError::Failed(e)) => ("failed", Some(format!("{e:#}"))),
|
||||
};
|
||||
let wake_from = if wake_from.starts_with("bash-task-") {
|
||||
"bash-task".to_owned()
|
||||
} else {
|
||||
wake_from
|
||||
};
|
||||
TurnStatRow {
|
||||
started_at,
|
||||
ended_at,
|
||||
duration_ms,
|
||||
model,
|
||||
wake_from,
|
||||
input_tokens: cost.input_tokens,
|
||||
output_tokens: cost.output_tokens,
|
||||
cache_read_input_tokens: cost.cache_read_input_tokens,
|
||||
cache_creation_input_tokens: cost.cache_creation_input_tokens,
|
||||
last_input_tokens: ctx.input_tokens,
|
||||
last_output_tokens: ctx.output_tokens,
|
||||
last_cache_read_input_tokens: ctx.cache_read_input_tokens,
|
||||
last_cache_creation_input_tokens: ctx.cache_creation_input_tokens,
|
||||
tool_call_count,
|
||||
tool_call_breakdown_json,
|
||||
open_threads_count,
|
||||
open_reminders_count,
|
||||
result_kind,
|
||||
note,
|
||||
session_id: bus.current_session_id(),
|
||||
}
|
||||
}
|
||||
780
hive-agent/src/stats.rs
Normal file
780
hive-agent/src/stats.rs
Normal file
|
|
@ -0,0 +1,780 @@
|
|||
//! Read-side aggregations over the per-agent `turn_stats.sqlite` for
|
||||
//! the agent's `/stats` web page. Owned by the agent (same process
|
||||
//! that writes the sink) so per-MCP extensions can register more
|
||||
//! providers without the host needing to know their schemas.
|
||||
//!
|
||||
//! Best-effort: any sqlite error returns an empty snapshot rather than
|
||||
//! propagating — the stats page is decorative, not authoritative, and
|
||||
//! a missing db on a brand-new agent shouldn't 500 the route.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::{Connection, OpenFlags};
|
||||
use serde::Serialize;
|
||||
|
||||
use hive_sh4re::ReminderStats;
|
||||
use hive_sh4re::wire_time::now_unix;
|
||||
|
||||
/// Window param accepted by `/api/stats?window=`. Each maps to a
|
||||
/// total span + the bucket width used to roll up trend series.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Window {
|
||||
Hour,
|
||||
FourHour,
|
||||
Day,
|
||||
ThreeDay,
|
||||
Week,
|
||||
Month,
|
||||
/// All available data: the range starts at the earliest recorded turn
|
||||
/// (`MIN(started_at)`) rather than a fixed lookback, with an adaptive
|
||||
/// bucket width so the trend series stays bounded at any span.
|
||||
All,
|
||||
}
|
||||
|
||||
impl Window {
|
||||
#[must_use]
|
||||
pub fn parse(s: &str) -> Self {
|
||||
match s {
|
||||
"1h" => Self::Hour,
|
||||
"4h" => Self::FourHour,
|
||||
"3d" => Self::ThreeDay,
|
||||
"7d" => Self::Week,
|
||||
"30d" => Self::Month,
|
||||
"all" => Self::All,
|
||||
// Default (incl. `label()`'s own canonical `"24h"`/`"1d"`).
|
||||
_ => Self::Day,
|
||||
}
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Hour => "1h",
|
||||
Self::FourHour => "4h",
|
||||
Self::Day => "24h",
|
||||
Self::ThreeDay => "3d",
|
||||
Self::Week => "7d",
|
||||
Self::Month => "30d",
|
||||
Self::All => "all",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn span_secs(self) -> i64 {
|
||||
match self {
|
||||
Self::Hour => 3600,
|
||||
Self::FourHour => 4 * 3600,
|
||||
Self::Day => 24 * 3600,
|
||||
Self::ThreeDay => 3 * 24 * 3600,
|
||||
Self::Week => 7 * 24 * 3600,
|
||||
Self::Month => 30 * 24 * 3600,
|
||||
// `All` has no fixed lookback — its range is computed from
|
||||
// `MIN(started_at)` in `snapshot()`. 0 is only a safe fallback
|
||||
// (→ `from == now` → empty range) if ever reached generically.
|
||||
Self::All => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn bucket_secs(self) -> i64 {
|
||||
match self {
|
||||
// 5-min buckets for 1h (12 buckets), 15-min for 4h (16 buckets),
|
||||
// hourly for 24h + 3d, daily for 7d + 30d. `All` shares the daily
|
||||
// fallback, but its real bucket width is chosen adaptively in
|
||||
// `snapshot()` via `adaptive_bucket_secs` — this arm is only
|
||||
// reached defensively.
|
||||
Self::Hour => 300,
|
||||
Self::FourHour => 900,
|
||||
Self::Day | Self::ThreeDay => 3600,
|
||||
Self::Week | Self::Month | Self::All => 24 * 3600,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bucket width for the unbounded `all` window, laddered by the actual
|
||||
/// span so the trend series stays bounded (≈ ≤100 buckets) at any range:
|
||||
/// hourly ≤ 2d, daily ≤ 90d, weekly ≤ 2y, ~monthly (30d) beyond.
|
||||
fn adaptive_bucket_secs(span_secs: i64) -> i64 {
|
||||
const HOUR: i64 = 3600;
|
||||
const DAY: i64 = 24 * HOUR;
|
||||
match span_secs {
|
||||
s if s <= 2 * DAY => HOUR,
|
||||
s if s <= 90 * DAY => DAY,
|
||||
s if s <= 730 * DAY => 7 * DAY,
|
||||
_ => 30 * DAY,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Snapshot {
|
||||
pub window: &'static str,
|
||||
pub bucket_seconds: i64,
|
||||
pub now: i64,
|
||||
pub from: i64,
|
||||
/// Total turns in the window.
|
||||
pub turn_count: u64,
|
||||
/// Time-bucketed trend series, oldest first. Always covers the
|
||||
/// full window even for empty buckets (so charts paint a stable
|
||||
/// x-axis instead of skipping gaps).
|
||||
pub buckets: Vec<Bucket>,
|
||||
/// Top tools by call count across the window. Capped to 10.
|
||||
pub tool_breakdown: Vec<KeyCount>,
|
||||
/// Top shell commands ("favorite tools") by invocation count across
|
||||
/// the window, capped to 10. Normalised command heads recorded per
|
||||
/// bash task into the `bash_commands` table by hive-bash-mcp. Empty
|
||||
/// until that capture lands (or on any agent that hasn't run a bash
|
||||
/// task) — the table is created lazily by the writer, so a read
|
||||
/// before the first insert returns an empty list, not an error.
|
||||
pub bash_breakdown: Vec<KeyCount>,
|
||||
pub wake_mix: Vec<KeyCount>,
|
||||
pub result_mix: Vec<KeyCount>,
|
||||
/// Distinct models seen in the window, sorted. Each bucket's
|
||||
/// `model_counts` keys into this set; the stats page uses it as
|
||||
/// the stacked-bar series list (stable order + colours).
|
||||
pub models: Vec<String>,
|
||||
/// Across-window p50 / p95 / avg of `duration_ms`. Same numbers
|
||||
/// as the per-bucket fields but aggregated over the whole window
|
||||
/// for the headline summary chips.
|
||||
pub duration_summary: DurationSummary,
|
||||
/// Reminder activity stats: counts of scheduled, delivered, and
|
||||
/// pending reminders over the window (fetched from the broker RPC).
|
||||
/// None if the RPC call failed or hasn't been integrated yet.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reminder_stats: Option<ReminderStats>,
|
||||
/// First-turn input tokens of the most recent fresh claude session
|
||||
/// that started in the window — a proxy for system-prompt + CLAUDE.md
|
||||
/// sprawl (a fresh session's first turn pays the full static prefix
|
||||
/// uncached, so this is the current "cold context" cost). `None` until
|
||||
/// the sessions capture (per-session `session_id`) has data; every
|
||||
/// pre-capture `turn_stats` row has a NULL `session_id` and is excluded.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub first_turn_ctx: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Bucket {
|
||||
/// Unix timestamp of the bucket start.
|
||||
pub ts: i64,
|
||||
pub turn_count: u64,
|
||||
pub avg_duration_ms: f64,
|
||||
pub p50_duration_ms: f64,
|
||||
pub p95_duration_ms: f64,
|
||||
/// Sums across the bucket. JS picks how to combine them
|
||||
/// (input + output for cost, etc.) so we don't bake a policy in.
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cache_read_input_tokens: u64,
|
||||
pub cache_creation_input_tokens: u64,
|
||||
/// Mean of `last_input_tokens` across the bucket (the context
|
||||
/// size at turn-end — useful for spotting drift toward compaction).
|
||||
pub avg_ctx_tokens: f64,
|
||||
pub max_ctx_tokens: u64,
|
||||
/// Turn count per model in this bucket. Model choice greatly
|
||||
/// affects token cost, so this lets the operator line model usage
|
||||
/// up against the cost series over time.
|
||||
pub model_counts: HashMap<String, u64>,
|
||||
/// Turn count per `result_kind` in this bucket. Lets the stats
|
||||
/// page chart error / rate-limit / compaction outcomes *over time*
|
||||
/// (the window-total lives in `Snapshot::result_mix`).
|
||||
pub result_counts: HashMap<String, u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct KeyCount {
|
||||
pub key: String,
|
||||
pub count: u64,
|
||||
}
|
||||
|
||||
// Field names drop the `_ms` unit suffix (satisfies `clippy::struct_field_names`
|
||||
// once this crate is a bin — pub structs lose the lib API-name exemption), but
|
||||
// the serialized keys keep `_ms` via `serde(rename)` so the `/api/stats` JSON
|
||||
// contract the agent web UI reads (`frontend/packages/agent/src/stats.js`) is
|
||||
// unchanged.
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
pub struct DurationSummary {
|
||||
#[serde(rename = "avg_ms")]
|
||||
pub avg: f64,
|
||||
#[serde(rename = "p50_ms")]
|
||||
pub p50: f64,
|
||||
#[serde(rename = "p95_ms")]
|
||||
pub p95: f64,
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn snapshot_default(window: Window) -> Snapshot {
|
||||
let path = default_path();
|
||||
match snapshot(&path, window) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, path = %path.display(), "stats: snapshot failed");
|
||||
empty_snapshot(window)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_path() -> PathBuf {
|
||||
crate::paths::harness_dir().join("hyperhive-turn-stats.sqlite")
|
||||
}
|
||||
|
||||
fn empty_snapshot(window: Window) -> Snapshot {
|
||||
let now = now_unix();
|
||||
let from = now - window.span_secs();
|
||||
let buckets = fill_buckets(from, now, window.bucket_secs(), &HashMap::new());
|
||||
Snapshot {
|
||||
window: window.label(),
|
||||
bucket_seconds: window.bucket_secs(),
|
||||
now,
|
||||
from,
|
||||
turn_count: 0,
|
||||
buckets,
|
||||
tool_breakdown: Vec::new(),
|
||||
bash_breakdown: Vec::new(),
|
||||
wake_mix: Vec::new(),
|
||||
result_mix: Vec::new(),
|
||||
models: Vec::new(),
|
||||
duration_summary: DurationSummary::default(),
|
||||
reminder_stats: None,
|
||||
first_turn_ctx: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(path: &Path, window: Window) -> Result<Snapshot> {
|
||||
// Read-only open so we can't corrupt the db via a query bug.
|
||||
let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||
.with_context(|| format!("open {} read-only", path.display()))?;
|
||||
// turn_stats is rollback-journal (not WAL): a read landing while the
|
||||
// harness's own sink is mid-INSERT gets SQLITE_BUSY, which propagates up
|
||||
// and blanks the whole stats page. Wait out the brief write instead —
|
||||
// matches hive-c0re's host-side reader (`hive_stats::read_agent`).
|
||||
conn.busy_timeout(std::time::Duration::from_millis(500))
|
||||
.with_context(|| format!("set busy_timeout on {}", path.display()))?;
|
||||
let now = now_unix();
|
||||
// Fixed windows look back a constant span; `all` starts at the earliest
|
||||
// recorded turn (`MIN(started_at)`, falling back to `now` on an empty
|
||||
// table) and sizes its buckets adaptively from that span.
|
||||
let (from, bucket_secs) = match window {
|
||||
Window::All => {
|
||||
let min_ts: Option<i64> =
|
||||
conn.query_row("SELECT MIN(started_at) FROM turn_stats", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
let from = min_ts.unwrap_or(now);
|
||||
(from, adaptive_bucket_secs(now - from))
|
||||
}
|
||||
_ => (now - window.span_secs(), window.bucket_secs()),
|
||||
};
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT started_at, duration_ms,
|
||||
input_tokens, output_tokens,
|
||||
cache_read_input_tokens, cache_creation_input_tokens,
|
||||
last_input_tokens,
|
||||
tool_call_breakdown_json,
|
||||
wake_from, result_kind, model
|
||||
FROM turn_stats
|
||||
WHERE started_at >= ?1
|
||||
ORDER BY started_at ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map([from], |row| {
|
||||
Ok(Row {
|
||||
started_at: row.get(0)?,
|
||||
duration_ms: row.get::<_, i64>(1)?,
|
||||
input_tokens: u64_from_i64(row.get::<_, i64>(2)?),
|
||||
output_tokens: u64_from_i64(row.get::<_, i64>(3)?),
|
||||
cache_read_input_tokens: u64_from_i64(row.get::<_, i64>(4)?),
|
||||
cache_creation_input_tokens: u64_from_i64(row.get::<_, i64>(5)?),
|
||||
last_input_tokens: u64_from_i64(row.get::<_, i64>(6)?),
|
||||
tool_breakdown_json: row.get::<_, Option<String>>(7)?,
|
||||
wake_from: row.get::<_, String>(8)?,
|
||||
result_kind: row.get::<_, String>(9)?,
|
||||
model: row.get::<_, String>(10)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut by_bucket: HashMap<i64, BucketAcc> = HashMap::new();
|
||||
let mut tool_totals: HashMap<String, u64> = HashMap::new();
|
||||
let mut wake_totals: HashMap<String, u64> = HashMap::new();
|
||||
let mut result_totals: HashMap<String, u64> = HashMap::new();
|
||||
let mut model_set: HashSet<String> = HashSet::new();
|
||||
let mut all_durations: Vec<i64> = Vec::new();
|
||||
let mut turn_count: u64 = 0;
|
||||
|
||||
for r in rows {
|
||||
let r = r?;
|
||||
turn_count += 1;
|
||||
let bucket_ts = (r.started_at / bucket_secs) * bucket_secs;
|
||||
let acc = by_bucket.entry(bucket_ts).or_default();
|
||||
acc.turn_count += 1;
|
||||
acc.durations.push(r.duration_ms.max(0));
|
||||
acc.input_tokens = acc.input_tokens.saturating_add(r.input_tokens);
|
||||
acc.output_tokens = acc.output_tokens.saturating_add(r.output_tokens);
|
||||
acc.cache_read_input_tokens = acc
|
||||
.cache_read_input_tokens
|
||||
.saturating_add(r.cache_read_input_tokens);
|
||||
acc.cache_creation_input_tokens = acc
|
||||
.cache_creation_input_tokens
|
||||
.saturating_add(r.cache_creation_input_tokens);
|
||||
acc.ctx_sum = acc.ctx_sum.saturating_add(r.last_input_tokens);
|
||||
acc.ctx_max = acc.ctx_max.max(r.last_input_tokens);
|
||||
*acc.model_counts.entry(r.model.clone()).or_insert(0) += 1;
|
||||
*acc.result_counts.entry(r.result_kind.clone()).or_insert(0) += 1;
|
||||
|
||||
all_durations.push(r.duration_ms.max(0));
|
||||
*wake_totals.entry(r.wake_from).or_insert(0) += 1;
|
||||
*result_totals.entry(r.result_kind).or_insert(0) += 1;
|
||||
model_set.insert(r.model);
|
||||
|
||||
if let Some(json) = r.tool_breakdown_json
|
||||
&& let Ok(map) = serde_json::from_str::<HashMap<String, u64>>(&json)
|
||||
{
|
||||
for (k, v) in map {
|
||||
*tool_totals.entry(k).or_insert(0) += v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let buckets = fill_buckets(from, now, bucket_secs, &by_bucket);
|
||||
let duration_summary = summarize_durations(&mut all_durations);
|
||||
let mut models: Vec<String> = model_set.into_iter().collect();
|
||||
models.sort_unstable();
|
||||
|
||||
Ok(Snapshot {
|
||||
window: window.label(),
|
||||
bucket_seconds: bucket_secs,
|
||||
now,
|
||||
from,
|
||||
turn_count,
|
||||
buckets,
|
||||
tool_breakdown: top_n(tool_totals, 10),
|
||||
bash_breakdown: read_bash_breakdown(&conn, from).unwrap_or_default(),
|
||||
wake_mix: top_n(wake_totals, 20),
|
||||
result_mix: top_n(result_totals, 20),
|
||||
models,
|
||||
duration_summary,
|
||||
reminder_stats: None, // filled in by api_stats in web_ui.rs via fetch_reminder_stats RPC
|
||||
// Inert-until-capture: `.ok()` maps both "no fresh session in the
|
||||
// window yet" (QueryReturnedNoRows) and "sessions table absent on
|
||||
// an older db" (Err) to None, same decoupling as read_bash_breakdown.
|
||||
first_turn_ctx: read_first_turn_ctx(&conn, from).ok(),
|
||||
})
|
||||
}
|
||||
|
||||
/// First-turn input tokens of the most recent fresh claude session that
|
||||
/// started in `[from, now]`. Tracks system-prompt + CLAUDE.md sprawl:
|
||||
/// the first turn of a fresh session (`--continue` suppressed) pays the
|
||||
/// full static prefix as uncached input, so watching this over time
|
||||
/// surfaces creep. Uses the agreed per-session derive — the first turn
|
||||
/// (`ORDER BY started_at LIMIT 1`) of the latest session row.
|
||||
///
|
||||
/// Returns `Err` when the `sessions` table doesn't exist (older db) or no
|
||||
/// fresh session in the window has a recorded turn yet; the caller maps
|
||||
/// that to `None` (inert-until-capture), same as `read_bash_breakdown`.
|
||||
fn read_first_turn_ctx(conn: &Connection, from: i64) -> rusqlite::Result<u64> {
|
||||
conn.query_row(
|
||||
"SELECT input_tokens FROM turn_stats
|
||||
WHERE session_id = (
|
||||
SELECT id FROM sessions WHERE started_at >= ?1 ORDER BY started_at DESC LIMIT 1
|
||||
)
|
||||
ORDER BY started_at ASC
|
||||
LIMIT 1",
|
||||
[from],
|
||||
|row| row.get::<_, i64>(0).map(u64_from_i64),
|
||||
)
|
||||
}
|
||||
|
||||
/// Aggregate the top shell-command heads ("favorite tools") over
|
||||
/// `[from, now]` from the `bash_commands` table — one row per bash task
|
||||
/// (`ts INTEGER NOT NULL, head TEXT NOT NULL`), written by hive-bash-mcp.
|
||||
///
|
||||
/// Returns `Err` (which the caller maps to an empty list) when the
|
||||
/// table doesn't exist yet — the writer creates it lazily on first
|
||||
/// insert, so any agent that hasn't run a bash task since the capture
|
||||
/// shipped simply has no table. Decoupling it this way means the read
|
||||
/// side is inert-until-data and needs no schema coordination here.
|
||||
fn read_bash_breakdown(conn: &Connection, from: i64) -> rusqlite::Result<Vec<KeyCount>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT head, COUNT(*) AS n
|
||||
FROM bash_commands
|
||||
WHERE ts >= ?1
|
||||
GROUP BY head",
|
||||
)?;
|
||||
let rows = stmt.query_map([from], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
u64_from_i64(row.get::<_, i64>(1)?),
|
||||
))
|
||||
})?;
|
||||
let mut totals: HashMap<String, u64> = HashMap::new();
|
||||
for r in rows {
|
||||
let (head, n) = r?;
|
||||
*totals.entry(head).or_insert(0) += n;
|
||||
}
|
||||
Ok(top_n(totals, 10))
|
||||
}
|
||||
|
||||
struct Row {
|
||||
started_at: i64,
|
||||
duration_ms: i64,
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
cache_read_input_tokens: u64,
|
||||
cache_creation_input_tokens: u64,
|
||||
last_input_tokens: u64,
|
||||
tool_breakdown_json: Option<String>,
|
||||
wake_from: String,
|
||||
result_kind: String,
|
||||
model: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct BucketAcc {
|
||||
turn_count: u64,
|
||||
durations: Vec<i64>,
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
cache_read_input_tokens: u64,
|
||||
cache_creation_input_tokens: u64,
|
||||
ctx_sum: u64,
|
||||
ctx_max: u64,
|
||||
model_counts: HashMap<String, u64>,
|
||||
result_counts: HashMap<String, u64>,
|
||||
}
|
||||
|
||||
fn fill_buckets(
|
||||
from: i64,
|
||||
now: i64,
|
||||
bucket_secs: i64,
|
||||
by_bucket: &HashMap<i64, BucketAcc>,
|
||||
) -> Vec<Bucket> {
|
||||
let start = (from / bucket_secs) * bucket_secs;
|
||||
let mut out = Vec::new();
|
||||
let mut ts = start;
|
||||
while ts <= now {
|
||||
let bucket = if let Some(acc) = by_bucket.get(&ts) {
|
||||
let mut sorted = acc.durations.clone();
|
||||
sorted.sort_unstable();
|
||||
let avg = if sorted.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
#[allow(
|
||||
clippy::cast_precision_loss,
|
||||
reason = "stat magnitudes (counts, token + duration sums) stay well under f64's 2^53 exact-integer range, so this averaging cast loses no precision in practice"
|
||||
)]
|
||||
let sum_f = sorted.iter().sum::<i64>() as f64;
|
||||
#[allow(
|
||||
clippy::cast_precision_loss,
|
||||
reason = "stat magnitudes (counts, token + duration sums) stay well under f64's 2^53 exact-integer range, so this averaging cast loses no precision in practice"
|
||||
)]
|
||||
let len_f = sorted.len() as f64;
|
||||
sum_f / len_f
|
||||
};
|
||||
let p50 = percentile(&sorted, 50);
|
||||
let p95 = percentile(&sorted, 95);
|
||||
let avg_ctx = if acc.turn_count == 0 {
|
||||
0.0
|
||||
} else {
|
||||
#[allow(
|
||||
clippy::cast_precision_loss,
|
||||
reason = "stat magnitudes (counts, token + duration sums) stay well under f64's 2^53 exact-integer range, so this averaging cast loses no precision in practice"
|
||||
)]
|
||||
let sum_f = acc.ctx_sum as f64;
|
||||
#[allow(
|
||||
clippy::cast_precision_loss,
|
||||
reason = "stat magnitudes (counts, token + duration sums) stay well under f64's 2^53 exact-integer range, so this averaging cast loses no precision in practice"
|
||||
)]
|
||||
let cnt_f = acc.turn_count as f64;
|
||||
sum_f / cnt_f
|
||||
};
|
||||
Bucket {
|
||||
ts,
|
||||
turn_count: acc.turn_count,
|
||||
avg_duration_ms: avg,
|
||||
p50_duration_ms: p50,
|
||||
p95_duration_ms: p95,
|
||||
input_tokens: acc.input_tokens,
|
||||
output_tokens: acc.output_tokens,
|
||||
cache_read_input_tokens: acc.cache_read_input_tokens,
|
||||
cache_creation_input_tokens: acc.cache_creation_input_tokens,
|
||||
avg_ctx_tokens: avg_ctx,
|
||||
max_ctx_tokens: acc.ctx_max,
|
||||
model_counts: acc.model_counts.clone(),
|
||||
result_counts: acc.result_counts.clone(),
|
||||
}
|
||||
} else {
|
||||
Bucket {
|
||||
ts,
|
||||
turn_count: 0,
|
||||
avg_duration_ms: 0.0,
|
||||
p50_duration_ms: 0.0,
|
||||
p95_duration_ms: 0.0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
avg_ctx_tokens: 0.0,
|
||||
max_ctx_tokens: 0,
|
||||
model_counts: HashMap::new(),
|
||||
result_counts: HashMap::new(),
|
||||
}
|
||||
};
|
||||
out.push(bucket);
|
||||
ts += bucket_secs;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn summarize_durations(all: &mut [i64]) -> DurationSummary {
|
||||
if all.is_empty() {
|
||||
return DurationSummary::default();
|
||||
}
|
||||
all.sort_unstable();
|
||||
#[allow(
|
||||
clippy::cast_precision_loss,
|
||||
reason = "stat magnitudes (counts, token + duration sums) stay well under f64's 2^53 exact-integer range, so this averaging cast loses no precision in practice"
|
||||
)]
|
||||
let sum_f = all.iter().sum::<i64>() as f64;
|
||||
#[allow(
|
||||
clippy::cast_precision_loss,
|
||||
reason = "stat magnitudes (counts, token + duration sums) stay well under f64's 2^53 exact-integer range, so this averaging cast loses no precision in practice"
|
||||
)]
|
||||
let len_f = all.len() as f64;
|
||||
DurationSummary {
|
||||
avg: sum_f / len_f,
|
||||
p50: percentile(all, 50),
|
||||
p95: percentile(all, 95),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss
|
||||
)]
|
||||
fn percentile(sorted: &[i64], pct: u8) -> f64 {
|
||||
if sorted.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
if sorted.len() == 1 {
|
||||
return sorted[0] as f64;
|
||||
}
|
||||
// Nearest-rank, clamped.
|
||||
let rank = ((f64::from(pct) / 100.0) * (sorted.len() as f64 - 1.0)).round() as usize;
|
||||
sorted[rank.min(sorted.len() - 1)] as f64
|
||||
}
|
||||
|
||||
fn top_n(map: HashMap<String, u64>, n: usize) -> Vec<KeyCount> {
|
||||
let mut v: Vec<KeyCount> = map
|
||||
.into_iter()
|
||||
.map(|(key, count)| KeyCount { key, count })
|
||||
.collect();
|
||||
v.sort_unstable_by(|a, b| b.count.cmp(&a.count).then_with(|| a.key.cmp(&b.key)));
|
||||
v.truncate(n);
|
||||
v
|
||||
}
|
||||
|
||||
fn u64_from_i64(v: i64) -> u64 {
|
||||
u64::try_from(v).unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::params;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
static SEQ: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
fn tmp_db() -> PathBuf {
|
||||
let n = SEQ.fetch_add(1, Ordering::SeqCst);
|
||||
let pid = std::process::id();
|
||||
std::env::temp_dir().join(format!("hyperhive-stats-test-{pid}-{n}.sqlite"))
|
||||
}
|
||||
|
||||
fn seed_db(path: &Path, rows: &[(i64, i64, &str, &str, &str, &str)]) {
|
||||
let conn = Connection::open(path).unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE turn_stats (
|
||||
id INTEGER PRIMARY KEY,
|
||||
started_at INTEGER NOT NULL,
|
||||
ended_at INTEGER NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
wake_from TEXT NOT NULL,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
last_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
last_output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
last_cache_read_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
last_cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
tool_call_count INTEGER NOT NULL DEFAULT 0,
|
||||
tool_call_breakdown_json TEXT,
|
||||
open_threads_count INTEGER,
|
||||
open_reminders_count INTEGER,
|
||||
result_kind TEXT NOT NULL,
|
||||
note TEXT
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
for (started, dur, model, wake, result, tools_json) in rows {
|
||||
conn.execute(
|
||||
"INSERT INTO turn_stats
|
||||
(started_at, ended_at, duration_ms, model, wake_from,
|
||||
last_input_tokens, tool_call_breakdown_json, result_kind)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, 1000, ?6, ?7)",
|
||||
params![
|
||||
started,
|
||||
started + dur / 1000,
|
||||
dur,
|
||||
model,
|
||||
wake,
|
||||
tools_json,
|
||||
result
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_aggregates_rows() {
|
||||
let db = tmp_db();
|
||||
let _ = std::fs::remove_file(&db);
|
||||
let now = now_unix();
|
||||
seed_db(
|
||||
&db,
|
||||
&[
|
||||
(
|
||||
now - 600,
|
||||
5_000,
|
||||
"opus",
|
||||
"recv",
|
||||
"ok",
|
||||
r#"{"Read":2,"Bash":1}"#,
|
||||
),
|
||||
(now - 300, 10_000, "opus", "recv", "ok", r#"{"Read":3}"#),
|
||||
(now - 100, 20_000, "sonnet", "operator", "failed", "{}"),
|
||||
],
|
||||
);
|
||||
let s = snapshot(&db, Window::Day).unwrap();
|
||||
assert_eq!(s.turn_count, 3);
|
||||
assert_eq!(s.window, "24h");
|
||||
assert_eq!(s.bucket_seconds, 3600);
|
||||
let tool_map: HashMap<_, _> = s
|
||||
.tool_breakdown
|
||||
.iter()
|
||||
.map(|kc| (kc.key.clone(), kc.count))
|
||||
.collect();
|
||||
assert_eq!(tool_map.get("Read").copied(), Some(5));
|
||||
assert_eq!(tool_map.get("Bash").copied(), Some(1));
|
||||
let wake_map: HashMap<_, _> = s
|
||||
.wake_mix
|
||||
.iter()
|
||||
.map(|kc| (kc.key.clone(), kc.count))
|
||||
.collect();
|
||||
assert_eq!(wake_map.get("recv").copied(), Some(2));
|
||||
assert_eq!(wake_map.get("operator").copied(), Some(1));
|
||||
let result_map: HashMap<_, _> = s
|
||||
.result_mix
|
||||
.iter()
|
||||
.map(|kc| (kc.key.clone(), kc.count))
|
||||
.collect();
|
||||
assert_eq!(result_map.get("ok").copied(), Some(2));
|
||||
assert_eq!(result_map.get("failed").copied(), Some(1));
|
||||
// Model breakdown: 2 opus + 1 sonnet, all in the same hour
|
||||
// bucket given the 24h window.
|
||||
assert_eq!(s.models, vec!["opus".to_string(), "sonnet".to_string()]);
|
||||
let mut model_totals: HashMap<String, u64> = HashMap::new();
|
||||
for b in &s.buckets {
|
||||
for (k, v) in &b.model_counts {
|
||||
*model_totals.entry(k.clone()).or_insert(0) += v;
|
||||
}
|
||||
}
|
||||
assert_eq!(model_totals.get("opus").copied(), Some(2));
|
||||
assert_eq!(model_totals.get("sonnet").copied(), Some(1));
|
||||
// Durations: [5000, 10000, 20000] → avg ≈ 11666.67, p50 = 10000, p95 ~ 20000
|
||||
assert!((s.duration_summary.avg - 11_666.666_666_666_666).abs() < 1.0);
|
||||
assert!((s.duration_summary.p50 - 10_000.0).abs() < 1.0);
|
||||
assert!((s.duration_summary.p95 - 20_000.0).abs() < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_window_still_paints_buckets() {
|
||||
let db = tmp_db();
|
||||
let _ = std::fs::remove_file(&db);
|
||||
seed_db(&db, &[]);
|
||||
let s = snapshot(&db, Window::Day).unwrap();
|
||||
assert_eq!(s.turn_count, 0);
|
||||
// 24h / 1h buckets = ~24-25 buckets covering the window.
|
||||
assert!(s.buckets.len() >= 24);
|
||||
assert!(s.buckets.iter().all(|b| b.turn_count == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn week_uses_daily_buckets() {
|
||||
let db = tmp_db();
|
||||
let _ = std::fs::remove_file(&db);
|
||||
seed_db(&db, &[]);
|
||||
let s = snapshot(&db, Window::Week).unwrap();
|
||||
assert_eq!(s.window, "7d");
|
||||
assert_eq!(s.bucket_seconds, 86_400);
|
||||
assert!(s.buckets.len() >= 7);
|
||||
}
|
||||
|
||||
/// `bash_breakdown` degrades gracefully when the `bash_commands`
|
||||
/// table hasn't been created yet (the capture side hasn't shipped /
|
||||
/// run on this agent). A `seed_db` DB has no such table, so the read
|
||||
/// must yield an empty list rather than erroring the whole snapshot.
|
||||
#[test]
|
||||
fn bash_breakdown_empty_without_table() {
|
||||
let db = tmp_db();
|
||||
let _ = std::fs::remove_file(&db);
|
||||
seed_db(&db, &[(now_unix() - 100, 1000, "opus", "recv", "ok", "{}")]);
|
||||
let s = snapshot(&db, Window::Day).unwrap();
|
||||
assert!(s.bash_breakdown.is_empty());
|
||||
}
|
||||
|
||||
/// With a populated `bash_commands` table, `bash_breakdown` rolls up
|
||||
/// per-head counts (busiest first) and respects the window cutoff.
|
||||
#[test]
|
||||
fn bash_breakdown_aggregates_heads() {
|
||||
let db = tmp_db();
|
||||
let _ = std::fs::remove_file(&db);
|
||||
seed_db(&db, &[]);
|
||||
let now = now_unix();
|
||||
let conn = Connection::open(&db).unwrap();
|
||||
conn.execute_batch("CREATE TABLE bash_commands (ts INTEGER NOT NULL, head TEXT NOT NULL);")
|
||||
.unwrap();
|
||||
// 3x cargo + 2x git inside the window, 1x rg outside it.
|
||||
for (ts, head) in [
|
||||
(now - 100, "cargo"),
|
||||
(now - 200, "cargo"),
|
||||
(now - 300, "cargo"),
|
||||
(now - 400, "git"),
|
||||
(now - 500, "git"),
|
||||
(now - (2 * 24 * 3600), "rg"), // older than the 24h window
|
||||
] {
|
||||
conn.execute(
|
||||
"INSERT INTO bash_commands (ts, head) VALUES (?1, ?2)",
|
||||
params![ts, head],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let s = snapshot(&db, Window::Day).unwrap();
|
||||
let map: HashMap<_, _> = s
|
||||
.bash_breakdown
|
||||
.iter()
|
||||
.map(|kc| (kc.key.clone(), kc.count))
|
||||
.collect();
|
||||
assert_eq!(map.get("cargo").copied(), Some(3));
|
||||
assert_eq!(map.get("git").copied(), Some(2));
|
||||
// `rg` fell outside the 24h window — excluded.
|
||||
assert_eq!(map.get("rg").copied(), None);
|
||||
// top_n orders busiest first.
|
||||
assert_eq!(
|
||||
s.bash_breakdown.first().map(|kc| kc.key.as_str()),
|
||||
Some("cargo")
|
||||
);
|
||||
}
|
||||
}
|
||||
710
hive-agent/src/turn.rs
Normal file
710
hive-agent/src/turn.rs
Normal file
|
|
@ -0,0 +1,710 @@
|
|||
//! Per-turn claude policy layer. The generic subprocess mechanics — spawning
|
||||
//! `claude --print`, streaming + classifying stream-json, session
|
||||
//! lookup/archive — live in the `hive-claude` crate. This module owns the
|
||||
//! hyperhive-specific policy on top: building the per-turn config from the
|
||||
//! bus, bridging the output stream onto the event bus (`BusSink`), and the
|
||||
//! compaction / auto-reset / retry state machine (`drive_turn`).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
use hive_claude::{Config, InfiniteSession, PercentPolicy, Sink};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::events::{Bus, LiveEvent};
|
||||
use crate::mcp_config;
|
||||
|
||||
// Hive-enforced claude settings ship at `/etc/claude-code/managed-settings.json`,
|
||||
// which claude-code auto-discovers (precedence #1, read-only, un-overridable) —
|
||||
// so the harness no longer passes `--settings`. We turn off claude's in-session
|
||||
// auto-compaction and its cross-session auto-memory because hyperhive owns those
|
||||
// concerns (`/compact` on overflow, notes persistence under `/state`). How the
|
||||
// file is wired (the nix asset) + the full rationale live in
|
||||
// `docs/turn-loop/claude-invocation.md`. Unknown keys are silently ignored by
|
||||
// claude-code; if a key gets renamed we'll spot it because the corresponding
|
||||
// behavior will start firing mid-turn again.
|
||||
//
|
||||
// The subprocess mechanics — spawning `claude --print`, streaming +
|
||||
// classifying stream-json, session lookup/archive — live in the generic
|
||||
// `hive-claude` crate. This module is the hyperhive *policy* layer on top:
|
||||
// it builds the per-turn [`Config`] from the bus, forwards the stream to the
|
||||
// event bus via [`BusSink`], and owns compaction / auto-reset / retry.
|
||||
|
||||
/// Fixed, harness-owned claude session title. Every turn / compact /
|
||||
/// checkpoint resumes THIS title (`--resume <title>`); the create path
|
||||
/// names it (`--name <title>`). One constant identity per agent means
|
||||
/// compaction and the post-compact retry provably target the same session —
|
||||
/// there is no scraped UUID to go stale, empty, or diverge. Each agent runs
|
||||
/// in its own container (own `~/.claude` + own `/state` cwd), so even the
|
||||
/// shared default never collides across agents. Override via
|
||||
/// `HIVE_SESSION_TITLE`.
|
||||
const DEFAULT_SESSION_TITLE: &str = "hive-session";
|
||||
|
||||
/// How long to sleep after detecting a rate-limit before re-entering the
|
||||
/// serve loop. Overridable via `HIVE_RATE_LIMIT_SLEEP_SECS`. Default is
|
||||
/// 5 minutes — enough for most short-lived throttles; the operator can
|
||||
/// tune down for tight retry scenarios or up if they're hitting sustained
|
||||
/// capacity limits.
|
||||
const DEFAULT_RATE_LIMIT_SLEEP_SECS: u64 = 300;
|
||||
|
||||
/// Idle watchdog window: kill claude and surface `ApiStall` if it produces no
|
||||
/// stdout for this long. The timer resets on every stdout line, so a large but
|
||||
/// still-streaming turn is never cut — only a complete silence trips it.
|
||||
/// Default is 10 minutes: long enough for legitimate big-context turns, short
|
||||
/// enough to cut a multi-retry Anthropic connection storm (atlas telemetry:
|
||||
/// attempt:11 took ~6.7min on a 371k-token context). Override via
|
||||
/// `HIVE_TURN_IDLE_SECS`; `0` disables the watchdog (wait indefinitely).
|
||||
const DEFAULT_TURN_IDLE_SECS: u64 = 600;
|
||||
|
||||
/// How long to park after an `ApiStall` before requeueing the message, giving
|
||||
/// the API a chance to recover. Overridable via `HIVE_STALL_SLEEP_SECS`.
|
||||
const DEFAULT_STALL_SLEEP_SECS: u64 = 60;
|
||||
|
||||
/// Assumed prompt-cache TTL. Claude caches prompt prefixes — ~5 minutes on
|
||||
/// the API (pay-per-token), ~1 hour on Claude Max (subscription). When the
|
||||
/// idle gap exceeds this, the cache prefix has likely expired and the next
|
||||
/// turn re-uploads the full transcript regardless of whether we resume or
|
||||
/// start fresh. A fresh session with a small context is therefore equally
|
||||
/// cheap but gives the model a clean slate. Default is 3600s (1h) matching
|
||||
/// the subscription TTL; API (pay-per-token) users should set
|
||||
/// `HIVE_CACHE_TTL_SECS=300`. Override via `HIVE_CACHE_TTL_SECS`; set to
|
||||
/// `0` to disable (always resume).
|
||||
const DEFAULT_CACHE_TTL_SECS: u64 = 3600;
|
||||
|
||||
/// Default proactive-compaction watermark, as a percent of the effective
|
||||
/// context window. Overridable via `HIVE_COMPACT_WATERMARK_PERCENT`.
|
||||
const DEFAULT_COMPACT_PERCENT: u8 = 75;
|
||||
|
||||
/// Synthetic wake prompt for the proactive notes-checkpoint turn. Not an
|
||||
/// inbox message — the harness injects it directly so the agent gets one
|
||||
/// turn to persist durable state before `/compact` collapses the
|
||||
/// turn-by-turn history into a summary.
|
||||
const CHECKPOINT_PROMPT: &str = "[system] Context checkpoint — no inbox message to handle.\n\n\
|
||||
Your conversation context has grown large and the harness is about to run `/compact`, \
|
||||
which collapses the detailed turn-by-turn history into a short summary. Anything you \
|
||||
do not persist now is effectively lost after the next turn.\n\n\
|
||||
Use THIS turn to flush anything worth keeping into your durable `/state` files: update \
|
||||
your notes / CLAUDE.md / TODO.md with in-flight task state, decisions made, important \
|
||||
file paths, and whatever you would need to resume cleanly 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 the turn.";
|
||||
|
||||
/// The set of files claude reads on every invocation: the MCP server
|
||||
/// config (`--mcp-config`) and the pre-rendered role/tools system
|
||||
/// prompt (`--system-prompt-file`). Static settings are no longer
|
||||
/// passed here — they live at `/etc/claude-code/managed-settings.json`
|
||||
/// and claude auto-discovers them.
|
||||
/// Materialised once at harness startup; shared between the turn loop
|
||||
/// and the operator-driven `/compact` path so both invocations look
|
||||
/// identical to claude (same MCP surface, same allowed tools, same
|
||||
/// role prompt — only the stdin payload differs).
|
||||
#[derive(Clone)]
|
||||
pub struct TurnFiles {
|
||||
pub mcp_config: PathBuf,
|
||||
pub system_prompt: PathBuf,
|
||||
}
|
||||
|
||||
impl TurnFiles {
|
||||
/// Write the two per-turn files (MCP config + system prompt) into the
|
||||
/// agent's config dir. Idempotent — overwrites whatever was there.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if any of the config files cannot be written to disk.
|
||||
pub async fn prepare(socket: &Path, label: &str) -> Result<Self> {
|
||||
Ok(Self {
|
||||
mcp_config: write_mcp_config().await?,
|
||||
system_prompt: write_system_prompt(socket, label).await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the MCP config blob claude reads from `--mcp-config <path>`.
|
||||
/// The built-in hyperhive surface is served over HTTP by the persistent
|
||||
/// `hive-mcp-http` daemon, so no per-turn stdio child is spawned; extra
|
||||
/// servers declared via `hyperhive.extraMcpServers` are still stdio bridges.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the config file cannot be written.
|
||||
pub async fn write_mcp_config() -> Result<PathBuf> {
|
||||
let parent = crate::paths::config_dir();
|
||||
tokio::fs::create_dir_all(&parent).await.ok();
|
||||
let path = parent.join("claude-mcp-config.json");
|
||||
let body = mcp_config::render_claude_config();
|
||||
tokio::fs::write(&path, body).await?;
|
||||
tracing::info!(path = %path.display(), "wrote claude MCP config");
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Thin re-export of [`crate::prompt::write_system_prompt`] for
|
||||
/// callers that already import this module. The actual rendering +
|
||||
/// marker-block logic lives in `prompt.rs`; this is just the public
|
||||
/// entry point the binaries call.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the system prompt file cannot be written.
|
||||
pub async fn write_system_prompt(socket: &Path, label: &str) -> Result<PathBuf> {
|
||||
crate::prompt::write_system_prompt(socket, label).await
|
||||
}
|
||||
|
||||
/// One claude turn's outcome: `Ok(compacted)` on success, or a [`TurnError`]
|
||||
/// the serve loop must act on. The `compacted` bool is `true` when a
|
||||
/// compaction ran this turn (reactively on overflow, or proactively per the
|
||||
/// policy — or an operator `/compact` at turn end); it's recorded as
|
||||
/// `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-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*
|
||||
/// a generic failure — the serve loop reacts to each differently (requeue,
|
||||
/// park, escalate).
|
||||
#[derive(Debug)]
|
||||
pub enum TurnError {
|
||||
/// claude saw "Prompt is too long" and even a reactive compact + retry
|
||||
/// (inside [`InfiniteSession::run`]) couldn't bring it back under the
|
||||
/// window. Rare. [`drive_turn`] archives the session (so the next turn
|
||||
/// starts fresh) and the serve loop requeues the in-flight message, which
|
||||
/// redelivers into that fresh session — the wake prompt itself is tiny, so
|
||||
/// the overflow was the accumulated context, which the archive clears.
|
||||
PromptTooLong,
|
||||
/// The Anthropic API refused the request due to a rate limit, per-account
|
||||
/// usage cap, or exhausted credit balance. The serve loop should park for
|
||||
/// `rate_limit_sleep_secs()` and requeue — NOT bubble up as a crash.
|
||||
RateLimited,
|
||||
/// The Anthropic API rejected the request with 401 (OAuth session
|
||||
/// expired or revoked). The serve loop should flip the container
|
||||
/// into `needs_login_idle` and stop driving turns until the
|
||||
/// operator re-auths via the per-agent web UI.
|
||||
AuthFailed,
|
||||
/// `--resume <title>` missed AND the lib's create self-heal also failed to
|
||||
/// resolve the session — "shouldn't happen" (a resume-miss is normally
|
||||
/// self-healed inside [`InfiniteSession::attempt`]). Rather than ack + drop
|
||||
/// the wake message, the serve loop requeues it so the next turn retries;
|
||||
/// no status park.
|
||||
SessionNotFound,
|
||||
/// The harness-side idle watchdog killed claude after `turn_idle_secs()` of
|
||||
/// output silence — indicative of an Anthropic API stall (e.g. a multi-retry
|
||||
/// connection storm burning minutes of wall-clock with no stream progress).
|
||||
/// The serve loop parks for `stall_sleep_secs()` and requeues, like the
|
||||
/// rate-limit path — NOT a crash.
|
||||
ApiStall,
|
||||
/// A hard failure with no recovery — the serve loop escalates it to the
|
||||
/// parent (`send_to_parent`).
|
||||
Failed(anyhow::Error),
|
||||
}
|
||||
|
||||
/// Parse an env var as `u64`, ignoring absent / blank / unparseable values.
|
||||
/// Returns the raw value including `0` (several knobs use `0` as "disable").
|
||||
fn env_u64(name: &str) -> Option<u64> {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u64>().ok())
|
||||
}
|
||||
|
||||
/// Like [`env_u64`] but also rejects `0`, falling back to `default` — for
|
||||
/// knobs where `0` is meaningless rather than a "disable" sentinel.
|
||||
fn env_u64_positive(name: &str, default: u64) -> u64 {
|
||||
env_u64(name).filter(|&v| v > 0).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// How long to sleep after a rate-limit before re-entering the serve loop.
|
||||
/// Reads `HIVE_RATE_LIMIT_SLEEP_SECS` if set to a valid positive integer.
|
||||
#[must_use]
|
||||
pub fn rate_limit_sleep_secs() -> u64 {
|
||||
env_u64_positive("HIVE_RATE_LIMIT_SLEEP_SECS", DEFAULT_RATE_LIMIT_SLEEP_SECS)
|
||||
}
|
||||
|
||||
/// Idle-watchdog window in seconds. Reads `HIVE_TURN_IDLE_SECS`; `0` disables
|
||||
/// the watchdog. Absent / unparseable falls back to [`DEFAULT_TURN_IDLE_SECS`].
|
||||
#[must_use]
|
||||
pub fn turn_idle_secs() -> u64 {
|
||||
env_u64("HIVE_TURN_IDLE_SECS").unwrap_or(DEFAULT_TURN_IDLE_SECS)
|
||||
}
|
||||
|
||||
/// How long to park after an `ApiStall` before requeueing. Reads
|
||||
/// `HIVE_STALL_SLEEP_SECS` if set to a valid positive integer.
|
||||
#[must_use]
|
||||
pub fn stall_sleep_secs() -> u64 {
|
||||
env_u64_positive("HIVE_STALL_SLEEP_SECS", DEFAULT_STALL_SLEEP_SECS)
|
||||
}
|
||||
|
||||
/// Resolve the effective context-window size for watermark calculations.
|
||||
/// Priority order (first wins):
|
||||
/// 1. API-reported window from the last `result` event's `modelUsage.*.contextWindow`.
|
||||
/// 2. `HIVE_CONTEXT_WINDOW_TOKENS_*` env vars (Nix-configured per-model defaults).
|
||||
/// 3. Hard fallback: 200 000.
|
||||
///
|
||||
/// The API-reported window is the authoritative per-inference active
|
||||
/// context limit. It reflects what the model actually enforces — which
|
||||
/// for models with large prompt caches (e.g. 1 M total cache) may be
|
||||
/// significantly smaller than the cache capacity (e.g. 200 k active window
|
||||
/// for `claude-sonnet-4-6`).
|
||||
fn effective_context_window(bus: &Bus) -> u64 {
|
||||
bus.api_context_window()
|
||||
.unwrap_or_else(|| crate::harness_state::context_window_tokens(&bus.model()))
|
||||
}
|
||||
|
||||
/// Resolve the auto-reset watermark. Priority order:
|
||||
/// 1. `HIVE_AUTO_RESET_WATERMARK_TOKENS` env var (explicit override).
|
||||
/// 2. 50% of `effective_context_window(bus)`.
|
||||
///
|
||||
/// `0` disables auto-reset entirely.
|
||||
fn auto_reset_watermark_tokens(bus: &Bus) -> u64 {
|
||||
env_u64("HIVE_AUTO_RESET_WATERMARK_TOKENS").unwrap_or_else(|| effective_context_window(bus) / 2)
|
||||
}
|
||||
|
||||
/// Resolve the assumed cache TTL: `HIVE_CACHE_TTL_SECS` if set, else
|
||||
/// `DEFAULT_CACHE_TTL_SECS`.
|
||||
fn cache_ttl_secs() -> u64 {
|
||||
env_u64_positive("HIVE_CACHE_TTL_SECS", DEFAULT_CACHE_TTL_SECS)
|
||||
}
|
||||
|
||||
/// Proactive-compaction watermark as a percent of the effective context
|
||||
/// window (default [`DEFAULT_COMPACT_PERCENT`]). `0` disables proactive
|
||||
/// compaction — the reactive on-overflow path still applies. Reads
|
||||
/// `HIVE_COMPACT_WATERMARK_PERCENT`; the legacy `autoCompact = false` switch
|
||||
/// (which sets `HIVE_COMPACT_WATERMARK_TOKENS=0`) is still honoured as disable.
|
||||
fn compact_percent() -> u8 {
|
||||
if env_u64("HIVE_COMPACT_WATERMARK_TOKENS") == Some(0) {
|
||||
return 0;
|
||||
}
|
||||
let pct =
|
||||
env_u64("HIVE_COMPACT_WATERMARK_PERCENT").unwrap_or(u64::from(DEFAULT_COMPACT_PERCENT));
|
||||
// `min(100)` is ≤ 100, so this `try_from` is infallible.
|
||||
u8::try_from(pct.min(100)).expect("value clamped to <= 100 fits in u8")
|
||||
}
|
||||
|
||||
/// The agent's durable session type: the constant-title [`InfiniteSession`]
|
||||
/// with hyperhive's percent-of-window compaction policy. Built once by the
|
||||
/// serve loop (see [`make_session`]) and threaded through the turns, rather
|
||||
/// than rebuilt each time — it's effectively stateless, so one instance serves
|
||||
/// the whole run.
|
||||
pub type AgentSession = InfiniteSession<PercentPolicy>;
|
||||
|
||||
/// Construct the agent's durable session: constant title + on-disk store + a
|
||||
/// percent-of-window compaction policy that checkpoints (`CHECKPOINT_PROMPT`)
|
||||
/// before compacting. Called once at serve-loop start. `percent` comes from a
|
||||
/// boot-time env var and `default_window` is only a fallback for turns where
|
||||
/// the model didn't report a window, so a single build at startup is fine.
|
||||
#[must_use]
|
||||
pub fn make_session(bus: &Bus) -> AgentSession {
|
||||
InfiniteSession::new(
|
||||
session_title(),
|
||||
session_store(),
|
||||
PercentPolicy {
|
||||
percent: compact_percent(),
|
||||
default_window: Some(effective_context_window(bus)),
|
||||
checkpoint_prompt: Some(CHECKPOINT_PROMPT.to_string()),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Drive one turn end-to-end. The durable [`InfiniteSession`] owns the
|
||||
/// resume-or-create + compaction loop (reactive on overflow, and proactive per
|
||||
/// the percent policy — including the pre-compaction checkpoint turn). This
|
||||
/// layer wraps it with the two hyperhive-specific concerns:
|
||||
///
|
||||
/// - **Session reset (pre-turn)** — an operator reset (`/api/new-session`) or
|
||||
/// the auto-reset heuristic (context large AND prompt cache gone cold)
|
||||
/// archives the current session at this turn boundary so the run starts
|
||||
/// fresh. The two are mutually exclusive. This is deliberately *not* part of
|
||||
/// the infinite-session abstraction — it's the hive escape hatch.
|
||||
/// - **401 retry** — a transient token-refresh race can 401 once and clear, so
|
||||
/// 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-agent` serve loop, which owns the shared
|
||||
/// `session` ([`make_session`]) and threads it in.
|
||||
pub async fn drive_turn(
|
||||
prompt: &str,
|
||||
files: &TurnFiles,
|
||||
bus: &Bus,
|
||||
session: &AgentSession,
|
||||
) -> TurnOutcome {
|
||||
if bus.take_session_reset() {
|
||||
// Operator-requested (deferred from `POST /api/new-session`).
|
||||
bus.emit(LiveEvent::Note {
|
||||
text: "operator: resetting session — archiving before this turn".into(),
|
||||
});
|
||||
archive_session(bus);
|
||||
} else {
|
||||
// Heuristic: context large AND prompt cache gone cold.
|
||||
maybe_auto_reset(bus);
|
||||
}
|
||||
let config = claude_config(bus, files);
|
||||
let sink = BusSink::new(bus);
|
||||
let mut result = session.run(&config, prompt, &sink).await;
|
||||
if matches!(result, Err(hive_claude::Error::AuthFailed)) {
|
||||
bus.emit(LiveEvent::Note {
|
||||
text: "got 401 — retrying once before parking for re-login".into(),
|
||||
});
|
||||
result = session.run(&config, prompt, &sink).await;
|
||||
}
|
||||
let outcome = match result {
|
||||
Ok(progress) => {
|
||||
// Apply the turn's parsed usage / model / context-window to the bus
|
||||
// (badges, stats, auto-reset watermark input).
|
||||
apply_telemetry(bus, &progress.telemetry);
|
||||
if progress.created {
|
||||
// Fresh session minted this turn → flag it so the bin loop
|
||||
// mints a `sessions` row + stamps its id onto this turn's stats.
|
||||
bus.mark_fresh_session();
|
||||
bus.emit(LiveEvent::Note {
|
||||
text: format!("created fresh session titled \"{}\"", session_title()),
|
||||
});
|
||||
}
|
||||
Ok(progress.compacted)
|
||||
}
|
||||
Err(e) => error_to_turn(e),
|
||||
};
|
||||
if matches!(outcome, Err(TurnError::PromptTooLong)) {
|
||||
// The lib already compacted + retried and the session is still over the
|
||||
// window. Archive it here (session lifecycle stays hive-side) so the
|
||||
// requeued message — handled by the serve loop — redelivers into a
|
||||
// fresh session that fits.
|
||||
bus.emit(LiveEvent::Note {
|
||||
text: "context still over the window after compaction — archiving session so the \
|
||||
retried message starts fresh"
|
||||
.into(),
|
||||
});
|
||||
archive_session(bus);
|
||||
return Err(TurnError::PromptTooLong);
|
||||
}
|
||||
// Operator `/compact` (`POST /api/compact`) deferred to the turn boundary:
|
||||
// run it now that the turn is done, so it works mid-turn rather than only
|
||||
// when the agent is idle. Only on a healthy turn — no point spawning a
|
||||
// compaction after a rate-limited / auth-failed / crashed one.
|
||||
// `is_ok()` first: `take_compact()` clears the flag, so it must only fire
|
||||
// when the compaction will actually run. On an unhealthy turn
|
||||
// (rate-limited / auth-failed / failed) the flag is left set for the next
|
||||
// turn or the idle `run_pending_compact` to service — not silently eaten.
|
||||
if outcome.is_ok() && bus.take_compact() {
|
||||
bus.emit(LiveEvent::Note {
|
||||
text: "operator: /compact — running at turn end".into(),
|
||||
});
|
||||
// Reflect `Compacting` in the UI like the idle path (`run_pending_compact`)
|
||||
// does; the serve loop resets to `Idle` once this turn returns.
|
||||
bus.set_state(crate::events::TurnState::Compacting);
|
||||
let _ = session.compact(&config, &sink).await;
|
||||
return Ok(true);
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
/// Pre-turn auto-reset check. If context is large AND the prompt cache has
|
||||
/// gone cold (idle time >= cache TTL), archive the current session so the
|
||||
/// next wake-up turn's `--resume <title>` misses and self-heals into a fresh
|
||||
/// `--name <title>` session. No preceding checkpoint turn — running any turn
|
||||
/// before the reset would re-upload and re-warm the cache, which defeats the
|
||||
/// cost-optimisation purpose entirely.
|
||||
fn maybe_auto_reset(bus: &Bus) {
|
||||
let watermark = auto_reset_watermark_tokens(bus);
|
||||
if watermark == 0 {
|
||||
return; // auto-reset disabled
|
||||
}
|
||||
let Some(ctx_tokens) = bus.last_ctx_usage().map(|u| u.context_tokens()) else {
|
||||
return; // no usage reading yet — first turn, nothing to reset
|
||||
};
|
||||
if ctx_tokens < watermark {
|
||||
return;
|
||||
}
|
||||
let last_ended = bus.last_turn_ended_unix();
|
||||
if last_ended == 0 {
|
||||
return; // no completed turn yet
|
||||
}
|
||||
// Compute idle seconds using the same clock as now_unix (unix epoch, i64).
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs());
|
||||
let idle_secs = now.saturating_sub(u64::try_from(last_ended).unwrap_or(0));
|
||||
let ttl = cache_ttl_secs();
|
||||
if idle_secs < ttl {
|
||||
return;
|
||||
}
|
||||
bus.emit(LiveEvent::Note {
|
||||
text: format!(
|
||||
"context {ctx_tokens} tokens, idle {idle_secs}s >= cache TTL {ttl}s \
|
||||
— dropping session (cache cold, fresh start is equally cheap)"
|
||||
),
|
||||
});
|
||||
archive_session(bus);
|
||||
}
|
||||
|
||||
/// Emit the per-turn `TurnEnd` event + log line. Single owner so outcome
|
||||
/// semantics stay consistent across every agent role.
|
||||
pub fn emit_turn_end(bus: &Bus, outcome: &TurnOutcome) {
|
||||
match outcome {
|
||||
Ok(_) => {
|
||||
bus.emit(LiveEvent::TurnEnd {
|
||||
ok: true,
|
||||
note: None,
|
||||
});
|
||||
tracing::info!("turn finished");
|
||||
}
|
||||
Err(TurnError::PromptTooLong) => {
|
||||
bus.emit(LiveEvent::TurnEnd {
|
||||
ok: false,
|
||||
note: Some("context too long after compaction — session archived, retrying".into()),
|
||||
});
|
||||
tracing::warn!("turn prompt-too-long; archived session and requeueing");
|
||||
}
|
||||
Err(TurnError::RateLimited) => {
|
||||
bus.emit(LiveEvent::TurnEnd {
|
||||
ok: false,
|
||||
note: Some("rate limited — parking until quota resets".into()),
|
||||
});
|
||||
tracing::warn!("turn rate-limited");
|
||||
}
|
||||
Err(TurnError::AuthFailed) => {
|
||||
bus.emit(LiveEvent::TurnEnd {
|
||||
ok: false,
|
||||
note: Some("authentication failed (401) — waiting for re-login".into()),
|
||||
});
|
||||
tracing::warn!("turn auth-failed (401)");
|
||||
}
|
||||
Err(TurnError::SessionNotFound) => {
|
||||
bus.emit(LiveEvent::TurnEnd {
|
||||
ok: false,
|
||||
note: Some("session resume + create both missed — requeueing".into()),
|
||||
});
|
||||
tracing::warn!("turn session-not-found; requeueing message");
|
||||
}
|
||||
Err(TurnError::ApiStall) => {
|
||||
bus.emit(LiveEvent::TurnEnd {
|
||||
ok: false,
|
||||
note: Some(format!(
|
||||
"claude killed after {}s of output silence — API stall suspected, parking + requeueing",
|
||||
turn_idle_secs()
|
||||
)),
|
||||
});
|
||||
tracing::warn!("turn killed: API stall (idle watchdog)");
|
||||
}
|
||||
Err(TurnError::Failed(e)) => {
|
||||
let note = format!("{e:#}");
|
||||
bus.emit(LiveEvent::TurnEnd {
|
||||
ok: false,
|
||||
note: Some(note.clone()),
|
||||
});
|
||||
tracing::warn!(error = %note, "turn failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Service a pending operator `/compact` (`Bus::request_compact`) while the
|
||||
/// agent is idle — the serve loop calls this when a `recv` returns no message,
|
||||
/// so a queued `/compact` runs even when no turn is driving. (The in-flight
|
||||
/// case is handled at the end of [`drive_turn`].) Resume-only via
|
||||
/// [`InfiniteSession::compact`]: a missing session is a harmless no-op. Returns
|
||||
/// `true` if a compaction ran.
|
||||
pub async fn run_pending_compact(files: &TurnFiles, bus: &Bus, session: &AgentSession) -> bool {
|
||||
if !bus.take_compact() {
|
||||
return false;
|
||||
}
|
||||
bus.emit(LiveEvent::Note {
|
||||
text: "operator: /compact — running on idle session".into(),
|
||||
});
|
||||
bus.set_state(crate::events::TurnState::Compacting);
|
||||
let config = claude_config(bus, files);
|
||||
let sink = BusSink::new(bus);
|
||||
match session.compact(&config, &sink).await {
|
||||
Ok(()) => bus.emit(LiveEvent::Note {
|
||||
text: "/compact done".into(),
|
||||
}),
|
||||
Err(e) => bus.emit(LiveEvent::Note {
|
||||
text: format!("/compact failed: {e}"),
|
||||
}),
|
||||
}
|
||||
bus.set_state(crate::events::TurnState::Idle);
|
||||
true
|
||||
}
|
||||
|
||||
/// The constant session title for this agent. `HIVE_SESSION_TITLE` overrides
|
||||
/// the compiled-in [`DEFAULT_SESSION_TITLE`]; each agent runs in its own
|
||||
/// container (own `~/.claude` + own `/state` cwd), so even the shared default
|
||||
/// never collides across agents.
|
||||
#[must_use]
|
||||
pub fn session_title() -> String {
|
||||
std::env::var("HIVE_SESSION_TITLE")
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_SESSION_TITLE.to_string())
|
||||
}
|
||||
|
||||
/// The cwd claude is spawned in: the agent's durable `/state` dir when it
|
||||
/// exists, else the harness process cwd. Claude derives its per-project
|
||||
/// session dir from this path, so the same value feeds both the [`Config`] and
|
||||
/// the [`hive_claude::SessionStore`].
|
||||
fn session_cwd() -> PathBuf {
|
||||
let state_dir = crate::paths::state_dir();
|
||||
if state_dir.is_dir() {
|
||||
state_dir
|
||||
} else {
|
||||
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
||||
}
|
||||
}
|
||||
|
||||
/// The on-disk session store for this agent (claude home + spawn cwd), used to
|
||||
/// locate + archive the harness session by title.
|
||||
fn session_store() -> hive_claude::SessionStore {
|
||||
hive_claude::SessionStore::new(crate::paths::claude_dir(), session_cwd())
|
||||
}
|
||||
|
||||
/// Build the per-turn `hive_claude::Config` from the bus (model / effort) and
|
||||
/// the materialised `TurnFiles` (system prompt + MCP config), plus the fixed
|
||||
/// tool allow-lists and the optional docs `--add-dir`.
|
||||
fn claude_config(bus: &Bus, files: &TurnFiles) -> Config {
|
||||
let mut add_dirs = Vec::new();
|
||||
// hyperhive.docs.enable wires HIVE_DOCS_DIR to the in-container reference
|
||||
// docs; expose it as an additional readable directory when set.
|
||||
if let Some(docs_dir) = std::env::var_os("HIVE_DOCS_DIR")
|
||||
&& !docs_dir.is_empty()
|
||||
{
|
||||
add_dirs.push(PathBuf::from(docs_dir));
|
||||
}
|
||||
let cwd = {
|
||||
let state_dir = crate::paths::state_dir();
|
||||
state_dir.is_dir().then_some(state_dir)
|
||||
};
|
||||
Config {
|
||||
model: bus.model(),
|
||||
effort: Some(bus.effort()),
|
||||
cwd,
|
||||
system_prompt_file: Some(files.system_prompt.clone()),
|
||||
mcp_config: Some(files.mcp_config.clone()),
|
||||
strict_mcp_config: true,
|
||||
tools: Some(mcp_config::builtin_tools_arg()),
|
||||
allowed_tools: Some(mcp_config::allowed_tools_arg()),
|
||||
add_dirs,
|
||||
// Idle watchdog: `0` disables (wait indefinitely), any positive value
|
||||
// caps output silence. Policy lives here; the driver just enforces it.
|
||||
idle_timeout: match turn_idle_secs() {
|
||||
0 => None,
|
||||
secs => Some(std::time::Duration::from_secs(secs)),
|
||||
},
|
||||
..Config::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a `hive_claude::Error` onto the harness's `TurnOutcome`. The recognized
|
||||
/// sentinels become their matching outcomes; a residual `SessionNotFound`
|
||||
/// (create path itself missed — shouldn't happen) settles as `Ok`; genuine
|
||||
/// failures become `Failed` (converting the typed lib error into `anyhow`).
|
||||
fn error_to_turn(err: hive_claude::Error) -> TurnOutcome {
|
||||
use hive_claude::Error;
|
||||
match err {
|
||||
Error::PromptTooLong => Err(TurnError::PromptTooLong),
|
||||
Error::RateLimited => Err(TurnError::RateLimited),
|
||||
Error::AuthFailed => Err(TurnError::AuthFailed),
|
||||
Error::SessionNotFound => Err(TurnError::SessionNotFound),
|
||||
Error::IdleTimeout => Err(TurnError::ApiStall),
|
||||
other => Err(TurnError::Failed(other.into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridges a claude run's raw output stream onto the hyperhive event bus:
|
||||
/// per-turn tool-call counting (`observe_stream`), the live SSE stream, and
|
||||
/// non-JSON stdout + stderr as Notes. Stateless — usage/model/context-window
|
||||
/// parsing lives in `hive-claude` and is applied from the run's returned
|
||||
/// `Telemetry` (see `apply_telemetry`).
|
||||
struct BusSink<'a> {
|
||||
bus: &'a Bus,
|
||||
}
|
||||
|
||||
impl<'a> BusSink<'a> {
|
||||
fn new(bus: &'a Bus) -> Self {
|
||||
Self { bus }
|
||||
}
|
||||
}
|
||||
|
||||
impl Sink for BusSink<'_> {
|
||||
fn on_event(&self, event: &Value) {
|
||||
// Raw-event concerns only: per-turn tool-call counting + the live SSE
|
||||
// stream. Usage / model / context-window parsing lives in the lib now
|
||||
// and is applied from the run's returned `Telemetry` (see `drive_turn`
|
||||
// → `apply_telemetry`).
|
||||
self.bus.observe_stream(event);
|
||||
self.bus.emit(LiveEvent::Stream(event.clone()));
|
||||
}
|
||||
|
||||
fn on_stdout_line(&self, line: &str) {
|
||||
self.bus.emit(LiveEvent::Note {
|
||||
text: format!("(non-json) {line}"),
|
||||
});
|
||||
}
|
||||
|
||||
fn on_stderr_line(&self, line: &str) {
|
||||
// Mirror to journald so post-mortems work without the web UI / events
|
||||
// sqlite; the bus Note is what the dashboard renders.
|
||||
tracing::warn!(line = %line, "claude stderr");
|
||||
self.bus.emit(LiveEvent::Note {
|
||||
text: format!("stderr: {line}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a completed turn's parsed [`hive_claude::Telemetry`] to the bus:
|
||||
/// per-inference context usage + cumulative cost, the resolved model id, and
|
||||
/// the API-reported context window (the authoritative window for the auto-reset
|
||||
/// watermark). Skips a degenerate turn that parsed nothing so it doesn't reset
|
||||
/// the badges to zero.
|
||||
fn apply_telemetry(bus: &Bus, telemetry: &hive_claude::Telemetry) {
|
||||
// On a degenerate turn that emitted a `result` but no `assistant` event,
|
||||
// the per-inference `context` stays zero while `cost` (cumulative) is not.
|
||||
// Fall back to `cost` as the ctx proxy so the ctx badge + auto-reset
|
||||
// watermark don't go stale-to-zero. Only a turn that parsed nothing at all
|
||||
// (both zero) is skipped.
|
||||
let ctx = if telemetry.context.context_tokens() == 0 {
|
||||
telemetry.cost
|
||||
} else {
|
||||
telemetry.context
|
||||
};
|
||||
if ctx.context_tokens() == 0 {
|
||||
return;
|
||||
}
|
||||
bus.record_turn_usage(ctx, telemetry.cost);
|
||||
bus.set_resolved_model(telemetry.model.clone());
|
||||
if let Some(window) = telemetry.context_window {
|
||||
bus.set_api_context_window(window);
|
||||
}
|
||||
}
|
||||
|
||||
/// Archive (do NOT delete) the harness's own session so the next turn's
|
||||
/// `--resume <title>` misses and self-heals into a fresh `--name <title>`
|
||||
/// session. Delegates the rename to [`hive_claude::SessionStore::archive_by_title`]
|
||||
/// (which touches only the file carrying OUR `customTitle`, leaving any `choom`
|
||||
/// session sharing the cwd alone) and surfaces the result as a Note. Best-
|
||||
/// effort: never fails a turn. Only ever called at a turn boundary (top of
|
||||
/// `drive_turn` for an operator reset, or `maybe_auto_reset` pre-turn) so no
|
||||
/// claude process holds the session file open when it's renamed.
|
||||
fn archive_session(bus: &Bus) {
|
||||
let title = session_title();
|
||||
match session_store().archive_by_title(&title) {
|
||||
Ok(Some(path)) => {
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("?")
|
||||
.to_string();
|
||||
tracing::info!(path = %path.display(), "archived claude session");
|
||||
bus.emit(LiveEvent::Note {
|
||||
text: format!("archived session \"{title}\" ({name}) — next turn starts fresh"),
|
||||
});
|
||||
}
|
||||
Ok(None) => bus.emit(LiveEvent::Note {
|
||||
text: format!(
|
||||
"no existing session titled \"{title}\" to archive — next turn starts fresh"
|
||||
),
|
||||
}),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to archive claude session");
|
||||
bus.emit(LiveEvent::Note {
|
||||
text: format!("failed to archive session \"{title}\": {e}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
422
hive-agent/src/turn_stats.rs
Normal file
422
hive-agent/src/turn_stats.rs
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
//! Per-turn analytics sink. One sqlite row per claude turn captures:
|
||||
//! identity (`model`, `wake_from`, `result_kind`), timing (`started_at`,
|
||||
//! `ended_at`, `duration_ms`), cost (token counts), and behaviour (tool-call
|
||||
//! count + per-tool breakdown).
|
||||
//!
|
||||
//! **Captured but not yet read** (written every turn, no reader today —
|
||||
//! kept for a future chart / backfill, not consumed by `stats::snapshot`
|
||||
//! or the host rollup): `tool_call_count` (the snapshot recomputes tool
|
||||
//! totals from `tool_call_breakdown_json` instead), `open_threads_count` +
|
||||
//! `open_reminders_count` (planned: a loose-ends-over-time trend), and
|
||||
//! `note` (failure detail for `result_kind = "failed"`).
|
||||
//!
|
||||
//! Lives next to `hyperhive-events.sqlite` in the agent's state dir
|
||||
//! so the host-side state vacuum sweep can reach both. Schema is
|
||||
//! intentionally append-only — every column has a default so future
|
||||
//! additions don't break old readers; new columns land via
|
||||
//! `ALTER TABLE ... ADD COLUMN ... DEFAULT ...` in the migration
|
||||
//! block.
|
||||
//!
|
||||
//! Writes are best-effort: a failed insert logs a warning and lets
|
||||
//! the turn loop continue. The next turn either succeeds or the
|
||||
//! operator sees the journal trail.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::{Connection, params};
|
||||
|
||||
/// SQL bootstrap. CREATE TABLE IF NOT EXISTS so first-boot agents
|
||||
/// and existing ones converge on the same shape. The base table is
|
||||
/// fresh-install only; additive migrations land via `MIGRATIONS`
|
||||
/// below as try-and-ignore ALTERs so existing dbs catch up.
|
||||
const SCHEMA: &str = "
|
||||
CREATE TABLE IF NOT EXISTS turn_stats (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
started_at INTEGER NOT NULL,
|
||||
ended_at INTEGER NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
wake_from TEXT NOT NULL,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
last_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
last_output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
last_cache_read_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
last_cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
tool_call_count INTEGER NOT NULL DEFAULT 0,
|
||||
tool_call_breakdown_json TEXT,
|
||||
open_threads_count INTEGER,
|
||||
open_reminders_count INTEGER,
|
||||
result_kind TEXT NOT NULL,
|
||||
note TEXT,
|
||||
session_id INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_turn_stats_started
|
||||
ON turn_stats (started_at DESC);
|
||||
-- NOTE: the index on session_id is created in MIGRATIONS, not here. On an
|
||||
-- existing pre-session db the `CREATE TABLE IF NOT EXISTS` above is a no-op
|
||||
-- (the old table has no session_id column), so indexing session_id in this
|
||||
-- batch would fail with `no such column` and abort the whole SCHEMA apply —
|
||||
-- which disables the stats sink. The column is added by MIGRATIONS first.
|
||||
-- One row per fresh claude session (minted when --continue is suppressed).
|
||||
-- turn_stats.session_id FKs here so per-session stats (first-turn tokens,
|
||||
-- per-session totals, turn count, duration) are one GROUP BY away.
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
started_at INTEGER NOT NULL,
|
||||
model TEXT NOT NULL
|
||||
);
|
||||
";
|
||||
|
||||
/// Additive column migrations. Each runs unconditionally and ignores
|
||||
/// `duplicate column name` errors — sqlite < 3.35 lacks
|
||||
/// `ADD COLUMN IF NOT EXISTS`, so try-and-ignore is the portable path.
|
||||
/// New columns MUST carry a default so existing rows decode.
|
||||
const MIGRATIONS: &[&str] = &[
|
||||
"ALTER TABLE turn_stats ADD COLUMN last_input_tokens INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE turn_stats ADD COLUMN last_output_tokens INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE turn_stats ADD COLUMN last_cache_read_input_tokens INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE turn_stats ADD COLUMN last_cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0",
|
||||
// Nullable FK to sessions.id — no default; pre-migration rows stay NULL
|
||||
// (the surface treats NULL as "no session", inert until capture lands).
|
||||
"ALTER TABLE turn_stats ADD COLUMN session_id INTEGER",
|
||||
// Index on session_id — must run AFTER the column is added, so it lives
|
||||
// here rather than in SCHEMA (see the note there). Idempotent.
|
||||
"CREATE INDEX IF NOT EXISTS idx_turn_stats_session ON turn_stats (session_id)",
|
||||
];
|
||||
|
||||
/// One row to be inserted. `Option`-wrapped fields default to NULL
|
||||
/// when the harness couldn't gather them (e.g. socket roundtrip for
|
||||
/// `open_threads` failed) so a partial row beats no row.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TurnStatRow {
|
||||
pub started_at: i64,
|
||||
pub ended_at: i64,
|
||||
pub duration_ms: i64,
|
||||
pub model: String,
|
||||
pub wake_from: String,
|
||||
/// Cumulative across every inference in the turn (cost signal).
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cache_read_input_tokens: u64,
|
||||
pub cache_creation_input_tokens: u64,
|
||||
/// Last inference's usage — the actual context size at turn end.
|
||||
pub last_input_tokens: u64,
|
||||
pub last_output_tokens: u64,
|
||||
pub last_cache_read_input_tokens: u64,
|
||||
pub last_cache_creation_input_tokens: u64,
|
||||
/// Captured, not yet read — the snapshot recomputes tool totals from
|
||||
/// `tool_call_breakdown_json` (see the module doc).
|
||||
pub tool_call_count: u64,
|
||||
/// Per-tool breakdown as JSON: `{"Read":12,"Bash":3,...}`. None
|
||||
/// when no tools were called (saves a sqlite write of `"{}"`).
|
||||
pub tool_call_breakdown_json: Option<String>,
|
||||
/// Post-turn loose-ends snapshot. Captured, not yet read — planned to
|
||||
/// feed a loose-ends-over-time trend on the stats page.
|
||||
pub open_threads_count: Option<u64>,
|
||||
pub open_reminders_count: Option<u64>,
|
||||
/// `"ok" | "failed" | "prompt_too_long"`.
|
||||
pub result_kind: &'static str,
|
||||
/// Failure detail for `result_kind = "failed"`. Captured, not yet read.
|
||||
pub note: Option<String>,
|
||||
/// FK to `sessions.id` for the fresh claude session this turn belongs
|
||||
/// to. `None` on pre-capture rows (and when the stats db couldn't mint
|
||||
/// a session) so the read side degrades to empty.
|
||||
pub session_id: Option<i64>,
|
||||
}
|
||||
|
||||
/// Thin sqlite wrapper. Cloning is cheap (Arc-shared connection).
|
||||
#[derive(Clone)]
|
||||
pub struct TurnStats {
|
||||
inner: std::sync::Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl TurnStats {
|
||||
/// Open the per-agent stats db, creating the file + schema if
|
||||
/// missing. Returns `None` when the db can't be opened (read-only
|
||||
/// fs in tests, missing state dir) — the harness logs and
|
||||
/// continues without a sink rather than failing the turn loop.
|
||||
#[must_use]
|
||||
pub fn open_default() -> Option<Self> {
|
||||
let path = default_path();
|
||||
match Self::open(&path) {
|
||||
Ok(s) => Some(s),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = ?e,
|
||||
path = %path.display(),
|
||||
"turn_stats: open failed; per-turn analytics disabled"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn open(path: &Path) -> Result<Self> {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let conn = Connection::open(path)
|
||||
.with_context(|| format!("open turn_stats db {}", path.display()))?;
|
||||
conn.execute_batch(SCHEMA)
|
||||
.context("apply turn_stats schema")?;
|
||||
for stmt in MIGRATIONS {
|
||||
// Ignore "duplicate column name" — the migration already ran.
|
||||
// Any other error is logged but doesn't fail open() because the
|
||||
// base schema works and we'd rather keep the harness alive than
|
||||
// crash on an upgrade hiccup.
|
||||
if let Err(e) = conn.execute(stmt, []) {
|
||||
let msg = e.to_string();
|
||||
if !msg.contains("duplicate column name") {
|
||||
tracing::warn!(error = %msg, stmt, "turn_stats migration failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
inner: std::sync::Arc::new(Mutex::new(conn)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Insert a row. Best-effort — logs + swallows errors so a sqlite
|
||||
/// hiccup (locked db, full disk) doesn't crash the harness.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
pub fn record(&self, row: &TurnStatRow) {
|
||||
let conn = self.inner.lock().unwrap();
|
||||
let res = conn.execute(
|
||||
"INSERT INTO turn_stats (
|
||||
started_at, ended_at, duration_ms, model, wake_from,
|
||||
input_tokens, output_tokens,
|
||||
cache_read_input_tokens, cache_creation_input_tokens,
|
||||
last_input_tokens, last_output_tokens,
|
||||
last_cache_read_input_tokens, last_cache_creation_input_tokens,
|
||||
tool_call_count, tool_call_breakdown_json,
|
||||
open_threads_count, open_reminders_count,
|
||||
result_kind, note, session_id
|
||||
) VALUES (
|
||||
?1, ?2, ?3, ?4, ?5,
|
||||
?6, ?7,
|
||||
?8, ?9,
|
||||
?10, ?11,
|
||||
?12, ?13,
|
||||
?14, ?15,
|
||||
?16, ?17,
|
||||
?18, ?19, ?20
|
||||
)",
|
||||
params![
|
||||
row.started_at,
|
||||
row.ended_at,
|
||||
row.duration_ms,
|
||||
row.model,
|
||||
row.wake_from,
|
||||
i64::try_from(row.input_tokens).unwrap_or(i64::MAX),
|
||||
i64::try_from(row.output_tokens).unwrap_or(i64::MAX),
|
||||
i64::try_from(row.cache_read_input_tokens).unwrap_or(i64::MAX),
|
||||
i64::try_from(row.cache_creation_input_tokens).unwrap_or(i64::MAX),
|
||||
i64::try_from(row.last_input_tokens).unwrap_or(i64::MAX),
|
||||
i64::try_from(row.last_output_tokens).unwrap_or(i64::MAX),
|
||||
i64::try_from(row.last_cache_read_input_tokens).unwrap_or(i64::MAX),
|
||||
i64::try_from(row.last_cache_creation_input_tokens).unwrap_or(i64::MAX),
|
||||
i64::try_from(row.tool_call_count).unwrap_or(i64::MAX),
|
||||
row.tool_call_breakdown_json,
|
||||
row.open_threads_count
|
||||
.map(|n| i64::try_from(n).unwrap_or(i64::MAX)),
|
||||
row.open_reminders_count
|
||||
.map(|n| i64::try_from(n).unwrap_or(i64::MAX)),
|
||||
row.result_kind,
|
||||
row.note,
|
||||
row.session_id,
|
||||
],
|
||||
);
|
||||
if let Err(e) = res {
|
||||
tracing::warn!(error = ?e, "turn_stats: insert failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Mint a new session row at fresh-session start, returning its `id`
|
||||
/// for stamping onto this session's `turn_stats` rows. Best-effort —
|
||||
/// returns `None` (and logs) on any sqlite error, so a hiccup degrades
|
||||
/// to NULL `session_id` rows rather than crashing the turn loop.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn start_session(&self, started_at: i64, model: &str) -> Option<i64> {
|
||||
let conn = self.inner.lock().unwrap();
|
||||
match conn.execute(
|
||||
"INSERT INTO sessions (started_at, model) VALUES (?1, ?2)",
|
||||
params![started_at, model],
|
||||
) {
|
||||
Ok(_) => Some(conn.last_insert_rowid()),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "turn_stats: start_session insert failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Token counts from the most recently inserted row, if any.
|
||||
/// Returns `(ctx, cost)` — both backfill `Bus` on startup so the
|
||||
/// per-agent web UI's ctx + cost badges paint with real numbers on
|
||||
/// cold load instead of waiting for the next `TokenUsageChanged`
|
||||
/// SSE event. Best-effort: any sqlite error returns `(None, None)`.
|
||||
///
|
||||
/// Pre-migration rows (before the `last_*_tokens` columns existed)
|
||||
/// have last-inference zeros — those rows yield `ctx = None` so the
|
||||
/// badge stays empty until the next real turn rather than showing a
|
||||
/// misleading 0.
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
#[must_use]
|
||||
pub fn last_usage(
|
||||
&self,
|
||||
) -> (
|
||||
Option<hive_claude::TokenUsage>,
|
||||
Option<hive_claude::TokenUsage>,
|
||||
) {
|
||||
let conn = self.inner.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT input_tokens, output_tokens,
|
||||
cache_read_input_tokens, cache_creation_input_tokens,
|
||||
last_input_tokens, last_output_tokens,
|
||||
last_cache_read_input_tokens, last_cache_creation_input_tokens
|
||||
FROM turn_stats
|
||||
-- `id` (AUTOINCREMENT) is monotonic with insertion, so this is
|
||||
-- the most-recently-inserted row even among same-second turns
|
||||
-- (which `started_at DESC` would order arbitrarily).
|
||||
ORDER BY id DESC
|
||||
LIMIT 1",
|
||||
[],
|
||||
|row| {
|
||||
let g = |i: usize| -> rusqlite::Result<u64> {
|
||||
Ok(u64::try_from(row.get::<_, i64>(i)?).unwrap_or(0))
|
||||
};
|
||||
let cost = hive_claude::TokenUsage {
|
||||
input_tokens: g(0)?,
|
||||
output_tokens: g(1)?,
|
||||
cache_read_input_tokens: g(2)?,
|
||||
cache_creation_input_tokens: g(3)?,
|
||||
};
|
||||
let last = hive_claude::TokenUsage {
|
||||
input_tokens: g(4)?,
|
||||
output_tokens: g(5)?,
|
||||
cache_read_input_tokens: g(6)?,
|
||||
cache_creation_input_tokens: g(7)?,
|
||||
};
|
||||
let ctx = if last == hive_claude::TokenUsage::default() {
|
||||
None
|
||||
} else {
|
||||
Some(last)
|
||||
};
|
||||
Ok((ctx, Some(cost)))
|
||||
},
|
||||
)
|
||||
.unwrap_or((None, None))
|
||||
}
|
||||
}
|
||||
|
||||
fn default_path() -> PathBuf {
|
||||
crate::paths::harness_dir().join("hyperhive-turn-stats.sqlite")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A `turn_stats` db in the *pre-session* shape: the original table with no
|
||||
/// `session_id` column, plus the `started_at` index. This is what every
|
||||
/// agent created before the sessions feature has on disk.
|
||||
fn seed_pre_session_db(path: &Path) {
|
||||
let conn = Connection::open(path).unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE turn_stats (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
started_at INTEGER NOT NULL,
|
||||
ended_at INTEGER NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
wake_from TEXT NOT NULL,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
last_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
last_output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
last_cache_read_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
last_cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
tool_call_count INTEGER NOT NULL DEFAULT 0,
|
||||
tool_call_breakdown_json TEXT,
|
||||
open_threads_count INTEGER,
|
||||
open_reminders_count INTEGER,
|
||||
result_kind TEXT NOT NULL,
|
||||
note TEXT
|
||||
);
|
||||
CREATE INDEX idx_turn_stats_started ON turn_stats (started_at DESC);",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn sample_row() -> TurnStatRow {
|
||||
TurnStatRow {
|
||||
started_at: 100,
|
||||
ended_at: 101,
|
||||
duration_ms: 1_000,
|
||||
model: "opus".to_owned(),
|
||||
wake_from: "recv".to_owned(),
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
last_input_tokens: 10,
|
||||
last_output_tokens: 5,
|
||||
last_cache_read_input_tokens: 0,
|
||||
last_cache_creation_input_tokens: 0,
|
||||
tool_call_count: 1,
|
||||
tool_call_breakdown_json: None,
|
||||
open_threads_count: None,
|
||||
open_reminders_count: None,
|
||||
result_kind: "ok",
|
||||
note: None,
|
||||
session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression: a pre-session db must `open()` cleanly (the `session_id`
|
||||
/// index used to live in `SCHEMA` and aborted the apply with
|
||||
/// `no such column`, silently disabling the stats sink), get the column
|
||||
/// added by `MIGRATIONS`, and then accept writes.
|
||||
#[test]
|
||||
fn open_upgrades_pre_session_db_and_writes() {
|
||||
let path = std::env::temp_dir().join("hyperhive-turnstats-pre-session-regression.sqlite");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
seed_pre_session_db(&path);
|
||||
|
||||
let stats = TurnStats::open(&path).expect("open() must succeed on a pre-session db");
|
||||
stats.record(&sample_row());
|
||||
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
let rows: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM turn_stats", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(rows, 1, "the row must be written once the column is added");
|
||||
let has_session_id: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('turn_stats') \
|
||||
WHERE name = 'session_id'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(has_session_id, 1, "MIGRATIONS must add session_id");
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
133
hive-agent/src/vacuum.rs
Normal file
133
hive-agent/src/vacuum.rs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
//! Agent-side cleanup of this agent's own harness artifacts: completed
|
||||
//! bash-task files and verbose `stream` event rows.
|
||||
//!
|
||||
//! Runs IN the harness (not host-side in hive-c0re) because the files are
|
||||
//! owned by the agent user. Under privsep hive-c0re runs as the unprivileged
|
||||
//! `hive-core` user and cannot delete agent-owned files — the old host-side
|
||||
//! sweeps hit `PermissionDenied` on the bash-task trio and an
|
||||
//! attempt-to-write-a-readonly-database error on `events.sqlite`. The harness
|
||||
//! owns these paths, so the deletes succeed here.
|
||||
//!
|
||||
//! Trade-off (accepted — issue tracker "perms borked"): a misbehaving harness
|
||||
//! could skip its own cleanup, which the host-side version was meant to
|
||||
//! prevent. But a compromised harness is already inside the container trust
|
||||
//! boundary (`docs/security.md`), and these are ephemeral local artifacts — so
|
||||
//! the honest fix is to clean them up where they live.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use hive_sh4re::wire_time::now_unix;
|
||||
use rusqlite::{Connection, Result, params};
|
||||
|
||||
/// How often the sweep runs.
|
||||
const VACUUM_INTERVAL: Duration = Duration::from_hours(1);
|
||||
/// Keep completed bash-task files this long before deleting their trio.
|
||||
const BASH_KEEP_SECS: i64 = 48 * 3600;
|
||||
/// Keep verbose `stream` event rows this long before pruning. Other event
|
||||
/// kinds are never deleted by this sweep — they carry the semantic per-turn
|
||||
/// history the operator scrolls back through.
|
||||
const STREAM_KEEP_SECS: i64 = 14 * 24 * 3600;
|
||||
/// Terminal bash-task statuses whose files are eligible for deletion.
|
||||
const TERMINAL_STATUSES: &[&str] = &["done", "timed_out", "interrupted"];
|
||||
|
||||
/// Background loop: hourly, prune this agent's stale bash-task files and
|
||||
/// verbose event rows. Detached task — runs for the harness's lifetime;
|
||||
/// errors are logged, never fatal.
|
||||
pub async fn run() {
|
||||
loop {
|
||||
sweep_once();
|
||||
tokio::time::sleep(VACUUM_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn sweep_once() {
|
||||
let harness = crate::paths::harness_dir();
|
||||
|
||||
let tasks_dir = harness.join("bash-tasks");
|
||||
if tasks_dir.is_dir() {
|
||||
let removed = vacuum_bash_tasks(&tasks_dir, now_unix() - BASH_KEEP_SECS);
|
||||
if removed > 0 {
|
||||
tracing::info!(removed, "bash-tasks vacuum");
|
||||
}
|
||||
}
|
||||
|
||||
let events_db = harness.join("hyperhive-events.sqlite");
|
||||
if events_db.exists() {
|
||||
match vacuum_events(&events_db) {
|
||||
Ok(0) => {}
|
||||
Ok(n) => tracing::info!(removed = n, "events vacuum"),
|
||||
Err(e) => tracing::warn!(error = ?e, "events vacuum failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete eligible bash-task trios in `dir`. Returns the count of `.json`
|
||||
/// sentinels removed (each represents one task; `.out`/`.err` deletions are
|
||||
/// not counted separately).
|
||||
fn vacuum_bash_tasks(dir: &Path, cutoff: i64) -> u64 {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else {
|
||||
return 0;
|
||||
};
|
||||
let mut removed: u64 = 0;
|
||||
for entry in rd.flatten() {
|
||||
let path = entry.path();
|
||||
// Only process the .json sentinel; derive sibling paths from it.
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let Some(stem) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
|
||||
continue;
|
||||
};
|
||||
if should_delete(&path, cutoff) {
|
||||
delete_trio(dir, &stem);
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// Return `true` when the task file has a terminal status and a
|
||||
/// `completed_at` older than `cutoff`.
|
||||
fn should_delete(json_path: &Path, cutoff: i64) -> bool {
|
||||
let Ok(raw) = std::fs::read_to_string(json_path) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) else {
|
||||
return false;
|
||||
};
|
||||
let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
|
||||
if !TERMINAL_STATUSES.contains(&status) {
|
||||
return false;
|
||||
}
|
||||
let completed_at = v
|
||||
.get("completed_at")
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.unwrap_or(i64::MAX);
|
||||
completed_at < cutoff
|
||||
}
|
||||
|
||||
/// Delete the `.json`, `.out`, and `.err` files for a task. Errors are
|
||||
/// logged but do not abort the sweep.
|
||||
fn delete_trio(dir: &Path, stem: &str) {
|
||||
for ext in ["json", "out", "err"] {
|
||||
let path = dir.join(format!("{stem}.{ext}"));
|
||||
if path.exists()
|
||||
&& let Err(e) = std::fs::remove_file(&path)
|
||||
{
|
||||
tracing::warn!(path = %path.display(), error = ?e, "bash-tasks vacuum: remove failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prune verbose `stream` event rows older than [`STREAM_KEEP_SECS`] from the
|
||||
/// agent's `events.sqlite`. Returns the number of rows deleted.
|
||||
fn vacuum_events(path: &Path) -> Result<u64> {
|
||||
let conn = Connection::open(path)?;
|
||||
let cutoff = now_unix() - STREAM_KEEP_SECS;
|
||||
let removed = conn.execute(
|
||||
"DELETE FROM events WHERE kind = 'stream' AND ts < ?1",
|
||||
params![cutoff],
|
||||
)?;
|
||||
Ok(u64::try_from(removed).unwrap_or(0))
|
||||
}
|
||||
157
hive-agent/src/web_ui/actions.rs
Normal file
157
hive-agent/src/web_ui/actions.rs
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
//! Operator action POST handlers (send, cancel, compact, model, effort, reset).
|
||||
|
||||
use axum::{
|
||||
Form,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::{AppState, error_response};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct SendForm {
|
||||
body: String,
|
||||
}
|
||||
|
||||
pub(super) async fn post_send(
|
||||
State(state): State<AppState>,
|
||||
Form(form): Form<SendForm>,
|
||||
) -> Response {
|
||||
let body = form.body.trim().to_owned();
|
||||
if body.is_empty() {
|
||||
return error_response(StatusCode::BAD_REQUEST, "send: `body` required");
|
||||
}
|
||||
match super::broker_request(&state.socket, &hive_sh4re::Request::OperatorMsg { body }).await {
|
||||
// 200 instead of 303 → the client doesn't refetch /api/state.
|
||||
// The operator message becomes a broker `Sent` (already shown
|
||||
// server-side in the dashboard); on the agent side, the
|
||||
// resulting `TurnStart` SSE event drives the terminal + the
|
||||
// inbox row gets consumed by the time `TurnEnd` fires the
|
||||
// existing turn-end refresh.
|
||||
Ok(hive_sh4re::Response::Ok) => (axum::http::StatusCode::OK, "ok").into_response(),
|
||||
Ok(hive_sh4re::Response::Err { message }) => error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("send failed: {message}"),
|
||||
),
|
||||
Ok(other) => error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("send failed: unexpected response: {other:?}"),
|
||||
),
|
||||
Err(e) => super::broker_error_response(&e, "send"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn post_cancel_turn(State(state): State<AppState>) -> Response {
|
||||
let out = super::sigint_claude().await;
|
||||
let note = match out {
|
||||
Ok(o) if o.status.success() => "operator: /cancel — sent SIGINT to claude".to_owned(),
|
||||
Ok(o) if o.status.code() == Some(1) => {
|
||||
"operator: /cancel — no claude process to interrupt".to_owned()
|
||||
}
|
||||
Ok(o) => format!(
|
||||
"operator: /cancel — pkill exited {} stderr={}",
|
||||
o.status,
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
),
|
||||
Err(e) => format!("operator: /cancel — pkill failed: {e}"),
|
||||
};
|
||||
state
|
||||
.bus
|
||||
.emit(crate::events::LiveEvent::Note { text: note });
|
||||
(axum::http::StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
||||
/// Operator-initiated `/compact`. Deferred: sets the `compact_pending` flag
|
||||
/// that `turn::drive_turn` consumes at the end of the current/next turn, so it
|
||||
/// works while a turn is in flight (a mid-turn compaction would race the live
|
||||
/// claude process) rather than only when the agent is idle. Returns 200
|
||||
/// immediately; the compaction stream lands in the live panel when it runs.
|
||||
pub(super) async fn post_compact(State(state): State<AppState>) -> Response {
|
||||
state.bus.request_compact();
|
||||
state.bus.emit(crate::events::LiveEvent::Note {
|
||||
text: "operator: /compact queued — runs at the end of the current turn".into(),
|
||||
});
|
||||
(axum::http::StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
||||
/// Request a session reset. The current session is archived (its backing
|
||||
/// `<uuid>.jsonl` renamed out of claude's resolution glob) at the next turn
|
||||
/// boundary, so the following turn's `--resume` misses and self-heals into a
|
||||
/// freshly-named session. History is preserved on disk, not deleted.
|
||||
///
|
||||
/// Deferred (a one-shot flag consumed by `drive_turn`) rather than applied
|
||||
/// here: renaming the session file while a claude turn is mid-write would
|
||||
/// race the live process. Between turns there is no open session file (one
|
||||
/// claude per container, serialized by the serve loop), so the archive is
|
||||
/// safe there. Useful when the session-resume context is poisoned (claude
|
||||
/// went off the rails, hit an unrecoverable refusal, etc.) and a full reset
|
||||
/// is cheaper than asking claude to forget mid-stream.
|
||||
pub(super) async fn post_new_session(State(state): State<AppState>) -> Response {
|
||||
state.bus.request_session_reset();
|
||||
state.bus.emit(crate::events::LiveEvent::Note {
|
||||
text: "operator: session reset queued — takes effect at the next turn".into(),
|
||||
});
|
||||
(axum::http::StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct ModelForm {
|
||||
model: String,
|
||||
}
|
||||
|
||||
/// Switch the model for future turns. The current turn (if any)
|
||||
/// keeps its model; `/model <name>` applies starting with the next
|
||||
/// `recv` cycle. Empty / whitespace-only inputs are rejected. No
|
||||
/// claude-side validation — we just hand the string through to
|
||||
/// `claude --model <name>`; an unknown model surfaces as a turn
|
||||
/// failure in the live panel and the operator can revert.
|
||||
pub(super) async fn post_set_model(
|
||||
State(state): State<AppState>,
|
||||
Form(form): Form<ModelForm>,
|
||||
) -> Response {
|
||||
let name = form.model.trim();
|
||||
if name.is_empty() {
|
||||
return error_response(StatusCode::BAD_REQUEST, "model: name required");
|
||||
}
|
||||
state.bus.set_model(name);
|
||||
state.bus.emit(crate::events::LiveEvent::Note {
|
||||
text: format!("operator: /model — claude model set to '{name}' for future turns"),
|
||||
});
|
||||
tracing::info!(%name, "operator set model");
|
||||
(axum::http::StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct EffortForm {
|
||||
effort: String,
|
||||
}
|
||||
|
||||
/// Switch the claude effort level for future sessions. Operator-only
|
||||
/// (the dashboard picker POSTs here through the gateway). Validated
|
||||
/// server-side against [`crate::harness_state::EFFORT_LEVELS`] — an out-of-set
|
||||
/// value is rejected rather than handed to `claude --effort`, since an
|
||||
/// unknown level would fail every subsequent launch. Applies on the next
|
||||
/// session start (no mid-session swap).
|
||||
pub(super) async fn post_set_effort(
|
||||
State(state): State<AppState>,
|
||||
Form(form): Form<EffortForm>,
|
||||
) -> Response {
|
||||
let level = form.effort.trim();
|
||||
if !crate::harness_state::is_valid_effort(level) {
|
||||
return error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!(
|
||||
"effort: level must be one of {}",
|
||||
crate::harness_state::EFFORT_LEVELS.join(", ")
|
||||
),
|
||||
);
|
||||
}
|
||||
state.bus.set_effort(level);
|
||||
state.bus.emit(crate::events::LiveEvent::Note {
|
||||
text: format!("operator: /effort — claude effort set to '{level}' for future sessions"),
|
||||
});
|
||||
tracing::info!(%level, "operator set effort");
|
||||
(axum::http::StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
117
hive-agent/src/web_ui/auth.rs
Normal file
117
hive-agent/src/web_ui/auth.rs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
//! Login / logout flow handlers (`/login/*`, `/api/logout`).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Form,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::login::LoginState;
|
||||
use crate::login_session::{LoginSession, drop_if_finished};
|
||||
|
||||
use super::{AppState, error_response};
|
||||
|
||||
pub(super) async fn post_login_start(State(state): State<AppState>) -> Response {
|
||||
drop_if_finished(&state.session);
|
||||
{
|
||||
let guard = state.session.lock().unwrap();
|
||||
if guard.is_some() {
|
||||
return (axum::http::StatusCode::OK, "ok").into_response();
|
||||
}
|
||||
}
|
||||
match LoginSession::start() {
|
||||
Ok(session) => {
|
||||
*state.session.lock().unwrap() = Some(Arc::new(session));
|
||||
// Flip status from needs_login_idle → needs_login_in_progress
|
||||
// so the web UI's badge + polling kick in (polling is still
|
||||
// the right tool for the streaming session output during
|
||||
// the login flow itself; events drop the poll for
|
||||
// *everything else*).
|
||||
state.bus.emit_status("needs_login_in_progress");
|
||||
(axum::http::StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
Err(e) => error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("login start failed: {e:#}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct CodeForm {
|
||||
code: String,
|
||||
}
|
||||
|
||||
pub(super) async fn post_login_code(
|
||||
State(state): State<AppState>,
|
||||
Form(form): Form<CodeForm>,
|
||||
) -> Response {
|
||||
let session = state.session.lock().unwrap().clone();
|
||||
let Some(session) = session else {
|
||||
return error_response(StatusCode::CONFLICT, "no login session running");
|
||||
};
|
||||
if let Err(e) = session.submit_code(&form.code).await {
|
||||
return error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("submit code failed: {e:#}"),
|
||||
);
|
||||
}
|
||||
(axum::http::StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
||||
pub(super) async fn post_login_cancel(State(state): State<AppState>) -> Response {
|
||||
let session = state.session.lock().unwrap().take();
|
||||
if let Some(session) = session {
|
||||
session.close_stdin().await;
|
||||
session.kill();
|
||||
}
|
||||
// Back to needs_login_idle (LoginState unchanged, session gone).
|
||||
state.bus.emit_status("needs_login_idle");
|
||||
(axum::http::StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
||||
/// Operator-driven `/logout`: SIGINT claude, delete the credential
|
||||
/// files (via [`crate::login::clear_session`]), flip `LoginState::NeedsLogin`.
|
||||
/// The turn loop's next iteration parks into `wait_for_login` which
|
||||
/// resumes when a fresh credentials file appears via `/login/code`.
|
||||
/// Always returns 200 with a body describing what happened. See
|
||||
/// [`docs/web-ui/agent.md::Per-agent endpoints`](../../../docs/web-ui/agent.md)
|
||||
/// (the `/api/logout` bullet) for the three-step rationale +
|
||||
/// preservation invariants.
|
||||
pub(super) async fn post_logout(State(state): State<AppState>) -> Response {
|
||||
// Step 1: SIGINT claude (best-effort, matches `post_cancel_turn`).
|
||||
let _ = super::sigint_claude().await;
|
||||
// Step 2: delete OAuth credential files only — login::clear_session owns
|
||||
// the file set and preserves session-history files alongside them.
|
||||
let dir = crate::paths::claude_dir();
|
||||
let cleared = crate::login::clear_session(&dir).await;
|
||||
let wipe_summary = if cleared.wiped.is_empty() {
|
||||
"no credential files present (already logged out)".to_owned()
|
||||
} else {
|
||||
format!("wiped {}", cleared.wiped.join(", "))
|
||||
};
|
||||
let warn_suffix = if cleared.warnings.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" (warnings: {})", cleared.warnings.join("; "))
|
||||
};
|
||||
// Step 3: flip LoginState + emit Note. Turn loop sees the flip on
|
||||
// its next iteration and parks into wait_for_login.
|
||||
*state.login.lock().unwrap() = LoginState::NeedsLogin;
|
||||
state.bus.emit(crate::events::LiveEvent::Note {
|
||||
text: format!(
|
||||
"operator: /logout — {wipe_summary} in {}{warn_suffix}",
|
||||
dir.display()
|
||||
),
|
||||
});
|
||||
state.bus.emit_status("needs_login_idle");
|
||||
(
|
||||
axum::http::StatusCode::OK,
|
||||
format!("ok: {wipe_summary} in {}{warn_suffix}", dir.display()),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
333
hive-agent/src/web_ui/mod.rs
Normal file
333
hive-agent/src/web_ui/mod.rs
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
//! Per-container HTTP UI. SPA shape: `GET /` returns a static shell;
|
||||
//! `GET /static/*` serves CSS + JS; `GET /api/state` returns the page
|
||||
//! state as JSON; the JS app renders. Live events stream on
|
||||
//! `/events/stream`. Action POSTs (`/send`, `/login/*`) return either a
|
||||
//! 303 Redirect (for browsers that submit the form normally) or just
|
||||
//! 200 OK — the JS app re-fetches `/api/state` afterwards.
|
||||
//!
|
||||
//! Handlers are split by concern into the submodules below; this file owns the
|
||||
//! shared [`AppState`], the listener + router wiring in [`serve`], and a couple
|
||||
//! of small shared helpers ([`error_response`], [`SOCKET_FETCH_TIMEOUT`]).
|
||||
|
||||
mod actions;
|
||||
mod auth;
|
||||
mod proxy;
|
||||
mod screen;
|
||||
mod state;
|
||||
mod stats;
|
||||
mod stream;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use axum::{
|
||||
Router,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
};
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
use crate::events::Bus;
|
||||
use crate::login::LoginState;
|
||||
use crate::login_session::LoginSession;
|
||||
|
||||
/// Deadline for broker-backed fetches on web-UI request paths. The
|
||||
/// page's critical fields (status, turn state, usage) are all
|
||||
/// in-memory; a busy or stalled hive-c0re must degrade the
|
||||
/// socket-backed extras (inbox rows, loose ends, reminder stats)
|
||||
/// instead of hanging the whole response — an unbounded await here is
|
||||
/// what let `/api/state` stall long enough to bork the terminal.
|
||||
const SOCKET_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
||||
|
||||
/// Live login state for the web UI. The harness updates this in place as it
|
||||
/// transitions between `NeedsLogin` and `Online`; the UI reads on each
|
||||
/// render.
|
||||
pub type LoginStateCell = Arc<Mutex<LoginState>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
label: String,
|
||||
login: LoginStateCell,
|
||||
session: Arc<Mutex<Option<Arc<LoginSession>>>>,
|
||||
bus: Bus,
|
||||
socket: PathBuf,
|
||||
/// VNC port from the `HIVE_GUI_VNC_PORT` env var at startup.
|
||||
/// `None` when unset (gui not enabled for this agent).
|
||||
gui_vnc_port: Option<u16>,
|
||||
}
|
||||
|
||||
/// Bind the per-container web listener and serve the SPA.
|
||||
///
|
||||
/// `HIVE_WEB_SOCKET` opt-in selects unix-socket vs TCP binding; the
|
||||
/// dual-mode transition + gateway-side consumer live in
|
||||
/// [`docs/web-ui/shape.md::Listener bind`](../../../docs/web-ui/shape.md) and
|
||||
/// [`docs/gateway.md::Per-agent unix-socket upstream`](../../../docs/gateway.md).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if neither the TCP listener (default) nor the
|
||||
/// unix-socket bind (`HIVE_WEB_SOCKET`, if set) can be acquired, or
|
||||
/// if `HIVE_STATIC_DIR` is missing.
|
||||
pub async fn serve(
|
||||
label: String,
|
||||
port: u16,
|
||||
login: LoginStateCell,
|
||||
bus: Bus,
|
||||
socket: PathBuf,
|
||||
) -> Result<()> {
|
||||
let gui_vnc_port = read_gui_vnc_port();
|
||||
let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR")
|
||||
.map(PathBuf::from)
|
||||
.context(
|
||||
"HIVE_STATIC_DIR env var not set — point it at the merged \
|
||||
per-agent dist (see hyperhive.frontend.mergedDist in nix)",
|
||||
)?;
|
||||
if !static_dir.is_dir() {
|
||||
anyhow::bail!(
|
||||
"HIVE_STATIC_DIR ({}) is not a directory",
|
||||
static_dir.display()
|
||||
);
|
||||
}
|
||||
tracing::info!(static_dir = %static_dir.display(), "web UI static dir resolved");
|
||||
let state = AppState {
|
||||
label,
|
||||
login,
|
||||
session: Arc::new(Mutex::new(None)),
|
||||
bus,
|
||||
socket,
|
||||
gui_vnc_port,
|
||||
};
|
||||
let app: Router<AppState> = Router::new()
|
||||
.route("/api/state", get(state::api_state))
|
||||
.route("/api/dashboard-state", get(state::api_dashboard_state))
|
||||
.route("/events/stream", get(stream::events_stream))
|
||||
.route("/events/history", get(stream::events_history))
|
||||
.route("/send", post(actions::post_send))
|
||||
.route("/login/start", post(auth::post_login_start))
|
||||
.route("/login/code", post(auth::post_login_code))
|
||||
.route("/login/cancel", post(auth::post_login_cancel))
|
||||
.route("/api/cancel", post(actions::post_cancel_turn))
|
||||
.route("/api/compact", post(actions::post_compact))
|
||||
.route("/api/model", post(actions::post_set_model))
|
||||
.route("/api/effort", post(actions::post_set_effort))
|
||||
.route("/api/new-session", post(actions::post_new_session))
|
||||
.route("/api/logout", post(auth::post_logout))
|
||||
.route("/api/loose-ends", get(stats::api_loose_ends))
|
||||
.route("/api/bash-tasks", get(stats::api_bash_tasks))
|
||||
.route("/api/stats", get(stats::api_stats))
|
||||
.route("/screen/ws", get(screen::screen_ws))
|
||||
.route("/icon", get(screen::serve_icon));
|
||||
// Mount any `hyperhive.extraWebProxies` under `/extra/<name>/` before the
|
||||
// static fallback so declared proxies win over `ServeDir`.
|
||||
let app = proxy::mount_extra_proxies(app)
|
||||
// Anything else (`/`, `/stats`, `/screen`, `/static/*`)
|
||||
// falls through to the merged dist. ServeDir auto-appends
|
||||
// `.html` when the URL is a bare path that matches a file
|
||||
// (so `/stats` → `dist/stats.html`, `/screen` → `dist/
|
||||
// screen.html`). Per-agent `extraFiles` additions are
|
||||
// already layered into this same directory (see
|
||||
// hyperhive.frontend.mergedDist in nix).
|
||||
.fallback_service(ServeDir::new(&static_dir))
|
||||
.with_state(state);
|
||||
// `HIVE_WEB_SOCKET` opt-in: when set + non-empty, bind a
|
||||
// `UnixListener` at the given path. Empty string treated as
|
||||
// unset so a stray `HIVE_WEB_SOCKET=` doesn't trap us into an
|
||||
// un-bindable empty path. Falls through to the TCP path below
|
||||
// otherwise. See docs/gateway.md::Per-agent unix-socket upstream
|
||||
// for the gateway-side consumer.
|
||||
if let Some(socket_path) = std::env::var_os("HIVE_WEB_SOCKET")
|
||||
&& !socket_path.is_empty()
|
||||
{
|
||||
let path = PathBuf::from(socket_path);
|
||||
let listener = bind_unix(&path)?;
|
||||
tracing::info!(socket = %path.display(), "web UI listening on unix socket");
|
||||
axum::serve(listener, app).await?;
|
||||
return Ok(());
|
||||
}
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||
let listener = bind_with_retry(addr, "web UI").await?;
|
||||
tracing::info!(%port, "web UI listening on tcp");
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bind a `UnixListener` at `path` and drop a `.bound` marker next
|
||||
/// to it so c0re's gateway-map writer knows the socket is live.
|
||||
/// Best-effort unlinks any stale socket left from a crashed previous
|
||||
/// harness (clean exit removes it, but `bind(2)` refuses to overwrite
|
||||
/// an existing file) and `mkdir -p`s the parent for first-boot. Mode
|
||||
/// `0o666` — world-accessible so the gateway container's nginx process
|
||||
/// can `connect(2)` without sharing a group with the agent user.
|
||||
/// The per-agent subdir (`/run/hive-agent/<name>/`) is only accessible
|
||||
/// to containers that have it bind-mounted, so world-accessible sockets
|
||||
/// are not a material risk.
|
||||
///
|
||||
/// Marker-gating + the gateway-side consumer: see
|
||||
/// [`docs/gateway.md::Per-agent unix-socket upstream`](../../../docs/gateway.md).
|
||||
fn bind_unix(path: &Path) -> Result<tokio::net::UnixListener> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("create socket parent dir {}", parent.display()))?;
|
||||
}
|
||||
// Best-effort: ENOENT is fine (no stale file); any other error
|
||||
// surfaces via the bind below with a clearer "AddrInUse" / perms
|
||||
// message than a partial cleanup would.
|
||||
let _ = std::fs::remove_file(path);
|
||||
let listener = tokio::net::UnixListener::bind(path)
|
||||
.with_context(|| format!("bind unix socket at {}", path.display()))?;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o666))
|
||||
.with_context(|| format!("set perms on {}", path.display()))?;
|
||||
// Best-effort ready marker: failed write isn't fatal (the harness
|
||||
// still binds + serves), it just means the gateway side keeps the
|
||||
// TCP upstream for one more sync tick.
|
||||
if let Some(parent) = path.parent() {
|
||||
let marker = parent.join("hyperhive-socket-bound");
|
||||
if let Err(e) = std::fs::write(&marker, b"") {
|
||||
tracing::warn!(
|
||||
marker = %marker.display(), error = %e,
|
||||
"failed to write hyperhive-socket-bound marker — gateway may keep TCP upstream"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(listener)
|
||||
}
|
||||
|
||||
/// Maximum bind attempts before `bind_with_retry` gives up on `AddrInUse`.
|
||||
const MAX_BIND_ATTEMPTS: u32 = 12;
|
||||
|
||||
/// Bind a TCP listener with `SO_REUSEADDR` set, retrying on `AddrInUse` with
|
||||
/// exponential backoff capped at 2s, up to [`MAX_BIND_ATTEMPTS`] attempts. If
|
||||
/// the port is still held after the final attempt, returns the `AddrInUse`
|
||||
/// error rather than looping forever (a genuine collision needs the operator,
|
||||
/// not an unbounded wait).
|
||||
///
|
||||
/// Retry rationale + dashboard-banner-on-real-collision:
|
||||
/// see [`docs/web-ui/shape.md::Listener bind`](../../../docs/web-ui/shape.md).
|
||||
async fn bind_with_retry(addr: SocketAddr, label: &str) -> Result<tokio::net::TcpListener> {
|
||||
let mut delay_ms = 250u64;
|
||||
let mut attempts = 0u32;
|
||||
loop {
|
||||
match try_bind(addr) {
|
||||
Ok(l) => {
|
||||
if attempts > 0 {
|
||||
tracing::info!(
|
||||
%addr, attempts,
|
||||
"{label}: bind succeeded after retry"
|
||||
);
|
||||
}
|
||||
return Ok(l);
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
|
||||
let attempt = attempts + 1;
|
||||
if attempt >= MAX_BIND_ATTEMPTS {
|
||||
return Err(e).with_context(|| {
|
||||
format!("bind {label} on {addr}: still AddrInUse after {attempt} attempts")
|
||||
});
|
||||
}
|
||||
tracing::warn!(
|
||||
%addr, attempt,
|
||||
"{label}: AddrInUse, retrying in {delay_ms}ms"
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
|
||||
attempts += 1;
|
||||
delay_ms = (delay_ms * 2).min(2000);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(e).with_context(|| format!("bind {label} on {addr}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_bind(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
|
||||
let sock = match addr {
|
||||
SocketAddr::V4(_) => tokio::net::TcpSocket::new_v4()?,
|
||||
SocketAddr::V6(_) => tokio::net::TcpSocket::new_v6()?,
|
||||
};
|
||||
sock.set_reuseaddr(true)?;
|
||||
sock.bind(addr)?;
|
||||
sock.listen(1024)
|
||||
}
|
||||
|
||||
/// The fixed VNC port weston bound, from the `HIVE_GUI_VNC_PORT` env var
|
||||
/// the harness service sets when gui is enabled (see weston-vnc.nix).
|
||||
/// `None` when unset (gui not enabled for this agent) or unparseable.
|
||||
/// The port is a fixed, container-local value — no per-agent hashing, no
|
||||
/// marker file — because network isolation is unconditional (each agent
|
||||
/// has its own netns, so the port can't collide across containers).
|
||||
fn read_gui_vnc_port() -> Option<u16> {
|
||||
std::env::var("HIVE_GUI_VNC_PORT").ok()?.parse().ok()
|
||||
}
|
||||
|
||||
/// SIGINT any running `claude` process in this container (best-effort). Shared
|
||||
/// by `/api/cancel` and `/api/logout`. Returns the `pkill` `Output` so callers
|
||||
/// can inspect the exit status (0 = signalled, 1 = no process matched) or
|
||||
/// ignore it.
|
||||
async fn sigint_claude() -> std::io::Result<std::process::Output> {
|
||||
tokio::process::Command::new("pkill")
|
||||
.args(["-INT", "claude"])
|
||||
.output()
|
||||
.await
|
||||
}
|
||||
|
||||
fn error_response(status: StatusCode, message: &str) -> Response {
|
||||
// Plain text — JS app surfaces in `alert()`, HTML wrapping would just
|
||||
// be noise. Status is per-caller: 400 for bad input, 409 for a
|
||||
// retryable state conflict (turn in flight / hive-c0re busy), 500 only
|
||||
// for a genuine server/transport failure — the frontend shows the code
|
||||
// in its alert, so a benign "busy, retry" must not read as a 500.
|
||||
(status, message.to_owned()).into_response()
|
||||
}
|
||||
|
||||
/// Why a deadline-bounded broker request via the per-agent socket didn't
|
||||
/// yield a response. Kept distinct so action handlers pick the right status
|
||||
/// code (see [`broker_error_response`]) while decorative fetches `.ok()` both.
|
||||
enum BrokerError {
|
||||
/// Outran [`SOCKET_FETCH_TIMEOUT`] — hive-c0re is busy or stalled. A
|
||||
/// retryable state conflict (→ 409), not a server fault.
|
||||
Timeout,
|
||||
/// The socket transport itself failed (connect / encode / decode).
|
||||
Transport(anyhow::Error),
|
||||
}
|
||||
|
||||
/// Issue a broker request over the per-agent socket, bounded by
|
||||
/// [`SOCKET_FETCH_TIMEOUT`] so a busy or stalled hive-c0re degrades the
|
||||
/// response instead of hanging it. Callers match the returned [`Response`]
|
||||
/// variant themselves; the error side distinguishes a retryable timeout from
|
||||
/// a transport failure. This is the one shared broker-call scaffold — every
|
||||
/// web-UI handler that talks to the broker goes through it.
|
||||
async fn broker_request(
|
||||
socket: &Path,
|
||||
req: &hive_sh4re::Request,
|
||||
) -> std::result::Result<hive_sh4re::Response, BrokerError> {
|
||||
match tokio::time::timeout(
|
||||
SOCKET_FETCH_TIMEOUT,
|
||||
crate::client::request::<_, hive_sh4re::Response>(socket, req),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(resp)) => Ok(resp),
|
||||
Ok(Err(e)) => Err(BrokerError::Transport(e)),
|
||||
Err(_) => Err(BrokerError::Timeout),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a [`BrokerError`] to an operator-facing error response: a timeout is a
|
||||
/// retryable "busy" conflict (409), a transport failure is a 500. `action`
|
||||
/// prefixes the message (e.g. `"send"`, `"get_loose_ends"`).
|
||||
fn broker_error_response(err: &BrokerError, action: &str) -> Response {
|
||||
match err {
|
||||
BrokerError::Timeout => error_response(
|
||||
StatusCode::CONFLICT,
|
||||
&format!("{action}: timed out — hive-c0re busy, retry"),
|
||||
),
|
||||
BrokerError::Transport(e) => error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("{action}: transport: {e:#}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
255
hive-agent/src/web_ui/proxy.rs
Normal file
255
hive-agent/src/web_ui/proxy.rs
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
//! Extra web proxies (`HIVE_EXTRA_WEB_PROXIES` / `hyperhive.extraWebProxies`).
|
||||
//!
|
||||
//! Each declared entry mounts a transparent reverse-proxy at `/extra/<name>/`
|
||||
//! in the per-agent web UI, forwarding every request (method, headers, body)
|
||||
//! to the configured upstream. The `/extra/` namespace keeps user-declared
|
||||
//! proxies from ever colliding with the native agent endpoints (`/api/*`,
|
||||
//! `/events/*`, …). Response bodies are buffered whole (MVP): an SSE upstream
|
||||
//! appears as one large response rather than streaming.
|
||||
//!
|
||||
//! Upstreams are either a regular `http(s)://` URL (forwarded via `reqwest`)
|
||||
//! or a Unix domain socket, spelled `unix:<path>` (e.g.
|
||||
//! `unix:/run/myapp/http.sock`) — forwarded via a raw hyper/1.1 client
|
||||
//! dialing the socket directly, since `reqwest` has no UDS transport.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
body::Bytes,
|
||||
extract::State,
|
||||
http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use http_body_util::{BodyExt, Full};
|
||||
use hyper_util::rt::TokioIo;
|
||||
|
||||
use super::AppState;
|
||||
|
||||
/// Hop-by-hop headers stripped on both the request and response side — they
|
||||
/// describe a single transport hop and must not be forwarded to the upstream
|
||||
/// or back to the client.
|
||||
const HOP_BY_HOP: [&str; 6] = [
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"transfer-encoding",
|
||||
"te",
|
||||
"trailer",
|
||||
"upgrade",
|
||||
];
|
||||
|
||||
/// Nest every proxy declared in `HIVE_EXTRA_WEB_PROXIES` (a JSON object
|
||||
/// `{"<name>": "<upstream_url>"}`) under `/extra/<name>/` on `app`. Absent /
|
||||
/// blank / invalid JSON is a no-op (logged). Called from [`super::serve`]
|
||||
/// before the static-dir fallback so `/extra/*` wins over `ServeDir`.
|
||||
pub(super) fn mount_extra_proxies(mut app: Router<AppState>) -> Router<AppState> {
|
||||
let Ok(json) = std::env::var("HIVE_EXTRA_WEB_PROXIES") else {
|
||||
return app;
|
||||
};
|
||||
let Ok(map) = serde_json::from_str::<HashMap<String, String>>(&json) else {
|
||||
tracing::warn!("HIVE_EXTRA_WEB_PROXIES: invalid JSON — ignoring");
|
||||
return app;
|
||||
};
|
||||
for (name, upstream) in &map {
|
||||
if let Some(svc) = extra_proxy_service(upstream) {
|
||||
let mount = format!("/extra/{}", name.trim_matches('/'));
|
||||
tracing::info!(mount, upstream, "mounting extra web proxy");
|
||||
app = app.nest_service(mount.as_str(), svc);
|
||||
}
|
||||
}
|
||||
app
|
||||
}
|
||||
|
||||
/// Build a transparent reverse-proxy service forwarding every request to
|
||||
/// `upstream` — either an `http(s)://` URL or a `unix:<path>` Unix domain
|
||||
/// socket. The caller nests it at a path prefix; axum strips the prefix
|
||||
/// before the service sees the request. Returns `None` when `upstream` is
|
||||
/// blank or the underlying client won't build.
|
||||
fn extra_proxy_service(upstream: &str) -> Option<Router<()>> {
|
||||
let upstream = upstream.trim();
|
||||
if upstream.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(sock_path) = upstream.strip_prefix("unix:") {
|
||||
let sock_path = sock_path.trim();
|
||||
if sock_path.is_empty() {
|
||||
tracing::warn!("extra proxy: `unix:` upstream has an empty socket path — ignoring");
|
||||
return None;
|
||||
}
|
||||
return Some(
|
||||
Router::new()
|
||||
.fallback(unix_proxy_handler)
|
||||
.with_state(Arc::new(PathBuf::from(sock_path))),
|
||||
);
|
||||
}
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => Arc::new(c),
|
||||
Err(e) => {
|
||||
tracing::warn!("extra proxy: failed to build reqwest client: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let base = Arc::new(upstream.trim_end_matches('/').to_owned());
|
||||
Some(
|
||||
Router::new()
|
||||
.fallback(proxy_handler)
|
||||
.with_state((client, base)),
|
||||
)
|
||||
}
|
||||
|
||||
/// Forward the request to `{base}{stripped_path_and_query}`, strip hop-by-hop
|
||||
/// headers both ways, buffer the full response body, and return it verbatim.
|
||||
async fn proxy_handler(
|
||||
State((client, base)): State<(Arc<reqwest::Client>, Arc<String>)>,
|
||||
method: Method,
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let path_and_query = uri
|
||||
.path_and_query()
|
||||
.map_or("/", axum::http::uri::PathAndQuery::as_str);
|
||||
let url = format!("{base}{path_and_query}");
|
||||
|
||||
let mut req_headers = headers;
|
||||
for h in HOP_BY_HOP {
|
||||
req_headers.remove(h);
|
||||
}
|
||||
|
||||
let upstream_resp = match client
|
||||
.request(
|
||||
reqwest::Method::from_bytes(method.as_str().as_bytes()).unwrap_or(reqwest::Method::GET),
|
||||
&url,
|
||||
)
|
||||
.headers(
|
||||
req_headers
|
||||
.iter()
|
||||
.filter_map(|(k, v)| {
|
||||
let n = reqwest::header::HeaderName::from_bytes(k.as_str().as_bytes()).ok()?;
|
||||
let v = reqwest::header::HeaderValue::from_bytes(v.as_bytes()).ok()?;
|
||||
Some((n, v))
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
.body(body.to_vec())
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!(url, "proxy: upstream request failed: {e}");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let status =
|
||||
StatusCode::from_u16(upstream_resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
|
||||
let mut resp_headers = HeaderMap::new();
|
||||
for (name, value) in upstream_resp.headers() {
|
||||
if HOP_BY_HOP.contains(&name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if let (Ok(n), Ok(v)) = (
|
||||
HeaderName::from_bytes(name.as_str().as_bytes()),
|
||||
HeaderValue::from_bytes(value.as_bytes()),
|
||||
) {
|
||||
resp_headers.insert(n, v);
|
||||
}
|
||||
}
|
||||
match upstream_resp.bytes().await {
|
||||
Ok(b) => (status, resp_headers, b).into_response(),
|
||||
Err(e) => {
|
||||
tracing::warn!(url, "proxy: failed to read upstream response: {e}");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward the request to `sock_path` over a Unix domain socket, dialing a
|
||||
/// fresh connection per request (MVP — no connection pooling, matching the
|
||||
/// simplicity of the HTTP path above). `reqwest` has no UDS transport, so
|
||||
/// this speaks raw HTTP/1.1 via `hyper`'s low-level client directly over a
|
||||
/// `tokio::net::UnixStream`.
|
||||
async fn unix_proxy_handler(
|
||||
State(sock_path): State<Arc<PathBuf>>,
|
||||
method: Method,
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let path_and_query = uri
|
||||
.path_and_query()
|
||||
.map_or("/", axum::http::uri::PathAndQuery::as_str)
|
||||
.to_owned();
|
||||
|
||||
match forward_over_unix_socket(&sock_path, method, &path_and_query, headers, body).await {
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
socket = %sock_path.display(),
|
||||
path = path_and_query,
|
||||
"extra proxy: unix upstream request failed: {e}"
|
||||
);
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dial `sock_path`, send one HTTP/1.1 request built from the given parts,
|
||||
/// and return the axum `Response` built from whatever comes back. Split out
|
||||
/// of [`unix_proxy_handler`] so the handler can map every failure mode
|
||||
/// (connect, handshake, send, body read) to the same `BAD_GATEWAY` fallback
|
||||
/// with one `?`-chain instead of a matching arm per step.
|
||||
async fn forward_over_unix_socket(
|
||||
sock_path: &Path,
|
||||
method: Method,
|
||||
path_and_query: &str,
|
||||
mut headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> anyhow::Result<Response> {
|
||||
let stream = tokio::net::UnixStream::connect(sock_path).await?;
|
||||
let io = TokioIo::new(stream);
|
||||
|
||||
let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await?;
|
||||
// The connection future drives I/O in the background; drop-and-forget is
|
||||
// fine here since we only ever send one request per dialed socket.
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = conn.await {
|
||||
tracing::debug!("extra proxy: unix connection closed: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
for h in HOP_BY_HOP {
|
||||
headers.remove(h);
|
||||
}
|
||||
// HTTP/1.1 requires a Host header; a UDS peer has no meaningful
|
||||
// hostname, so `localhost` is the conventional placeholder (matches
|
||||
// what tools like `curl --unix-socket` send by default).
|
||||
if !headers.contains_key(hyper::header::HOST) {
|
||||
headers.insert(hyper::header::HOST, HeaderValue::from_static("localhost"));
|
||||
}
|
||||
|
||||
let mut req_builder = hyper::Request::builder().method(method).uri(path_and_query);
|
||||
if let Some(h) = req_builder.headers_mut() {
|
||||
*h = headers;
|
||||
}
|
||||
let req = req_builder.body(Full::new(body))?;
|
||||
|
||||
let upstream_resp = sender.send_request(req).await?;
|
||||
let status = StatusCode::from_u16(upstream_resp.status().as_u16())?;
|
||||
let mut resp_headers = HeaderMap::new();
|
||||
for (name, value) in upstream_resp.headers() {
|
||||
if HOP_BY_HOP.contains(&name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
resp_headers.insert(name.clone(), value.clone());
|
||||
}
|
||||
let body_bytes = upstream_resp.into_body().collect().await?.to_bytes();
|
||||
Ok((status, resp_headers, body_bytes).into_response())
|
||||
}
|
||||
96
hive-agent/src/web_ui/screen.rs
Normal file
96
hive-agent/src/web_ui/screen.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
//! VNC screen websocket relay + agent icon.
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
use super::AppState;
|
||||
|
||||
/// This agent's icon. Serves the operator-configured SVG from
|
||||
/// `/etc/hyperhive/icon.svg` (set via the `hyperhive.icon` agent.nix
|
||||
/// option) when present, otherwise the bundled default hyperhive logo.
|
||||
/// Always returns an image, so consumers (dashboard, favicon) can hit
|
||||
/// `/icon` unconditionally without probing whether one is configured.
|
||||
pub(super) async fn serve_icon() -> impl IntoResponse {
|
||||
// Per-agent icon overrides go through `/etc/hyperhive/icon.svg`
|
||||
// (set via the `hyperhive.icon` agent.nix option); the bundled
|
||||
// default is resolved at runtime from
|
||||
// `$HIVE_ASSETS_DIR/branding/hyperhive.svg`. If neither file can
|
||||
// be read we serve an empty body — keeps the response a valid SVG
|
||||
// content-type without a panic on a misconfigured container.
|
||||
let body = std::fs::read_to_string("/etc/hyperhive/icon.svg").unwrap_or_else(|_| {
|
||||
std::fs::read_to_string(hive_sh4re::assets::branding_svg()).unwrap_or_default()
|
||||
});
|
||||
([("content-type", "image/svg+xml")], body)
|
||||
}
|
||||
|
||||
/// WebSocket handler: upgrade then pump bytes between the WS client and
|
||||
/// the VNC server on `127.0.0.1:<vnc_port>`. Returns 404 when gui is not
|
||||
/// enabled for this agent.
|
||||
pub(super) async fn screen_ws(
|
||||
ws: axum::extract::ws::WebSocketUpgrade,
|
||||
State(state): State<AppState>,
|
||||
) -> Response {
|
||||
let Some(vnc_port) = state.gui_vnc_port else {
|
||||
return (StatusCode::NOT_FOUND, "gui not enabled for this agent").into_response();
|
||||
};
|
||||
ws.on_upgrade(move |socket| relay_ws_vnc(socket, vnc_port))
|
||||
}
|
||||
|
||||
/// Pure byte pump: forwards raw bytes between the WebSocket client and
|
||||
/// the VNC TCP stream. Transparent to any RFB variant (plain, `VeNCrypt`).
|
||||
async fn relay_ws_vnc(socket: axum::extract::ws::WebSocket, vnc_port: u16) {
|
||||
// Import futures traits locally so they don't conflict with
|
||||
// tokio_stream::StreamExt used at module scope.
|
||||
use axum::extract::ws::Message;
|
||||
use futures_util::{SinkExt, StreamExt as _};
|
||||
|
||||
let addr = format!("127.0.0.1:{vnc_port}");
|
||||
let Ok(tcp) = tokio::net::TcpStream::connect(&addr).await else {
|
||||
tracing::warn!(%addr, "screen/ws: could not connect to VNC server");
|
||||
return;
|
||||
};
|
||||
let (mut tcp_rx, mut tcp_tx) = tcp.into_split();
|
||||
let (mut ws_tx, mut ws_rx) = socket.split();
|
||||
|
||||
// WS → TCP
|
||||
let ws_to_tcp = tokio::spawn(async move {
|
||||
while let Some(Ok(msg)) = futures_util::StreamExt::next(&mut ws_rx).await {
|
||||
match msg {
|
||||
Message::Binary(data) if tcp_tx.write_all(&data).await.is_err() => {
|
||||
break;
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => {} // ping/pong/text: ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// TCP → WS
|
||||
let tcp_to_ws = tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
loop {
|
||||
match tcp_rx.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
if ws_tx
|
||||
.send(Message::Binary(buf[..n].to_vec().into()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for either direction to close, then let both tasks drop.
|
||||
tokio::select! {
|
||||
_ = ws_to_tcp => {}
|
||||
_ = tcp_to_ws => {}
|
||||
}
|
||||
}
|
||||
394
hive-agent/src/web_ui/state.rs
Normal file
394
hive-agent/src/web_ui/state.rs
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
//! `/api/state` + `/api/dashboard-state` snapshot builders.
|
||||
|
||||
use axum::extract::State;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::login::LoginState;
|
||||
use crate::login_session::drop_if_finished;
|
||||
|
||||
use super::AppState;
|
||||
|
||||
pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
|
||||
// Capture seq *before* any reads so the dedupe contract is
|
||||
// "events with seq > snapshot.seq are post-snapshot, never missed."
|
||||
let seq = state.bus.current_seq();
|
||||
drop_if_finished(&state.session);
|
||||
let login = *state.login.lock().unwrap();
|
||||
let session_snapshot = state.session.lock().unwrap().clone();
|
||||
let (status, session_view) = match (login, session_snapshot) {
|
||||
(LoginState::Online, _) if state.bus.is_rate_limited() => ("rate_limited", None),
|
||||
(LoginState::Online, _) => ("online", None),
|
||||
(LoginState::NeedsLogin, None) => ("needs_login_idle", None),
|
||||
(LoginState::NeedsLogin, Some(s)) => (
|
||||
"needs_login_in_progress",
|
||||
Some(SessionView {
|
||||
url: s.url(),
|
||||
output: s.output(),
|
||||
finished: s.finished(),
|
||||
exit_note: s.exit_note(),
|
||||
}),
|
||||
),
|
||||
};
|
||||
let dashboard_port = std::env::var("HIVE_DASHBOARD_PORT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u16>().ok())
|
||||
.unwrap_or(7000);
|
||||
let inbox = recent_inbox(&state.socket).await;
|
||||
let (turn_state, turn_state_since) = state.bus.state_snapshot();
|
||||
let model = state.bus.model();
|
||||
let context_window_tokens = state.bus.effective_context_window(&model);
|
||||
let ctx_usage = state.bus.last_ctx_usage();
|
||||
let cost_usage = state.bus.last_cost_usage();
|
||||
let effort = state.bus.effort();
|
||||
axum::Json(StateSnapshot {
|
||||
seq,
|
||||
label: state.label.clone(),
|
||||
qualified_label: crate::identity::qualify(&state.label),
|
||||
dashboard_port,
|
||||
status,
|
||||
session: session_view,
|
||||
inbox,
|
||||
turn_state,
|
||||
turn_state_since,
|
||||
model,
|
||||
context_window_tokens,
|
||||
ctx_usage,
|
||||
cost_usage,
|
||||
links: agent_links(&state.label, state.gui_vnc_port.is_some()),
|
||||
forge_public_url: std::env::var("HIVE_FORGE_PUBLIC_URL")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty()),
|
||||
hive_name: crate::identity::hive_name(),
|
||||
swarm_name: crate::identity::swarm_name(),
|
||||
available_models: available_models(),
|
||||
effort,
|
||||
available_efforts: crate::harness_state::EFFORT_LEVELS
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Lean snapshot of the agent-owned fields that the dashboard card
|
||||
/// needs. Served at `GET /api/dashboard-state` (accessible through the
|
||||
/// gateway at `/agent/<name>/api/dashboard-state`). The dashboard
|
||||
/// fetches this once per running agent to get fresh, agent-authoritative
|
||||
/// values instead of relying on hive-c0re's periodic file-reads.
|
||||
///
|
||||
/// Structural fields (running, `needs_update`, `deployed_sha`, parent, …)
|
||||
/// continue to come from hive-c0re's `/api/state`; this endpoint covers
|
||||
/// only the fields the agent itself is the source of truth for.
|
||||
pub(super) async fn api_dashboard_state(
|
||||
State(state): State<AppState>,
|
||||
) -> axum::Json<DashboardState> {
|
||||
let (status_text, status_set_at) = read_own_status();
|
||||
let rate_limited = state.bus.is_rate_limited();
|
||||
let model = state.bus.model();
|
||||
let context_window_tokens = state.bus.effective_context_window(&model);
|
||||
// Full context-window size = input + cache-read + cache-creation. Using
|
||||
// raw `input_tokens` here reported only the *uncached* sliver, which is
|
||||
// ~0 once prompt caching kicks in — so every card showed `ctx·0k`. Match
|
||||
// the agent page (which ships the whole `ctx_usage` and sums it) and the
|
||||
// cache-TTL logic in turn.rs, both of which use `context_tokens()`.
|
||||
let ctx_tokens = state.bus.last_ctx_usage().map(|u| u.context_tokens());
|
||||
axum::Json(DashboardState {
|
||||
status_text,
|
||||
status_set_at,
|
||||
ctx_tokens,
|
||||
context_window_tokens,
|
||||
rate_limited,
|
||||
links: agent_links(&state.label, state.gui_vnc_port.is_some()),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct StateSnapshot {
|
||||
/// Bus seq at the moment this snapshot was assembled. Clients dedupe
|
||||
/// their buffered SSE traffic against this value: events with
|
||||
/// `seq <= snapshot.seq` are already reflected (or pre-date the
|
||||
/// snapshot); `seq > snapshot.seq` is post-snapshot. Reset to 0 on
|
||||
/// harness restart — clients treat reconnect as a fresh world.
|
||||
seq: u64,
|
||||
label: String,
|
||||
/// Hive-qualified long name (`${label}@${hyperhive.domain}`) when
|
||||
/// the host has been configured for a multi-hive swarm; falls back
|
||||
/// to the short label when the hive domain env var is unset.
|
||||
/// The frontend uses this for the page title / agent self-introduction;
|
||||
/// when it equals `label`, the page renders the short form unchanged.
|
||||
qualified_label: String,
|
||||
dashboard_port: u16,
|
||||
/// `"online"` | `"rate_limited"` | `"needs_login_idle"` | `"needs_login_in_progress"`.
|
||||
status: &'static str,
|
||||
/// Present when `status == "needs_login_in_progress"`.
|
||||
session: Option<SessionView>,
|
||||
/// Last N messages addressed to this agent, newest-first. Pulled
|
||||
/// from the broker via the per-agent socket on each render.
|
||||
/// Empty on transport failure.
|
||||
inbox: Vec<hive_sh4re::InboxRow>,
|
||||
/// Authoritative turn-loop state from the harness and the unix
|
||||
/// timestamp the state was entered. The JS computes the age
|
||||
/// client-side off this rather than tracking it from SSE events.
|
||||
turn_state: crate::events::TurnState,
|
||||
turn_state_since: i64,
|
||||
/// Currently-active claude model name. Reflected on the page so
|
||||
/// the operator can see what they just switched to (and what's
|
||||
/// in flight). Mutable at runtime via `POST /api/model`.
|
||||
model: String,
|
||||
/// Effective context-window token budget for the current model.
|
||||
/// Primary source: API-reported `modelUsage.*.contextWindow` from
|
||||
/// the last result event (authoritative per-inference active window).
|
||||
/// Falls back to `HIVE_CONTEXT_WINDOW_TOKENS_*` env vars, then 200 000.
|
||||
/// Consumers (e.g. dashboard badge) use this to render ctx-usage %.
|
||||
context_window_tokens: u64,
|
||||
/// Last-inference token usage from the most recent completed
|
||||
/// turn — represents the current context-window size at turn-end.
|
||||
/// `null` until the first turn finishes.
|
||||
ctx_usage: Option<hive_claude::TokenUsage>,
|
||||
/// Cumulative token usage across the most recent turn's inferences
|
||||
/// (cost signal). `null` until the first turn finishes.
|
||||
cost_usage: Option<hive_claude::TokenUsage>,
|
||||
/// Navigation links for this agent page. Also served via
|
||||
/// `DashboardState.links` (`GET /api/dashboard-state`) for the
|
||||
/// dashboard card's icon strip. Both are produced by `agent_links()`
|
||||
/// — single source of truth. See [`docs/web-ui/dashboard.md::Container row`]
|
||||
/// for the frontend resolver + which links appear in which conditions.
|
||||
links: Vec<AgentLink>,
|
||||
/// Public URL of the forge served by hive-gateway (e.g.
|
||||
/// `"https://forge.pr1ma.darkest.space"`). Sourced from
|
||||
/// `HIVE_FORGE_PUBLIC_URL`; `None` when `forge.behindGateway=false`
|
||||
/// or the env var is absent. The frontend uses this to build forge
|
||||
/// nav-strip links instead of hardcoding `<hostname>:3000`.
|
||||
forge_public_url: Option<String>,
|
||||
/// Human name of this hive instance (e.g. `"pr1ma"`). Sourced
|
||||
/// from `HYPERHIVE_HIVE_NAME`; `None` when unset. The frontend
|
||||
/// uses this for the page `<title>` and header label so browser
|
||||
/// tabs disambiguate when multiple hives are open in parallel.
|
||||
hive_name: Option<String>,
|
||||
/// Human name of the swarm (e.g. `"constellat1on"`). Sourced from
|
||||
/// `HYPERHIVE_SWARM_NAME`; `None` when unset.
|
||||
swarm_name: Option<String>,
|
||||
/// Ordered list of model short-names the operator has declared as
|
||||
/// available on this hive. Sourced from `HIVE_AVAILABLE_MODELS`
|
||||
/// (comma-separated, set by `services.hyperhive.availableModels`).
|
||||
/// Falls back to `["haiku", "sonnet", "opus"]` when the env var is
|
||||
/// absent or empty. The frontend model quick-picker renders one button
|
||||
/// per entry in this list, so operators can add new models or drop
|
||||
/// ones they don't want without touching the frontend code.
|
||||
available_models: Vec<String>,
|
||||
/// Currently-active claude effort level. Reflected on the page so the
|
||||
/// operator's effort picker shows the live selection. Mutable at
|
||||
/// runtime via `POST /api/effort`; applies on the next session.
|
||||
effort: String,
|
||||
/// Selectable effort levels for the picker, ascending. Fixed set
|
||||
/// (`low`, `medium`, `high`, `xhigh`, `max`) — sourced from
|
||||
/// [`crate::harness_state::EFFORT_LEVELS`], not operator-configurable like
|
||||
/// `available_models`. The frontend renders one button per entry.
|
||||
available_efforts: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SessionView {
|
||||
/// First `https://…` claude emitted on stdout, if any.
|
||||
url: Option<String>,
|
||||
/// Accumulated stdout + stderr.
|
||||
output: String,
|
||||
finished: bool,
|
||||
exit_note: Option<String>,
|
||||
}
|
||||
|
||||
/// One navigation link in the agent page header row. The same JSON
|
||||
/// shape appears in both `StateSnapshot.links` (`GET /api/state`,
|
||||
/// per-agent page) and `DashboardState.links` (`GET /api/dashboard-state`,
|
||||
/// dashboard card icon strip). `agent_links()` is the single source
|
||||
/// of truth for what links an agent exposes.
|
||||
#[derive(Serialize)]
|
||||
struct AgentLink {
|
||||
/// `kind = Container | Forge` → path; `kind = External` → full URL.
|
||||
/// The frontend prepends the right base before rendering.
|
||||
url: String,
|
||||
icon: String,
|
||||
label: String,
|
||||
kind: AgentLinkKind,
|
||||
}
|
||||
|
||||
/// Resolution hint for `AgentLink.url`. The agent backend can't know
|
||||
/// which hostname the browser sees (especially when the dashboard
|
||||
/// proxies the call from a different origin), so it labels each link
|
||||
/// and lets the frontend prepend the right base.
|
||||
#[derive(Serialize, Clone, Copy)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum AgentLinkKind {
|
||||
/// `url` is a path on the agent's container web UI (`/stats`,
|
||||
/// `/screen`). Agent page: same-origin path. Dashboard:
|
||||
/// `http://<host>:<container.port><url>`.
|
||||
Container,
|
||||
/// `url` is a path on the local Forgejo (`/<label>`,
|
||||
/// `/agent-configs/<label>`). Both surfaces:
|
||||
/// `http://<host>:3000<url>`.
|
||||
Forge,
|
||||
/// `url` is already a fully-qualified absolute URL — use as-is.
|
||||
/// Agent-declared `hyperhive.dashboardLinks` extras arrive here.
|
||||
External,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub(super) struct DashboardState {
|
||||
/// Free-text status set by `set_status`, read directly from the
|
||||
/// `hyperhive-status` file the harness writes. `None` when unset.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
status_text: Option<String>,
|
||||
/// Unix timestamp (seconds) when the status file was last written.
|
||||
/// `None` when no status is set.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
status_set_at: Option<i64>,
|
||||
/// Full context-window size from the most recent completed turn
|
||||
/// (`ctx_usage.context_tokens()` = input + cache-read + cache-creation).
|
||||
/// `None` until the first turn finishes. Drives the `ctx·Nk` card badge.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
ctx_tokens: Option<u64>,
|
||||
/// Effective context-window budget for the current model. Same
|
||||
/// derivation as `StateSnapshot::context_window_tokens`.
|
||||
context_window_tokens: u64,
|
||||
/// True while the harness is parked after a rate-limit response.
|
||||
rate_limited: bool,
|
||||
/// Navigation links for the dashboard card's icon strip. This is
|
||||
/// the authoritative source — includes the screen link (GUI agents)
|
||||
/// which hive-c0re's disk-based fallback cannot determine.
|
||||
links: Vec<AgentLink>,
|
||||
}
|
||||
|
||||
/// Read the agent's own free-text status and the timestamp when it was
|
||||
/// set, directly from the `hyperhive-status` file in the state dir.
|
||||
/// Mirrors `hive_c0re::container_view::read_agent_status` but runs
|
||||
/// inside the agent container using its own state dir.
|
||||
fn read_own_status() -> (Option<String>, Option<i64>) {
|
||||
let path = crate::paths::state_dir().join("hyperhive-status");
|
||||
let meta = std::fs::metadata(&path).ok();
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|t| !t.is_empty())
|
||||
.map(str::to_owned);
|
||||
let mtime = meta.and_then(|m| {
|
||||
m.modified().ok().and_then(|t| {
|
||||
t.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()
|
||||
.and_then(|d| i64::try_from(d.as_secs()).ok())
|
||||
})
|
||||
});
|
||||
if text.is_none() {
|
||||
(None, None)
|
||||
} else {
|
||||
(text, mtime)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the navigation link list for the agent page header. URLs
|
||||
/// are paths (relative) for `Container`/`Forge` targets and absolute
|
||||
/// for `External`; the frontend resolves each against its `kind`
|
||||
/// against the right base so the backend never has to guess the
|
||||
/// operator's browser host. See
|
||||
/// [`docs/web-ui/dashboard.md::Container row`](../../../docs/web-ui/dashboard.md) for
|
||||
/// the resolver + how `deployed:<sha>` ships alongside.
|
||||
fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> {
|
||||
let mut links = Vec::new();
|
||||
|
||||
links.push(AgentLink {
|
||||
url: "stats.html".to_owned(),
|
||||
icon: "📊".to_owned(),
|
||||
label: "stats".to_owned(),
|
||||
kind: AgentLinkKind::Container,
|
||||
});
|
||||
|
||||
if gui_enabled {
|
||||
links.push(AgentLink {
|
||||
url: "screen.html".to_owned(),
|
||||
icon: "🖥".to_owned(),
|
||||
label: "screen".to_owned(),
|
||||
kind: AgentLinkKind::Container,
|
||||
});
|
||||
}
|
||||
|
||||
if crate::paths::state_dir().join("forge-token").is_file() {
|
||||
links.push(AgentLink {
|
||||
url: format!("/{label}"),
|
||||
icon: "⬡".to_owned(),
|
||||
label: "forge".to_owned(),
|
||||
kind: AgentLinkKind::Forge,
|
||||
});
|
||||
links.push(AgentLink {
|
||||
url: format!("/agent-configs/{label}"),
|
||||
icon: "↳".to_owned(),
|
||||
label: "config".to_owned(),
|
||||
kind: AgentLinkKind::Forge,
|
||||
});
|
||||
}
|
||||
|
||||
// Agent-declared extras (`hyperhive.dashboardLinks` → the
|
||||
// `hive-dashboard-links` NixOS oneshot writes them to
|
||||
// `{state_dir}/hyperhive-dashboard-links.json`). Shape on disk
|
||||
// is `{label, icon, url}` with absolute URLs — those become
|
||||
// `kind = External` links, passed through verbatim.
|
||||
let extras_path = crate::paths::state_dir().join("hyperhive-dashboard-links.json");
|
||||
if let Ok(text) = std::fs::read_to_string(&extras_path)
|
||||
&& !text.trim().is_empty()
|
||||
&& let Ok(extras) = serde_json::from_str::<Vec<ExtraLink>>(&text)
|
||||
{
|
||||
for e in extras {
|
||||
links.push(AgentLink {
|
||||
url: e.url,
|
||||
icon: e.icon,
|
||||
label: e.label,
|
||||
kind: AgentLinkKind::External,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
links
|
||||
}
|
||||
|
||||
/// On-disk shape of `hyperhive-dashboard-links.json` (the
|
||||
/// `hive-dashboard-links` NixOS oneshot's output). Mapped to
|
||||
/// `AgentLink { kind: External }` inside `agent_links`.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ExtraLink {
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
icon: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
/// Best-effort: pull the last 30 messages addressed to us via the
|
||||
/// per-agent / manager socket. Empty list on any transport / decode
|
||||
/// failure — the inbox section is decorative, not authoritative.
|
||||
async fn recent_inbox(socket: &std::path::Path) -> Vec<hive_sh4re::InboxRow> {
|
||||
const LIMIT: u64 = 30;
|
||||
// Deadline-bounded (via `broker_request`): `/api/state` must render even
|
||||
// when hive-c0re is busy — an empty inbox section beats a hung snapshot.
|
||||
match super::broker_request(socket, &hive_sh4re::Request::Recent { limit: LIMIT }).await {
|
||||
Ok(hive_sh4re::Response::Recent { rows }) => rows,
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read `HIVE_AVAILABLE_MODELS` (comma-separated short names injected by
|
||||
/// `services.hyperhive.availableModels`) and return the parsed list.
|
||||
/// Falls back to `["haiku", "sonnet", "opus"]` when the env var is absent
|
||||
/// or resolves to an empty list after trimming.
|
||||
fn available_models() -> Vec<String> {
|
||||
const DEFAULT: &[&str] = &["haiku", "sonnet", "opus"];
|
||||
// Absent / empty / all-whitespace env all funnel to the single
|
||||
// emptiness check below — no separate up-front guard needed.
|
||||
let models: Vec<String> = std::env::var("HIVE_AVAILABLE_MODELS")
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if models.is_empty() {
|
||||
DEFAULT.iter().map(ToString::to_string).collect()
|
||||
} else {
|
||||
models
|
||||
}
|
||||
}
|
||||
132
hive-agent/src/web_ui/stats.rs
Normal file
132
hive-agent/src/web_ui/stats.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
//! Stats + loose-ends + bash-tasks read endpoints.
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::{AppState, error_response};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct StatsQuery {
|
||||
window: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) async fn api_stats(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Query(q): axum::extract::Query<StatsQuery>,
|
||||
) -> axum::Json<crate::stats::Snapshot> {
|
||||
let window = crate::stats::Window::parse(q.window.as_deref().unwrap_or("24h"));
|
||||
let mut snapshot = crate::stats::snapshot_default(window);
|
||||
// Pass the window span to the reminder-stats RPC so the broker
|
||||
// filters its counts to the same time range as the chart data.
|
||||
let window_secs = window.span_secs();
|
||||
let window_secs_u = u64::try_from(window_secs).unwrap_or(0);
|
||||
snapshot.reminder_stats = fetch_reminder_stats(&state.socket, window_secs_u).await;
|
||||
axum::Json(snapshot)
|
||||
}
|
||||
|
||||
/// Fetch reminder activity stats from the broker via the per-agent / manager
|
||||
/// socket. Returns None on any transport / decode failure — the stats are
|
||||
/// decorative, not authoritative.
|
||||
async fn fetch_reminder_stats(
|
||||
socket: &std::path::Path,
|
||||
window_secs: u64,
|
||||
) -> Option<hive_sh4re::ReminderStats> {
|
||||
match super::broker_request(
|
||||
socket,
|
||||
&hive_sh4re::Request::ReminderRollup {
|
||||
since_secs: window_secs,
|
||||
agent: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(hive_sh4re::Response::ReminderRollup(stats)) => Some(stats),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Proxy this agent's loose-ends list via the per-agent socket. The
|
||||
/// web UI surfaces the result as a collapsible section in the page
|
||||
/// so the operator can see at a glance what's pending against the
|
||||
/// agent (questions asked by it, peer questions targeting it,
|
||||
/// reminders it scheduled, approvals for the manager). Same data
|
||||
/// the `mcp__hyperhive__get_loose_ends` tool sees from inside the
|
||||
/// container.
|
||||
pub(super) async fn api_loose_ends(State(state): State<AppState>) -> Response {
|
||||
match super::broker_request(
|
||||
&state.socket,
|
||||
&hive_sh4re::Request::GetLooseEnds { agent: None },
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(hive_sh4re::Response::LooseEnds { loose_ends }) => {
|
||||
axum::Json(serde_json::json!({ "loose_ends": loose_ends })).into_response()
|
||||
}
|
||||
Ok(hive_sh4re::Response::Err { message }) => error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("get_loose_ends: {message}"),
|
||||
),
|
||||
Ok(other) => error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("get_loose_ends: unexpected response: {other:?}"),
|
||||
),
|
||||
Err(e) => super::broker_error_response(&e, "get_loose_ends"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/bash-tasks` — snapshot of this agent's in-flight bash tasks.
|
||||
///
|
||||
/// The `hive-bash-mcp` daemon runs in this same container and writes one
|
||||
/// `<id>.json` ([`hive_sh4re::TaskFile`]) per task under the harness
|
||||
/// `bash-tasks/` dir. This reads that dir and returns the tasks still
|
||||
/// `Pending` or `Running`, so the agent page can show what's running without
|
||||
/// going through the broker. Snapshot only — the page polls/refreshes it like
|
||||
/// `/api/loose-ends`; there's no live SSE push for task state yet. Unreadable
|
||||
/// or malformed files (incl. the daemon's `.json.tmp` scratch writes, which
|
||||
/// don't match the `.json` extension) are skipped so one stray file can't
|
||||
/// fail the whole list.
|
||||
pub(super) async fn api_bash_tasks() -> Response {
|
||||
let dir = crate::paths::harness_dir().join("bash-tasks");
|
||||
// The dir scan + per-file reads are blocking fs I/O; run them off the
|
||||
// async executor so a slow or large tasks dir can't stall other requests.
|
||||
let tasks = tokio::task::spawn_blocking(move || {
|
||||
let mut tasks: Vec<hive_sh4re::TaskFile> = Vec::new();
|
||||
let Ok(rd) = std::fs::read_dir(&dir) else {
|
||||
return tasks;
|
||||
};
|
||||
for entry in rd.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(task) = serde_json::from_str::<hive_sh4re::TaskFile>(&text) else {
|
||||
continue;
|
||||
};
|
||||
if matches!(
|
||||
task.status,
|
||||
hive_sh4re::TaskStatus::Pending | hive_sh4re::TaskStatus::Running
|
||||
) {
|
||||
tasks.push(task);
|
||||
}
|
||||
}
|
||||
// Running before Pending, then oldest-first so a long-runner sits on top.
|
||||
tasks.sort_by(|a, b| {
|
||||
let rank = |s: &hive_sh4re::TaskStatus| match s {
|
||||
hive_sh4re::TaskStatus::Running => 0,
|
||||
_ => 1,
|
||||
};
|
||||
rank(&a.status)
|
||||
.cmp(&rank(&b.status))
|
||||
.then(a.created_at.cmp(&b.created_at))
|
||||
});
|
||||
tasks
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
axum::Json(serde_json::json!({ "tasks": tasks })).into_response()
|
||||
}
|
||||
77
hive-agent/src/web_ui/stream.rs
Normal file
77
hive-agent/src/web_ui/stream.rs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
//! Live SSE event stream + history endpoints.
|
||||
|
||||
use std::convert::Infallible;
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::{Query, State};
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use serde::Deserialize;
|
||||
use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream};
|
||||
|
||||
use super::AppState;
|
||||
|
||||
/// Query params for the paginated history endpoint.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct HistoryParams {
|
||||
/// Cursor: only return events with sqlite row id < `before`.
|
||||
/// Omit for the initial (most-recent) page.
|
||||
before: Option<i64>,
|
||||
/// Page size (default 100, capped at `HISTORY_CAPACITY`).
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
pub(super) async fn events_history(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<HistoryParams>,
|
||||
) -> Json<serde_json::Value> {
|
||||
use crate::events::HISTORY_CAPACITY;
|
||||
let limit = params.limit.unwrap_or(100).min(HISTORY_CAPACITY);
|
||||
let before = params.before;
|
||||
let is_initial = before.is_none();
|
||||
|
||||
// Capture seq *before* the read on initial loads so the SSE dedupe
|
||||
// window is "drop buffered events you've already seen in history",
|
||||
// never "lose an event that fired between the read and the seq."
|
||||
// On paginated loads (`before` is set) seq is not needed.
|
||||
let seq = if is_initial {
|
||||
Some(state.bus.current_seq())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (events, min_id, has_more) = state.bus.history_page(before, limit);
|
||||
let mut resp = serde_json::json!({
|
||||
"events": events,
|
||||
"min_id": min_id,
|
||||
"has_more": has_more,
|
||||
});
|
||||
if let Some(s) = seq {
|
||||
resp["seq"] = serde_json::json!(s);
|
||||
}
|
||||
Json(resp)
|
||||
}
|
||||
|
||||
pub(super) async fn events_stream(
|
||||
State(state): State<AppState>,
|
||||
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
||||
tracing::info!("sse: client subscribed");
|
||||
let rx = state.bus.subscribe();
|
||||
// Prime THIS connection with a one-off "hello" so it can clear the
|
||||
// connecting placeholder immediately. Injected into this subscriber's own
|
||||
// stream rather than emitted to the bus — a bus emit would spam every
|
||||
// already-connected client with a spurious note each time anyone opens
|
||||
// the stream.
|
||||
let hello = Event::default().data(
|
||||
serde_json::to_string(&crate::events::LiveEvent::Note {
|
||||
text: "live stream attached".into(),
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
let live = BroadcastStream::new(rx).filter_map(|res| {
|
||||
let ev = res.ok()?;
|
||||
let json = serde_json::to_string(&ev).ok()?;
|
||||
Some(Ok(Event::default().data(json)))
|
||||
});
|
||||
let stream = tokio_stream::once(Ok(hello)).chain(live);
|
||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||
}
|
||||
Loading…
Reference in a new issue