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
+73 -15
View File
@@ -67,6 +67,9 @@ pub struct ZeroClawDriveExecutor {
/// put mission concepts into every tier that has no missions.
pub struct MissionTap {
pub pool: sqlx::PgPool,
/// Which workspace's live feed these frames belong to. Every subscriber is
/// workspace-scoped, so a frame without this could not be routed.
pub workspace_id: uuid::Uuid,
pub mission_id: uuid::Uuid,
pub phase_id: Option<uuid::Uuid>,
pub run_id: Option<uuid::Uuid>,
@@ -294,7 +297,16 @@ impl ZeroClawDriveExecutor {
.await
.map_err(|e| OrchestratorError::Executor(format!("ws send failed: {e}")))?;
let (outcome, trace) = match tokio::time::timeout(TURN_TIMEOUT, Self::drain(&mut ws)).await
let (outcome, trace) = match tokio::time::timeout(
TURN_TIMEOUT,
Self::drain(
&mut ws,
self.tap.as_ref().and_then(|t| {
crate::live_bus::agent_id_from_alias(alias).map(|a| (t.workspace_id, a))
}),
),
)
.await
{
Ok(res) => res?,
Err(_) => {
@@ -457,7 +469,13 @@ impl ZeroClawDriveExecutor {
/// trace is separate from [`TurnOutcome`] deliberately: that type is the
/// shared orchestrator contract used by every tier, and tool telemetry is a
/// mission concern.
async fn drain<S>(ws: &mut S) -> Result<(TurnOutcome, ToolTrace), OrchestratorError>
/// `live` is the push target for this turn: `Some((workspace, agent))` when
/// the turn belongs to a mission AND runs under a claw alias. `None` for the
/// governor/door/evaluator, whose output belongs to no agent.
async fn drain<S>(
ws: &mut S,
live: Option<(uuid::Uuid, uuid::Uuid)>,
) -> Result<(TurnOutcome, ToolTrace), OrchestratorError>
where
S: StreamExt<Item = Result<Message, tokio_tungstenite::tungstenite::Error>>
+ SinkExt<Message>
@@ -478,6 +496,24 @@ impl ZeroClawDriveExecutor {
"chunk" => {
if let Some(c) = v.get("content").and_then(|c| c.as_str()) {
output.push_str(c);
// Push, don't wait for the poll. This is the
// whole point of the bus: the reasoning card
// previously showed a step's text only after the
// step ended and the row was written, so an
// agent mid-thought looked idle for seconds.
if let Some((ws_id, agent_id)) = live {
if !c.trim().is_empty() {
crate::live_bus::global().publish(
ws_id,
"agent.reasoning.delta",
serde_json::json!({
"agentId": agent_id.to_string(),
"text": c,
"channel": "say",
}),
);
}
}
}
}
"done" => {
@@ -634,22 +670,31 @@ mod tests {
/// second error on top of the first.
#[test]
fn the_container_name_is_derived_or_absent_never_wrong() {
let ex = |url: &str| ZeroClawDriveExecutor::new(
url.to_string(),
String::new(),
std::collections::HashMap::new(),
"scout".into(),
);
let ex = |url: &str| {
ZeroClawDriveExecutor::new(
url.to_string(),
String::new(),
std::collections::HashMap::new(),
"scout".into(),
)
};
assert_eq!(
ex("http://cm-runtime-mission-019fec2d596f:42617").container_name().as_deref(),
ex("http://cm-runtime-mission-019fec2d596f:42617")
.container_name()
.as_deref(),
Some("cm-runtime-mission-019fec2d596f")
);
assert_eq!(
ex("https://host.example:8443/base").container_name().as_deref(),
ex("https://host.example:8443/base")
.container_name()
.as_deref(),
Some("host.example")
);
// No scheme is still a host.
assert_eq!(ex("clawmates-runtime:42617").container_name().as_deref(), Some("clawmates-runtime"));
assert_eq!(
ex("clawmates-runtime:42617").container_name().as_deref(),
Some("clawmates-runtime")
);
assert_eq!(ex("").container_name(), None);
}
@@ -792,18 +837,31 @@ mod tests {
assert_eq!(
trace.calls,
vec![
ToolCall { tool: "Read".into(), path: Some("/mission/repo/src/a.rs".into()) },
ToolCall { tool: "Bash".into(), path: None },
ToolCall {
tool: "Read".into(),
path: Some("/mission/repo/src/a.rs".into())
},
ToolCall {
tool: "Bash".into(),
path: None
},
// `arguments_summary` said "src/main.rs". It is prose, so it is
// not a file touch — a path scraped from a sentence would put
// files on the map that no agent opened.
ToolCall { tool: "Grep".into(), path: None },
ToolCall {
tool: "Grep".into(),
path: None
},
]
);
assert_eq!(trace.unmatched.get("a_frame_we_have_never_seen"), Some(&2));
assert_eq!(trace.unmatched.get("session_start"), Some(&1));
// `done` terminates the drain and is not an unmatched frame.
assert!(!trace.unmatched.contains_key("done"), "{:?}", trace.unmatched);
assert!(
!trace.unmatched.contains_key("done"),
"{:?}",
trace.unmatched
);
}
#[tokio::test]