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
+41 -18
View File
@@ -752,6 +752,10 @@ pub async fn world_live(
// On first seed we still emit — the World engine needs the initial
// scale for every pawn.
let mut last_bytes: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
// Push channel for frames that cannot wait for the 2s poll — an agent's
// reasoning arrives token by token. Subscribed BEFORE the first poll so
// nothing produced during the initial queries is missed.
let mut live_rx = crate::live_bus::global().subscribe();
loop {
let roster = match cm_db::repo::agents::roster(&pool, ws).await {
Ok(r) => r,
@@ -1128,7 +1132,12 @@ pub async fn world_live(
WHERE m.workspace_id = $1
AND e.id > $2
AND e.agent_id IS NOT NULL
AND e.kind IN ('reasoning', 'tool.call')
-- 'reasoning' is NOT read here: live_bus pushes those
-- as the runtime emits them, and emitting from both
-- delivered every turn twice — once pushed, once polled
-- ~2s later. The row is still written, as the durable
-- record; this feed just is not its second mouth.
AND e.kind = 'tool.call'
ORDER BY e.id
LIMIT 200",
)
@@ -1145,22 +1154,13 @@ pub async fn world_live(
let kind: String = r.get("kind");
let detail: serde_json::Value = r.get("detail");
let target: Option<String> = r.get("target");
if kind == "tool.call" {
yield sse("agent.tool.call", json!({
"agentId": agent_id.to_string(),
"tool": target.clone().unwrap_or_default(),
"target": detail.get("path").and_then(|v| v.as_str()),
}));
} else {
let text = detail.get("text").and_then(|v| v.as_str()).unwrap_or("");
if !text.is_empty() {
yield sse("agent.reasoning.delta", json!({
"agentId": agent_id.to_string(),
"text": text,
"channel": "say",
}));
}
}
debug_assert_eq!(kind, "tool.call", "query filters to tool.call");
let _ = kind;
yield sse("agent.tool.call", json!({
"agentId": agent_id.to_string(),
"tool": target.clone().unwrap_or_default(),
"target": detail.get("path").and_then(|v| v.as_str()),
}));
}
}
@@ -1290,7 +1290,30 @@ pub async fn world_live(
}
first = false;
tokio::time::sleep(Duration::from_secs(2)).await;
// Forward pushed frames until the next poll is due, instead of
// sleeping through them. This is what makes the reasoning stream
// token-level: a chunk reaches the browser as the runtime emits it,
// while everything queryable keeps its 2s cadence.
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
loop {
let left = deadline.saturating_duration_since(tokio::time::Instant::now());
if left.is_zero() {
break;
}
match tokio::time::timeout(left, live_rx.recv()).await {
Ok(Ok(ev)) => {
if ev.workspace_id == ws.as_uuid() {
yield sse(&ev.kind, ev.data);
}
}
// Lagged: this subscriber fell behind and frames were
// dropped for it. Keep going — a live view that skips is
// right, and blocking the producer would be wrong.
Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue,
Ok(Err(_)) => break,
Err(_) => break,
}
}
}
};