Watch
0
0
Fork
You've already forked hyperhive
0

agents: remove send(operator) and the operator inbox; alert on turn failures

Agents reach the operator over matrix now (#5115). A `send` to
`operator` is refused, by the broker and by the MCP allow-list check,
with an error that names the matrix MCP tools. The dashboard's Y3R C4LL
tab goes with it: the operator inbox, `GET /api/operator-inbox`,
`Broker::unread_for_recipient`, and the browser notifications that only
ever fired on operator-bound messages.

The harness no longer posts turn failures and plugin-install failures
to the operator inbox. Both are already logged at warn; a new Grafana
rule, `hh-agent-turn-failed`, fires per agent on the harness's
`turn failed` line.

Dashboard-to-agent messaging (op-send, the agent web UI's message box,
mark-all-read for agent inboxes) is unchanged.

Decisions per mara on #5115: q1 a, q2 a plus a grafana alert, q3 keep
dashboard-to-agent messaging and leave swarm-level code alone.
This commit is contained in:
atlas 2026-10-12 00:44:11 +02:00 • committed by mara
commit 7e6a4c7727
37 changed files with 246 additions and 848 deletions

View file

@ -36,7 +36,7 @@ use crate::lifecycle;
(name = "build_logs", description = "build log headers, full rows, and raw text downloads"),
(name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"),
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
(name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats"),
(name = "misc_api", description = "mark-all-read, compose, spawn-request, hive stats"),
(name = "permissions", description = "tool-group + capability assignment for agents"),
(name = "schedules", description = "read-only scheduled-prompt list + rebuild-queue cancel"),
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
@ -89,7 +89,7 @@ struct AppState {
handler; splitting that exhaustive list across helpers would \
obscure the route map for no readability gain"
)]
pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
fn app(coord: Arc<Coordinator>) -> axum::Router {
// API-only: the gateway static-serves the dashboard dist and proxies
// non-static requests here (see `nix/host-modules/hive-gateway/vhosts.nix`).
// Unmatched paths 404.
@ -115,7 +115,6 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
.routes(routes!(health::get_health_ready))
.routes(routes!(state_snapshot::api_state))
.routes(routes!(state_files::get_state_file))
.routes(routes!(misc_api::api_operator_inbox))
.routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::api_container_resources))
.routes(routes!(misc_api::post_mark_all_read))
@ -156,18 +155,21 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
// the nix store (see the module doc comment above `ApiDoc`). `api`
// is `Clone`; each request gets its own owned copy for `Json` to
// serialize.
let app = router
router
.route(
"/api/openapi.json",
get(move || async move { Json(api.clone()) }),
)
.with_state(AppState { coord });
.with_state(AppState { coord })
}
pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
// Binds loopback-only; external access via gateway.
// Rationale: docs/networking/gateway.md::Firewall posture.
let addr = SocketAddr::from(([127, 0, 0, 1], port));
let listener = bind_with_retry(addr).await?;
tracing::info!(%addr, "dashboard listening");
axum::serve(listener, app).await?;
axum::serve(listener, app(coord)).await?;
Ok(())
}
@ -287,6 +289,47 @@ fn error_response(message: &str) -> Response {
mod tests {
use super::*;
/// Status code of a bare HTTP/1.1 GET against the dashboard's own route
/// table, served on an ephemeral loopback port.
async fn status_of(path: &str) -> u16 {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let dir = tempfile::tempdir().expect("tempdir");
let coord = Coordinator::open(
&dir.path().join("broker.sqlite"),
crate::coordinator::HiveEnv::default(),
crate::hive_stats::PriceTable::new(),
1,
)
.expect("open coordinator");
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("local addr");
let server = tokio::spawn(axum::serve(listener, app(Arc::new(coord))).into_future());
let mut stream = tokio::net::TcpStream::connect(addr).await.expect("connect");
stream
.write_all(
format!("GET {path} HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n").as_bytes(),
)
.await
.expect("write");
let mut reply = String::new();
stream.read_to_string(&mut reply).await.expect("read");
server.abort();
reply
.split_whitespace()
.nth(1)
.and_then(|code| code.parse().ok())
.unwrap_or_else(|| panic!("no status line in {reply:?}"))
}
#[tokio::test]
async fn operator_inbox_route_is_gone() {
assert_eq!(status_of("/health/live").await, 200);
assert_eq!(status_of("/api/operator-inbox").await, 404);
}
#[test]
fn problem_details_carry_rfc9457_status_and_detail() {
// Contract the frontend depends on: the problem_details crate
@ -329,7 +372,6 @@ mod router_build_probe {
.routes(routes!(health::get_health_ready))
.routes(routes!(state_snapshot::api_state))
.routes(routes!(state_files::get_state_file))
.routes(routes!(misc_api::api_operator_inbox))
.routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::api_container_resources))
.routes(routes!(misc_api::post_mark_all_read))