Files
clawmates/crates/cm-api/src/mission_orchestrator.rs
T
Omar SobhandClaude Opus 5 f7f3dfe495 feat(fleet): GLM as a real microVM backend, and per-role models for claws
Three threads, all of which end at the same place: a mission whose verifier does
not share a model with the coder it reviews.

**GLM has a credential contract now.** `microvm_credential_for` returned one env
var name, which quietly assumed every provider reads its secret from the same
place Anthropic does. It returns a `Credential { source, target }` instead —
z.ai's key lives in the server's `ZAI_API_KEY` and Claude Code reads it as
`ANTHROPIC_AUTH_TOKEN`, and collapsing those two names is what forces a guess at
the other end. A wrong guess here sends one provider's credential to another
provider's endpoint.

`images/agent-glm` is the same CLI at the same pinned version as `agent-claude`
with `ANTHROPIC_BASE_URL` baked in. The split is deliberate: the ENDPOINT is a
property of the image, the CREDENTIAL is a property of the turn. That makes the
dangerous mix-up unrepresentable — a GLM VM cannot be handed an Anthropic
subscription token, and a claude VM cannot be pointed at z.ai. Asserted both
ways, because "the GLM VM must not carry CLAUDE_CODE_OAUTH_TOKEN" is the
property that costs a credential if it ever stops holding.

Kimi stays refused. `KIMI_API_KEY` is set and Moonshot serves an
Anthropic-compatible API, but I have not verified its base URL against the
running service, and this function is precisely where guessing a URL is
expensive. It becomes an arm the day someone measures it.

`api.z.ai` joins the node's default egress allow-list. A default that cannot
run the images we ship is a trap rather than a policy — the alternative is an
operator discovering it as a hung agent with no model access.

**Per-role models for claws** (migration 0071). `template_roles` had no model
column, so `mint_team_from_template` bound every role of every mission team to
one literal — a template whose whole point is an independent reviewer minted a
reviewer sharing a model with the coder. A role may now name its own; roles that
say nothing still take the mint's default, so every template written before this
behaves exactly as it did. The literal is now that default rather than a
hardcode.

**A harness scenario for the roster flow.** `verify-mission-delivery.sh roster`
runs the whole Slice 5 loop — planner proposes, human approves, mission runs —
and asserts the roster LANDED on the mission row rather than trusting the API's
answer. That distinction is not theoretical: the first live approval returned an
error while leaving the proposal marked approved.

Built and proven on tank ahead of the deploy: `clawmates/agent-glm:dev` reports
`2.1.223` and `BASE=https://api.z.ai/api/anthropic`, and
`fc-build-rootfs.sh … glm 8G` boots a VM from it that has git, can write
/mission, and answers `claude --version`.

533 tests pass, clippy clean. Migration 0071.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-06 17:14:53 -07:00

698 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Mission-launch orchestrator. Slice 4.
//!
//! When a mission's status flips from `draft` to `running`, this
//! module fires:
//! 1. Materialize a team from `team_template_id` (if set + no
//! `team_id` yet) — inserts agents, ingests brain seeds,
//! records agent_template_link lineage, wires team_members.
//! 2. Bind the resulting `team_id` back onto the mission.
//!
//! Phase execution (running research/coding/benchmark/security_scan
//! against the world) is layered on top by Slices 58.
//!
//! Design notes:
//! - Runtime provisioning is opt-in. Claws are provisioned against the
//! mission's OWN daemon (`RuntimeProvisioner::for_gateway` with the
//! per-mission endpoint), falling back to the global gateway only when
//! there is no per-mission runtime. Provisioning into the global gateway
//! while the run executes on a per-mission daemon leaves that daemon
//! without the `claw_*` agents — it falls back to the default `scout`
//! agent, which cannot see `/mission/repo`.
//! Missing runtime = "insert DB rows only, no live claw" — the
//! mission still boots; live claws land the moment the runtime
//! env is configured + the mission re-launches.
//! - Every step is best-effort logged so a partial materialization
//! can be picked up by a subsequent launch rather than blocking.
use cm_db::repo::team_templates::TeamTemplateDetail;
use cm_domain::{AccessPolicy, Agent, AgentStatus, WorkspaceId};
use sqlx::PgPool;
use uuid::Uuid;
use crate::runtime_provision::RuntimeProvisioner;
/// Called from `routes::missions::set_status` when the transition is
/// draft→running. Materializes the team + returns the new team_id
/// (or an existing one — no-op when the mission already has a team).
///
/// Non-fatal on failure: logs + returns Ok(None) so the mission still
/// becomes `running` and the user can inspect the error via server
/// logs + re-attempt. Later slices surface this on the canvas.
pub async fn on_launch(
pool: &PgPool,
workspace_id: WorkspaceId,
user_id: cm_domain::UserId,
mission_id: Uuid,
node_hub: Option<std::sync::Arc<crate::fleet::NodeHub>>,
) -> Result<Option<Uuid>, String> {
eprintln!("mission_orchestrator::on_launch fired mission_id={mission_id}");
let Some(mission) = cm_db::repo::missions::get(pool, mission_id, workspace_id.as_uuid())
.await
.map_err(|e| format!("load mission: {e}"))?
else {
return Err("mission not found".into());
};
// ensure_checkout is idempotent (fetch+reset on existing clones,
// clone on missing dirs) so we run it BEFORE the team_id short-
// circuit: a re-launched or retried mission still needs a fresh
// repo checkout even though its team was minted on the first
// launch. Non-fatal — logs and continues on failure.
match crate::mission_workspace::ensure_checkout(pool, workspace_id, mission_id).await {
Ok(Some(path)) => eprintln!(
"mission_orchestrator: repo checked out at {} for mission {mission_id}",
path.display()
),
Ok(None) => eprintln!(
"mission_orchestrator: mission {mission_id} has no repo bound, skipping checkout"
),
Err(e) => eprintln!(
"mission_orchestrator: repo checkout for {mission_id} failed (continuing): {e}"
),
}
// Provision the per-mission ZeroClaw runtime container (C3).
// Idempotent: returns the endpoint if the container is already
// running. Falls back silently when docker is unreachable so
// dev-mode + tests still work — the topology_worker will use the
// shared runtime endpoint in that case.
// The mission's own runtime endpoint. Claws MUST be provisioned against
// THIS gateway, not the global one — see RuntimeProvisioner::for_gateway.
let mut mission_gateway: Option<String> = None;
// Not for a microVM mission: the ZeroClaw daemon it would start is never
// spoken to, and it would sit holding a pairing code and ~3 GB of image for
// the life of the mission. Observed doing exactly that on the first real run.
if let Some(prov) = crate::mission_runtime::MissionRuntimeProvisioner::from_env()
.filter(|_| mission.runtime_kind != "microvm")
{
match prov.ensure_container(mission_id).await {
Ok(ec) => {
mission_gateway = Some(ec.endpoint.clone());
let container_name = crate::mission_runtime::container_name(mission_id);
if let Err(e) = cm_db::repo::missions::set_runtime_binding(
pool,
mission_id,
workspace_id.as_uuid(),
Some(&container_name),
Some(&ec.endpoint),
ec.pairing_code.as_deref(),
)
.await
{
eprintln!(
"mission_orchestrator: bind runtime container for {mission_id} failed: {e}"
);
} else {
eprintln!(
"mission_orchestrator: runtime container {container_name}{} (paired={}) for mission {mission_id}",
ec.endpoint,
ec.pairing_code.is_some()
);
}
}
Err(e) => eprintln!(
"mission_orchestrator: provision runtime container for {mission_id} failed (continuing with shared runtime): {e}"
),
}
} else {
eprintln!(
"mission_orchestrator: docker unreachable, mission {mission_id} will use shared runtime"
);
}
// microVM PLACEMENT MUST COME BEFORE the early return below. It did not, and
// the first real microvm mission failed with "mission has no target_node_id" —
// the executor's own guard firing correctly on a mission this function had
// returned from before ever choosing a node for it.
// microVM placement. KVM is a hard predicate, not a preference: gw-04 —
// where every mission runs today — is itself a VM without nested
// virtualisation and has no /dev/kvm, so a microvm mission landing there
// cannot start. Resolve a capable node now and fail the launch if there is
// none, because the alternative is a mission that sits in 'running' having
// never had anywhere to run.
if mission.runtime_kind == "microvm" {
// Capable means BOTH: it can host a microVM, and it holds the image this
// mission's backend names. Asking only for `microvm` sent the first real
// microVM mission to a node without `rootfs-claude.ext4`.
let backend = mission.backend.as_deref();
let capable =
cm_db::repo::nodes::online_for_backend(pool, mission.workspace_id, backend)
.await
.map_err(|e| format!("looking up nodes for backend {backend:?}: {e}"))?;
let how_to_fix = format!(
"needs /dev/kvm + firecracker (scripts/fc-node-setup.sh) AND the {} rootfs \
built on that node (scripts/fc-build-rootfs.sh <host> <image> {})",
backend.unwrap_or("default"),
backend.unwrap_or("<name>")
);
let how_to_fix = how_to_fix.as_str();
let chosen = match mission.target_node_id {
// An explicit target is a request, not a guarantee. Honour it only
// if the node actually reports the capability.
Some(want) => *capable
.iter()
.find(|n| n.as_uuid() == want)
.ok_or_else(|| format!(
"mission targets node {want}, which is not an online node that can \
run backend {:?}{how_to_fix}",
backend.unwrap_or("default")
))?,
None => *capable.first().ok_or_else(|| format!(
// Names the BACKEND, not just "microvm capability". Both nodes
// report that capability; what one of them lacked was the image.
// 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}",
backend.unwrap_or("default")
))?,
};
sqlx::query("UPDATE missions SET target_node_id = $1, updated_at = now() WHERE id = $2")
.bind(chosen.as_uuid())
.bind(mission_id)
.execute(pool)
.await
.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`
// inside a VM (`microvm_executor`), so there is no claw graph to provision —
// and demanding one rejected the launch of a well-formed mission with "pick
// teams in the wizard". This is the third of three team gates on a path that
// uses no teams; the other two are in `routes::missions` (draft→running) and
// `phase_runner::launch_phase` (no matching teams → stay pending).
//
// Returning before the picks below, not filtering them, because provisioning
// claws that never run is not a cheaper version of the same thing — it is a
// runtime binding and a pairing code describing something nothing uses.
if mission.runtime_kind == "microvm" {
eprintln!(
"mission_orchestrator: mission {mission_id} is a microvm mission — no team to \
materialise; its phases execute in a VM"
);
return Ok(None);
}
// Skip team materialization if already bound.
if mission.team_id.is_some() {
eprintln!(
"mission_orchestrator::on_launch team_id already bound for mission_id={mission_id} — skipping team materialization"
);
return Ok(mission.team_id);
}
// New multi-team model: config.phase_teams = {
// "research": ["template-uuid", ...],
// "coding": ["template-uuid", ...]
// }
// Mints one team per (phase-purpose, template) pair. The FIRST
// minted team gets bound to mission.team_id for backward-compat
// with the single-team surfaces (Team tab, legacy code).
//
// Fallback: if config.phase_teams is absent, use the legacy
// single team_template_id path so existing missions still work.
let phase_teams = mission
.config
.get("phase_teams")
.and_then(|v| v.as_object());
let picks: Vec<(String, Uuid)> = if let Some(pt) = phase_teams {
let mut out = Vec::new();
for (purpose, list) in pt.iter() {
if let Some(arr) = list.as_array() {
for item in arr {
if let Some(id_str) = item.as_str() {
if let Ok(id) = Uuid::parse_str(id_str) {
out.push((purpose.clone(), id));
}
}
}
}
}
out
} else if let Some(id) = mission.team_template_id {
vec![("mission".to_string(), id)]
} else {
return Err(
"mission has no team_template_id and no config.phase_teams — pick teams in the wizard"
.to_string(),
);
};
if picks.is_empty() {
return Err(
"mission's config.phase_teams is empty — pick at least one team in the wizard".into(),
);
}
// Provision into the mission's own daemon when we have one (so the daemon
// that actually runs the turns knows these claws); fall back to the global
// gateway only for dev/no-docker setups where the run uses it too.
let provisioner = match mission_gateway.clone() {
Some(url) => RuntimeProvisioner::for_gateway(url),
None => RuntimeProvisioner::from_env(),
};
let mut first_team_id: Option<Uuid> = None;
let mut provisioned_claws: Vec<cm_domain::AgentId> = Vec::new();
for (purpose, template_id) in &picks {
let template = cm_db::repo::team_templates::get(pool, *template_id)
.await
.map_err(|e| format!("load template {template_id}: {e}"))?
.ok_or_else(|| format!("template {template_id} not found"))?;
let team_name = format!(
"{} · {} · {}",
mission.title, purpose, template.template.name
);
let team_id = mint_team_from_template(
TeamMint {
pool,
workspace_id,
user_id,
provisioner: provisioner.as_ref(),
template: &template,
team_name: &team_name,
default_model: MINTED_CLAW_MODEL,
},
&mut provisioned_claws,
)
.await?;
// Record (mission, team, purpose) in mission_teams so the Team
// tab can group by phase purpose without parsing team names.
sqlx::query("INSERT INTO mission_teams (mission_id, team_id, purpose) VALUES ($1, $2, $3)")
.bind(mission_id)
.bind(team_id)
.bind(purpose)
.execute(pool)
.await
.map_err(|e| format!("record mission_team {team_id}: {e}"))?;
if first_team_id.is_none() {
first_team_id = Some(team_id);
}
}
let team_id = first_team_id.expect("picks non-empty guaranteed above");
// Bind the first team onto the mission for legacy single-team paths.
sqlx::query("UPDATE missions SET team_id = $1, updated_at = now() WHERE id = $2")
.bind(team_id)
.bind(mission_id)
.execute(pool)
.await
.map_err(|e| format!("bind team on mission: {e}"))?;
// Pin every provisioned claw's workspace to /mission/repo so
// file_edit / content_search / glob_search / git_operations operate
// on the mission's checked-out repo instead of the empty per-agent
// sandbox. This CANNOT go through the config prop API (workspace.path
// is a PathBuf the prop-schema won't expose — see provision_claw), so
// we patch the shared config file directly on the per-mission runtime
// container. The daemon picks it up on the same reload that surfaces
// the freshly-provisioned claws for the run.
//
// FATAL, deliberately. This was "non-fatal: agents still write (to the
// sandbox) but the committer can't find the changes in /mission/repo" —
// which is to say, the mission runs to completion and delivers nothing.
// Mission `019fcf62` did exactly that: the pin failed with `argument list
// too long`, one line of stderr scrolled past, and phase 0 reported
// `completed` with zero files, no commit error and no push error. A launch
// that cannot bind its agents to the repo has no path to delivering work,
// so it must fail at launch where someone is still looking.
//
// Not for a microVM mission: its agent is a `claude -p` inside a VM on a
// fleet node, not a ZeroClaw claw in a container here, so there is no
// workspace to pin. Leaving it would make a microVM launch FAIL on a
// container it was never going to use.
if !provisioned_claws.is_empty() && mission_gateway.is_some() && mission.runtime_kind != "microvm"
{
if let Some(mp) = crate::mission_runtime::MissionRuntimeProvisioner::from_env() {
mp.pin_agent_workspaces(mission_id, &provisioned_claws, "/mission/repo")
.await
.map_err(|e| {
format!(
"could not pin agent workspaces to /mission/repo ({e}) — the mission \
would run with its agents writing to their sandboxes, delivering nothing"
)
})?;
// The daemon reads config ONCE at boot and never re-reads the
// file, so the pin is invisible until it restarts. Its agents were
// created through its own config API, so they are already
// persisted to the file and survive the restart; the pairing code
// is re-minted on every launch. Equally fatal: an unrestarted
// daemon is an unpinned daemon.
mp.restart_container(mission_id).await.map_err(|e| {
format!("could not restart the runtime to apply the workspace pin: {e}")
})?;
}
}
// Herdr second-runtime: if runtime_kind='local_herdr', spawn a
// pane on target_node running the first available local CLI.
// Non-fatal on failure — the operator sees the error in server
// logs and can manually retry via POST /herdr-dispatch.
if mission.runtime_kind == "local_herdr" {
if let (Some(hub), Some(node_id)) = (node_hub, mission.target_node_id) {
let prompt = mission.description.clone().unwrap_or_default();
// CLI selection: mission.config.cli overrides; else default.
// (Per-template default_cli fallback was in the single-team
// path; the multi-team path doesn't have one canonical
// template to consult, so we keep the mission-level knob.)
let cli = mission
.config
.get("cli")
.and_then(|v| v.as_str())
.map(str::to_string)
.unwrap_or_else(|| "claude".to_string());
match crate::fleet_herdr::dispatch(
hub,
cm_domain::NodeId::from(node_id),
mission_id,
&cli,
&prompt,
)
.await
{
Ok(handle) => eprintln!(
"mission_orchestrator: herdr pane {} spawned on node {}",
handle.pane_id, node_id
),
Err(e) => eprintln!(
"mission_orchestrator: herdr dispatch for {mission_id} failed (continuing): {e}"
),
}
} else {
eprintln!(
"mission_orchestrator: mission {mission_id} is local_herdr but node_hub or target_node missing"
);
}
}
Ok(Some(team_id))
}
/// The read-only inputs for minting a team. Grouped into a struct so the
/// signature stays readable as the orchestrator accumulates context — the
/// growing positional list was also easy to mis-order at the call site,
/// since `team_name` and `default_model` are both `&str`.
struct TeamMint<'a> {
pool: &'a PgPool,
workspace_id: WorkspaceId,
user_id: cm_domain::UserId,
provisioner: Option<&'a RuntimeProvisioner>,
template: &'a TeamTemplateDetail,
team_name: &'a str,
default_model: &'a str,
}
async fn mint_team_from_template(
mint: TeamMint<'_>,
provisioned_claws: &mut Vec<cm_domain::AgentId>,
) -> Result<Uuid, String> {
let TeamMint {
pool,
workspace_id,
user_id,
provisioner,
template,
team_name,
default_model,
} = mint;
// Build the topology graph from role slots so the team's `graph`
// NOT NULL column is satisfied + downstream topology executors
// have a valid shape to iterate over.
let roles: Vec<&str> = template.roles.iter().map(|r| r.slot.as_str()).collect();
let topology_kind = parse_topology_kind(&template.template.default_topology);
let graph = cm_topology::build(topology_kind, &roles)
.map_err(|e| format!("build topology graph: {e}"))?;
let graph_json =
serde_json::to_value(&graph).map_err(|e| format!("serialize topology graph: {e}"))?;
let team_id = Uuid::now_v7();
cm_db::repo::teams::insert_team_with_lifecycle(
pool,
team_id,
workspace_id,
team_name,
&template.template.default_topology,
&graph_json,
"permanent",
)
.await
.map_err(|e| format!("insert team: {e}"))?;
// Stamp template lineage on the team row (Slice 3 columns).
sqlx::query(
"UPDATE teams SET template_id = $1, template_version = $2,
risk_profile = $3, mcp_bundles = $4
WHERE id = $5",
)
.bind(template.template.id)
.bind(template.template.version)
.bind(&template.template.risk_profile)
.bind(serde_json::json!(template.template.mcp_bundles))
.bind(team_id)
.execute(pool)
.await
.map_err(|e| format!("stamp template lineage: {e}"))?;
// For each role: create agent, provision runtime, ingest brain
// seed, record link, bind to topology node.
for (idx, role) in template.roles.iter().enumerate() {
// Node id in the graph follows cm_topology's `n0..` convention
// — read the actual node id from the graph so team_members
// stays consistent with the topology.
let Some(node) = graph.nodes.get(idx) else {
return Err(format!(
"topology graph produced {} nodes but template has {} roles",
graph.nodes.len(),
template.roles.len(),
));
};
let agent = Agent {
id: cm_domain::AgentId::new(),
workspace_id,
name: format!("{} · {}", team_name, role.slot),
job_title: role.slot.clone(),
// This is the ONLY consumer of the templates' `system_prompt` prose,
// and it feeds the *chat* path, not missions: it lands in
// `agents.system_prompt`, which `cm_runtime::brain::compose_system`
// uses as the base prompt for a claw's chat turns. A mission turn
// never sees it — `topology_exec::build_prompt` synthesizes its own
// one-line system text from the role slot alone. So deleting the
// template prose to save mission tokens would save exactly zero and
// would leave every mission-minted claw with no identity in chat.
system_prompt: role.system_prompt.clone(),
avatar: String::new(),
accent: default_accent_for(&role.slot).to_string(),
wallpaper: String::new(),
managed_by: user_id,
status: AgentStatus::Online,
};
cm_db::repo::agents::insert(pool, &agent, &AccessPolicy::default())
.await
.map_err(|e| format!("insert agent {}: {e}", role.slot))?;
let claw_id = agent.id.as_uuid();
// The ROLE's model when the template names one, else the mint's default.
// Before migration 0071 there was no role model at all, so every claw of
// every mission team ran the same one — including a reviewer reviewing
// the coder it shares a model with.
let role_model = role
.model
.as_deref()
.map(str::trim)
.filter(|m| !m.is_empty())
.unwrap_or(default_model);
cm_db::repo::agents::set_model_binding(pool, agent.id, role_model)
.await
.map_err(|e| format!("set_model_binding {claw_id}: {e}"))?;
// Runtime provisioning is opt-in — no-op if unconfigured.
// Pass the team template's risk_profile so the claw actually
// gets the tools its role expects (research_readonly for
// scout/researcher, coding_readwrite for coder/tester/committer,
// etc.). Passing "toolfree" — the old default — left every
// agent with zero tools regardless of what its prompt asked for.
//
// Workspace pinning to /mission/repo is NOT done here (the
// config prop-schema can't set workspace.path — see
// provision_claw's doc); the caller pins the collected claws
// out-of-band via MissionRuntimeProvisioner::pin_agent_workspaces.
if let Some(p) = provisioner {
match p
.provision_claw(claw_id, role_model, &template.template.risk_profile)
.await
{
Ok(_) => provisioned_claws.push(agent.id),
Err(e) => eprintln!(
"mission_orchestrator: provision claw {claw_id} failed (continuing): {e}"
),
}
}
// Ingest brain seed (Slice 3.5d). Non-fatal on failure —
// agent still works from system_prompt alone.
if let Some(seed) = role.brain_seed.as_deref().filter(|s| !s.trim().is_empty()) {
if let Err(e) =
crate::brain_seed::ingest(claw_id, seed.to_string(), role.system_prompt.clone())
.await
{
eprintln!(
"mission_orchestrator: brain_seed ingest for {claw_id} failed (continuing): {e}"
);
}
}
// Record lineage (Slice 3.5d) so the MCP skills server can
// merge template default skills with per-agent overrides.
cm_db::repo::agent_template_link::upsert(
pool,
claw_id,
template.template.id,
template.template.version,
&role.slot,
)
.await
.map_err(|e| format!("agent_template_link upsert {claw_id}: {e}"))?;
cm_db::repo::agent_template_link::mark_seeded(pool, claw_id)
.await
.map_err(|e| format!("mark_seeded {claw_id}: {e}"))?;
// Wire team_members using the topology node id.
cm_db::repo::teams::add_member(pool, team_id, &node.id, claw_id, &role.slot)
.await
.map_err(|e| format!("team_members add {claw_id}: {e}"))?;
// Audit for parity with individual claw creation path.
let _ = cm_db::repo::audit::append(
pool,
workspace_id,
cm_db::repo::audit::Actor::User(user_id),
"agent.created",
"agent",
&agent.id.to_string(),
serde_json::json!({
"name": agent.name,
"job_title": agent.job_title,
"source": "mission_orchestrator",
"template_id": template.template.id.to_string(),
"template_version": template.template.version,
"role_slot": role.slot,
}),
)
.await;
}
Ok(team_id)
}
/// The model a minted claw runs on when its template role does not name one.
///
/// A DEFAULT now, not a hardcode: `template_roles.model` (migration 0071) lets a
/// template put its reviewer on a different model from the coder it reviews,
/// which is the correlated failure the cross-provider judge exists to break,
/// one layer down. Roles that say nothing still land here, so every template
/// that existed before 0071 behaves exactly as it did.
const MINTED_CLAW_MODEL: &str = "claude-sonnet-5";
/// The graph a COMPOSED microVM mission runs, built from its team template
/// without minting a single claw.
///
/// A composed mission needs the template's *shape* — how many nodes, in what
/// pattern, playing what roles — and nothing else it carries. Its nodes are VMs,
/// so provisioning claws for them would create agents, containers and `.brain`
/// files that nothing ever dials; that is exactly why `on_launch` returns early
/// for a microVM mission, and this is how the composed path gets its graph
/// anyway rather than by undoing that.
///
/// `purposes` is the phase's purpose list, matched against `config.phase_teams`;
/// missions using the legacy single `team_template_id` fall back to it.
/// Returns `None` when the mission picked no template at all.
pub async fn composed_graph(
pool: &PgPool,
mission_id: Uuid,
purposes: &[&str],
) -> Result<Option<serde_json::Value>, String> {
let row: Option<(serde_json::Value, Option<Uuid>)> =
sqlx::query_as("SELECT config, team_template_id FROM missions WHERE id = $1")
.bind(mission_id)
.fetch_optional(pool)
.await
.map_err(|e| format!("load mission {mission_id}: {e}"))?;
let Some((config, legacy_template)) = row else {
return Err(format!("mission {mission_id} not found"));
};
// An APPROVED roster wins over the template. It is the more specific answer
// — a model sized it for this mission's actual task and a human accepted it
// — and it is the only path on which nodes carry per-node backends, which is
// how a mission runs more than one provider. Stored already built and
// validated (`routes::mission_roster::decide`), so nothing here can turn a
// refused roster into a running one.
if let Some(roster) = config.get("roster").filter(|v| v.is_object()) {
// Parsed rather than trusted: a graph the orchestrator cannot plan would
// otherwise be claimed and fail as "missing or invalid graph", which
// reads as a runtime fault instead of a bad roster.
serde_json::from_value::<cm_topology::TopologyGraph>(roster.clone())
.map_err(|e| format!("mission {mission_id}: the approved roster is not a runnable topology: {e}"))?;
return Ok(Some(roster.clone()));
}
let template_id = config
.get("phase_teams")
.and_then(|v| v.as_object())
.and_then(|pt| {
// First template named by any purpose this phase answers to, in the
// phase's own preference order — the same order `launch_phase` uses
// to pick teams, so a composed mission and a ZeroClaw one resolve the
// same template for the same phase.
purposes.iter().find_map(|p| {
pt.get(*p)
.and_then(|v| v.as_array())
.and_then(|a| a.first())
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok())
})
})
.or(legacy_template);
let Some(template_id) = template_id else {
return Ok(None);
};
let template = cm_db::repo::team_templates::get(pool, template_id)
.await
.map_err(|e| format!("load template {template_id}: {e}"))?
.ok_or_else(|| format!("template {template_id} not found"))?;
let roles: Vec<&str> = template.roles.iter().map(|r| r.slot.as_str()).collect();
if roles.is_empty() {
return Err(format!("template {template_id} defines no roles"));
}
let graph = cm_topology::build(
parse_topology_kind(&template.template.default_topology),
&roles,
)
.map_err(|e| format!("build topology graph for template {template_id}: {e}"))?;
serde_json::to_value(&graph)
.map(Some)
.map_err(|e| format!("serialize topology graph: {e}"))
}
fn parse_topology_kind(s: &str) -> cm_topology::TopologyKind {
use cm_topology::TopologyKind;
match s {
"pipeline" => TopologyKind::Pipeline,
"hierarchical" => TopologyKind::Hierarchical,
"star_moe" => TopologyKind::StarMoe,
_ => TopologyKind::HubSpoke,
}
}
fn default_accent_for(slot: &str) -> &'static str {
match slot {
"planner" | "arch_analyst" | "designer" | "scene_designer" | "api_designer" => "#7cd6e0",
"coder" | "kernel_author" | "shader_author" => "#5fd08a",
"tester" | "bench_engineer" | "perf_engineer" => "#ffb44a",
"reviewer" | "db_engineer" => "#c98af0",
"committer" | "spec_integrator" => "#ff8a7a",
_ => "#8a8a92",
}
}