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
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user