fix(phase_runner): inject per-node claw agent aliases into topology graph
ci / gates (push) Successful in 6s
ci / rust (push) Failing after 10s
ci / frontend (push) Successful in 26s
ci / e2e (push) Skipped
ci / publish (push) Skipped

The topology graph shipped from team.graph only carries node.role,
not node.agent. The executor then defaults to alias_for(role) which
falls to ZEROCLAW_DEFAULT_AGENT (scout) — no such agent → 400.

Look up team_members(node_id → claw_id) at enqueue time and stamp
node.agent = claw_<hex> onto every node. Executor now dials the
specific claw provisioned for THIS teams role.

Was masked pre-C3 because the shared runtime hit the same 400 —
never noticed because no one clicked through to a real run there.
This commit is contained in:
Omar Sobh
2026-07-23 09:56:26 -07:00
parent aea732e712
commit 3b243588b8
+59
View File
@@ -222,6 +222,11 @@ async fn launch_phase(
for r in &team_rows { for r in &team_rows {
let team_id: Uuid = r.get("team_id"); let team_id: Uuid = r.get("team_id");
let graph: serde_json::Value = r.get("graph"); let graph: serde_json::Value = r.get("graph");
// Inject each node's explicit `agent` alias so the executor
// dials the exact claw provisioned for THIS team's role,
// instead of falling through to the env-based ZEROCLAW_AGENT_MAP
// (which points at ambient names that don't exist per-team).
let graph = inject_node_agents(pool, team_id, graph).await;
let run_id = Uuid::now_v7(); let run_id = Uuid::now_v7();
sqlx::query( sqlx::query(
"INSERT INTO topology_runs "INSERT INTO topology_runs
@@ -347,3 +352,57 @@ async fn close_finished_missions(pool: &PgPool) -> Result<(), String> {
.map_err(|e| format!("close finished missions: {e}"))?; .map_err(|e| format!("close finished missions: {e}"))?;
Ok(()) Ok(())
} }
/// Walk the team's graph nodes and set `node.agent = "claw_<hex>"` for
/// each based on the `team_members(team_id, node_id, claw_id)` map.
/// Nodes without a matching member row are left alone (the executor
/// will fall through to the env alias map / default).
///
/// The graph shape we care about: `{ "nodes": [ { "id": "n0", ... } ] }`.
/// Non-object graphs (or graphs without a nodes array) are returned
/// unchanged.
async fn inject_node_agents(
pool: &sqlx::PgPool,
team_id: Uuid,
graph: serde_json::Value,
) -> serde_json::Value {
let members = match sqlx::query(
"SELECT node_id, claw_id FROM team_members WHERE team_id = $1",
)
.bind(team_id)
.fetch_all(pool)
.await
{
Ok(rows) => rows,
Err(e) => {
eprintln!("phase_runner: load team_members({team_id}) failed: {e}");
return graph;
}
};
let mut by_node: std::collections::HashMap<String, Uuid> = std::collections::HashMap::new();
for row in members {
let node_id: String = row.get("node_id");
let claw_id: Uuid = row.get("claw_id");
by_node.insert(node_id, claw_id);
}
if by_node.is_empty() {
return graph;
}
let mut graph = graph;
if let Some(nodes) = graph.get_mut("nodes").and_then(|v| v.as_array_mut()) {
for node in nodes {
let Some(obj) = node.as_object_mut() else {
continue;
};
let id = obj.get("id").and_then(|v| v.as_str()).map(str::to_string);
let Some(id) = id else { continue };
if let Some(claw_id) = by_node.get(&id) {
obj.insert(
"agent".to_string(),
serde_json::Value::String(crate::runtime_provision::claw_alias(*claw_id)),
);
}
}
}
graph
}