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
@@ -185,23 +185,33 @@ async fn run_job(
|
||||
// the missions row; else fall back to the shared env-derived
|
||||
// gateway (pre-C3 missions + non-mission runs). This is what
|
||||
// isolates agents' workspace filesystem to that mission's repo.
|
||||
let mission_binding: Option<(Option<String>, Option<String>)> =
|
||||
sqlx::query_as::<_, (Option<String>, Option<String>)>(
|
||||
"SELECT m.runtime_endpoint, m.runtime_pairing_code
|
||||
type MissionBinding = (Option<String>, Option<String>, Uuid, Option<Uuid>);
|
||||
let mission_binding: Option<MissionBinding> = sqlx::query_as::<_, MissionBinding>(
|
||||
"SELECT m.runtime_endpoint, m.runtime_pairing_code, m.id, r.mission_phase_id
|
||||
FROM topology_runs r
|
||||
JOIN missions m ON m.id = r.mission_id
|
||||
WHERE r.id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
// What this run's turns will be attributed to. `None` when the run belongs
|
||||
// to no mission — a bare topology run has no phase to hang tool calls on.
|
||||
let tap = mission_binding
|
||||
.as_ref()
|
||||
.map(|(_, _, mission_id, phase_id)| crate::topology_exec::MissionTap {
|
||||
pool: pool.clone(),
|
||||
mission_id: *mission_id,
|
||||
phase_id: *phase_id,
|
||||
run_id: Some(id),
|
||||
});
|
||||
let leaf_result = match mission_binding {
|
||||
Some((Some(url), Some(code))) => {
|
||||
Some((Some(url), Some(code), _, _)) => {
|
||||
ZeroClawDriveExecutor::from_env_for_gateway_with_code(url, code)
|
||||
}
|
||||
Some((Some(url), None)) => ZeroClawDriveExecutor::from_env_for_gateway(url),
|
||||
Some((Some(url), None, _, _)) => ZeroClawDriveExecutor::from_env_for_gateway(url),
|
||||
_ => ZeroClawDriveExecutor::from_env(),
|
||||
};
|
||||
let leaf = match leaf_result {
|
||||
@@ -211,6 +221,13 @@ async fn run_job(
|
||||
return;
|
||||
}
|
||||
};
|
||||
// The tap rides on the leaf executor, so the recursive tiers get it too:
|
||||
// they drive the same leaf all the way down, and a company-tier mission's
|
||||
// tool calls belong to its phase exactly as a team-tier one's do.
|
||||
let leaf = match tap {
|
||||
Some(t) => leaf.with_tap(t),
|
||||
None => leaf,
|
||||
};
|
||||
|
||||
// Select the executor by deploy tier: `team` drives claws directly; the
|
||||
// upper tiers drive the recursive sub-topology executor (which runs each
|
||||
|
||||
Reference in New Issue
Block a user