//! `/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, Authed(user): Authed, Path(id): Path, ) -> Result, 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)> = 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::>() .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::>() .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, Authed(user): Authed, Path(id): Path, ) -> Result>, 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, } /// `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, Authed(user): Authed, Path((id, pid)): Path<(Uuid, Uuid)>, Json(body): Json, ) -> Result, 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, }))) }