Files
clawmates/crates/cm-api/src/routes/mission_roster.rs
T
Omar SobhandClaude Opus 5 5c2c63f8e8 feat(missions): a human can finally reach the plan/roster review gate
Phase 4 of the plan, plus the PLAN_COMPLETE decision and the gitea_forge
cleanup from Phase 5.

THE REVIEW UI

mission_plan and mission_roster have been complete and reachable by curl
since they shipped, with zero frontend. That matters more than a missing
screen usually would: the decide step is not a convenience, it IS the
safety mechanism. Approving a plan replaces the mission's phases; approving
a roster flips it to the composed engine. A gate nobody can reach is a gate
that is always open or always shut.

MissionProposalDrawer, modelled on LevelUpDrawer which already does
load → review → decide. Reached from a mission's SETUP tab. Verified end to
end against the live backend, not just compiled: a model proposed a roster,
approval flipped the mission to `composed`, and approval on a non-draft
mission was refused.

The plan view shows each phase's done_when, and says plainly when one is
absent — a phase without a completion condition is never judged and reports
completed whatever it did, so its absence is the thing worth seeing.

AND THE DEFECT BUILDING IT FOUND

Every refusal path computed a precise reason — "the mission is running, not
a draft", "no node can boot that backend any more" — logged it to stderr,
and returned a bare {"error":"bad request"}. The person who needed the
sentence was the one clicking Approve; they got two words, and the reason
went to a server log they cannot read.

ApiError::Refused(String) carries it now. Same argument ApiError::Unavailable
was added for ("a 500 with 'internal error' sent them looking for a bug that
was not there"), one status code down. Live: the 400 now reads "this mission
is completed — a roster can only be approved while it is a draft, because
approving one rewrites how the mission will run".

PLAN_COMPLETE, decided

The Skill-Use measurement found that int-xx-marker-protocol documents
PLAN_COMPLETE and task_card_parser never implemented it, so an agent
following the skill exactly was silently ignored. Implemented rather than
removed from the skill: the planner needs a way to say it is done
specifying, and agents already emit it.

Marker ids are now strictly INT-<digits>. `starts_with("INT-")` accepted the
range form `INT-01..02` — observed live — which parsed into an id matching
no real item, so a task card appeared for something that did not exist while
the two items it covered stayed open. Rejecting is right: an ignored marker
is visible, a plausible row is not.

GITEA_FORGE, REMOVED

Named in nine places, defined in none. Harmless while provision_claw ignored
the bundle list; once the list was honoured, an undefined name became a
capability an agent is told it has and does not. Removed from seven team
templates, a workflow recipe, the auto-provision path, and a dropdown a user
could pick it from.

A new test asserts every bundle a template names is defined in the runtime
config — and it immediately found `web_fetch` in two templates I had missed
removing by hand. Same shape as the skill-binding test, one layer up.

Agents reach the forge through git over HTTPS with the ambient GITEA_TOKEN,
which is why nothing ever broke.

Full workspace suite green (106 binaries); frontend builds clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 11:56:54 -07:00

332 lines
13 KiB
Rust

//! `/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(", ")
},
);
// On the SUBSCRIPTION, like every mission VM — not the metered API key.
// `Runtime::complete` with a bare model name resolves to the default
// provider, which is the pay-as-you-go key; this planner died with
// "credit balance is too low" while missions on the same box ran fine.
// `author_model` is what ANSWERED, not what was asked for. When opus is
// capped the chain steps down to haiku and then to GLM, and a plan drafted
// by the third link but filed as an opus plan is a silent quality change.
let (raw, author_model) = crate::subscription::complete_with_fallback(
&state.runtime,
ROSTER_SYSTEM,
&prompt,
PLANNER_MODEL,
2000,
false,
)
.await
.map_err(|e| {
eprintln!("mission {id}: roster proposal failed: {e}");
// A rate-limited subscription is a 503 the operator can act on, not
// a 500 that reads as "this server is broken".
crate::subscription::as_api_error(&e)
})?;
// 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,
&author_model,
)
.await
.map_err(|e| {
eprintln!("mission {id}: could not store proposal: {e}");
ApiError::Internal
})?;
eprintln!(
"mission_roster: mission {id}{} proposed {} member(s): {}",
author_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,
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" {
return Err(ApiError::Refused(format!(
"this mission is {} — a {} can only be approved while it is a draft, \
because approving one rewrites how the mission will run",
mission.status, "roster"
)));
}
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 reason = why.to_string();
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;
// The proposal has just been auto-rejected, so the caller is about to
// re-read a list where it says "rejected" with no visible cause. The
// reason is the whole content of this response.
return Err(ApiError::Refused(format!(
"this roster no longer applies to the fleet as it is now, so it was \
rejected: {reason}"
)));
}
let graph = roster.graph().map_err(|e| {
eprintln!("mission {id}: approved roster does not build a graph: {e}");
ApiError::Internal
})?;
// Claiming the proposal and writing the mission are ONE transaction. Doing
// them as two statements left the first real approval in production marked
// `approved` with nothing written to the mission — and the partial unique
// index then makes that permanent, since no other proposal for that mission
// can ever be approved.
let claimed = cm_db::repo::mission_team_proposals::approve_and_apply(
&state.pool,
pid,
id,
ws.as_uuid().to_owned(),
&graph,
body.note.as_deref(),
Some(user.user_id.as_uuid().to_owned()),
)
.await
.map_err(|e| {
eprintln!("mission {id}: could not apply roster {pid}: {e}");
ApiError::Internal
})?;
if !claimed {
return Err(ApiError::BadRequest);
}
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,
})))
}