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:
co-authored by
Claude Opus 5
parent
171f901bcd
commit
529497febb
@@ -285,8 +285,25 @@ pub async fn online_for_backend(
|
||||
workspace_id: uuid::Uuid,
|
||||
backend: Option<&str>,
|
||||
) -> Result<Vec<NodeId>, DbError> {
|
||||
let want = backend_key(backend);
|
||||
// `@>` on the array asks "does this node's list contain that name" — the
|
||||
online_for_backends(pool, workspace_id, &[backend_key(backend).to_string()]).await
|
||||
}
|
||||
|
||||
/// Nodes that can run EVERY one of these backends.
|
||||
///
|
||||
/// A composed mission runs its whole graph on one node, and the graph's nodes
|
||||
/// may each name their own backend — an independent verifier on another
|
||||
/// provider is the entire point of the roster. Asking only for the mission's
|
||||
/// backend placed such a mission on a node with `claude` and no
|
||||
/// `canary-claude`, and the run died at the second graph node with
|
||||
/// `no rootfs for backend "canary-claude" on this node`. The full harness
|
||||
/// caught it; nothing before it had a reason to.
|
||||
pub async fn online_for_backends(
|
||||
pool: &PgPool,
|
||||
workspace_id: uuid::Uuid,
|
||||
backends: &[String],
|
||||
) -> Result<Vec<NodeId>, DbError> {
|
||||
// `@>` on the array asks "does this node's list contain ALL of these" —
|
||||
// containment, not intersection, which is exactly the question here and the
|
||||
// whole reason the node reports an array rather than a count.
|
||||
let rows: Vec<(uuid::Uuid,)> = sqlx::query_as(
|
||||
"SELECT id FROM nodes
|
||||
@@ -302,9 +319,12 @@ pub async fn online_for_backend(
|
||||
ORDER BY id",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.bind(serde_json::Value::Array(vec![serde_json::Value::String(
|
||||
want.to_string(),
|
||||
)]))
|
||||
.bind(serde_json::Value::Array(
|
||||
backends
|
||||
.iter()
|
||||
.map(|b| serde_json::Value::String(b.clone()))
|
||||
.collect(),
|
||||
))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(id,)| NodeId::from(id)).collect())
|
||||
@@ -315,7 +335,7 @@ pub async fn online_for_backend(
|
||||
/// Must agree with `clawmates-node::microvm::rootfs_for`, which resolves the same
|
||||
/// three spellings to the default image. If these two drift, placement promises
|
||||
/// an image the booter cannot find — or refuses one it has.
|
||||
fn backend_key(backend: Option<&str>) -> &str {
|
||||
pub fn backend_key(backend: Option<&str>) -> &str {
|
||||
match backend {
|
||||
None | Some("") | Some("default") => "default",
|
||||
Some(b) => b,
|
||||
|
||||
Reference in New Issue
Block a user