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
+125
View File
@@ -228,6 +228,61 @@ async fn world_files(pool: &PgPool, ws: WorkspaceId, only: Option<Uuid>) -> Vec<
.collect()
}
/// One row of `mission_events` — what an agent actually did.
struct ActRow {
id: i64,
mission_id: String,
phase_id: Option<String>,
agent_id: Option<String>,
kind: String,
target: String,
}
/// Structured mission activity since `after`, oldest first.
///
/// `after < 0` means "the first pass has not run yet": everything is returned
/// so the caller can seed its cursor and draw the backlog as settled history.
async fn world_acts(pool: &PgPool, ws: WorkspaceId, only: Option<Uuid>, after: i64) -> Vec<ActRow> {
let rows = sqlx::query(
"SELECT e.id,
e.mission_id::text AS mission_id,
e.phase_id::text AS phase_id,
e.agent_id::text AS agent_id,
e.kind,
e.target
FROM mission_events e
JOIN missions m ON m.id = e.mission_id
WHERE m.workspace_id = $1
AND e.kind IN ('tool.call', 'file.touch')
AND e.target IS NOT NULL
AND e.id > $2
AND ( m.status = 'running'
OR ( m.status IN ('completed', 'failed')
AND m.completed_at > now() - interval '24 hours' ) )
AND ($3::uuid IS NULL OR m.id = $3)
ORDER BY e.id
LIMIT 500",
)
.bind(ws.as_uuid())
.bind(after)
.bind(only)
.fetch_all(pool)
.await
.unwrap_or_default();
rows.into_iter()
.filter_map(|r| {
Some(ActRow {
id: r.get::<i64, _>("id"),
mission_id: r.get::<String, _>("mission_id"),
phase_id: r.get::<Option<String>, _>("phase_id"),
agent_id: r.get::<Option<String>, _>("agent_id"),
kind: r.get::<String, _>("kind"),
target: r.get::<Option<String>, _>("target")?,
})
})
.collect()
}
/// Agents that execute a phase of this kind, via the purposes the phase runner
/// itself uses. Returns empty for a teamless (microVM) mission.
async fn phase_agents(
@@ -550,6 +605,13 @@ pub async fn world_live(
// (phase, path) pairs already announced — a delivered file is a fact
// that happened once, not a recurring event.
let mut last_file: HashSet<String> = HashSet::new();
// `mission_events` cursor. -1 until the first pass seeds it, which is
// what separates BACKFILL from MOTION: everything already in the table
// when a subscriber arrives is history and is drawn as a settled map,
// and only what lands afterwards is animated. Without the distinction,
// opening a finished mission would replay an hour of tool calls as a
// burst storm and read as a mission that just did all of it at once.
let mut event_cursor: i64 = -1;
// Audit-log cursor for edge-initiated inter-agent events (delegation,
// A2A) that bypass the run loop. -1 until seeded on the first pass.
let mut audit_cursor: i64 = -1;
@@ -730,6 +792,69 @@ pub async fn world_live(
);
}
// Structured activity — the motion channel. Tool calls and file
// touches recorded at the source by the container tap and the
// microVM `PostToolUse` hook. Never parsed from prose: a tool name
// in a log is indistinguishable from an agent TALKING about a tool.
{
let backfill = event_cursor < 0;
for a in world_acts(&pool, ws, only_mission, event_cursor.max(0)).await {
event_cursor = event_cursor.max(a.id);
match a.kind.as_str() {
"file.touch" => {
let key = format!("{}|{}", a.phase_id.as_deref().unwrap_or(""), a.target);
if !last_file.insert(key) {
continue;
}
yield sse(
"mission.file",
json!({
"missionId": a.mission_id,
"phaseId": a.phase_id,
"agentId": a.agent_id,
"path": a.target,
// Backlog is history: it draws the file and
// stops there. Only what lands while someone
// is watching is motion.
"source": if backfill { "diff" } else { "tool" },
}),
);
}
// A tool call with an agent is a pawn leaving its
// station and coming back; without one (a microVM phase
// has no platform agent) it is only a node lighting up,
// which is the truth rather than an invented traveller.
_ if !backfill => {
let node_id = format!("tool:{}", a.target);
match &a.agent_id {
Some(agent) => yield sse(
"world.touch",
json!({
"agentId": agent,
"nodeId": node_id,
"kind": "event",
"weight": 0.45,
}),
),
None => yield sse(
"node.activity",
json!({
"nodeId": node_id,
"label": a.target,
"kind": "event",
"heat": 0.5,
}),
),
}
}
// A backlogged tool call draws nothing: the orb would be
// a tool nobody is using, permanently lit on a map of
// work that already finished.
_ => {}
}
}
}
// Real convergence: each running agent beams toward its active-run node.
for (run_id, agent_id) in &runs {
let node_id = format!("run:{}", &run_id[..run_id.len().min(8)]);