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