`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]>
326 lines
11 KiB
Rust
326 lines
11 KiB
Rust
//! End-to-end coverage for `mission_orchestrator::on_launch` — the
|
|
//! draft→running transition that materializes a team from a template,
|
|
//! inserts agents, seeds brains, records template lineage, and binds
|
|
//! members via team_members. Runs against a real Postgres (cm-testkit).
|
|
//!
|
|
//! What this test locks in:
|
|
//! * Given a mission with `team_template_id` and no `team_id`,
|
|
//! `on_launch` materializes exactly one team per role in the
|
|
//! template.
|
|
//! * The materialized team carries `template_id` + `template_version`
|
|
//! + `risk_profile` + `mcp_bundles` from the template row.
|
|
//! * Every role produces one `agents` row + one
|
|
//! `agent_template_link` row (seeded=true).
|
|
//! * `team_members` binds every claw to a topology node id.
|
|
//! * The mission row's `team_id` gets updated.
|
|
//! * Re-invoking is a no-op (returns the existing team_id, doesn't
|
|
//! duplicate agents).
|
|
|
|
use cm_api::mission_orchestrator;
|
|
use cm_db::repo::{team_templates, users, workspaces};
|
|
use cm_domain::{Role, User, UserId, Workspace, WorkspaceId};
|
|
use serde_json::json;
|
|
use sqlx::Row;
|
|
use uuid::Uuid;
|
|
|
|
async fn seed_workspace(pool: &sqlx::PgPool) -> WorkspaceId {
|
|
let ws = Workspace {
|
|
id: WorkspaceId::new(),
|
|
name: "Missions Test".into(),
|
|
plan: "team".into(),
|
|
};
|
|
workspaces::insert(pool, &ws).await.unwrap();
|
|
ws.id
|
|
}
|
|
|
|
async fn seed_owner(pool: &sqlx::PgPool, ws: WorkspaceId) -> UserId {
|
|
let user = User {
|
|
id: UserId::new(),
|
|
workspace_id: ws,
|
|
email: "[email protected]".into(),
|
|
role: Role::Owner,
|
|
display_name: "Owner".into(),
|
|
created_at: time::OffsetDateTime::UNIX_EPOCH,
|
|
};
|
|
users::insert(pool, &user).await.unwrap();
|
|
user.id
|
|
}
|
|
|
|
async fn seed_test_template(pool: &sqlx::PgPool) -> Uuid {
|
|
let id = Uuid::now_v7();
|
|
team_templates::upsert_builtin(
|
|
pool,
|
|
team_templates::UpsertBuiltin {
|
|
id,
|
|
key: "test_backend",
|
|
name: "Test Backend Team",
|
|
stack: vec!["rust".into(), "postgres".into()],
|
|
default_topology: "pipeline",
|
|
risk_profile: "medium",
|
|
mcp_bundles: vec!["clawmates_skills".into()],
|
|
version: 1,
|
|
description: Some("Fixture template for orchestrator test"),
|
|
config: json!({}),
|
|
category: "development",
|
|
roles: vec![
|
|
team_templates::UpsertBuiltinRole {
|
|
slot: "planner",
|
|
order_idx: 0,
|
|
system_prompt: "Plan the feature. Break it into INT-XX items.",
|
|
skills: vec!["decompose-int-items".into()],
|
|
brain_seed: Some("# Planner\nBreak features into INT items."),
|
|
},
|
|
team_templates::UpsertBuiltinRole {
|
|
slot: "coder",
|
|
order_idx: 1,
|
|
system_prompt: "Implement one INT item at a time.",
|
|
skills: vec!["write-rust-current-edition".into()],
|
|
brain_seed: Some("# Coder\nOne INT per commit."),
|
|
},
|
|
team_templates::UpsertBuiltinRole {
|
|
slot: "reviewer",
|
|
order_idx: 2,
|
|
system_prompt: "Review each commit before merge.",
|
|
skills: vec!["code-review-checklist".into()],
|
|
brain_seed: None,
|
|
},
|
|
],
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
id
|
|
}
|
|
|
|
async fn seed_mission(
|
|
pool: &sqlx::PgPool,
|
|
ws: WorkspaceId,
|
|
template_id: Uuid,
|
|
title: &str,
|
|
) -> Uuid {
|
|
let id = Uuid::now_v7();
|
|
sqlx::query(
|
|
"INSERT INTO missions
|
|
(id, workspace_id, title, template_kind, team_template_id, schedule, status, config)
|
|
VALUES ($1, $2, $3, 'research_and_code', $4, '{\"kind\":\"one_shot\"}'::jsonb,
|
|
'draft', '{}'::jsonb)",
|
|
)
|
|
.bind(id)
|
|
.bind(ws.as_uuid())
|
|
.bind(title)
|
|
.bind(template_id)
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
id
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn on_launch_materializes_team_from_template() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = seed_workspace(&pool).await;
|
|
let owner = seed_owner(&pool, ws).await;
|
|
let template_id = seed_test_template(&pool).await;
|
|
let mission_id = seed_mission(&pool, ws, template_id, "Test Mission").await;
|
|
|
|
let team_id = mission_orchestrator::on_launch(&pool, ws, owner, mission_id, None)
|
|
.await
|
|
.expect("on_launch succeeds")
|
|
.expect("returns a team id");
|
|
|
|
// Team row exists with template lineage stamped.
|
|
let team_row = sqlx::query(
|
|
"SELECT template_id, template_version, risk_profile, mcp_bundles
|
|
FROM teams WHERE id = $1",
|
|
)
|
|
.bind(team_id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
let stamped_template_id: Uuid = team_row.get("template_id");
|
|
let stamped_version: i32 = team_row.get("template_version");
|
|
let stamped_risk: String = team_row.get("risk_profile");
|
|
assert_eq!(stamped_template_id, template_id);
|
|
assert_eq!(stamped_version, 1);
|
|
assert_eq!(stamped_risk, "medium");
|
|
|
|
// One agent per role — three total.
|
|
let agent_count: i64 =
|
|
sqlx::query_scalar("SELECT count(*)::bigint FROM team_members WHERE team_id = $1")
|
|
.bind(team_id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(agent_count, 3, "expected one member per role");
|
|
|
|
// Every member has a matching agents row + role_slot binding.
|
|
let member_slots: Vec<String> = sqlx::query_scalar(
|
|
"SELECT tm.role FROM team_members tm
|
|
JOIN agents a ON a.id = tm.claw_id
|
|
WHERE tm.team_id = $1
|
|
ORDER BY tm.role",
|
|
)
|
|
.bind(team_id)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(member_slots, vec!["coder", "planner", "reviewer"]);
|
|
|
|
// Every claw has an agent_template_link row with seeded=true.
|
|
let seeded_count: i64 = sqlx::query_scalar(
|
|
"SELECT count(*)::bigint FROM agent_template_link atl
|
|
JOIN team_members tm ON tm.claw_id = atl.agent_id
|
|
WHERE tm.team_id = $1
|
|
AND atl.template_id = $2",
|
|
)
|
|
.bind(team_id)
|
|
.bind(template_id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
seeded_count, 3,
|
|
"expected every claw to have seeded lineage"
|
|
);
|
|
|
|
// Mission row was updated to point at the new team.
|
|
let bound_team_id: Uuid = sqlx::query_scalar("SELECT team_id FROM missions WHERE id = $1")
|
|
.bind(mission_id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(bound_team_id, team_id);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn on_launch_is_idempotent() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = seed_workspace(&pool).await;
|
|
let owner = seed_owner(&pool, ws).await;
|
|
let template_id = seed_test_template(&pool).await;
|
|
let mission_id = seed_mission(&pool, ws, template_id, "Idempotency Mission").await;
|
|
|
|
let team_a = mission_orchestrator::on_launch(&pool, ws, owner, mission_id, None)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let team_b = mission_orchestrator::on_launch(&pool, ws, owner, mission_id, None)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(
|
|
team_a, team_b,
|
|
"second invocation should return the same team_id"
|
|
);
|
|
|
|
// Still exactly three agents — no duplication.
|
|
let agent_count: i64 =
|
|
sqlx::query_scalar("SELECT count(*)::bigint FROM team_members WHERE team_id = $1")
|
|
.bind(team_a)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(agent_count, 3);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn on_launch_no_template_hard_fails() {
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = seed_workspace(&pool).await;
|
|
let owner = seed_owner(&pool, ws).await;
|
|
|
|
// Mission with no team_template_id.
|
|
let mission_id = Uuid::now_v7();
|
|
sqlx::query(
|
|
"INSERT INTO missions
|
|
(id, workspace_id, title, template_kind, schedule, status, config)
|
|
VALUES ($1, $2, 'no template', 'refactor', '{\"kind\":\"one_shot\"}'::jsonb,
|
|
'draft', '{}'::jsonb)",
|
|
)
|
|
.bind(mission_id)
|
|
.bind(ws.as_uuid())
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
let result = mission_orchestrator::on_launch(&pool, ws, owner, mission_id, None).await;
|
|
let err = result.expect_err("no template + no team must be a hard error");
|
|
assert!(
|
|
err.contains("no team_template_id") && err.contains("config.phase_teams"),
|
|
"unexpected error message: {err}"
|
|
);
|
|
|
|
// Mission stays with team_id NULL — no partial materialization.
|
|
let team_id: Option<Uuid> = sqlx::query_scalar("SELECT team_id FROM missions WHERE id = $1")
|
|
.bind(mission_id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
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");
|
|
}
|