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:
co-authored by
Claude Opus 5
parent
c2fa8067e1
commit
9e61e3ba35
@@ -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)]);
|
||||
|
||||
Reference in New Issue
Block a user