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
@@ -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 level_up;
|
||||
pub mod library;
|
||||
pub mod mission_roster;
|
||||
pub mod missions;
|
||||
pub mod nodes;
|
||||
pub mod oauth;
|
||||
|
||||
Reference in New Issue
Block a user