fix(placement): a composed graph needs every backend its nodes name

The full harness found it — 12 of 13 scenarios green, `roster` red:

  roster: the planner sized this mission at 2 member(s)              PASS
  roster: the approved roster is on the mission (2 nodes, composed)  PASS
  roster: this run added 1 line(s) for a 2-member roster             FAIL

  topology_runs.error: turn executor failed: node n1 in a microVM:
    vm_create failed: no rootfs for backend "canary-claude" on this node

The roster proposed `verifier@canary-claude`. Placement asked
`online_for_backend` about the MISSION's backend — `claude` — and architect
answered, holding `claude` and `local-ornith`. The graph's first node ran and
delivered, the second could not boot, and the mission finished half-done. The
question placement asked was true and insufficient.

A composed graph runs on ONE node, so that node needs every image its nodes ask
for. `required_backends` collects the mission's plus each
`config.roster.nodes[].attrs.backend`, and `online_for_backends` passes the
whole set to the same jsonb `@>` — containment already means "contains ALL of
these", so the query shape did not have to change, only what it was asked.

This is the failure mode the roster feature creates by existing: its entire
purpose is putting a verifier on a different provider, which is exactly what
makes one node insufficient. Nothing before the full suite had a reason to
exercise it — the composed scenario uses one backend for all five nodes.

`NoCapableNode` now names the set and says why one node must hold all of them.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-09 15:36:40 -07:00
co-authored by Claude Opus 5
parent 171f901bcd
commit 529497febb
4 changed files with 136 additions and 18 deletions
+6 -1
View File
@@ -420,7 +420,12 @@ pub async fn capacity(
) -> Result<Json<Value>, ApiError> {
let ws = user.workspace_id.as_uuid().to_owned();
let (fit, unfit) =
crate::vm_placement::survey(&state.pool, &state.node_hub, ws, q.backend.as_deref())
crate::vm_placement::survey(
&state.pool,
&state.node_hub,
ws,
&crate::vm_placement::required_backends(q.backend.as_deref(), None),
)
.await
.map_err(|e| {
eprintln!("fleet capacity survey failed: {e}");