diff --git a/Cargo.lock b/Cargo.lock
index eba3981f..6ec8564a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -453,7 +453,6 @@ dependencies = [
"clap",
"hive-sh4re",
"rmcp",
- "rusqlite",
"schemars",
"serde",
"serde_json",
diff --git a/TODO.md b/TODO.md
index c4bb48d1..59d85386 100644
--- a/TODO.md
+++ b/TODO.md
@@ -29,6 +29,14 @@ Pick anything from here when relevant. Cross-cutting design notes live in
- **Per-agent UI substance.** Show last N inbox messages, last turn timing,
link back to dashboard.
+- **Delivered events history persistence.** The `events::Bus` ring
+ buffer (500 events, in-memory) backfills the terminal on page load
+ but dies on harness restart, and only ever holds the most recent
+ turn or two. Persist to sqlite (`events(agent, id, ts, kind,
+ payload_json)`) so the operator can scroll back through prior
+ turns, and so `/events/history` survives restart. Cap rows per
+ agent or auto-vacuum on age, same trade-off as the bounded broker
+ entry below.
- **State badge: compacting + napping states.** Idle/thinking already
ship (driven from SSE turn_start/turn_end). Add `compacting π¦` and
`napping π΄` once the `/compact` trigger and `nap` tool exist β
@@ -106,6 +114,8 @@ Pick anything from here when relevant. Cross-cutting design notes live in
## Lifecycle / reliability
+- **Bounded broker.** Cap rows per recipient or auto-vacuum delivered
+ messages older than a threshold. sqlite is growing unbounded.
- **Container crash events.** Watch `container@*.service` via D-Bus, push
`HelperEvent::ContainerCrash` to the manager's inbox so the manager can
react (restart, escalate, etc.).
diff --git a/hive-ag3nt/Cargo.toml b/hive-ag3nt/Cargo.toml
index f1a253e3..19586b84 100644
--- a/hive-ag3nt/Cargo.toml
+++ b/hive-ag3nt/Cargo.toml
@@ -12,7 +12,6 @@ axum.workspace = true
clap.workspace = true
hive-sh4re.workspace = true
rmcp.workspace = true
-rusqlite.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
diff --git a/hive-ag3nt/assets/agent.css b/hive-ag3nt/assets/agent.css
index 013fc82e..249daa59 100644
--- a/hive-ag3nt/assets/agent.css
+++ b/hive-ag3nt/assets/agent.css
@@ -126,26 +126,6 @@ pre.diff {
}
#state-row {
margin: 0.4em 0 0.2em;
- display: flex;
- align-items: center;
- gap: 0.6em;
-}
-.btn-cancel-turn {
- font-family: inherit;
- font-size: 0.8em;
- letter-spacing: 0.08em;
- background: transparent;
- color: var(--red);
- border: 1px solid var(--red);
- border-radius: 999px;
- padding: 0.2em 0.8em;
- cursor: pointer;
- text-shadow: 0 0 4px currentColor;
- transition: box-shadow 0.15s ease, background 0.15s ease;
-}
-.btn-cancel-turn:hover {
- background: rgba(243, 139, 168, 0.1);
- box-shadow: 0 0 10px -2px currentColor;
}
.state-badge {
display: inline-block;
diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js
index a2f620f4..6183e939 100644
--- a/hive-ag3nt/assets/app.js
+++ b/hive-ag3nt/assets/app.js
@@ -161,24 +161,10 @@
let termAPI = null;
const SLASH_COMMANDS = [
- { name: '/help', desc: 'list slash commands' },
- { name: '/clear', desc: 'wipe the terminal panel (local-only)' },
- { name: '/cancel', desc: 'SIGINT the in-flight claude turn' },
+ { name: '/help', desc: 'list slash commands' },
+ { name: '/clear', desc: 'wipe the terminal panel (local-only)' },
];
- async function postCancelTurn() {
- try {
- const resp = await fetch('/api/cancel', { method: 'POST', redirect: 'manual' });
- const ok = resp.ok || resp.type === 'opaqueredirect'
- || (resp.status >= 200 && resp.status < 400);
- if (!ok && termAPI) {
- termAPI.row('turn-end-fail', 'β /cancel failed: http ' + resp.status);
- }
- } catch (err) {
- if (termAPI) termAPI.row('turn-end-fail', 'β /cancel failed: ' + err);
- }
- }
-
function handleSlashCommand(line) {
if (!termAPI) return false;
const trimmed = line.trim();
@@ -195,9 +181,6 @@
termAPI.clear();
termAPI.row('note', 'Β· terminal cleared (local view only β server history kept)');
return true;
- case '/cancel':
- postCancelTurn();
- return true;
default:
termAPI.row('turn-end-fail', 'β unknown slash command: ' + cmd + ' β try /help');
return true;
@@ -299,8 +282,6 @@
const age = fmtAge(Date.now() - stateSince);
badge.textContent = def.glyph + ' ' + def.text + ' Β· ' + age;
badge.className = 'state-badge state-' + stateName;
- const cancelBtn = $('cancel-btn');
- if (cancelBtn) cancelBtn.hidden = stateName !== 'thinking';
}
function setState(next) {
if (next === stateName) return;
@@ -321,16 +302,6 @@
}
startStateTicker();
- // Wire the cancel-turn button (visible only while state === thinking).
- (() => {
- const btn = $('cancel-btn');
- if (!btn) return;
- btn.addEventListener('click', () => {
- btn.disabled = true;
- postCancelTurn().finally(() => { btn.disabled = false; });
- });
- })();
-
// Track banner activity by reference-counting in-flight turns. A turn
// can begin while the previous turn_end is still in the pipeline (rare
// but happens on tight wake cycles), so we count rather than toggle.
diff --git a/hive-ag3nt/assets/index.html b/hive-ag3nt/assets/index.html
index 397fd327..219f8d9f 100644
--- a/hive-ag3nt/assets/index.html
+++ b/hive-ag3nt/assets/index.html
@@ -15,7 +15,6 @@
β¦ booting
-
diff --git a/hive-ag3nt/src/bin/hive-ag3nt.rs b/hive-ag3nt/src/bin/hive-ag3nt.rs
index bddee64c..bc5af540 100644
--- a/hive-ag3nt/src/bin/hive-ag3nt.rs
+++ b/hive-ag3nt/src/bin/hive-ag3nt.rs
@@ -58,7 +58,6 @@ async fn main() -> Result<()> {
let login_state = Arc::new(Mutex::new(initial));
let ui_state = login_state.clone();
let bus = Bus::new();
- spawn_events_vacuum(bus.clone());
let ui_bus = bus.clone();
let ui_socket = cli.socket.clone();
tokio::spawn(async move {
@@ -154,23 +153,6 @@ async fn serve(
}
}
-/// Vacuum events older than 7 days, cap to 2000 most-recent rows.
-/// Runs immediately, then hourly.
-fn spawn_events_vacuum(bus: Bus) {
- tokio::spawn(async move {
- let interval_secs = 3600u64;
- let keep_secs: i64 = 7 * 24 * 3600;
- let keep_rows = 2000;
- loop {
- let n = bus.vacuum(keep_secs, keep_rows);
- if n > 0 {
- tracing::info!(removed = n, "events vacuum");
- }
- tokio::time::sleep(Duration::from_secs(interval_secs)).await;
- }
- });
-}
-
/// Per-turn user prompt. The role/tools/etc. is in the system prompt
/// (`prompts/agent.md` β `claude --system-prompt-file`); this is just the
/// wake signal claude reacts to. `unread` is the count of *other*
@@ -194,3 +176,4 @@ async fn inbox_unread(socket: &Path) -> u64 {
_ => 0,
}
}
+
diff --git a/hive-ag3nt/src/bin/hive-m1nd.rs b/hive-ag3nt/src/bin/hive-m1nd.rs
index 256c4abe..d3cbd8d7 100644
--- a/hive-ag3nt/src/bin/hive-m1nd.rs
+++ b/hive-ag3nt/src/bin/hive-m1nd.rs
@@ -61,7 +61,6 @@ async fn main() -> Result<()> {
let login_state = Arc::new(Mutex::new(initial));
let ui_state = login_state.clone();
let bus = Bus::new();
- spawn_events_vacuum(bus.clone());
let ui_bus = bus.clone();
let ui_socket = cli.socket.clone();
tokio::spawn(async move {
@@ -90,22 +89,6 @@ async fn main() -> Result<()> {
}
}
-/// Vacuum events older than 7 days, cap to 2000 most-recent rows.
-fn spawn_events_vacuum(bus: Bus) {
- tokio::spawn(async move {
- let interval_secs = 3600u64;
- let keep_secs: i64 = 7 * 24 * 3600;
- let keep_rows = 2000;
- loop {
- let n = bus.vacuum(keep_secs, keep_rows);
- if n > 0 {
- tracing::info!(removed = n, "events vacuum");
- }
- tokio::time::sleep(Duration::from_secs(interval_secs)).await;
- }
- });
-}
-
async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> {
tracing::info!(socket = %socket.display(), "hive-m1nd serve");
let mcp_config = turn::write_mcp_config(socket).await?;
diff --git a/hive-ag3nt/src/events.rs b/hive-ag3nt/src/events.rs
index 54297d32..fb95223e 100644
--- a/hive-ag3nt/src/events.rs
+++ b/hive-ag3nt/src/events.rs
@@ -8,31 +8,17 @@
//! 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::collections::VecDeque;
use std::sync::{Arc, Mutex};
-use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
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.
-const HISTORY_CAPACITY: usize = 2000;
-/// Default sqlite db path. Lives under `/state/` so it survives
-/// destroy/recreate but goes away on purge. Overridable via the
-/// `HYPERHIVE_EVENTS_DB` env var (used in tests and one-shot tools).
-const DEFAULT_EVENTS_DB: &str = "/state/hyperhive-events.sqlite";
-
-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);
-";
+/// Max `LiveEvent`s the `Bus` keeps in its ring buffer. The web UI fetches
+/// this on page load to backfill the terminal so the operator sees the
+/// last turn(s) without having to wait for the next one.
+const HISTORY_CAPACITY: usize = 500;
/// One row of the agent's live stream. Serialised to JSON for SSE delivery.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -59,122 +45,29 @@ pub enum LiveEvent {
TurnEnd { ok: bool, note: Option },
}
-/// sqlite-backed event log. Wraps a `Connection` behind a `Mutex` so the
-/// `Bus` (which clones cheaply) shares one writer.
-struct EventStore {
- conn: Mutex,
-}
-
-impl EventStore {
- fn open(path: &Path) -> rusqlite::Result {
- 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",
- };
- 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(())
- }
-
- fn recent(&self, limit: usize) -> rusqlite::Result> {
- let limit_i = i64::try_from(limit).unwrap_or(i64::MAX);
- let conn = self.conn.lock().unwrap();
- let mut stmt = conn.prepare(
- "SELECT payload_json FROM events
- ORDER BY id DESC
- LIMIT ?1",
- )?;
- let rows = stmt.query_map(params![limit_i], |row| {
- let s: String = row.get(0)?;
- Ok(serde_json::from_str::(&s).ok())
- })?;
- let mut out: Vec = rows.flatten().flatten().collect();
- out.reverse();
- Ok(out)
- }
-
- /// Drop rows older than `older_than_secs` AND any rows beyond
- /// `keep_rows` newest. Two-stage so a quiet agent keeps a useful
- /// tail and a chatty one is bounded.
- fn vacuum(&self, older_than_secs: i64, keep_rows: usize) -> rusqlite::Result {
- let now = 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 cutoff = now - older_than_secs;
- let conn = self.conn.lock().unwrap();
- let by_age = conn.execute("DELETE FROM events WHERE ts < ?1", params![cutoff])?;
- let keep_i = i64::try_from(keep_rows).unwrap_or(i64::MAX);
- let by_count = conn.execute(
- "DELETE FROM events
- WHERE id NOT IN (
- SELECT id FROM events ORDER BY id DESC LIMIT ?1
- )",
- params![keep_i],
- )?;
- Ok(u64::try_from(by_age + by_count).unwrap_or(0))
- }
-}
-
#[derive(Clone)]
pub struct Bus {
tx: Arc>,
- /// 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/` mount in dev / test scenarios.
- store: Option>,
+ history: Arc>>,
}
impl Bus {
- /// Open the default events db (`/state/hyperhive-events.sqlite`, or
- /// `HYPERHIVE_EVENTS_DB`). 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 = std::env::var_os("HYPERHIVE_EVENTS_DB")
- .map_or_else(|| PathBuf::from(DEFAULT_EVENTS_DB), PathBuf::from);
- 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);
Self {
tx: Arc::new(tx),
- store,
+ history: Arc::new(Mutex::new(VecDeque::with_capacity(HISTORY_CAPACITY))),
}
}
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 mut h = self.history.lock().unwrap();
+ if h.len() == HISTORY_CAPACITY {
+ h.pop_front();
+ }
+ h.push_back(event.clone());
}
// Lagged subscribers drop events β fine; the UI is a tail, not a log.
let _ = self.tx.send(event);
@@ -184,22 +77,11 @@ impl Bus {
self.tx.subscribe()
}
- /// Most recent events, oldest first, capped at `HISTORY_CAPACITY`.
- /// Drives the terminal pre-fill when the operator opens the agent
- /// page; without a store (db open failed) this is empty.
+ /// Snapshot of the in-memory event ring buffer, oldest first. Drives the
+ /// terminal pre-fill when the operator opens the agent page.
#[must_use]
pub fn history(&self) -> Vec {
- let Some(store) = &self.store else {
- return Vec::new();
- };
- store.recent(HISTORY_CAPACITY).unwrap_or_default()
- }
-
- /// Drop events older than `older_than_secs` and keep only the
- /// newest `keep_rows`. Called periodically by the harness.
- pub fn vacuum(&self, older_than_secs: i64, keep_rows: usize) -> u64 {
- let Some(store) = &self.store else { return 0 };
- store.vacuum(older_than_secs, keep_rows).unwrap_or(0)
+ self.history.lock().unwrap().iter().cloned().collect()
}
}
diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs
index 3f7bb781..4e5c1207 100644
--- a/hive-ag3nt/src/web_ui.rs
+++ b/hive-ag3nt/src/web_ui.rs
@@ -79,7 +79,6 @@ pub async fn serve(
.route("/login/start", post(post_login_start))
.route("/login/code", post(post_login_code))
.route("/login/cancel", post(post_login_cancel))
- .route("/api/cancel", post(post_cancel_turn))
.with_state(state);
let addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = tokio::net::TcpListener::bind(addr)
@@ -274,33 +273,6 @@ async fn post_login_cancel(State(state): State) -> Response {
Redirect::to("/").into_response()
}
-/// Cancel the in-flight claude turn. Coarse-grained: shells out
-/// `pkill -INT claude` since there's at most one claude per container.
-/// SIGINT (not SIGTERM) so claude flushes anything in-flight and emits a
-/// final result row. Emits a Note so the operator sees the cancel
-/// landed; the actual state transition back to `idle` happens when
-/// `run_claude` wakes up and the harness emits `TurnEnd`.
-async fn post_cancel_turn(State(state): State) -> Response {
- let out = tokio::process::Command::new("pkill")
- .args(["-INT", "claude"])
- .output()
- .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(note));
- Redirect::to("/").into_response()
-}
-
fn error_response(message: &str) -> Response {
// Plain text β JS app surfaces in `alert()`, HTML wrapping would just
// be noise.
diff --git a/hive-c0re/src/broker.rs b/hive-c0re/src/broker.rs
index bd6b45f9..232b15c1 100644
--- a/hive-c0re/src/broker.rs
+++ b/hive-c0re/src/broker.rs
@@ -171,21 +171,6 @@ impl Broker {
}
}
- /// Delete delivered messages older than `older_than_secs`. Undelivered
- /// rows are always kept regardless of age β those are still in flight
- /// from the broker's POV. Returns the number of rows removed.
- pub fn vacuum_delivered(&self, older_than_secs: i64) -> Result {
- let cutoff = now_unix() - older_than_secs;
- let conn = self.conn.lock().unwrap();
- let n = conn.execute(
- "DELETE FROM messages
- WHERE delivered_at IS NOT NULL
- AND delivered_at < ?1",
- params![cutoff],
- )?;
- Ok(u64::try_from(n).unwrap_or(0))
- }
-
pub fn recv(&self, recipient: &str) -> Result