feat(telemetry): push bus for live agent frames
deploy / test (push) Successful in 4m25s
deploy / build (push) Successful in 5m20s

/api/world/live is a 2s database poll, which is right for queryable state and
wrong for a token stream: reasoning only became visible after a step finished
and its row was written. This adds a process-wide broadcast bus that
topology_exec publishes to as the runtime's WebSocket delivers frames, and the
SSE handler forwards without waiting for the next tick.

Measured: the pushed frame arrived ~2.2s before the polled copy of the same
text.

Design notes worth keeping:
- A global (OnceLock), not an AppState field. The publisher is reached through
  phase_runner -> topology_worker -> MissionTap, none of which hold AppState;
  threading a handle through all of them would put a UI concern into four
  layers that have no other reason to know about one.
- Lossy by design. A slow subscriber lags and skips rather than applying
  backpressure to the agent producing. mission_events remains the durable
  record; this bus is the fast path, never the source of truth.
- Only `claw_<uuid>` aliases are attributed. The governor, door and evaluator
  drive real turns under other names, and attributing their output to an agent
  would put words in someone's mouth. Asserted in a test.
- The poll no longer emits `reasoning`: with both paths live, every turn
  arrived TWICE — once pushed, once polled ~2s later. The row is still written;
  this feed just is not its second mouth.

CEILING, measured rather than assumed: turns are not token-level because the
runtime is not streaming. zeroclaw's claude_cli provider runs
`claude -p --output-format json`, which returns ONE result object when the turn
completes — there are no incremental tokens to forward. Making this genuinely
token-by-token needs `--output-format stream-json` and incremental parsing in
the zeroclaw fork, not here. The bus is in place and will carry them the day it
does.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-15 16:29:12 -07:00
co-authored by Claude Opus 5
parent ba9d7aa185
commit 43436d7181
5 changed files with 242 additions and 33 deletions
+126
View File
@@ -0,0 +1,126 @@
//! A process-wide push bus for live taxonomy events.
//!
//! `/api/world/live` is a 2-second database poll. That is the right shape for
//! state you can query — statuses, phases, telemetry — and the wrong shape for a
//! token stream: an agent's reasoning only becomes visible after the step
//! finishes and its text is persisted, so the REASONING STREAM card showed
//! completed paragraphs rather than an agent thinking.
//!
//! This carries the frames that cannot wait for a round trip through Postgres.
//! `topology_exec` publishes as the runtime's WebSocket delivers them; the SSE
//! handler subscribes and forwards, so a chunk reaches the browser in one hop.
//!
//! **Why a global rather than a field on `AppState`.** The publisher is
//! `topology_exec`, reached through `phase_runner` → `topology_worker` →
//! `MissionTap`, none of which hold `AppState`. Threading a handle through all
//! of them would put a UI concern into four layers that have no other reason to
//! know about one. There is exactly one bus per process and it holds no
//! per-request state, so a `OnceLock` is the honest representation.
//!
//! **Lossy on purpose.** A slow reader lags and skips rather than applying
//! backpressure to the agent that is producing. Dropping frames degrades a live
//! view; blocking would slow the mission to the speed of the slowest open tab.
//! The durable record is `mission_events` — this bus is the fast path, never the
//! source of truth.
use std::sync::{Arc, OnceLock};
use serde_json::Value;
use tokio::sync::broadcast;
use uuid::Uuid;
/// Bounded so a stalled subscriber costs memory once, not unboundedly. At
/// token granularity a busy mission produces a few hundred frames a second;
/// this is roughly a couple of seconds of slack before a slow reader starts
/// skipping.
const CAPACITY: usize = 2048;
#[derive(Debug, Clone)]
pub struct LiveEvent {
/// Every subscriber is workspace-scoped; the bus is not.
pub workspace_id: Uuid,
/// A taxonomy type, e.g. `agent.reasoning.delta`.
pub kind: String,
pub data: Value,
}
pub struct LiveBus {
tx: broadcast::Sender<LiveEvent>,
}
impl LiveBus {
fn new() -> LiveBus {
let (tx, _rx) = broadcast::channel(CAPACITY);
LiveBus { tx }
}
/// Publish. Returns immediately, and succeeds even with no subscribers —
/// nobody watching is the normal case, not an error.
pub fn publish(&self, workspace_id: Uuid, kind: &str, data: Value) {
let _ = self.tx.send(LiveEvent {
workspace_id,
kind: kind.to_string(),
data,
});
}
pub fn subscribe(&self) -> broadcast::Receiver<LiveEvent> {
self.tx.subscribe()
}
}
static BUS: OnceLock<Arc<LiveBus>> = OnceLock::new();
pub fn global() -> &'static Arc<LiveBus> {
BUS.get_or_init(|| Arc::new(LiveBus::new()))
}
/// The claw alias the runtime dispatches on (`claw_<uuid>`) → the agent id the
/// UI keys on. Returns `None` for any other alias — the governor, the door and
/// the evaluator all drive turns under names that are not claws, and attributing
/// their output to an agent would put words in someone's mouth.
pub fn agent_id_from_alias(alias: &str) -> Option<Uuid> {
Uuid::parse_str(alias.strip_prefix("claw_")?).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_claw_aliases_resolve_to_an_agent() {
let id = Uuid::now_v7();
assert_eq!(
agent_id_from_alias(&format!("claw_{id}")),
Some(id),
"the runtime's own alias form must resolve"
);
// These drive real turns and must NOT be attributed to an agent.
for other in ["scout", "coordinator", "door", "evaluator", "claw_nonsense"] {
assert_eq!(agent_id_from_alias(other), None, "{other}");
}
}
#[tokio::test]
async fn a_subscriber_receives_what_is_published() {
let bus = LiveBus::new();
let mut rx = bus.subscribe();
let ws = Uuid::now_v7();
bus.publish(
ws,
"agent.reasoning.delta",
serde_json::json!({"text": "hi"}),
);
let ev = rx.recv().await.expect("delivered");
assert_eq!(ev.workspace_id, ws);
assert_eq!(ev.kind, "agent.reasoning.delta");
}
/// Publishing with nobody listening must not error — that is the common
/// case (no browser open) and it must never disturb the mission.
#[test]
fn publishing_into_the_void_is_fine() {
let bus = LiveBus::new();
bus.publish(Uuid::now_v7(), "agent.tool.call", serde_json::json!({}));
}
}