Found by looking at what the fleet actually reports, not by reasoning about it: tank's `capabilities.rootfs` is `["agent-terminal", "claude", "default"]`. Slice 5 offered that list to the planner as the menu of backends and validated proposals against it — so a roster naming `agent-terminal` would have been proposed, validated, approved and launched, and then failed at the agent turn, because `microvm_credential_for` has no contract for it and refuses rather than forward an Anthropic subscription token to an unknown endpoint. Refusing at boot is correct and is exactly the wrong PLACE: it is three steps and one human approval after the point where the answer was already knowable. The menu is now the intersection of "a node can boot it" and "a mission agent can authenticate in it", which is what the question meant all along. `backend_can_run_a_mission` derives from the credential contract rather than restating it, so a backend gaining one (GLM and Kimi, when B4.6's base-URL contract is settled) becomes proposable in the same commit that makes it runnable — instead of in a second list someone has to remember. 528 tests pass, clippy clean. Co-Authored-By: Claude Opus 5 <[email protected]>
374 lines
16 KiB
Rust
374 lines
16 KiB
Rust
//! 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<String>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
/// 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<RosterMember>,
|
|
}
|
|
|
|
/// 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<String> },
|
|
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::<Vec<_>>()
|
|
.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<serde_json::Value, String> {
|
|
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<cm_topology::TopologyKind> {
|
|
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<Vec<String>, 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<String> = 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<RosterMember>) -> 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")
|
|
);
|
|
}
|
|
}
|