`team_engine='composed'` (the third name migration 0069 anticipated) runs a
mission as a durable ZeroClaw graph whose every node is a whole
Claude-Code-in-a-microVM session. Engine Z owns checkpoint/resume, cancellation
and per-node heterogeneity; Engine C owns shared context and cheap fan-out;
neither has the other's asset, which is why this is a composition and not a
compromise.
`MicroVmTurnExecutor` implements the existing `TurnExecutor`, so it inherits the
planners, the checkpoint, the stale-run recovery, `close_finished_phases`, the
evaluator, capture and delivery unchanged — the same trick `SubTopologyExecutor`
already plays with a heavy `run_turn`. Producer side emits ONE `queued` row
carrying the real graph and lets the worker claim it: the durability IS being
worker-driven, and the solo path's `tokio::spawn` has none of it. Still exactly
one `topology_runs` row per unit of work and one completion path — `finish()` is
now that one place, shared by every tier.
THE TRAP, solved and proven. A VM is inject → run → collect → destroy, so a
per-node VM with text-only handoff silently loses every file an earlier node
wrote: node 2 boots from the original checkout, sees nothing, and still reports
success. The mission's host checkout is the medium — every node injects from it
and collects back over it — and two properties make that safe rather than lucky:
`execute_resumable` is strictly sequential, so two VMs never write one directory;
and the vm id is deterministic per (phase, iteration, step), so a duplicate is
refused by the node ("vm already exists") instead of becoming a second writer.
NEGATIVE CONTROL, run rather than assumed: with `repo` swapped for a private
per-node workspace, `a_later_node_sees_an_earlier_nodes_files` FAILS with
`saw:[]`; restored, it passes. The `PhaseVm` seam exists for exactly this — it
models inject/collect through the real `mission_fs` tar path in milliseconds.
Two durability traps this tier walks into, both closed:
- `requeue_stale` fires at 180s on `updated_at`, and one node here can run for
an hour. `SubTopologyExecutor` keeps its parent alive from each leaf step;
there is nothing between the start and end of a VM turn, so the turn holds a
ticker that touches `updated_at` every 30s and aborts on drop. Without it a
healthy composed run is requeued mid-node and boots a second VM.
- the 15-minute stuck-run reaper asks "any step records since it was CREATED?",
which describes a healthy composed run as readily as a wedged one. Hence
`REAPABLE_TIERS` — worker-driven minus this tier. Reaping it would be #54 in
a different costume.
`on_launch` mints no team for a microVM mission, deliberately: claws in
containers are what a VM mission does not use. So `mission_orchestrator::
composed_graph` builds the shape from the team template directly — nodes, roles
and pattern, zero claws provisioned. Per-node `attrs["backend"]` and
`attrs["node_id"]` override the mission's, which is what makes a validator node
on another provider's image a first-class graph node; a malformed `node_id`
fails the node rather than quietly running it where the graph did not ask.
Refusals are recorded as a failed run, not returned as an error: `launch_phase`
is swept every ten seconds, so a returned error is a phase that retries forever
while the log repeats itself.
501 tests pass, clippy clean. NOT yet proven end to end: no composed mission has
run on the fleet, so the resume-after-a-killed-worker leg is argued from the DB
test and the step-numbering test, not from a real two-node run.
Co-Authored-By: Claude Opus 5 <[email protected]>
219 lines
8.1 KiB
Rust
219 lines
8.1 KiB
Rust
//! The sweepers must leave SELF-DRIVEN runs alone.
|
|
//!
|
|
//! A `tier='microvm'` or `tier='session'` run is inserted directly as `running` by
|
|
//! `phase_runner` and owned start-to-finish by its own `tokio::spawn`. Nothing
|
|
//! touches its `updated_at` or `checkpoint` while it is in flight, because there
|
|
//! is no per-step loop to hook.
|
|
//!
|
|
//! Both sweepers were written when every `running` row was a `cm_orchestrator` job
|
|
//! that checkpointed after each step, and neither filtered on tier. The result,
|
|
//! measured in production on 2026-08-06: `requeue_stale` declared a healthy microVM
|
|
//! run stale at 180 seconds, the worker claimed it, failed to deserialize its graph
|
|
//! placeholder, and killed the phase with "missing or invalid graph" — while the
|
|
//! agent went on working and its VM was orphaned for over an hour.
|
|
//!
|
|
//! **Every microVM mission that appeared to work did so by finishing inside three
|
|
//! minutes.** The end-to-end harness runs a 90-second mission, so it cannot see
|
|
//! this class at all — which is why the guard lives here, against the real SQL, and
|
|
//! costs milliseconds instead of eight minutes.
|
|
|
|
use cm_db::repo::topology_runs;
|
|
use cm_domain::WorkspaceId;
|
|
use uuid::Uuid;
|
|
|
|
/// Insert a run that is `running` and has looked idle for a long time — exactly
|
|
/// the shape a long agent turn presents.
|
|
async fn stale_running_run(pool: &sqlx::PgPool, ws: WorkspaceId, tier: &str) -> Uuid {
|
|
let id = Uuid::now_v7();
|
|
sqlx::query(
|
|
"INSERT INTO topology_runs
|
|
(id, workspace_id, task, kind, status, graph, tier,
|
|
created_at, updated_at)
|
|
VALUES ($1, $2, 'long turn', 'run', 'running', $3, $4,
|
|
now() - interval '30 minutes', now() - interval '30 minutes')",
|
|
)
|
|
.bind(id)
|
|
.bind(ws.as_uuid())
|
|
// The placeholder a self-driven run carries: no `kind`, so `TopologyGraph`
|
|
// cannot parse it. That is what turned a requeue into a hard failure.
|
|
.bind(serde_json::json!({ "nodes": [], "edges": [], "executor": tier }))
|
|
.bind(tier)
|
|
// `mission_id` is left NULL: it has an FK to `missions`, and `requeue_stale`
|
|
// does not look at it. The reaper DOES filter on `mission_id IS NOT NULL` —
|
|
// which is precisely what used to be mistaken for "orchestrator-driven" — and
|
|
// it now shares the same tier allowlist, asserted below.
|
|
.execute(pool)
|
|
.await
|
|
.expect("insert run");
|
|
id
|
|
}
|
|
|
|
async fn status_of(pool: &sqlx::PgPool, id: Uuid) -> String {
|
|
sqlx::query_scalar::<_, String>("SELECT status FROM topology_runs WHERE id = $1")
|
|
.bind(id)
|
|
.fetch_one(pool)
|
|
.await
|
|
.expect("read status")
|
|
}
|
|
|
|
async fn workspace(pool: &sqlx::PgPool) -> WorkspaceId {
|
|
let ws = cm_domain::Workspace {
|
|
id: WorkspaceId::new(),
|
|
name: "Sweeper".into(),
|
|
plan: "team".into(),
|
|
};
|
|
cm_db::repo::workspaces::insert(pool, &ws)
|
|
.await
|
|
.expect("workspace");
|
|
ws.id
|
|
}
|
|
|
|
/// The bug, in one assertion: 30 minutes idle and it must still be `running`.
|
|
#[tokio::test]
|
|
async fn requeue_stale_leaves_self_driven_runs_alone() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = workspace(&pool).await;
|
|
|
|
let microvm = stale_running_run(&pool, ws, "microvm").await;
|
|
let session = stale_running_run(&pool, ws, "session").await;
|
|
|
|
let moved = topology_runs::requeue_stale(&pool, 180.0)
|
|
.await
|
|
.expect("requeue");
|
|
|
|
assert_eq!(
|
|
status_of(&pool, microvm).await,
|
|
"running",
|
|
"a microvm run was requeued out from under a live VM ({moved} rows moved)"
|
|
);
|
|
assert_eq!(
|
|
status_of(&pool, session).await,
|
|
"running",
|
|
"a session run was requeued out from under a live agent"
|
|
);
|
|
}
|
|
|
|
/// And a worker-driven run in the same state MUST still be requeued, or the fix
|
|
/// would have been "stop sweeping" rather than "sweep the right rows".
|
|
#[tokio::test]
|
|
async fn requeue_stale_still_rescues_worker_driven_runs() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = workspace(&pool).await;
|
|
let team = stale_running_run(&pool, ws, "team").await;
|
|
|
|
topology_runs::requeue_stale(&pool, 180.0).await.expect("requeue");
|
|
|
|
assert_eq!(
|
|
status_of(&pool, team).await,
|
|
"queued",
|
|
"a genuinely stalled team run must still be recovered"
|
|
);
|
|
}
|
|
|
|
/// Defence in depth: even handed a queued self-driven row, the worker must not
|
|
/// adopt a job it cannot execute. Claiming one is what produced the
|
|
/// "missing or invalid graph" failure on a run that was perfectly healthy.
|
|
#[tokio::test]
|
|
async fn the_worker_will_not_claim_a_self_driven_run() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = workspace(&pool).await;
|
|
|
|
let id = Uuid::now_v7();
|
|
topology_runs::enqueue_run_tier(
|
|
&pool,
|
|
id,
|
|
ws,
|
|
"should never be claimed",
|
|
&serde_json::json!({ "nodes": [], "edges": [], "executor": "microvm" }),
|
|
"microvm",
|
|
)
|
|
.await
|
|
.expect("enqueue");
|
|
|
|
let claimed = topology_runs::claim_next_queued(&pool).await.expect("claim");
|
|
assert!(
|
|
claimed.is_none(),
|
|
"the worker claimed a microvm run: {:?}",
|
|
claimed.map(|c| c.tier)
|
|
);
|
|
assert_eq!(status_of(&pool, id).await, "queued", "and it must be left as it was");
|
|
}
|
|
|
|
/// The composed tier is the mirror image of the two above and must not be
|
|
/// mistaken for them: it runs VMs, but the WORKER drives its graph, so being
|
|
/// claimed and requeued is exactly what gives it checkpointing and resume.
|
|
#[tokio::test]
|
|
async fn the_worker_claims_and_rescues_a_composed_run() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = workspace(&pool).await;
|
|
|
|
let id = Uuid::now_v7();
|
|
topology_runs::enqueue_run_tier(
|
|
&pool,
|
|
id,
|
|
ws,
|
|
"compose the engines",
|
|
// A real graph, unlike the self-driven placeholder: the worker plans it.
|
|
&serde_json::json!({
|
|
"kind": "pipeline",
|
|
"nodes": [{ "id": "a", "role": "worker", "attrs": {} }],
|
|
"edges": []
|
|
}),
|
|
"microvm_graph",
|
|
)
|
|
.await
|
|
.expect("enqueue");
|
|
|
|
let claimed = topology_runs::claim_next_queued(&pool)
|
|
.await
|
|
.expect("claim")
|
|
.expect("a composed run must be claimable, or it never runs at all");
|
|
assert_eq!(claimed.tier, "microvm_graph");
|
|
assert_eq!(claimed.id, id);
|
|
|
|
// And a composed run whose worker died must come back: its checkpoint is
|
|
// what makes resume possible, and requeue is what triggers it.
|
|
sqlx::query("UPDATE topology_runs SET updated_at = now() - interval '30 minutes' WHERE id = $1")
|
|
.bind(id)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("age the run");
|
|
topology_runs::requeue_stale(&pool, 180.0).await.expect("requeue");
|
|
assert_eq!(
|
|
status_of(&pool, id).await,
|
|
"queued",
|
|
"a composed run orphaned by a dead worker must be recovered"
|
|
);
|
|
}
|
|
|
|
/// The allowlist is the single place this policy lives, so assert its membership
|
|
/// directly — a new self-driven tier added without touching it would otherwise be
|
|
/// exposed exactly as microvm was.
|
|
#[test]
|
|
fn the_allowlist_names_only_worker_driven_tiers() {
|
|
for driven in ["team", "swarm", "company", "org"] {
|
|
assert!(
|
|
topology_runs::WORKER_DRIVEN_TIERS.contains(&driven),
|
|
"{driven} is driven by the worker and must be sweepable"
|
|
);
|
|
}
|
|
for self_driven in ["microvm", "session"] {
|
|
assert!(
|
|
!topology_runs::WORKER_DRIVEN_TIERS.contains(&self_driven),
|
|
"{self_driven} owns its own lifecycle; sweeping it kills live work"
|
|
);
|
|
}
|
|
// `microvm_graph` is worker-driven but NOT reapable: one of its steps is a
|
|
// whole agent session in a VM, so "no step records in 15 minutes" is what a
|
|
// healthy composed run looks like, and reaping it would orphan a live VM —
|
|
// #54 in a different tier.
|
|
assert!(topology_runs::WORKER_DRIVEN_TIERS.contains(&"microvm_graph"));
|
|
assert!(!topology_runs::REAPABLE_TIERS.contains(&"microvm_graph"));
|
|
for reapable in topology_runs::REAPABLE_TIERS {
|
|
assert!(
|
|
topology_runs::WORKER_DRIVEN_TIERS.contains(reapable),
|
|
"{reapable} is reaped but never driven"
|
|
);
|
|
}
|
|
}
|