feat(telemetry): push bus for live agent frames
/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:
co-authored by
Claude Opus 5
parent
ba9d7aa185
commit
43436d7181
@@ -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!({}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user