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