feat(missions): Slice 5 — let a model size the mission's team
`routes/planner.rs` has had Opus proposing rosters since the Master Planner
shipped, and none of it ever reached a mission: the proposal lived in React state
and died with the tab. A mission's shape came from a team template instead —
fixed roles, and every claw minted `claude-sonnet-5` from a literal in
`mint_team_from_template`. That literal is why no mission has ever run more than
one provider.
A roster is `(topology_kind, [(role, backend)])`, which is exactly what the
composed executor already consumes: `Roster::graph` builds a `TopologyGraph` with
the backend in `attrs`, and `MicroVmTurnExecutor` reads `attrs["backend"]` per
node. So a verifier on another provider's rootfs stops being a bolt-on and
becomes a graph node — the correlated-failure break the independent judge exists
for, one layer down.
Three verbs, and the split is the point. **suggest** asks the model and persists
the answer, changing nothing. **decide** approves (writes `config.roster` and
switches the mission to the composed engine) or rejects. A proposal is never
applied on arrival: a model sizing a team is a suggestion about how many VMs to
boot, and this codebase treats model output that costs money as evidence for a
decision, not the decision.
Fail-closed at every seam, because each of these otherwise surfaces much later
and much more expensively:
- a backend no ONLINE node can boot is refused when PROPOSED, naming the ones
the fleet actually has. Placement would refuse it too — at launch, after the
roster was approved and someone believed the mission would run. The model is
handed that same list in its prompt, so the usual case never arises.
- an invented `topology_kind` is refused, not defaulted. `parse_topology_kind`
defaults to hub-spoke, which is right for a template we wrote and wrong for a
string a model just produced: running a `pipeline` proposal as a hub-and-spoke
changes what every node sees and nothing would say so.
- the roster is validated BEFORE it is stored, so a stored proposal is always
one that could be approved; and again at approval, against the fleet as it is
then — a node can go offline in between.
- `MAX_MEMBERS = 6`. Each member is a whole VM, not a subagent, and a model
asked to size a team proposes twelve happily.
Two properties live in SQL rather than in the handler: at most one approved
roster per mission (partial unique index — two approved rosters are two answers
to "what shape is this mission", and the executor reads one field), and
decide-once (`WHERE status = 'proposed'`, so a double-clicked approve claims
nothing the second time). Both tested against a real database, including that the
second approval is refused by Postgres rather than merely losing a race.
NEGATIVE CONTROL, run rather than assumed: with the roster preference removed
from `composed_graph`, `an_approved_roster_outranks_the_template` FAILS — 3 nodes
from the template instead of the roster's 2. A stored roster that is silently
ignored at launch is precisely the shape this project keeps paying for.
Not closed: per-role models for CLAWS. `template_roles` has no model column, so a
ZeroClaw team still mints one model for every role. The literal is now a named
constant that says so and points at the roster path, rather than sitting inline
where nobody reads it.
527 tests pass, clippy clean. Migration 0070. Not yet exercised against the
deployed stack — the route has never been called with a live model.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
abb97e6f03
commit
1797669296
@@ -30,6 +30,7 @@ pub mod papers;
|
|||||||
pub mod phase_config;
|
pub mod phase_config;
|
||||||
pub mod session_executor;
|
pub mod session_executor;
|
||||||
pub mod runtime_preflight;
|
pub mod runtime_preflight;
|
||||||
|
pub mod mission_roster;
|
||||||
pub mod mission_runtime;
|
pub mod mission_runtime;
|
||||||
pub mod mission_workspace;
|
pub mod mission_workspace;
|
||||||
pub mod node_rules;
|
pub mod node_rules;
|
||||||
@@ -486,6 +487,16 @@ pub fn router(state: AppState) -> Router {
|
|||||||
axum::routing::patch(routes::missions::set_status),
|
axum::routing::patch(routes::missions::set_status),
|
||||||
)
|
)
|
||||||
.route("/api/missions/{id}/refine", post(routes::missions::refine))
|
.route("/api/missions/{id}/refine", post(routes::missions::refine))
|
||||||
|
// Slice 5: let a model size the mission's team. Proposing, listing and
|
||||||
|
// deciding are separate verbs because only the last one spends money.
|
||||||
|
.route(
|
||||||
|
"/api/missions/{id}/team-proposals",
|
||||||
|
get(routes::mission_roster::list).post(routes::mission_roster::suggest),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/missions/{id}/team-proposals/{pid}/decide",
|
||||||
|
post(routes::mission_roster::decide),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/missions/{id}/herdr-dispatch",
|
"/api/missions/{id}/herdr-dispatch",
|
||||||
post(routes::missions::herdr_dispatch),
|
post(routes::missions::herdr_dispatch),
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ pub async fn on_launch(
|
|||||||
provisioner: provisioner.as_ref(),
|
provisioner: provisioner.as_ref(),
|
||||||
template: &template,
|
template: &template,
|
||||||
team_name: &team_name,
|
team_name: &team_name,
|
||||||
default_model: "claude-sonnet-5",
|
default_model: MINTED_CLAW_MODEL,
|
||||||
},
|
},
|
||||||
&mut provisioned_claws,
|
&mut provisioned_claws,
|
||||||
)
|
)
|
||||||
@@ -574,6 +574,20 @@ async fn mint_team_from_template(
|
|||||||
Ok(team_id)
|
Ok(team_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The model every claw a mission mints runs on.
|
||||||
|
///
|
||||||
|
/// One model for every role, which is a real limitation and not a preference:
|
||||||
|
/// `template_roles` has no `model` column, so a template cannot express "the
|
||||||
|
/// verifier runs elsewhere" — and a same-model verifier is the correlated
|
||||||
|
/// failure the independent judge exists to break.
|
||||||
|
///
|
||||||
|
/// The composed path closes this: an approved roster
|
||||||
|
/// (`routes::mission_roster`) carries a `backend` per node, and each backend is
|
||||||
|
/// a different provider's CLI in its own VM. Closing it for CLAWS as well needs
|
||||||
|
/// a per-role model on the template or on the mint, and neither exists yet —
|
||||||
|
/// stated here rather than left as a literal nobody notices.
|
||||||
|
const MINTED_CLAW_MODEL: &str = "claude-sonnet-5";
|
||||||
|
|
||||||
/// The graph a COMPOSED microVM mission runs, built from its team template
|
/// The graph a COMPOSED microVM mission runs, built from its team template
|
||||||
/// without minting a single claw.
|
/// without minting a single claw.
|
||||||
///
|
///
|
||||||
@@ -602,6 +616,21 @@ pub async fn composed_graph(
|
|||||||
return Err(format!("mission {mission_id} not found"));
|
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
|
let template_id = config
|
||||||
.get("phase_teams")
|
.get("phase_teams")
|
||||||
.and_then(|v| v.as_object())
|
.and_then(|v| v.as_object())
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
//! 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))
|
||||||
|
.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());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
//! `/api/missions/{id}/team-proposals` — let a model size the mission's team.
|
||||||
|
//!
|
||||||
|
//! Slice 5. The planner has been proposing rosters into React state for months;
|
||||||
|
//! this is where one reaches a mission. Three verbs, and the split between them
|
||||||
|
//! is the point:
|
||||||
|
//!
|
||||||
|
//! - **suggest** asks the model and PERSISTS the answer. It changes nothing
|
||||||
|
//! about the mission.
|
||||||
|
//! - **approve** writes the roster onto the mission, where the composed executor
|
||||||
|
//! reads it.
|
||||||
|
//! - **reject** records that a human said no, which is the only evidence we ever
|
||||||
|
//! collect about what the planner gets wrong.
|
||||||
|
//!
|
||||||
|
//! A proposal is never applied on arrival. A model sizing a team is a suggestion
|
||||||
|
//! about how many VMs to boot, and this codebase has an explicit rule about
|
||||||
|
//! model output that costs money: it is evidence for a decision, not the
|
||||||
|
//! decision.
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::Json;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::mission_roster::{available_backends, Roster};
|
||||||
|
use crate::{ApiError, AppState, Authed};
|
||||||
|
|
||||||
|
/// The model that sizes a mission's team.
|
||||||
|
///
|
||||||
|
/// The same one the Master Planner uses. Sizing a team is the kind of judgement
|
||||||
|
/// the planner's own system prompt calls for — and it is a once-per-mission call,
|
||||||
|
/// so the cost argument that keeps missions on cheaper models does not apply.
|
||||||
|
const PLANNER_MODEL: &str = "claude-opus-4-8";
|
||||||
|
|
||||||
|
const ROSTER_SYSTEM: &str = "You size the team for ONE software mission that runs inside Firecracker \
|
||||||
|
microVMs. Each member you propose is a WHOLE VM — a boot, a repository injected as a tar, a full \
|
||||||
|
Claude Code session, and a collect — running one after another, each one receiving the working tree the \
|
||||||
|
previous member left behind. That is expensive and it is serial, so propose the FEWEST members that \
|
||||||
|
genuinely divide the work. One member is a perfectly good answer and is usually the right one for a \
|
||||||
|
small change; Anthropic measure multi-agent work at 3-10x the tokens with wall-clock often LONGER, and \
|
||||||
|
the benefit is thoroughness rather than speed.\n\n\
|
||||||
|
Members run SEQUENTIALLY and share the repository, so do NOT propose members that would edit the same \
|
||||||
|
file, and do NOT split one change into stages (plan → implement → test) — a handoff loses context at \
|
||||||
|
every step and one careful pass beats an assembly line. The shape that DOES earn its cost is an \
|
||||||
|
implementer followed by an independent verifier that only checks.\n\n\
|
||||||
|
Give each member a `backend` ONLY when running it on a different provider's image is the point — an \
|
||||||
|
independent verifier on another provider breaks the correlated failure where the model that wrote the \
|
||||||
|
code also grades it. Omit `backend` to inherit the mission's.\n\n\
|
||||||
|
ALWAYS respond with STRICT JSON ONLY, no prose and no markdown: \
|
||||||
|
{\"topology_kind\":\"pipeline\",\"members\":[{\"role\":\"...\",\"backend\":null|\"...\",\
|
||||||
|
\"rationale\":\"one line\"}]}";
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ProposalResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub roster: Value,
|
||||||
|
pub author_model: String,
|
||||||
|
pub status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/missions/{id}/team-proposals` — ask the model for a roster.
|
||||||
|
pub async fn suggest(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<ProposalResponse>, ApiError> {
|
||||||
|
let ws = user.workspace_id;
|
||||||
|
let mission = cm_db::repo::missions::get(&state.pool, id, ws.as_uuid())
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Internal)?
|
||||||
|
.ok_or(ApiError::NotFound)?;
|
||||||
|
|
||||||
|
// The backends the FLEET can boot today, handed to the model as the menu.
|
||||||
|
// Without it the model invents plausible image names and the roster is
|
||||||
|
// refused after it was written, which reads as our bug rather than as a
|
||||||
|
// model guessing.
|
||||||
|
let available = available_backends(&state.pool, ws.as_uuid().to_owned())
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
eprintln!("mission {id}: could not read fleet backends: {e}");
|
||||||
|
ApiError::Internal
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let phases: Vec<(String, Option<String>)> = sqlx::query_as(
|
||||||
|
"SELECT kind, config->>'task' FROM mission_phases WHERE mission_id = $1 ORDER BY order_idx",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Internal)?;
|
||||||
|
|
||||||
|
let phase_text = phases
|
||||||
|
.iter()
|
||||||
|
.map(|(kind, task)| format!("- {kind}: {}", task.as_deref().unwrap_or("(no task text)")))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
let prompt = format!(
|
||||||
|
"MISSION: {}\n\nDESCRIPTION:\n{}\n\nPHASES:\n{}\n\nBACKENDS THIS FLEET CAN BOOT (use only \
|
||||||
|
these, or omit `backend`): {}\n\nPropose the roster now (JSON only).",
|
||||||
|
mission.title,
|
||||||
|
mission.description.as_deref().unwrap_or("(none)"),
|
||||||
|
if phase_text.is_empty() {
|
||||||
|
"(none declared)".to_string()
|
||||||
|
} else {
|
||||||
|
phase_text
|
||||||
|
},
|
||||||
|
if available.is_empty() {
|
||||||
|
"(none — omit backend on every member)".to_string()
|
||||||
|
} else {
|
||||||
|
available.join(", ")
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let raw = state
|
||||||
|
.runtime
|
||||||
|
.complete(ROSTER_SYSTEM, &prompt, PLANNER_MODEL, 2000, false)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
eprintln!("mission {id}: roster proposal failed: {e}");
|
||||||
|
ApiError::Internal
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// A model that answered with prose around its JSON has still answered; a
|
||||||
|
// model that answered with nothing usable has not, and that is a refusal
|
||||||
|
// rather than an empty roster.
|
||||||
|
let parsed: Value = crate::routes::claws::extract_json(&raw).ok_or_else(|| {
|
||||||
|
eprintln!("mission {id}: planner returned no JSON: {raw}");
|
||||||
|
ApiError::BadRequest
|
||||||
|
})?;
|
||||||
|
let roster: Roster = serde_json::from_value(parsed.clone()).map_err(|e| {
|
||||||
|
eprintln!("mission {id}: planner JSON is not a roster ({e}): {parsed}");
|
||||||
|
ApiError::BadRequest
|
||||||
|
})?;
|
||||||
|
// Validated BEFORE it is stored, so a stored proposal is always one that
|
||||||
|
// could be approved. Storing an invalid roster would mean the failure
|
||||||
|
// surfaces at approval time, pointing at the human rather than the model.
|
||||||
|
if let Err(why) = roster.validate(&available) {
|
||||||
|
eprintln!("mission {id}: planner proposed an unusable roster: {why}");
|
||||||
|
return Err(ApiError::BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pid = Uuid::now_v7();
|
||||||
|
let stored = serde_json::to_value(&roster).map_err(|_| ApiError::Internal)?;
|
||||||
|
cm_db::repo::mission_team_proposals::insert(
|
||||||
|
&state.pool,
|
||||||
|
pid,
|
||||||
|
id,
|
||||||
|
ws.as_uuid().to_owned(),
|
||||||
|
&stored,
|
||||||
|
PLANNER_MODEL,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
eprintln!("mission {id}: could not store proposal: {e}");
|
||||||
|
ApiError::Internal
|
||||||
|
})?;
|
||||||
|
eprintln!(
|
||||||
|
"mission_roster: mission {id} — {} proposed {} member(s): {}",
|
||||||
|
PLANNER_MODEL,
|
||||||
|
roster.members.len(),
|
||||||
|
roster
|
||||||
|
.members
|
||||||
|
.iter()
|
||||||
|
.map(|m| format!("{}{}", m.role, m.backend.as_deref().map(|b| format!("@{b}")).unwrap_or_default()))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Json(ProposalResponse {
|
||||||
|
id: pid,
|
||||||
|
roster: stored,
|
||||||
|
author_model: PLANNER_MODEL.to_string(),
|
||||||
|
status: "proposed".into(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/missions/{id}/team-proposals`
|
||||||
|
pub async fn list(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Vec<cm_db::repo::mission_team_proposals::MissionTeamProposal>>, ApiError> {
|
||||||
|
let rows =
|
||||||
|
cm_db::repo::mission_team_proposals::list(&state.pool, id, user.workspace_id.as_uuid().to_owned())
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Internal)?;
|
||||||
|
Ok(Json(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct DecideRequest {
|
||||||
|
/// `approved` or `rejected`.
|
||||||
|
pub status: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/missions/{id}/team-proposals/{pid}/decide` — accept or refuse.
|
||||||
|
///
|
||||||
|
/// Approving writes `config.roster` on the mission and switches it to the
|
||||||
|
/// composed engine, because a roster is a graph of VMs and that is the engine
|
||||||
|
/// that runs one. Draft-only: re-shaping a mission that is already running would
|
||||||
|
/// change what its next phase does with no record of the swap on the phase that
|
||||||
|
/// already ran.
|
||||||
|
pub async fn decide(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
Path((id, pid)): Path<(Uuid, Uuid)>,
|
||||||
|
Json(body): Json<DecideRequest>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
let ws = user.workspace_id;
|
||||||
|
let proposal = cm_db::repo::mission_team_proposals::get(&state.pool, pid, ws.as_uuid().to_owned())
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Internal)?
|
||||||
|
.ok_or(ApiError::NotFound)?;
|
||||||
|
if proposal.mission_id != id {
|
||||||
|
return Err(ApiError::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.status == "rejected" {
|
||||||
|
let decided = cm_db::repo::mission_team_proposals::decide(
|
||||||
|
&state.pool,
|
||||||
|
pid,
|
||||||
|
ws.as_uuid().to_owned(),
|
||||||
|
"rejected",
|
||||||
|
body.note.as_deref(),
|
||||||
|
Some(user.user_id.as_uuid().to_owned()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Internal)?;
|
||||||
|
return Ok(Json(json!({ "status": "rejected", "decided": decided })));
|
||||||
|
}
|
||||||
|
if body.status != "approved" {
|
||||||
|
return Err(ApiError::BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mission = cm_db::repo::missions::get(&state.pool, id, ws.as_uuid())
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Internal)?
|
||||||
|
.ok_or(ApiError::NotFound)?;
|
||||||
|
if mission.status != "draft" {
|
||||||
|
eprintln!("mission {id}: roster approval refused — mission is {}", mission.status);
|
||||||
|
return Err(ApiError::BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
let roster: Roster = serde_json::from_value(proposal.roster.clone()).map_err(|e| {
|
||||||
|
eprintln!("mission {id}: stored proposal {pid} is not a roster ({e})");
|
||||||
|
ApiError::Internal
|
||||||
|
})?;
|
||||||
|
// Re-validated at approval, against the fleet as it is NOW. A node can go
|
||||||
|
// offline between proposing and approving, and the cheapest place to find
|
||||||
|
// that out is still here rather than at VM boot.
|
||||||
|
let available = available_backends(&state.pool, ws.as_uuid().to_owned())
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Internal)?;
|
||||||
|
if let Err(why) = roster.validate(&available) {
|
||||||
|
eprintln!("mission {id}: roster {pid} is no longer applicable: {why}");
|
||||||
|
let _ = cm_db::repo::mission_team_proposals::decide(
|
||||||
|
&state.pool,
|
||||||
|
pid,
|
||||||
|
ws.as_uuid().to_owned(),
|
||||||
|
"rejected",
|
||||||
|
Some(&why.to_string()),
|
||||||
|
Some(user.user_id.as_uuid().to_owned()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Err(ApiError::BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim the decision FIRST. The unique index allows one approved proposal
|
||||||
|
// per mission, so this is what makes two approvals race safely: the loser
|
||||||
|
// updates nothing and never touches the mission.
|
||||||
|
let claimed = cm_db::repo::mission_team_proposals::decide(
|
||||||
|
&state.pool,
|
||||||
|
pid,
|
||||||
|
ws.as_uuid().to_owned(),
|
||||||
|
"approved",
|
||||||
|
body.note.as_deref(),
|
||||||
|
Some(user.user_id.as_uuid().to_owned()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
eprintln!("mission {id}: could not approve proposal {pid}: {e}");
|
||||||
|
ApiError::Internal
|
||||||
|
})?;
|
||||||
|
if !claimed {
|
||||||
|
return Err(ApiError::BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
let graph = roster.graph().map_err(|e| {
|
||||||
|
eprintln!("mission {id}: approved roster does not build a graph: {e}");
|
||||||
|
ApiError::Internal
|
||||||
|
})?;
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE missions
|
||||||
|
SET config = jsonb_set(coalesce(config, '{}'::jsonb), '{roster}', $2::jsonb, true),
|
||||||
|
team_engine = 'composed',
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1 AND workspace_id = $3",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(&graph)
|
||||||
|
.bind(ws.as_uuid())
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
eprintln!("mission {id}: could not write the approved roster: {e}");
|
||||||
|
ApiError::Internal
|
||||||
|
})?;
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"mission_roster: mission {id} now runs a {}-node composed graph from proposal {pid}",
|
||||||
|
roster.members.len()
|
||||||
|
);
|
||||||
|
Ok(Json(json!({
|
||||||
|
"status": "approved",
|
||||||
|
"team_engine": "composed",
|
||||||
|
"nodes": roster.members.len(),
|
||||||
|
"graph": graph,
|
||||||
|
})))
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ pub mod health;
|
|||||||
pub mod identity;
|
pub mod identity;
|
||||||
pub mod level_up;
|
pub mod level_up;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
|
pub mod mission_roster;
|
||||||
pub mod missions;
|
pub mod missions;
|
||||||
pub mod nodes;
|
pub mod nodes;
|
||||||
pub mod oauth;
|
pub mod oauth;
|
||||||
|
|||||||
@@ -258,3 +258,68 @@ async fn on_launch_no_template_hard_fails() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(team_id.is_none());
|
assert!(team_id.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Slice 5: an APPROVED roster outranks the team template.
|
||||||
|
///
|
||||||
|
/// The template gives every composed mission the same five roles on the same
|
||||||
|
/// image. A roster is the model's answer for THIS mission, and it is the only
|
||||||
|
/// path that carries a per-node backend — which is how a mission runs more than
|
||||||
|
/// one provider at all. If the template won, a heterogeneous roster would be
|
||||||
|
/// accepted, stored, and then silently ignored at launch.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_approved_roster_outranks_the_template() {
|
||||||
|
let pool = cm_testkit::test_pool().await;
|
||||||
|
let ws = seed_workspace(&pool).await;
|
||||||
|
let template_id = seed_test_template(&pool).await;
|
||||||
|
let mission = seed_mission(&pool, ws, template_id, "roster beats template").await;
|
||||||
|
|
||||||
|
// With no roster, the shape comes from the template — the behaviour every
|
||||||
|
// composed mission had before this slice.
|
||||||
|
let from_template = mission_orchestrator::composed_graph(&pool, mission, &["mission"])
|
||||||
|
.await
|
||||||
|
.expect("template graph")
|
||||||
|
.expect("the template supplies a shape");
|
||||||
|
let template_nodes = from_template["nodes"].as_array().unwrap().len();
|
||||||
|
assert!(template_nodes >= 1);
|
||||||
|
assert!(
|
||||||
|
from_template["nodes"][0]["attrs"].get("backend").is_none(),
|
||||||
|
"a template cannot express a per-node backend — that is the gap the roster fills"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Approve a roster the way the route does: the built graph under
|
||||||
|
// `config.roster`.
|
||||||
|
let roster = cm_api::mission_roster::Roster {
|
||||||
|
topology_kind: "pipeline".into(),
|
||||||
|
members: vec![
|
||||||
|
cm_api::mission_roster::RosterMember {
|
||||||
|
role: "implementer".into(),
|
||||||
|
backend: Some("claude".into()),
|
||||||
|
rationale: None,
|
||||||
|
},
|
||||||
|
cm_api::mission_roster::RosterMember {
|
||||||
|
role: "verifier".into(),
|
||||||
|
backend: Some("kimi".into()),
|
||||||
|
rationale: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let graph = roster.graph().expect("a runnable graph");
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE missions SET config = jsonb_set(config, '{roster}', $2::jsonb, true) WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(mission)
|
||||||
|
.bind(&graph)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let chosen = mission_orchestrator::composed_graph(&pool, mission, &["mission"])
|
||||||
|
.await
|
||||||
|
.expect("roster graph")
|
||||||
|
.expect("the roster supplies a shape");
|
||||||
|
let nodes = chosen["nodes"].as_array().unwrap();
|
||||||
|
assert_eq!(nodes.len(), 2, "the roster's two nodes, not the template's");
|
||||||
|
assert_eq!(nodes[0]["role"], "implementer");
|
||||||
|
assert_eq!(nodes[0]["attrs"]["backend"], "claude");
|
||||||
|
assert_eq!(nodes[1]["attrs"]["backend"], "kimi");
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
//! Model-authored mission rosters, and whether a human accepted them.
|
||||||
|
//!
|
||||||
|
//! See `migrations/0070_mission_team_proposals.sql` for why a proposal is
|
||||||
|
//! persisted rather than applied on arrival.
|
||||||
|
|
||||||
|
use crate::DbError;
|
||||||
|
use serde_json::Value;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct MissionTeamProposal {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub mission_id: Uuid,
|
||||||
|
pub roster: Value,
|
||||||
|
pub author_model: String,
|
||||||
|
pub status: String,
|
||||||
|
pub note: Option<String>,
|
||||||
|
#[serde(with = "time::serde::rfc3339")]
|
||||||
|
pub created_at: OffsetDateTime,
|
||||||
|
#[serde(with = "time::serde::rfc3339::option")]
|
||||||
|
pub decided_at: Option<OffsetDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
mission_id: Uuid,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
roster: &Value,
|
||||||
|
author_model: &str,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO mission_team_proposals
|
||||||
|
(id, mission_id, workspace_id, roster, author_model)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(mission_id)
|
||||||
|
.bind(workspace_id)
|
||||||
|
.bind(roster)
|
||||||
|
.bind(author_model)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every proposal for a mission, newest first. Rejected ones are included on
|
||||||
|
/// purpose: what a human turned down is the only record of what the planner
|
||||||
|
/// gets wrong.
|
||||||
|
pub async fn list(
|
||||||
|
pool: &PgPool,
|
||||||
|
mission_id: Uuid,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
) -> Result<Vec<MissionTeamProposal>, DbError> {
|
||||||
|
let rows = sqlx::query_as::<_, (Uuid, Uuid, Value, String, String, Option<String>, OffsetDateTime, Option<OffsetDateTime>)>(
|
||||||
|
"SELECT id, mission_id, roster, author_model, status, note, created_at, decided_at
|
||||||
|
FROM mission_team_proposals
|
||||||
|
WHERE mission_id = $1 AND workspace_id = $2
|
||||||
|
ORDER BY created_at DESC",
|
||||||
|
)
|
||||||
|
.bind(mission_id)
|
||||||
|
.bind(workspace_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(
|
||||||
|
|(id, mission_id, roster, author_model, status, note, created_at, decided_at)| {
|
||||||
|
MissionTeamProposal {
|
||||||
|
id,
|
||||||
|
mission_id,
|
||||||
|
roster,
|
||||||
|
author_model,
|
||||||
|
status,
|
||||||
|
note,
|
||||||
|
created_at,
|
||||||
|
decided_at,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
) -> Result<Option<MissionTeamProposal>, DbError> {
|
||||||
|
let row = sqlx::query_as::<_, (Uuid, Uuid, Value, String, String, Option<String>, OffsetDateTime, Option<OffsetDateTime>)>(
|
||||||
|
"SELECT id, mission_id, roster, author_model, status, note, created_at, decided_at
|
||||||
|
FROM mission_team_proposals
|
||||||
|
WHERE id = $1 AND workspace_id = $2",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(workspace_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(
|
||||||
|
|(id, mission_id, roster, author_model, status, note, created_at, decided_at)| {
|
||||||
|
MissionTeamProposal {
|
||||||
|
id,
|
||||||
|
mission_id,
|
||||||
|
roster,
|
||||||
|
author_model,
|
||||||
|
status,
|
||||||
|
note,
|
||||||
|
created_at,
|
||||||
|
decided_at,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a decision. Only a `proposed` row may be decided, so approving twice
|
||||||
|
/// — a double-click, a retried request — cannot re-apply a roster to a mission
|
||||||
|
/// that has since moved on. Returns whether this call was the one that decided.
|
||||||
|
pub async fn decide(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
status: &str,
|
||||||
|
note: Option<&str>,
|
||||||
|
decided_by: Option<Uuid>,
|
||||||
|
) -> Result<bool, DbError> {
|
||||||
|
let done = sqlx::query(
|
||||||
|
"UPDATE mission_team_proposals
|
||||||
|
SET status = $3, note = $4, decided_at = now(), decided_by = $5
|
||||||
|
WHERE id = $1 AND workspace_id = $2 AND status = 'proposed'",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(workspace_id)
|
||||||
|
.bind(status)
|
||||||
|
.bind(note)
|
||||||
|
.bind(decided_by)
|
||||||
|
.execute(pool)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
Ok(done == 1)
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ pub mod fleet_beszel;
|
|||||||
pub mod fleet_tailscale;
|
pub mod fleet_tailscale;
|
||||||
pub mod level_up;
|
pub mod level_up;
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
|
pub mod mission_team_proposals;
|
||||||
pub mod missions;
|
pub mod missions;
|
||||||
pub mod node_metrics;
|
pub mod node_metrics;
|
||||||
pub mod node_rules;
|
pub mod node_rules;
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
//! A mission may have many proposals and at most one approved roster.
|
||||||
|
//!
|
||||||
|
//! Both properties are enforced in SQL rather than in the handler, and both
|
||||||
|
//! matter for the same reason: the composed executor reads ONE field for what
|
||||||
|
//! shape a mission is, so a second approval would silently win by being written
|
||||||
|
//! last.
|
||||||
|
|
||||||
|
use cm_db::repo::mission_team_proposals as proposals;
|
||||||
|
use cm_domain::WorkspaceId;
|
||||||
|
use serde_json::json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
async fn workspace(pool: &sqlx::PgPool) -> WorkspaceId {
|
||||||
|
let ws = cm_domain::Workspace {
|
||||||
|
id: WorkspaceId::new(),
|
||||||
|
name: "Roster".into(),
|
||||||
|
plan: "team".into(),
|
||||||
|
};
|
||||||
|
cm_db::repo::workspaces::insert(pool, &ws).await.expect("workspace");
|
||||||
|
ws.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A mission row to hang proposals off — `mission_id` is a real FK.
|
||||||
|
async fn mission(pool: &sqlx::PgPool, ws: WorkspaceId) -> Uuid {
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO missions (id, workspace_id, title, template_kind, status, schedule, config)
|
||||||
|
VALUES ($1, $2, 'roster test', 'research_and_code', 'draft', '{}'::jsonb, '{}'::jsonb)",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(ws.as_uuid())
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("insert mission");
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn roster() -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"topology_kind": "pipeline",
|
||||||
|
"members": [
|
||||||
|
{"role": "implementer"},
|
||||||
|
{"role": "verifier", "backend": "kimi"}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole point of persisting: a proposal is a record, not a click. It
|
||||||
|
/// arrives `proposed`, applied to nothing.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_proposal_arrives_undecided_and_is_listed() {
|
||||||
|
let pool = cm_testkit::test_pool().await;
|
||||||
|
let ws = workspace(&pool).await;
|
||||||
|
let m = mission(&pool, ws).await;
|
||||||
|
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
proposals::insert(&pool, id, m, ws.as_uuid().to_owned(), &roster(), "claude-opus-4-8")
|
||||||
|
.await
|
||||||
|
.expect("insert");
|
||||||
|
|
||||||
|
let rows = proposals::list(&pool, m, ws.as_uuid().to_owned()).await.expect("list");
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert_eq!(rows[0].status, "proposed");
|
||||||
|
assert_eq!(rows[0].author_model, "claude-opus-4-8");
|
||||||
|
assert!(rows[0].decided_at.is_none());
|
||||||
|
assert_eq!(rows[0].roster["members"][1]["backend"], "kimi");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deciding twice must not decide twice. A double-clicked approve, or a retried
|
||||||
|
/// request, would otherwise re-apply a roster to a mission that has moved on.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn only_the_first_decision_counts() {
|
||||||
|
let pool = cm_testkit::test_pool().await;
|
||||||
|
let ws = workspace(&pool).await;
|
||||||
|
let m = mission(&pool, ws).await;
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
proposals::insert(&pool, id, m, ws.as_uuid().to_owned(), &roster(), "claude-opus-4-8")
|
||||||
|
.await
|
||||||
|
.expect("insert");
|
||||||
|
|
||||||
|
let first = proposals::decide(&pool, id, ws.as_uuid().to_owned(), "approved", None, None)
|
||||||
|
.await
|
||||||
|
.expect("decide");
|
||||||
|
assert!(first, "the first approval must claim the proposal");
|
||||||
|
|
||||||
|
let second = proposals::decide(&pool, id, ws.as_uuid().to_owned(), "rejected", None, None)
|
||||||
|
.await
|
||||||
|
.expect("decide");
|
||||||
|
assert!(!second, "a decided proposal must not be re-decided");
|
||||||
|
|
||||||
|
let rows = proposals::list(&pool, m, ws.as_uuid().to_owned()).await.expect("list");
|
||||||
|
assert_eq!(rows[0].status, "approved", "and the first decision stands");
|
||||||
|
assert!(rows[0].decided_at.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// At most one approved roster per mission, enforced by a partial unique index.
|
||||||
|
/// Two approved proposals are two answers to "what shape is this mission".
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_mission_cannot_have_two_approved_rosters() {
|
||||||
|
let pool = cm_testkit::test_pool().await;
|
||||||
|
let ws = workspace(&pool).await;
|
||||||
|
let m = mission(&pool, ws).await;
|
||||||
|
|
||||||
|
let a = Uuid::now_v7();
|
||||||
|
let b = Uuid::now_v7();
|
||||||
|
for id in [a, b] {
|
||||||
|
proposals::insert(&pool, id, m, ws.as_uuid().to_owned(), &roster(), "claude-opus-4-8")
|
||||||
|
.await
|
||||||
|
.expect("insert");
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(proposals::decide(&pool, a, ws.as_uuid().to_owned(), "approved", None, None)
|
||||||
|
.await
|
||||||
|
.expect("approve a"));
|
||||||
|
// The second approval must be REFUSED by the database, not merely lose a
|
||||||
|
// race in the handler.
|
||||||
|
let second = proposals::decide(&pool, b, ws.as_uuid().to_owned(), "approved", None, None).await;
|
||||||
|
assert!(second.is_err(), "a second approved roster was allowed: {second:?}");
|
||||||
|
|
||||||
|
// Rejecting it is still fine — the constraint is on approvals only, and the
|
||||||
|
// ones a human turned down are the record of what the planner gets wrong.
|
||||||
|
assert!(proposals::decide(&pool, b, ws.as_uuid().to_owned(), "rejected", Some("too many VMs"), None)
|
||||||
|
.await
|
||||||
|
.expect("reject b"));
|
||||||
|
let rows = proposals::list(&pool, m, ws.as_uuid().to_owned()).await.expect("list");
|
||||||
|
assert_eq!(rows.len(), 2, "a rejected proposal is kept, not deleted");
|
||||||
|
assert!(rows.iter().any(|r| r.status == "rejected" && r.note.as_deref() == Some("too many VMs")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Another workspace's proposal is not visible and not decidable. Every read
|
||||||
|
/// here is scoped, and this is the test that keeps it that way.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_proposal_belongs_to_its_workspace() {
|
||||||
|
let pool = cm_testkit::test_pool().await;
|
||||||
|
let ws = workspace(&pool).await;
|
||||||
|
let other = workspace(&pool).await;
|
||||||
|
let m = mission(&pool, ws).await;
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
proposals::insert(&pool, id, m, ws.as_uuid().to_owned(), &roster(), "claude-opus-4-8")
|
||||||
|
.await
|
||||||
|
.expect("insert");
|
||||||
|
|
||||||
|
assert!(proposals::get(&pool, id, other.as_uuid().to_owned()).await.expect("get").is_none());
|
||||||
|
assert!(proposals::list(&pool, m, other.as_uuid().to_owned()).await.expect("list").is_empty());
|
||||||
|
assert!(
|
||||||
|
!proposals::decide(&pool, id, other.as_uuid().to_owned(), "approved", None, None)
|
||||||
|
.await
|
||||||
|
.expect("decide"),
|
||||||
|
"another workspace must not be able to approve this roster"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
-- A model's proposal for how a mission's team should be shaped, and whether a
|
||||||
|
-- human accepted it.
|
||||||
|
--
|
||||||
|
-- `routes/planner.rs` has had Opus proposing 2-6 members with a model each since
|
||||||
|
-- the Master Planner shipped, and none of it has ever reached a mission: the
|
||||||
|
-- proposal lived in React state and died with the tab. Missions instead got a
|
||||||
|
-- team template picked in the wizard, whose roles are fixed and whose every claw
|
||||||
|
-- is minted `claude-sonnet-5` — a hardcode in `mint_team_from_template`, and the
|
||||||
|
-- reason no mission has ever run heterogeneous providers.
|
||||||
|
--
|
||||||
|
-- Persisting the proposal is what makes it reviewable and what makes an approval
|
||||||
|
-- an event rather than a click. A proposal is NEVER applied on arrival: a model
|
||||||
|
-- sizing a team is a suggestion about how to spend money on VMs, and the row
|
||||||
|
-- records who accepted it and when.
|
||||||
|
--
|
||||||
|
-- `status`:
|
||||||
|
-- proposed — the model's answer, applied to nothing.
|
||||||
|
-- approved — written onto the mission (`config.roster`); terminal.
|
||||||
|
-- rejected — declined; kept, because the ones a human turned down are the
|
||||||
|
-- only record of what the planner gets wrong.
|
||||||
|
CREATE TABLE IF NOT EXISTS mission_team_proposals (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
mission_id UUID NOT NULL REFERENCES missions (id) ON DELETE CASCADE,
|
||||||
|
workspace_id UUID NOT NULL,
|
||||||
|
-- The roster itself: {"topology_kind": "...", "members": [{role, backend,
|
||||||
|
-- model, rationale}, ...]}. Stored as the model produced it (after
|
||||||
|
-- validation) rather than normalised into columns — the shape is the
|
||||||
|
-- planner's contract, and splitting it here would mean migrating this table
|
||||||
|
-- every time the planner learns a field.
|
||||||
|
roster JSONB NOT NULL,
|
||||||
|
-- Which model authored it, so a bad roster can be traced to a model rather
|
||||||
|
-- than to "the planner".
|
||||||
|
author_model TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'proposed'
|
||||||
|
CHECK (status IN ('proposed', 'approved', 'rejected')),
|
||||||
|
-- Why the proposal was refused, or why an approval could not be applied.
|
||||||
|
note TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
decided_at TIMESTAMPTZ,
|
||||||
|
decided_by UUID
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS mission_team_proposals_mission_idx
|
||||||
|
ON mission_team_proposals (mission_id, created_at DESC);
|
||||||
|
|
||||||
|
-- At most one approved roster per mission. Two approved proposals would be two
|
||||||
|
-- answers to "what shape is this mission", and the executor reads one field —
|
||||||
|
-- so the second would silently win by being written last.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS mission_team_proposals_one_approved
|
||||||
|
ON mission_team_proposals (mission_id)
|
||||||
|
WHERE status = 'approved';
|
||||||
Reference in New Issue
Block a user