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