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
+37 -1
View File
@@ -84,6 +84,8 @@ pub struct Reclaimed {
/// Directories we tried and failed to remove. Reported rather than swallowed
/// — a GC that cannot collect is the thing being fixed.
pub failed: u64,
/// Rows swept from `mission_events`.
pub events: u64,
}
impl Reclaimed {
@@ -96,10 +98,12 @@ impl std::fmt::Display for Reclaimed {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"reclaimed {} orphan mission dir(s), {} scratch dir(s), {} output(s), {:.1} MiB{}",
"reclaimed {} orphan mission dir(s), {} scratch dir(s), {} output(s), \
{} mission event(s), {:.1} MiB{}",
self.orphan_dirs,
self.scratch_dirs,
self.outputs,
self.events,
self.bytes as f64 / (1024.0 * 1024.0),
if self.failed > 0 {
format!("{} COULD NOT BE REMOVED", self.failed)
@@ -116,9 +120,41 @@ async fn sweep_once(pool: &PgPool) -> Result<Reclaimed, String> {
reap_orphan_missions(pool, &root, &mut out).await?;
reap_scratch(&root, &mut out).await;
reap_outputs(pool, &root, &mut out).await;
reap_mission_events(pool, &mut out).await;
Ok(out)
}
/// How long a mission's structured activity is kept.
///
/// The World shows the last 24 hours of finished missions, so a week is
/// generous and still bounds a table that a single busy coding phase can add
/// hundreds of rows to. The per-phase cap bounds ONE phase; this bounds time.
const EVENT_RETENTION_DAYS: i32 = 7;
/// Sweep expired `mission_events`.
///
/// Bounded per pass rather than deleting the whole backlog in one statement: a
/// deployment that has been accumulating for months would otherwise take a long
/// lock on its first sweep after this ships. The sweep runs on a timer, so a
/// large backlog simply drains over several passes.
async fn reap_mission_events(pool: &PgPool, out: &mut Reclaimed) {
let res = sqlx::query(
"DELETE FROM mission_events
WHERE id IN (
SELECT id FROM mission_events
WHERE created_at < now() - make_interval(days => $1)
LIMIT 10000
)",
)
.bind(EVENT_RETENTION_DAYS)
.execute(pool)
.await;
match res {
Ok(r) => out.events += r.rows_affected(),
Err(e) => eprintln!("mission_gc: sweeping mission_events failed: {e}"),
}
}
/// Directories under the missions root with no mission row.
async fn reap_orphan_missions(
pool: &PgPool,