feat(placement): place per phase, and let a full fleet queue

Phase 1b: wires the capacity model from 3a2d76a into the launch path, and turns
the existing pending-phase loop into the queue.

Placement moves from mission launch to PHASE launch. A node chosen at launch is
chosen once, minutes before the first VM boots and hours before the last — and
re-placing between phases is free, because mission state lives on the gateway
checkout and every VM is inject -> run -> collect -> destroy. Pinning early
bought nothing and cost the ability to react to a node filling or draining
mid-mission. One call site serves both the solo and composed paths so they cannot
disagree; the composed worker reads `missions.target_node_id`, which placement
writes before dispatch.

QUEUEING, with no new machinery: a phase with no admissible node keeps its
`pending` status and creates no `topology_runs` row. `start_pending_phases`
retries every 10s — that loop already was a queue; nothing downstream ever sees a
run that did not happen.

The risk that creates is the one this codebase keeps paying for: a phase waiting
for capacity looks exactly like a phase nothing is working on. So the wait is
RECORDED, not merely logged — migration 0073 adds `capacity_blocked_since` and
`capacity_note`, stamped once and preserved across retries so the wait is
measured from the first refusal. It is bounded at two full turns: a fleet that
frees will free within one, and a phase that waited two hours must say so rather
than sit pending forever looking like a bug.

Mission launch still fails when NO node could ever run the backend — that is not
transient, waiting will not fix it, and `microvm-negctl` asserts such a mission
stays `draft`. Capacity refusals are transient and queue; capability refusals are
not and fail. The two are separate variants precisely so they cannot be confused.

257 lib tests, 20 binaries.
This commit is contained in:
Omar Sobh
2026-08-08 08:45:42 -07:00
parent 3a2d76aa43
commit d84d17207f
4 changed files with 143 additions and 29 deletions
+22 -25
View File
@@ -146,33 +146,30 @@ pub async fn on_launch(
backend.unwrap_or("<name>") backend.unwrap_or("<name>")
); );
let how_to_fix = how_to_fix.as_str(); let how_to_fix = how_to_fix.as_str();
let chosen = match mission.target_node_id { // CAPABILITY is checked here; CAPACITY is not, and no node is pinned.
// An explicit target is a request, not a guarantee. Honour it only //
// if the node actually reports the capability. // Placement moved to phase launch (`phase_runner`). A node chosen now
Some(want) => *capable // would be chosen once, minutes before the first VM boots and hours
.iter() // before the last — and re-placing between phases is free, because
.find(|n| n.as_uuid() == want) // mission state lives on the gateway checkout and every VM is
.ok_or_else(|| format!( // inject → run → collect → destroy. Pinning early bought nothing and
"mission targets node {want}, which is not an online node that can \ // cost the ability to react to a node filling or draining mid-mission.
run backend {:?} — {how_to_fix}", //
backend.unwrap_or("default") // Launching still FAILS here when no node could ever run this backend:
))?, // that is not transient, waiting will not fix it, and the harness's
None => *capable.first().ok_or_else(|| format!( // `microvm-negctl` scenario asserts such a mission stays `draft`.
// Names the BACKEND, not just "microvm capability". Both nodes if capable.is_empty() {
// report that capability; what one of them lacked was the image. return Err(format!(
// The first version of this message would have sent an operator to
// reinstall firecracker on a node that already had it.
"no online node can run backend {:?} — {how_to_fix}", "no online node can run backend {:?} — {how_to_fix}",
backend.unwrap_or("default") backend.unwrap_or("default")
))?, ));
}; }
sqlx::query("UPDATE missions SET target_node_id = $1, updated_at = now() WHERE id = $2") eprintln!(
.bind(chosen.as_uuid()) "mission_orchestrator: mission {mission_id} has {} node(s) able to run \
.bind(mission_id) backend {:?}; placement happens per phase",
.execute(pool) capable.len(),
.await backend.unwrap_or("default")
.map_err(|e| format!("pin mission {mission_id} to node {chosen:?}: {e}"))?; );
eprintln!("mission_orchestrator: mission {mission_id} placed on microvm node {chosen:?}");
} }
// A microVM mission materialises no team. Its phases run as one `claude -p` // A microVM mission materialises no team. Its phases run as one `claude -p`
+104 -1
View File
@@ -363,6 +363,34 @@ fn empty_delivery_is_a_failure(
/// so it is not held to producing them. /// so it is not held to producing them.
const PRODUCING_KINDS: &[&str] = &["coding", "research", "benchmark", "security_scan"]; const PRODUCING_KINDS: &[&str] = &["coding", "research", "benchmark", "security_scan"];
/// How long a phase may wait for a VM slot before it is failed.
///
/// Two full turns. A fleet that genuinely frees will free within one, so this
/// only fires when nothing is coming — and a phase that waited two hours must say
/// so rather than sit `pending` forever looking like a bug.
const CAPACITY_WAIT_MAX_SECS: f64 = 2.0 * 3600.0;
/// Stamp why a phase is waiting, returning how long it has waited so far.
///
/// The timestamp is set once and preserved across retries, so the wait is
/// measured from the first refusal rather than reset every 10s sweep.
async fn record_capacity_block(pool: &PgPool, phase_id: Uuid, note: &str) -> f64 {
let waited: Option<f64> = sqlx::query_scalar(
"UPDATE mission_phases
SET capacity_blocked_since = COALESCE(capacity_blocked_since, now()),
capacity_note = $2
WHERE id = $1
RETURNING EXTRACT(EPOCH FROM now() - capacity_blocked_since)::float8",
)
.bind(phase_id)
.bind(note)
.fetch_optional(pool)
.await
.ok()
.flatten();
waited.unwrap_or(0.0)
}
/// Enqueue topology_runs for every phase whose predecessors are done. /// Enqueue topology_runs for every phase whose predecessors are done.
async fn start_pending_phases( async fn start_pending_phases(
pool: &PgPool, pool: &PgPool,
@@ -668,6 +696,81 @@ async fn launch_phase(
// this mission specifically, and an env var that happens to be set must not // this mission specifically, and an env var that happens to be set must not
// silently run it somewhere else. // silently run it somewhere else.
if p.runtime_kind == "microvm" { if p.runtime_kind == "microvm" {
// PLACEMENT, for both the solo and composed paths, in one place so they
// cannot disagree about which node this phase runs on.
//
// Per phase rather than per mission: re-placing is free because mission
// state lives on the gateway checkout (inject -> run -> collect ->
// destroy), so a node that filled or drained since the last phase simply
// is not chosen for the next one.
// The node this phase will use. Starts as the mission's current pin (a
// request), becomes whatever placement actually chose.
let mut chosen_node = p.target_node_id;
match crate::vm_placement::choose(pool, hub, workspace_id, p.backend, chosen_node).await {
Ok(node) => {
if chosen_node != Some(node.as_uuid()) {
eprintln!(
"phase_runner: mission {mission_id} phase {phase_id} placed on node {} \
(was {:?})",
node.as_uuid(),
chosen_node
);
}
// Pin for the executors, which read `missions.target_node_id`,
// and clear any capacity wait now that one is satisfied.
let _ = sqlx::query(
"UPDATE missions SET target_node_id = $1, updated_at = now() WHERE id = $2",
)
.bind(node.as_uuid())
.bind(mission_id)
.execute(pool)
.await;
let _ = sqlx::query(
"UPDATE mission_phases
SET capacity_blocked_since = NULL, capacity_note = NULL
WHERE id = $1 AND capacity_blocked_since IS NOT NULL",
)
.bind(phase_id)
.execute(pool)
.await;
chosen_node = Some(node.as_uuid());
}
// TRANSIENT: the fleet is full, or we cannot read it. Leave the phase
// `pending` — `start_pending_phases` retries every 10s, and that loop
// IS the queue. No topology_runs row is created, so nothing downstream
// sees a run that never happened.
Err(e) if e.is_transient() => {
let msg = e.message();
// Recorded, not just logged: a phase waiting for capacity and a
// phase nothing is working on look identical from the outside,
// and this codebase has paid for that confusion repeatedly.
let blocked_for = record_capacity_block(pool, phase_id, &msg).await;
if blocked_for >= CAPACITY_WAIT_MAX_SECS {
eprintln!(
"phase_runner: mission {mission_id} phase {phase_id} waited \
{blocked_for:.0}s for a VM slot — failing it.\n{msg}"
);
let _ = sqlx::query(
"UPDATE mission_phases SET status = 'failed', completed_at = now(),
capacity_note = $2 WHERE id = $1",
)
.bind(phase_id)
.bind(format!("waited {blocked_for:.0}s for fleet capacity:\n{msg}"))
.execute(pool)
.await;
return Err(format!("phase {phase_id} exhausted its capacity wait"));
}
eprintln!(
"phase_runner: mission {mission_id} phase {phase_id} WAITING for a VM \
slot ({blocked_for:.0}s so far)\n{msg}"
);
return Ok(());
}
// Not transient — no node could ever run this backend. Waiting will
// not fix it, so fail the phase rather than queue it forever.
Err(e) => return Err(e.message()),
}
// A composed mission's graph comes from the same `mission_teams` row a // A composed mission's graph comes from the same `mission_teams` row a
// ZeroClaw mission would use — only its nodes run as VMs instead of // ZeroClaw mission would use — only its nodes run as VMs instead of
// claws. That is the whole point of composing the engines: the graph, // claws. That is the whole point of composing the engines: the graph,
@@ -704,7 +807,7 @@ async fn launch_phase(
iteration, iteration,
&task, &task,
p.backend, p.backend,
p.target_node_id, chosen_node,
p.team_engine, p.team_engine,
crate::vm_stop_gate::StopGate::for_phase(kind, p.config), crate::vm_stop_gate::StopGate::for_phase(kind, p.config),
) )
+2 -3
View File
@@ -34,7 +34,6 @@
use cm_db::repo::node_metrics::EvalRow; use cm_db::repo::node_metrics::EvalRow;
use cm_domain::NodeId; use cm_domain::NodeId;
use uuid::Uuid;
/// Memory a phase VM claims. Re-exported from the executor so there is ONE number /// Memory a phase VM claims. Re-exported from the executor so there is ONE number
/// — a scheduler and a launcher that disagree about VM size is a fleet that /// — a scheduler and a launcher that disagree about VM size is a fleet that
@@ -438,7 +437,7 @@ mod tests {
use super::*; use super::*;
fn nid(n: u128) -> NodeId { fn nid(n: u128) -> NodeId {
NodeId::from(Uuid::from_u128(n)) NodeId::from(uuid::Uuid::from_u128(n))
} }
/// THE test. Measured on tank: 60 GiB total, and five VMs booted moments ago /// THE test. Measured on tank: 60 GiB total, and five VMs booted moments ago
@@ -567,7 +566,7 @@ mod tests {
fn row_for(node_id: NodeId, total: i64, used: i64, disk_free: i64) -> EvalRow { fn row_for(node_id: NodeId, total: i64, used: i64, disk_free: i64) -> EvalRow {
EvalRow { EvalRow {
node_id, node_id,
workspace_id: cm_domain::WorkspaceId::from(Uuid::from_u128(1)), workspace_id: cm_domain::WorkspaceId::from(uuid::Uuid::from_u128(1)),
status: "online".into(), status: "online".into(),
cpu_pct: None, cpu_pct: None,
mem_pct: None, mem_pct: None,
+15
View File
@@ -0,0 +1,15 @@
-- Why a phase is waiting, so a queue never looks like a stall.
--
-- Placement moves from mission launch to PHASE launch, which makes the existing
-- `start_pending_phases` loop the queue: a phase with no admissible node simply
-- stays `pending` and is retried every 10s. That is the whole queue mechanism —
-- no new status, no sweeper, no migration for a `queued` state.
--
-- The risk it creates is the one this codebase keeps paying for: a phase waiting
-- for capacity is indistinguishable from a phase nothing is working on. These two
-- columns are what make the difference visible. `capacity_blocked_since` also
-- bounds the wait, so a fleet that never frees fails the phase with a reason
-- instead of holding it forever.
ALTER TABLE mission_phases
ADD COLUMN IF NOT EXISTS capacity_blocked_since timestamptz,
ADD COLUMN IF NOT EXISTS capacity_note text;