fix(missions): a solo microVM run showed the operator an empty Live and Output tab

Found by a frontend wiring sweep, then confirmed in the database.

Everything the UI shows of a run's CONTENT reads
`topology_runs.checkpoint.records`: `/api/missions/{id}/documents` behind the
output reader, and `/api/topology-runs/{id}/events` behind the live pane. The
`team` and `microvm_graph` tiers write those records. The SOLO microVM path
never did — it updated `status` and nothing else:

    tier          | checkpoint_null | records
    microvm_graph | f               | 2-5
    team          | f               | 5
    microvm       | t               | 0      <-- every one

So a single-phase microVM mission ran real work, delivered a real branch, and
showed an empty Live tab and an empty Output tab. The agent's own account of the
turn went to stderr via eprintln and nowhere a user could reach.

Note what was NOT broken, since that was the initial suspicion: the SSE path
matches (`/api/topology-runs/{id}/events` on both sides), and a sweep of all 130
frontend `/api/` calls against the 164 registered routes found zero genuinely
missing endpoints. The wiring was fine; the data was absent.

The run now persists its turn as one record shaped exactly like the ones those
two readers already parse — `node_id`, `role` (the phase kind), `phase`,
`output` — so no reader changes. Written with `checkpoint || $3::jsonb` so a
future writer of other checkpoint keys is not clobbered.

246 lib tests.
This commit is contained in:
Omar Sobh
2026-08-07 19:02:43 -07:00
parent 3616bc4733
commit 62509a5090
+36 -1
View File
@@ -699,6 +699,7 @@ async fn launch_phase(
hub, hub,
mission_id, mission_id,
phase_id, phase_id,
kind,
workspace_id, workspace_id,
iteration, iteration,
&task, &task,
@@ -955,6 +956,8 @@ async fn launch_microvm_phase(
hub: &std::sync::Arc<crate::fleet::NodeHub>, hub: &std::sync::Arc<crate::fleet::NodeHub>,
mission_id: Uuid, mission_id: Uuid,
phase_id: Uuid, phase_id: Uuid,
// The phase kind, used to label this run's single checkpoint record.
kind: &str,
workspace_id: Uuid, workspace_id: Uuid,
iteration: i32, iteration: i32,
task: &str, task: &str,
@@ -1006,6 +1009,9 @@ async fn launch_microvm_phase(
let team_engine = team_engine.map(str::to_string); let team_engine = team_engine.map(str::to_string);
let pool2 = pool.clone(); let pool2 = pool.clone();
let hub = hub.clone(); let hub = hub.clone();
// Moved in for the checkpoint record below: the reader labels each record by
// role, and for a solo run the phase kind is the only role there is.
let phase_kind = kind.to_string();
tokio::spawn(async move { tokio::spawn(async move {
// Everything fallible lives in here, so every outcome closes the run. // Everything fallible lives in here, so every outcome closes the run.
let outcome = async { let outcome = async {
@@ -1101,12 +1107,41 @@ async fn launch_microvm_phase(
// this task reporting how the VM turned out is an observation, and it may // this task reporting how the VM turned out is an observation, and it may
// land minutes later. Without the guard a cancelled run silently reappears // land minutes later. Without the guard a cancelled run silently reappears
// as completed or failed. // as completed or failed.
// Persist the turn as a checkpoint RECORD, not just a status.
//
// Everything the UI shows of a run's content reads
// `topology_runs.checkpoint.records`: `/api/missions/{id}/documents`
// (the output reader) and `/api/topology-runs/{id}/events` (the live
// pane). The composed and team tiers write it; the SOLO microVM path
// never did — measured as `checkpoint IS NULL, records = 0` for every
// `tier='microvm'` run, against 5 records for `team`.
//
// So a solo microVM mission produced real work and showed the operator
// an empty Live tab and an empty Output tab, with the agent's own
// account of the turn going to stderr and nowhere else.
//
// Shape matches what those two readers already parse — role, node_id,
// output — so no reader changes.
let record = serde_json::json!({
"records": [{
"node_id": "n0",
"role": phase_kind,
"phase": "work",
"output": note,
"tokens": 0,
"gated": [],
}]
});
if let Err(e) = sqlx::query( if let Err(e) = sqlx::query(
"UPDATE topology_runs SET status = $2, updated_at = now() "UPDATE topology_runs
SET status = $2,
checkpoint = COALESCE(checkpoint, '{}'::jsonb) || $3::jsonb,
updated_at = now()
WHERE id = $1 AND status <> 'cancelled'", WHERE id = $1 AND status <> 'cancelled'",
) )
.bind(run_id) .bind(run_id)
.bind(status) .bind(status)
.bind(&record)
.execute(&pool2) .execute(&pool2)
.await .await
{ {