//! A model-authored roster for a mission — Slice 5. //! //! The Master Planner has been proposing teams (2-6 members, a model each) since //! it shipped, and none of it reached a mission: the proposal lived in React //! state. A mission's shape came instead from a team template — fixed roles, and //! every claw minted `claude-sonnet-5`, which is why no mission has ever run //! heterogeneous providers. //! //! This is the seam. A roster is `(topology_kind, [(role, backend)])`, which is //! exactly what the composed executor consumes: `composed_graph` turns it into a //! `TopologyGraph`, and `MicroVmTurnExecutor` reads `attrs["backend"]` per node, //! so a `validator` role on a different provider's rootfs is a first-class graph //! node rather than a bolt-on. //! //! # Why the backend is validated here and not at boot //! //! Placement already refuses a mission whose backend no online node can run — //! but it refuses it at LAUNCH, after the roster was approved, the mission was //! created and someone believed it was going to run. A model that invents //! `rootfs-opus` is a normal thing for a model to do; discovering it three steps //! later is not. So a roster naming a backend the fleet cannot run is rejected //! when it is proposed, naming the backends that do exist. //! //! # What it deliberately does not do //! //! It does not mint claws. A composed mission's nodes are VMs, and provisioning //! containers for them would create agents and `.brain` files nothing ever //! dials — the same reason `on_launch` returns early for a microVM mission. use serde::{Deserialize, Serialize}; use uuid::Uuid; /// One member of a proposed roster. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RosterMember { /// The node's role, e.g. `implementer`, `verifier`. Becomes the graph node's /// role, which is what the per-node prompt is written around. pub role: String, /// Which rootfs image this node's VM boots (`missions.backend` per node). /// `None` inherits the mission's. #[serde(default, skip_serializing_if = "Option::is_none")] pub backend: Option, /// One line on why this member exists. Not consumed by anything — kept /// because a roster nobody can read is a roster nobody can refuse. #[serde(default, skip_serializing_if = "Option::is_none")] pub rationale: Option, } /// A proposed shape for a mission. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Roster { /// A `cm_topology::TopologyKind` name — `pipeline`, `hub_spoke`, … pub topology_kind: String, pub members: Vec, } /// Ceiling on a proposed roster. /// /// Each member is a whole VM: a boot, an inject, an agent session and a collect. /// Anthropic's own guidance tops out at 3-5 subagents, and every member here /// costs far more than a subagent does. A model asked to size a team will /// cheerfully propose twelve. pub const MAX_MEMBERS: usize = 6; /// Why a roster was refused. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Refusal { Empty, TooMany(usize), BlankRole(usize), /// A backend no online node can run, with the ones that exist. UnknownBackend { backend: String, available: Vec }, UnknownTopology(String), } impl std::fmt::Display for Refusal { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Refusal::Empty => write!(f, "the roster has no members, so there is nothing to run"), Refusal::TooMany(n) => write!( f, "the roster has {n} members and the ceiling is {MAX_MEMBERS} — each one is a whole \ VM, not a subagent" ), Refusal::BlankRole(i) => write!(f, "member {i} has no role"), Refusal::UnknownBackend { backend, available } => write!( f, "no online node can run backend {backend:?}; the fleet has: {}", if available.is_empty() { "(none — no node reports a microvm rootfs)".to_string() } else { available.join(", ") } ), Refusal::UnknownTopology(k) => write!( f, "{k:?} is not a topology kind this platform can plan; use one of: {}", cm_topology::TopologyKind::ALL .iter() .map(|k| k.as_str()) .collect::>() .join(", ") ), } } } impl Roster { /// Check a roster against the platform and the fleet. /// /// `available` is the set of backends at least one ONLINE node can boot. /// Fail-closed on every axis: an unrecognised topology, a blank role and an /// unbuildable backend are all refusals, because each of them becomes a /// failure much later and much more expensively. pub fn validate(&self, available: &[String]) -> Result<(), Refusal> { if self.members.is_empty() { return Err(Refusal::Empty); } if self.members.len() > MAX_MEMBERS { return Err(Refusal::TooMany(self.members.len())); } if parse_kind(&self.topology_kind).is_none() { return Err(Refusal::UnknownTopology(self.topology_kind.clone())); } for (i, m) in self.members.iter().enumerate() { if m.role.trim().is_empty() { return Err(Refusal::BlankRole(i)); } if let Some(b) = m.backend.as_deref().map(str::trim).filter(|b| !b.is_empty()) { if !available.iter().any(|a| a == b) { return Err(Refusal::UnknownBackend { backend: b.to_string(), available: available.to_vec(), }); } } } Ok(()) } /// The graph a composed run executes. /// /// Node ids follow `cm_topology::build`'s `n0..` convention so the graph is /// indistinguishable from a template-built one — the executor, the planners /// and the checkpoint all treat it the same. The per-member backend rides in /// `attrs`, which is the channel `MicroVmTurnExecutor` already reads. pub fn graph(&self) -> Result { let kind = parse_kind(&self.topology_kind) .ok_or_else(|| format!("unknown topology kind {:?}", self.topology_kind))?; let roles: Vec<&str> = self.members.iter().map(|m| m.role.trim()).collect(); let mut graph = cm_topology::build(kind, &roles).map_err(|e| format!("build topology: {e}"))?; for (node, member) in graph.nodes.iter_mut().zip(self.members.iter()) { if let Some(b) = member .backend .as_deref() .map(str::trim) .filter(|b| !b.is_empty()) { node.attrs.insert("backend".to_string(), b.to_string()); } } serde_json::to_value(&graph).map_err(|e| format!("serialize graph: {e}")) } } /// Topology kind by name, accepting exactly what the catalog declares. /// /// Deliberately not `unwrap_or(HubSpoke)`. `mission_orchestrator:: /// parse_topology_kind` does default, which is right for a stored template /// written by us and wrong for a string a model just invented: silently running /// a `pipeline` proposal as a hub-and-spoke would change what every node sees /// and nothing would say so. fn parse_kind(s: &str) -> Option { let want = s.trim(); cm_topology::TopologyKind::ALL .iter() .copied() .find(|k| k.as_str().eq_ignore_ascii_case(want)) } /// Backends at least one online node can actually boot. /// /// Read from the nodes' reported `rootfs` capability, so it answers "what can /// run today" rather than "what images did someone build once". pub async fn available_backends( pool: &sqlx::PgPool, workspace_id: Uuid, ) -> Result, String> { let rows: Vec<(serde_json::Value,)> = sqlx::query_as( "SELECT capabilities -> 'rootfs' FROM nodes WHERE workspace_id = $1 AND status = 'online' AND capabilities @> '{\"microvm\": true}'::jsonb", ) .bind(workspace_id) .fetch_all(pool) .await .map_err(|e| format!("read node rootfs capabilities: {e}"))?; let mut out: Vec = rows .into_iter() .filter_map(|(v,)| v.as_array().cloned()) .flatten() .filter_map(|v| v.as_str().map(str::to_string)) // A node reports every rootfs it has BUILT, which is not the same as // every rootfs a mission can run in. `agent-terminal` is on tank right // now: bootable, and with no credential contract, so an agent inside it // has nothing to authenticate with. Offering it to the planner would // produce a roster that validates, approves, launches, and then fails at // the agent turn — the expensive kind of late. .filter(|b| crate::mission_runtime::backend_can_run_a_mission(b)) .collect(); out.sort(); out.dedup(); Ok(out) } #[cfg(test)] mod tests { use super::*; fn member(role: &str, backend: Option<&str>) -> RosterMember { RosterMember { role: role.into(), backend: backend.map(str::to_string), rationale: None, } } fn roster(kind: &str, members: Vec) -> Roster { Roster { topology_kind: kind.into(), members, } } /// A backend the fleet cannot boot must be refused where it is PROPOSED. /// Placement would refuse it too — at launch, after the roster was approved /// and someone believed the mission was going to run. #[test] fn a_backend_no_node_can_run_is_refused_with_the_ones_that_exist() { let have = vec!["claude".to_string(), "kimi".to_string()]; let r = roster( "pipeline", vec![member("implementer", Some("claude")), member("verifier", Some("rootfs-opus"))], ); let err = r.validate(&have).unwrap_err(); assert_eq!( err, Refusal::UnknownBackend { backend: "rootfs-opus".into(), available: have.clone() } ); // The message must name what IS available, or the operator's next move // is a guess. let msg = err.to_string(); assert!(msg.contains("claude") && msg.contains("kimi"), "{msg}"); // And the same roster passes once every backend is one the fleet has. let ok = roster( "pipeline", vec![member("implementer", Some("claude")), member("verifier", Some("kimi"))], ); assert!(ok.validate(&have).is_ok()); } /// A member with no backend inherits the mission's, which is legitimate — /// the whole roster does not have to be heterogeneous to be useful. #[test] fn a_member_without_a_backend_is_not_a_refusal() { let r = roster("pipeline", vec![member("implementer", None)]); assert!(r.validate(&["claude".to_string()]).is_ok()); // Blank counts as absent, not as a backend named "". let r = roster("pipeline", vec![member("implementer", Some(" "))]); assert!(r.validate(&["claude".to_string()]).is_ok()); } /// A bootable image is not necessarily a runnable one. tank reports /// `agent-terminal` in its rootfs list today: a real image, with no /// credential contract, so an agent booted into it has nothing to /// authenticate with. Offering it to the planner would produce a roster that /// validates, approves, launches and then fails at the agent turn. #[test] fn only_backends_that_can_authenticate_are_offered() { assert!(crate::mission_runtime::backend_can_run_a_mission("claude")); assert!(crate::mission_runtime::backend_can_run_a_mission("default")); for unrunnable in ["agent-terminal", "agent-browser", "rootfs-opus"] { assert!( !crate::mission_runtime::backend_can_run_a_mission(unrunnable), "{unrunnable} has no credential contract and must not be proposable" ); } } /// The ceiling. Each member is a VM boot, an inject, a full agent session /// and a collect — a model asked to size a team proposes twelve happily. #[test] fn a_roster_is_bounded_and_non_empty() { let have = vec!["claude".to_string()]; assert_eq!(roster("pipeline", vec![]).validate(&have), Err(Refusal::Empty)); let many: Vec<_> = (0..MAX_MEMBERS + 1) .map(|i| member(&format!("r{i}"), None)) .collect(); assert_eq!( roster("pipeline", many).validate(&have), Err(Refusal::TooMany(MAX_MEMBERS + 1)) ); let exactly: Vec<_> = (0..MAX_MEMBERS).map(|i| member(&format!("r{i}"), None)).collect(); assert!(roster("pipeline", exactly).validate(&have).is_ok()); } /// An invented topology kind must be refused, NOT defaulted. Running a /// `pipeline` proposal as a hub-and-spoke changes what every node sees and /// nothing would say so — the same silent-substitution shape as a backend /// that quietly falls back to the default image. #[test] fn an_invented_topology_kind_is_refused_rather_than_defaulted() { let have = vec!["claude".to_string()]; let r = roster("assembly_line", vec![member("implementer", None)]); assert_eq!( r.validate(&have), Err(Refusal::UnknownTopology("assembly_line".into())) ); // Every kind the catalog declares is accepted, so this cannot drift out // of sync with what the orchestrator can actually plan. for kind in cm_topology::TopologyKind::ALL { let r = roster(kind.as_str(), vec![member("implementer", None)]); assert!(r.validate(&have).is_ok(), "{}", kind.as_str()); } } /// The graph is the handoff to the composed executor: node ids in /// `cm_topology`'s own convention, and the backend in the `attrs` channel /// `MicroVmTurnExecutor` reads. If this drifts, a heterogeneous roster runs /// every node on the mission default and looks fine. #[test] fn the_graph_carries_each_members_backend_where_the_executor_reads_it() { let r = roster( "pipeline", vec![ member("implementer", Some("claude")), member("verifier", Some("kimi")), member("scribe", None), ], ); let g = r.graph().expect("a runnable graph"); let nodes = g["nodes"].as_array().expect("nodes"); assert_eq!(nodes.len(), 3); assert_eq!(nodes[0]["role"], "implementer"); assert_eq!(nodes[0]["attrs"]["backend"], "claude"); assert_eq!(nodes[1]["attrs"]["backend"], "kimi"); assert!( nodes[2]["attrs"].get("backend").is_none(), "a member with no backend must inherit the mission's, not be stamped with one" ); // And it deserializes as the real thing the worker will parse — a graph // that only looks right as JSON fails at claim time with "missing or // invalid graph", which reads as a runtime fault rather than a bad // roster. let parsed: cm_topology::TopologyGraph = serde_json::from_value(g).expect("the worker must be able to parse it"); assert_eq!(parsed.nodes.len(), 3); assert_eq!( parsed.nodes[1].attrs.get("backend").map(String::as_str), Some("kimi") ); } }