feat(viz): what the agents actually did, as structured events

The World could draw a mission's shape but nothing about the work. The
detail existed only as prose in checkpoint.log and model output, where a
tool name is indistinguishable from an agent *talking about* a tool — so
it was never parsed, deliberately. `mission_events` is the structured
channel that replaces it.

Three taps, one table:

- Container tier: the `_ => {}` at the end of topology_exec's typed frame
  stream now matches `tool_call` and reads the tool's JSON ARGUMENTS for a
  path. Never the prose summary — a path scraped from a sentence would put
  files on the map that no agent opened, and the test proves a Grep whose
  summary says "src/main.rs" produces no file touch. The frame name itself
  is unverified, so the same commit ships an unmatched-frame-type
  histogram: a tap that matches nothing looks exactly like a mission that
  used no tools, and this is how one gw-04 run names the real frame.

- microVM tier: a `PostToolUse` hook, the seam vm_stop_gate already proved
  fires under `claude -p`. It copies stdin to /root/tap and exits 0
  unconditionally — a non-zero PostToolUse hook talks back to the model,
  which would turn the observer into a participant. Drained before collect,
  since the VM is destroyed moments later.

- Phase transitions: five identical copies of the pending→running UPDATE
  became one `mark_phase_running`, and `close_finished_phases` grew
  RETURNING. Its CASE decides each phase's status inside SQL from rows the
  statement does not change, so it cannot be re-derived afterwards without
  writing that CASE twice — without RETURNING it emits zero phase.completed
  and reports success.

The settings.json hazard the plan called out: the stop gate wrote the
WHOLE document, so a second hook writer would have silently erased it and
a coding phase would then complete having written nothing — the exact
failure the gate exists to catch. There is now one composer,
`vm_tool_tap::guest_settings`, one writer, and a source-walk test that
fails if anything else writes a settings document.

`mission_events.run_id` carries no FK on purpose: phase_runner DELETEs
topology_runs on retry, and a cascade would erase a phase's whole history
the moment it retried — silently, since a cascade is not an error.

world.rs streams it with a cursor that separates backfill from motion.
Everything already in the table when a subscriber arrives is drawn as
settled history; only what lands afterwards animates. Otherwise opening a
finished mission replays an hour of tool calls as a burst storm.

Bounded twice: 400 events per phase (enforced inside the INSERT, since
two concurrent taps would each read a count below the cap) and a 7-day
retention sweep in mission_gc.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-11 09:16:50 -07:00
co-authored by Claude Opus 5
parent c2fa8067e1
commit 9e61e3ba35
13 changed files with 1257 additions and 101 deletions
+227 -10
View File
@@ -54,6 +54,46 @@ pub struct ZeroClawDriveExecutor {
/// Bearer token, paired lazily and reused across turns.
token: Arc<Mutex<Option<String>>>,
http: reqwest::Client,
/// Where this executor's turns record what they did. `None` on every path
/// that is not a mission phase (the governor, the door, the evaluator) —
/// those turns belong to no phase and have nothing to attribute to.
tap: Option<Arc<MissionTap>>,
}
/// Where a turn's tool activity is written, and what it belongs to.
///
/// Carried on the executor rather than passed per turn because `TurnRequest`
/// is the shared orchestrator contract: threading a mission id through it would
/// put mission concepts into every tier that has no missions.
pub struct MissionTap {
pub pool: sqlx::PgPool,
pub mission_id: uuid::Uuid,
pub phase_id: Option<uuid::Uuid>,
pub run_id: Option<uuid::Uuid>,
}
/// One tool call, as the frame stream reported it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolCall {
pub tool: String,
/// The path the tool's **arguments** named, if any. Never extracted from a
/// prose summary — see [`crate::mission_events::tool_path`].
pub path: Option<String>,
}
/// What one turn's frames said about the work, beside its text.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ToolTrace {
pub calls: Vec<ToolCall>,
/// Frame `type` values this drain did not recognise, counted.
///
/// Shipped in the same change as the tap on purpose: the frame name
/// `tool_call` is taken from a comment in this file, not from a captured
/// frame. If the runtime calls it something else, the tap records nothing
/// and nothing anywhere errors — the World simply stays as sparse as it was
/// before. This histogram is how one gw-04 run names the real frame instead
/// of a bisect.
pub unmatched: std::collections::BTreeMap<String, u32>,
}
impl ZeroClawDriveExecutor {
@@ -71,9 +111,17 @@ impl ZeroClawDriveExecutor {
default_alias,
token: Arc::new(Mutex::new(None)),
http: reqwest::Client::new(),
tap: None,
}
}
/// Attach the mission this executor's turns belong to, so their tool calls
/// are recorded. Without it the executor behaves exactly as it did.
pub fn with_tap(mut self, tap: MissionTap) -> Self {
self.tap = Some(Arc::new(tap));
self
}
/// Build from the environment:
/// - `ZEROCLAW_GATEWAY_URL` (required) e.g. `http://127.0.0.1:42617`
/// - `ZEROCLAW_TOKEN` (preferred) a durable bearer token — pair once
@@ -203,6 +251,19 @@ impl ZeroClawDriveExecutor {
}
pub async fn drive(&self, alias: &str, prompt: &str) -> Result<TurnOutcome, OrchestratorError> {
self.drive_traced(alias, prompt).await.map(|(o, _)| o)
}
/// [`Self::drive`], also returning what the turn's frames said it did.
///
/// Exists so the tool tap is testable at all: `drive` discards the trace
/// after recording it, and a tap whose extraction is never asserted is
/// exactly the kind of code that silently records nothing.
pub(crate) async fn drive_traced(
&self,
alias: &str,
prompt: &str,
) -> Result<(TurnOutcome, ToolTrace), OrchestratorError> {
let token = self.ensure_paired().await?;
let ws_base = if let Some(rest) = self.gateway_url.strip_prefix("https") {
format!("wss{rest}")
@@ -226,7 +287,8 @@ impl ZeroClawDriveExecutor {
.await
.map_err(|e| OrchestratorError::Executor(format!("ws send failed: {e}")))?;
let outcome = match tokio::time::timeout(TURN_TIMEOUT, Self::drain(&mut ws)).await {
let (outcome, trace) = match tokio::time::timeout(TURN_TIMEOUT, Self::drain(&mut ws)).await
{
Ok(res) => res?,
Err(_) => {
// "turn timed out" on its own is unactionable, and the one place
@@ -253,7 +315,60 @@ impl ZeroClawDriveExecutor {
}
};
let _ = ws.close(None).await;
Ok(outcome)
self.record_trace(alias, &trace).await;
Ok((outcome, trace))
}
/// Persist what this turn's frames said the agent did.
///
/// Best-effort and after the fact: a telemetry write must not be able to
/// fail a turn that already succeeded.
async fn record_trace(&self, alias: &str, trace: &ToolTrace) {
if !trace.unmatched.is_empty() {
// Logged whether or not a tap is attached — the point is to learn
// the real frame names, and the paths with no tap see the same
// stream.
eprintln!(
"topology_exec: unmatched frame types this turn ({alias}): {:?}",
trace.unmatched
);
}
let Some(tap) = self.tap.as_ref() else { return };
if trace.calls.is_empty() {
return;
}
let agent_id = crate::runtime_provision::claw_from_alias(alias);
let event = |kind: &str, target: String, detail: serde_json::Value| {
crate::mission_events::MissionEvent {
mission_id: tap.mission_id,
phase_id: tap.phase_id,
run_id: tap.run_id,
agent_id,
kind: kind.to_string(),
target: Some(target),
detail,
}
};
let mut events = Vec::new();
for call in &trace.calls {
events.push(event(
crate::mission_events::TOOL_CALL,
call.tool.clone(),
serde_json::Value::Null,
));
// A file touch is a SECOND event, not a replacement: the tool call
// happened whether or not we could name a path in its arguments,
// and collapsing the two would make every unparseable tool call
// disappear from the record entirely.
if let Some(path) = &call.path {
events.push(event(
crate::mission_events::FILE_TOUCH,
crate::mission_events::repo_relative(path, GUEST_ROOTS),
serde_json::json!({ "tool": call.tool }),
));
}
}
crate::mission_events::record_all(&tap.pool, events).await;
}
/// The runtime container behind this executor, derived from its gateway URL
@@ -330,7 +445,12 @@ impl ZeroClawDriveExecutor {
}
/// Read frames until a terminal (`done`/`error`/`approval_request`) event.
async fn drain<S>(ws: &mut S) -> Result<TurnOutcome, OrchestratorError>
///
/// Returns the turn's outcome AND what its frames said the agent did. The
/// 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>
where
S: StreamExt<Item = Result<Message, tokio_tungstenite::tungstenite::Error>>
+ SinkExt<Message>
@@ -339,6 +459,7 @@ impl ZeroClawDriveExecutor {
let mut output = String::new();
let mut tokens: u64 = 0;
let mut gated: Vec<GatedAction> = Vec::new();
let mut trace = ToolTrace::default();
while let Some(frame) = ws.next().await {
let msg = frame.map_err(|e| OrchestratorError::Executor(format!("ws recv: {e}")))?;
@@ -384,8 +505,39 @@ impl ZeroClawDriveExecutor {
"aborted" => {
return Err(OrchestratorError::Executor("turn aborted".into()));
}
// session_start, thinking, tool_call, tool_result, …
_ => {}
// The action channel. `arguments` is read as JSON and
// nothing else is: the frame also carries a prose
// summary, and a path pulled out of THAT would be right
// often enough to be believed and wrong often enough to
// put files on the map that nobody edited.
"tool_call" => {
let tool = v
.get("tool")
.or_else(|| v.get("name"))
.and_then(|t| t.as_str())
.unwrap_or("")
.trim()
.to_string();
if !tool.is_empty() {
let args = v
.get("arguments")
.or_else(|| v.get("input"))
.cloned()
.unwrap_or(serde_json::Value::Null);
trace.calls.push(ToolCall {
path: crate::mission_events::tool_path(&args),
tool,
});
}
}
// session_start, thinking, tool_result, …
other => {
// Counted, not ignored. See `ToolTrace::unmatched`:
// the frame name above is unverified, and a tap
// that matches nothing looks exactly like a mission
// that used no tools.
*trace.unmatched.entry(other.to_string()).or_insert(0) += 1;
}
}
}
Message::Ping(p) => {
@@ -396,14 +548,21 @@ impl ZeroClawDriveExecutor {
}
}
Ok(TurnOutcome {
output: output.trim().to_string(),
tokens,
gated,
})
Ok((
TurnOutcome {
output: output.trim().to_string(),
tokens,
gated,
},
trace,
))
}
}
/// Guest workspace roots, stripped so a tool's absolute path becomes the
/// repo-relative one a person recognises.
const GUEST_ROOTS: &[&str] = &["/mission/repo", "/workspace", "/repo"];
impl TurnExecutor for ZeroClawDriveExecutor {
async fn run_turn(&self, req: TurnRequest) -> Result<TurnOutcome, OrchestratorError> {
// An explicit per-node agent (graph `node.attrs["agent"]`) wins, so one
@@ -505,6 +664,30 @@ mod tests {
})
}
/// A stream carrying tool calls and one frame type we do not know.
async fn tool_ws(ws: WebSocketUpgrade) -> Response {
ws.on_upgrade(|mut socket: WebSocket| async move {
let _ = socket.recv().await;
for f in [
json!({"type": "session_start"}),
json!({"type": "tool_call", "tool": "Read",
"arguments": {"file_path": "/mission/repo/src/a.rs"}}),
// A tool whose arguments name no path at all.
json!({"type": "tool_call", "tool": "Bash",
"arguments": {"command": "cargo test"}}),
// Prose that MENTIONS a path. It must not become a file touch.
json!({"type": "tool_call", "tool": "Grep",
"arguments_summary": "searching src/main.rs",
"arguments": {"pattern": "fn main"}}),
json!({"type": "a_frame_we_have_never_seen"}),
json!({"type": "a_frame_we_have_never_seen"}),
json!({"type": "done", "input_tokens": 1, "output_tokens": 1}),
] {
let _ = socket.send(AxMsg::Text(f.to_string().into())).await;
}
})
}
async fn approval_ws(ws: WebSocketUpgrade) -> Response {
ws.on_upgrade(|mut socket: WebSocket| async move {
let _ = socket.recv().await;
@@ -569,6 +752,40 @@ mod tests {
assert!(out.gated.is_empty());
}
/// Tool detail comes from arguments, and unknown frames are counted.
///
/// The two halves are one test because they are one risk. The frame type
/// `tool_call` is taken from a comment in this file, not from a captured
/// frame — so if it is wrong, the tap records nothing, the World stays as
/// sparse as it was, and NOTHING errors. The histogram is what turns that
/// into a log line naming the real frame.
#[tokio::test]
async fn tool_frames_give_up_their_arguments_and_unknown_frames_are_counted() {
let router = Router::new()
.route("/pair", post(pair))
.route("/ws/chat", get(tool_ws));
let base = serve(router).await;
let exec = ZeroClawDriveExecutor::new(base, "code".into(), HashMap::new(), "scout".into());
let (_out, trace) = exec.drive_traced("scout", "go").await.unwrap();
assert_eq!(
trace.calls,
vec![
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 },
]
);
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);
}
#[tokio::test]
async fn approval_request_is_recorded_as_blocked() {
let router = Router::new()