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]>
332 lines
13 KiB
Rust
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,
|
|
})))
|
|
}
|