feat(missions): Slice 4 — the two engines composed, with the file handoff proven

`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]>
This commit is contained in:
Omar Sobh
2026-08-06 13:00:44 -07:00
co-authored by Claude Opus 5
parent e31688bac5
commit 12147a1e01
9 changed files with 1141 additions and 17 deletions
+84 -4
View File
@@ -33,7 +33,12 @@ const REAP_STUCK_AFTER_SECS: i64 = 15 * 60;
/// Spawn the durable topology job worker. Polls for queued jobs every `poll`
/// interval; runs each to completion (or failure), checkpointing per step.
pub fn spawn(pool: PgPool, runtime: cm_runtime::Runtime, poll: Duration) {
pub fn spawn(
pool: PgPool,
runtime: cm_runtime::Runtime,
hub: Arc<crate::fleet::NodeHub>,
poll: Duration,
) {
// Fire the stuck-container reaper on its own cadence — checking
// once a minute is plenty and keeps this off the hot claim loop.
let reaper_pool = pool.clone();
@@ -55,7 +60,7 @@ pub fn spawn(pool: PgPool, runtime: cm_runtime::Runtime, poll: Duration) {
eprintln!("topology_worker: requeue_stale failed: {e}");
}
match cm_db::repo::topology_runs::claim_next_queued(&pool).await {
Ok(Some(job)) => run_job(&pool, &runtime, job).await,
Ok(Some(job)) => run_job(&pool, &runtime, &hub, job).await,
Ok(None) => tokio::time::sleep(poll).await,
Err(e) => {
eprintln!("topology_worker: claim failed: {e}");
@@ -90,7 +95,11 @@ async fn reap_stuck_runs(pool: &PgPool) -> Result<(), sqlx::Error> {
)
.bind(REAP_STUCK_AFTER_SECS as f64)
.bind(
cm_db::repo::topology_runs::WORKER_DRIVEN_TIERS
// REAPABLE, not worker-driven: `microvm_graph` is driven by this worker
// and must NOT be reaped — one of its steps is a whole agent session in a
// VM, so "no step records in 15 minutes" describes a healthy composed run
// as readily as a wedged one.
cm_db::repo::topology_runs::REAPABLE_TIERS
.iter()
.map(|s| (*s).to_string())
.collect::<Vec<_>>(),
@@ -119,6 +128,7 @@ async fn reap_stuck_runs(pool: &PgPool) -> Result<(), sqlx::Error> {
async fn run_job(
pool: &PgPool,
runtime: &cm_runtime::Runtime,
hub: &Arc<crate::fleet::NodeHub>,
job: cm_db::repo::topology_runs::ClaimedTopologyRun,
) {
let id = job.id;
@@ -156,9 +166,21 @@ async fn run_job(
// Resume from the last checkpoint, or start fresh.
let progress: RunProgress = job
.checkpoint
.clone()
.and_then(|c| serde_json::from_value(c).ok())
.unwrap_or_default();
// The composed engines (Slice 4): this graph's nodes are not claws, they are
// Claude-Code-in-a-microVM sessions. Branched BEFORE the leaf executor is
// built, because that build reads the ZeroClaw gateway config — a composed
// run must not fail for want of a runtime it never dials.
if job.tier == "microvm_graph" {
let result = run_composed(pool, hub, &job, &graph, progress).await;
finish(pool, id, result).await;
maybe_teardown_ephemeral_team(pool, runtime, id).await;
return;
}
// C3: prefer the mission's per-run runtime endpoint when set on
// the missions row; else fall back to the shared env-derived
// gateway (pre-C3 missions + non-mission runs). This is what
@@ -212,6 +234,14 @@ async fn run_job(
_ => drive(pool, id, &graph, &job.task, progress, &leaf).await,
};
finish(pool, id, result).await;
maybe_teardown_ephemeral_team(pool, runtime, id).await;
}
/// Write a driven run's terminal state. The single place a run finishes, shared
/// by every tier — a second one would be a second completion path, which is where
/// every microVM bug this project has hit came from.
async fn finish(pool: &PgPool, id: Uuid, result: Result<RunRecord, OrchestratorError>) {
match result {
Ok(record) => {
let value = serde_json::to_value(&record).unwrap_or(serde_json::Value::Null);
@@ -230,7 +260,57 @@ async fn run_job(
}
}
}
maybe_teardown_ephemeral_team(pool, runtime, id).await;
}
/// Drive a composed run: the outer graph is Engine Z, every node is a
/// Claude-Code-in-a-microVM session (Engine C).
///
/// The mission columns are read here rather than carried on the run row so a
/// re-placed or re-backed mission takes effect on resume, and so the composed
/// path has exactly one source of truth for where a VM boots.
async fn run_composed(
pool: &PgPool,
hub: &Arc<crate::fleet::NodeHub>,
job: &cm_db::repo::topology_runs::ClaimedTopologyRun,
graph: &TopologyGraph,
progress: RunProgress,
) -> Result<RunRecord, OrchestratorError> {
let mission_id = job.mission_id.ok_or_else(|| {
OrchestratorError::Executor(
"a composed run has no mission, so there is no checkout for its nodes \
to share"
.into(),
)
})?;
let phase_id = job.mission_phase_id.ok_or_else(|| {
OrchestratorError::Executor("a composed run must belong to a mission phase".into())
})?;
let mission: (Option<Uuid>, Option<String>, Option<String>) =
sqlx::query_as("SELECT target_node_id, backend, team_engine FROM missions WHERE id = $1")
.bind(mission_id)
.fetch_one(pool)
.await
.map_err(|e| OrchestratorError::Executor(format!("load mission {mission_id}: {e}")))?;
let exec = crate::microvm_turn_executor::for_fleet(
hub.clone(),
pool.clone(),
crate::microvm_turn_executor::ComposedRun {
run_id: job.id,
mission_id,
phase_id,
iteration: job.iteration.unwrap_or(1),
repo: crate::mission_workspace::checkout_path(mission_id),
target_node_id: mission.0,
backend: mission.1,
team_engine: mission.2,
// Resume continues the step numbering; restarting it would re-use a
// finished node's vm id.
completed_steps: progress.completed as u32,
},
);
drive(pool, job.id, graph, &job.task, progress, &exec).await
}
/// Post-terminal hook: if this run's team is `ephemeral` and no siblings are